Add engraphis connect --token so a purchased client can actually connect#75
Add engraphis connect --token so a purchased client can actually connect#75Coding-Dev-Tools wants to merge 14 commits into
engraphis connect --token so a purchased client can actually connect#75Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 78027222d2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryThis PR delivers the missing client half of the device-connect flow:
Confidence Score: 4/5Safe to merge with one open write-path exception that can surface a raw traceback after the single-use token is spent. The previously-flagged CloudUrlUnresolved (ValueError subclass) escape from save_bootstrap remains open: a transient DNS failure after the token is spent surfaces as a raw traceback, leaving the customer with a consumed token and no actionable message. Everything else is carefully engineered with strong test coverage. Files Needing Attention: engraphis/device_connect.py — the connect() function's exception handling around the save_bootstrap call.
|
| Filename | Overview |
|---|---|
| engraphis/device_connect.py | Core connect logic: token validation, identity management, HTTP transport, and session bootstrap — well-structured with careful credential hygiene; one uncaught ValueError path from save_bootstrap (previously flagged) is the only meaningful gap. |
| scripts/connect.py | CLI entry point following the existing init.py conventions: argparse, main(argv=None)->int, no prompts, correct --token - stdin handling, and separate JSON/human output paths. |
| scripts/entry.py | Verb dispatcher: rewrites sys.argv then calls each command's main(); correctly restored in finally. The _USAGE string claims 'Every command is also installed as engraphis-' — true only if those entries already exist in pyproject.toml from earlier PRs. |
| engraphis/cloud_session.py | Added preflight_save() and _refresh_lock_path() refactor: logic is sound, preflight probe uses tempfile+unlink so no session is partially created, and the lock path is now computed consistently. |
| tests/test_device_connect.py | Comprehensive test suite: 43 new tests covering happy path, per-status error copy, token hygiene (no leakage to stdout/stderr/disk), pre-flight blocking, stable identity, and the dispatcher — well-isolated with an autouse fixture. |
| pyproject.toml | Adds engraphis and engraphis-connect script entry points; minimal and correct change. |
Sequence Diagram
sequenceDiagram
participant U as User
participant CLI as scripts/connect.py
participant DC as device_connect.connect()
participant CS as cloud_session
participant CP as Control Plane
U->>CLI: engraphis connect --token engr_ct_...
CLI->>DC: connect(token, control_url, compute_url, ...)
DC->>DC: _validated_timeout()
DC->>DC: normalize_connect_token()
DC->>DC: _validated_control_url()
DC->>DC: validate_cloud_base_url(compute_url)
DC->>DC: client_identity() - write client_identity.json
DC->>CS: preflight_save() - mkstemp probe
CS-->>DC: session_path (write confirmed)
DC->>CP: POST /v1/devices/connect
CP-->>DC: 200 DeviceRegistrationResponse
DC->>DC: check refresh_credential present
DC->>CS: save_bootstrap(response, control_url, compute_url)
CS-->>DC: cloud_session.json written 0600
DC->>DC: summarize(response) - secrets excluded
DC-->>CLI: summary dict
CLI->>CS: configured() - verify session usable
CLI-->>U: Connected. Next steps: engraphis-dashboard
Reviews (3): Last reviewed commit: "Address connect review: manifest compute..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aae7d939b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…aces Three unresolved review threads on #75, plus one semantic conflict the merge with main could not see. `default_compute_url` now reads the manifest's `compute_plane` and falls back to `DEFAULT_COMPUTE_URL`. The review asked for the constant to be replaced outright, but the shipped `engraphis-commercial/v2` manifest declares only `control_plane`: reading the absent key alone resolves `""`, and `cloud_session.configured()` then rejects the session a production connect just saved, leaving a paying customer "connected" but unusable. Preferring the key removes the drift point the review named -- a manifest-only endpoint change needs no code change -- while the constant stays as the fallback that keeps today's manifest working. `_validated_timeout` refuses a non-finite or non-positive timeout. `argparse`'s `type=float` accepts `nan` and `inf`, which reach the pinned opener's deadline arithmetic and raise `ValueError`/`OverflowError` from inside `urllib`, neither caught by `post_connect`. That broke this module's contract that every failure is a `DeviceConnectError`, so `engraphis connect --timeout nan` printed a traceback instead of the command's error and exit code. Checked at the top of `connect()` so a bad flag is reported as a bad flag rather than masked by whatever the identity or storage pre-flight hits first, and again in `post_connect()` because it is public and owns the socket call. `connect()` now also translates `OSError` from `save_bootstrap`. The pre-flight added in aae7d93 closes the common case, but the state directory can still change between the pre-flight and the write, and `UnsafeStateFile` is an `OSError`, not a `CloudSessionError`, so it escaped as a traceback at the worst moment. The token is spent by then, so the copy says so instead of promising a retry with the same one. The `REGISTRATION` fixture no longer claims the cloud grants `export`. #76 removed that key from the plan->feature tables in both repos because the signed compliance export was never implemented; a fixture asserting the control plane returns it would re-establish exactly the drift that removal cleaned up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9d77f4f728
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6084dfd0b9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Coding-Dev-Tools
left a comment
There was a problem hiding this comment.
Review — Add engraphis connect --token so a purchased client can actually connect
✅ LGTM — closes a critical gap: save_bootstrap() had zero production callers, making every paid feature undeliverable.
Credential handling is correct: token in request body only, never printed/logged/persisted/quoted in errors. Malformed tokens rejected before any network call. The pinned HTTPS opener with redirect blocking and explicit timeout matches the existing pattern.
Pre-flight storage check (commit aae7d93) is a strong addition — spending the single-use token and then failing the write would strand the customer. The UnsafeStateFile translation into DeviceConnectError with the path to fix is actionable. The six tests asserting opener.calls == [] prove the POST is never issued on pre-flight failure.
Client identity as random ULIDs in the owner-only state directory is the right call over hardware fingerprints — avoids cross-account tracking and handles NIC changes correctly.
Compute URL resolution (manifest key → env → shipped default only when control is shipped) avoids silently half-connecting customers on custom deployments.
The follow-up commits addressing review findings (manifest compute endpoint, nan/inf timeout validation, OSError translation from save_bootstrap, IncompleteRead and CloudUrlUnresolved handling) are each correct and well-scoped.
CI: 1452 passed, 0 failed (+43 new tests). ruff clean.
One note: the test_create_fails_loudly_when_force_graph_is_unavailable flake mentioned in PR #74 is unrelated but worth tracking in CI — if it starts failing on this branch too, it is a subprocess timing issue on Windows, not a regression here.
🔍 Pre-PR Code Review — ✅ LGTM (approve blocked: own PR)Reviewer: Pre-PR Code Analyzer (automated) SecurityExceptional. Token never appears in logs/stdout/stderr/files. Redirects blocked. HTTPS pinned. Provider bodies never reflected. Preflight before token spend. Session file 0600. Client identity is random ULID. LogicNo bugs found. Timeout validation rejects nan/inf/≤0. Post-redemption failures properly mapped. Compute URL resolution correctly scoped. Tests43 new tests (958 lines) for ~902 lines of production code. Covers happy path, HTTP error mapping, token leak prevention, malformed tokens, preflight storage, post-redemption failures, transport, response validation, and CLI dispatch. Verdict: LGTM — ready to mergeSecurity-first design, thorough test coverage, all CI green (11 checks). Minor non-blocking: shell-history token exposure (documented trade-off with |
…nnect `cloud_session.save_bootstrap()` is the only function that writes `~/.engraphis/cloud_session.json`, and it had zero production callers — only tests reached it. Meanwhile `docs/AGENT_CONNECT.md` and `.env.example` told customers to prefer that file. Nothing created it, so a paying customer had no supported way to connect a client and every paid feature was undeliverable. This adds the client half of the device-connect flow that the control plane already serves: - `engraphis/device_connect.py` — token validation, stable client identity, the POST to `/v1/devices/connect`, status-keyed error copy, and the call into `save_bootstrap`. - `scripts/connect.py` — the CLI, following `scripts/init.py` conventions (`main(argv=None) -> int`, argparse, no prompts). - `scripts/entry.py` — an `engraphis` front door that dispatches to the existing `engraphis-<verb>` mains, so the portal's `engraphis connect --token ...` is a runnable command. `engraphis-connect` is installed too. Credential handling: the connect token travels in the request body and nowhere else — never printed, logged, or persisted, and never quoted back in an error. Malformed or truncated tokens are rejected before any network call, so a bad paste costs no rate budget and consumes no token. Network access uses the repo's pinned opener with redirects blocked and an explicit timeout; provider bodies are never reflected into messages. A 401 (expired / already used / invalid, which the service deliberately makes indistinguishable) stays distinct from a 402 lapsed subscription, because the fixes differ. Client ids are minted-once random ULIDs in the owner-only state directory rather than a hardware fingerprint: a MAC- or hostname-derived id is a cross-account identifier the customer never agreed to ship, and it changes under exactly the conditions where stability was the point. The command verifies `cloud_session.configured()` after writing, and says so explicitly when only the compute endpoint is missing rather than half-reporting success. Docs updated: `docs/AGENT_CONNECT.md` and `.env.example` now describe `engraphis connect --token` as the real path to the session file. Tests: 43 new, covering the happy path writing a usable session, 401 printing actionable copy and writing nothing, 402 staying distinguishable, the token never appearing in stdout/stderr or any written file, and short/malformed tokens being rejected before the network. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`connect()` spent the single-use connect token on the POST and only then called `save_bootstrap()`. If the state directory had become unwritable, or `cloud_session.json` had been replaced by a symlink, a hard link or a directory, the write failed after the token was already consumed: the customer was left with a spent token and no session, and had to go back to the portal for a new one. Worse, `atomic_private_text` raises `UnsafeStateFile`, which is an `OSError` and not a `CloudSessionError`, so it escaped `connect()`'s handler entirely and surfaced as a raw traceback rather than actionable copy. `cloud_session.preflight_save()` now runs before the exchange. It lives beside `_session_path()`, `_refresh_lock()` and `_save()` because that module owns the paths and the atomic write it predicts; a private copy of those rules in `device_connect` would drift from the save it is meant to model. It reuses the existing primitives -- `private_file_stat` on the session leaf and on its refresh lock, plus the randomized sibling temp file `atomic_private_text` has to create -- and never opens, creates or replaces the session leaf, so an existing session survives a failed pre-flight untouched and a first connect is not left half written. `_refresh_lock()` picks up the extracted `_refresh_lock_path()` so the lock filename is not duplicated. `device_connect._preflight_session_storage()` translates the failure into a `DeviceConnectError` that names the path to fix and states that the token is still redeemable, and `connect()` reuses the returned path for the summary instead of rebuilding it from `_state_dir()`. Tests: 6 new, all asserting `opener.calls == []` -- the POST is never issued. Session leaf and refresh lock replaced by a directory, a genuinely hard-linked session, an uncreatable state directory (`ENGRAPHIS_STATE_DIR` under a regular file), a read-only mount, and a pre-flight that creates nothing and leaves no probe file behind. All six fail against the pre-fix code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aces Three unresolved review threads on #75, plus one semantic conflict the merge with main could not see. `default_compute_url` now reads the manifest's `compute_plane` and falls back to `DEFAULT_COMPUTE_URL`. The review asked for the constant to be replaced outright, but the shipped `engraphis-commercial/v2` manifest declares only `control_plane`: reading the absent key alone resolves `""`, and `cloud_session.configured()` then rejects the session a production connect just saved, leaving a paying customer "connected" but unusable. Preferring the key removes the drift point the review named -- a manifest-only endpoint change needs no code change -- while the constant stays as the fallback that keeps today's manifest working. `_validated_timeout` refuses a non-finite or non-positive timeout. `argparse`'s `type=float` accepts `nan` and `inf`, which reach the pinned opener's deadline arithmetic and raise `ValueError`/`OverflowError` from inside `urllib`, neither caught by `post_connect`. That broke this module's contract that every failure is a `DeviceConnectError`, so `engraphis connect --timeout nan` printed a traceback instead of the command's error and exit code. Checked at the top of `connect()` so a bad flag is reported as a bad flag rather than masked by whatever the identity or storage pre-flight hits first, and again in `post_connect()` because it is public and owns the socket call. `connect()` now also translates `OSError` from `save_bootstrap`. The pre-flight added in aae7d93 closes the common case, but the state directory can still change between the pre-flight and the write, and `UnsafeStateFile` is an `OSError`, not a `CloudSessionError`, so it escaped as a traceback at the worst moment. The token is spent by then, so the copy says so instead of promising a retry with the same one. The `REGISTRATION` fixture no longer claims the cloud grants `export`. #76 removed that key from the plan->feature tables in both repos because the signed compliance export was never implemented; a fixture asserting the control plane returns it would re-establish exactly the drift that removal cleaned up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both paths fail *after* the control plane has consumed the single-use connect token, so a raw traceback left the customer unable to tell whether to retry or fetch a new token from the portal. `http.client.IncompleteRead` (a truncated chunked reply) subclasses `HTTPException`/`ValueError` -- neither `OSError` nor `URLError` -- so it escaped both of `post_connect`'s handlers. A third clause is added *after* the transport clause, because `RemoteDisconnected` is both a `ConnectionResetError` and a `BadStatusLine` and means the peer never answered: that one keeps the retryable 503 copy, and a test pins the ordering. `save_bootstrap` re-runs `validate_cloud_base_url` on both endpoints, which resolves the host. A resolver that dies mid-connect raises `CloudUrlUnresolved` and an endpoint that starts resolving to a rejected address raises a bare `ValueError`; both are `ValueError`, so neither the `CloudSessionError` nor the `OSError` handler in `connect()` covered them. Translated in `connect()` rather than by dropping the re-validation, which is still wanted for `save_bootstrap`'s other callers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d stop advising a retry after a spent token
Three review findings on `engraphis/device_connect.py`, each reproduced as a failing
test first.
1. The `HTTPError` drain was guarded with `except (OSError, ValueError)`.
`http.client.IncompleteRead.__mro__` is `(IncompleteRead, HTTPException, Exception,
BaseException, object)` -- it is neither an `OSError` nor a `ValueError`, so a
truncated chunked error body escaped the guard. Because the drain runs *inside* an
`except` block, the sibling `except http.client.HTTPException` clause could not catch
it either, and the customer got a raw traceback instead of the status-specific copy.
The guard now names `HTTPException`.
The comments at both call sites asserted the false premise that `IncompleteRead`
"subclasses HTTPException/ValueError"; both are corrected, and a new test pins the
real MRO so the claim cannot drift back.
2. `_validated_timeout` accepted any finite positive float, but `socket.settimeout`
converts it to an absolute deadline: `1e9` and above raise `OverflowError`
("timeout doesn't fit into C timeval" / "timestamp out of range for platform
time_t") from inside `urllib`, which `post_connect` does not catch -- so
`--timeout 10000000000` printed a traceback. Added a 3600s ceiling, asserted against
a real socket so the constant cannot drift into the overflowing range.
3. A parsed 200 with no `refresh_credential` told the customer to "Try again." The 200
already consumed the single-use token, so retrying deterministically returns 401 and
still saves nothing. The copy now says the token has been used and a new one is
needed from the portal, matching the truncated-reply path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6084dfd to
19e89e9
Compare
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 19e89e963e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Found while fixing the device-connect drain: `_post_refresh` carried the identical `except (OSError, ValueError)` guard around its error-body drain, and had no `http.client.HTTPException` clause at all -- it did not even import `http.client`. Two consequences, both reproduced as failing tests first: - A refresh reply that begins and then stops mid-body makes `HTTPResponse.read` raise `IncompleteRead`, which is not an `OSError`, so it escaped `_post_refresh` as a raw traceback. This is the token-refresh path every paid feature runs through, so the crash surfaced far from its cause. - `IncompleteRead` raised while draining an `HTTPError` body escaped the drain guard and replaced the status-specific copy (401 "connect this installation again", 403, 429) with a traceback. The drain guard is now the shared `_DRAIN_FAILURES` tuple, and a new `HTTPException` clause maps a truncated reply to the existing "temporarily unreachable" copy. That clause sits deliberately *after* the transport clause so `RemoteDisconnected` -- which is both a `ConnectionResetError` and a `BadStatusLine` -- keeps the behaviour it already had. In scope for this PR: it already modifies `cloud_session.py`, and `device_connect`'s drain comment cites this function as the same shape, which was only true of the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
Review round addressed in All three threads were correct and are fixed; each was reproduced as a failing test before the change rather than fixed on inspection:
One extra bug found while fixing (1), fixed here. The comment on the connect drain cited Two review comments had asserted the false premise that Offline gate, both rows measured locally on the same base: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 86b77e8779
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…sion before printing success Second review round, same family as the first: a failure that can only happen after the control plane answered must never advise retrying a token that answer consumed. - `post_connect`'s oversized / unparseable / non-object body branches said only "invalid connect response". `urllib` raises `HTTPError` for every status >= 400 and `_NoRedirect` turns a 3xx into one, so reaching those branches means a 2xx -- the token is gone. They now carry the same portal guidance as the truncated-reply and missing-credential paths, via a shared `_SPENT_TOKEN_SUFFIX`. - `save_bootstrap` failing with `CloudSessionError` forwarded the bare message. That is how `_refresh_lock` reports a lock that vanished or turned unsafe after the pre-flight, and because it is a `CloudSessionError` rather than an `OSError` it bypassed the clause that explains the token was spent. It now keeps the cause *and* the guidance. - `scripts/connect.py` printed the summary before calling `cloud_session.configured()`. That call reads the session back and can raise, so a complete `--json` success object reached stdout and the command then exited 1 -- a consumer parsing stdout accepted a failed connect. The check now runs first; on failure stdout stays empty. All three reproduced as failing tests first (7 new cases). The oversized case carries an explicit `pytest.param(id=...)` because a 64 KiB parameter overflows the 32767-character limit on `PYTEST_CURRENT_TEST`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9974ae2329
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…e preflight when a probe cannot be removed Third review round. `OSError` means opposite things either side of `opener.open()`, and both call sites had them sharing one `try`. - `device_connect.post_connect` now opens and reads in separate `try` blocks. Once `open` returns, urllib has parsed a success status line, so the control plane answered and the single-use token is spent; a `TimeoutError` or `ConnectionResetError` *mid-body* was inheriting the connection-phase copy and telling the customer to retry a token that could not work. Failures before the status line keep the retry copy, which is still right for them. - `cloud_session._post_refresh` gets the same split, with sharper consequences. The rotated credential reaches disk only after the body parses, so a post-response failure leaves the *spent* credential stored. `_public_session_error` maps 503 to `transient=True`, and `CloudFeatureClient.run_job` acts on that by retrying — which resubmits the spent credential, and this module already documents that the control plane answers replay by revoking the whole credential family. Every post-response branch (truncated body, oversized, unparseable, non-object, incomplete credentials) now returns 409, the existing non-transient "connect this installation again" bucket. A test asserts the status still maps to `transient is False`, since that mapping is the thing that actually prevents the retry. This prefers a false "reconnect" over a replay: if the truncation preceded the server's commit the reconnect was unnecessary, but the opposite mistake revokes the whole family and forces the same reconnect from a worse state. - `cloud_session.preflight_save` no longer swallows a failed `os.unlink` of its probe. Creating a file and removing one are separate rights: a directory ACL granting add-file but denying delete let `mkstemp` succeed and `unlink` fail, and `atomic_private_text` finishes with `os.replace`, which needs exactly the right that just failed. The preflight passed on a directory the real save could not use, redeeming the token before failing, and littered a probe on every attempt — the precise drift the preflight exists to prevent. Ten new test cases; every one failed before its fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd8258bd48
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`str(response.get(key) or "").strip()` reads like a coercion but is not a validation. JSON
arrays and objects arrive as Python `list`/`dict`, and `str()` renders their repr:
str(["tok_abc"] or "").strip() -> "['tok_abc']" # truthy, non-empty
So a control-plane reply carrying `"refresh_credential": ["tok_abc"]` passed the
missing-credential check, `save_bootstrap` persisted the literal text `['tok_abc']`,
`configured()` read it back as a usable session, and the CLI reported a successful
connection -- while the next refresh submitted that junk and failed. By then the single-use
connect token was spent, so the customer could not retry without a new one.
Adds `cloud_session.text_field()`, which returns the value only when it is genuinely a
string, and uses it at every untrusted-provider boundary: `save_bootstrap`, the rotation
handling in `access_for_workspace`, and the pre-save check in `device_connect.connect` --
the last one via the same helper the writer uses, so the two cannot disagree about what
counts as present.
Identity fields (`installation_id`, `device_id`, `member_id`, `refresh_expires_at`) are
dropped rather than stored as a repr, since the dashboard displays them verbatim.
Six new cases covering list / dict / int / bool credentials, the writer boundary directly,
and the identity fields; all failed before the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 43655a789b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4a113af4e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 04f8b6534a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9c2f68002
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Coding-Dev-Tools has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
* feat(dashboard): opt-in canvas graph engine behind ?graph-engine=next A second knowledge-graph renderer wrapping the already-vendored force-graph, opt-in behind ?graph-engine=next. Classic stays the default and the rollback path; graphRender falls through to it on any failure. Four styles are applied CSP-safely through a data-graph-style attribute rather than inline style. Fixed while reviewing, before this shipped: - XSS. setData stamped name on every node, and force-graph's nodeLabel defaults to that accessor and renders through innerHTML. Node names are entity labels extracted from ingested memories. Classic never set name, so the sink did not exist until this feature added it. Both accessors are now explicit and escaped. - Recursive Tarjan overflowed the stack at roughly 29.6k nodes on a chain component; rewritten iteratively. - The rAF loop never stopped: a hidden canvas kept repainting for the whole session. Added pause/resume driven from selectView and a real destroy(). - Unbounded O(V*E) betweenness on the main thread with Array.shift queues, now lazy, pivot-sampled and budgeted: 60k nodes went 25s to 3s, and 0s in the default view. - prefers-reduced-motion was ignored, against an existing dashboard contract. The CSP drift gate never scanned this asset -- it only ever read index.html, dashboard.css and dashboard.js -- so "the gate passes" was vacuous for any new first-party script. Extended to hold them to the same contract and to check that every /static script index.html references actually exists. All four new rules were negative-tested. The original test asserted formatting strings. Replaced with 24 tests that execute the asset under Node with only a bare window in scope, which is itself the load-order proof, and mutation-tested: removing the escaping fails them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(dashboard): lazy-load the graph assets so the CSP stays clean Adding force-graph.min.js and engraphis-graph.js to index.html loaded them on every dashboard page, not just the graph view. force-graph applies inline styles at runtime, the production CSP is style-src 'self', so the browser blocked them and logged an error per attempt. Eleven pre-existing e2e tests assert a clean console and failed -- a regression this branch introduced. dashboard.js already had loadForceGraph() lazily fetching the vendor bundle for the Classic renderer, so neither script ever needed to be in index.html. Both tags are removed and engraphis-graph.js gets a matching memoized loader. The naive version of that would silently downgrade a ?graph-engine=next deep link to Classic, because graphRenderEngine's guard cannot tell "not fetched yet" from "unavailable". The engine fetch now starts alongside the vendor bundle -- one round trip, not two -- and graphRender waits on it and re-enters, so only a genuine load failure degrades, and that path warns. Proved by executing the real routing source under Node with a stub DOM: deep link renders the engine, a failing asset falls back and warns. Both are tests, and reverting to defer-without-await fails them. The "referenced scripts exist" gate rule became three, so removing the tags strengthens it rather than hollowing it out: lazy script.src references are existence-checked (their only reference is a JS string literal), neither asset may reappear as a parsed script src in index.html, and each must keep a lazy loader so deferring cannot orphan them. All negative-tested. Local gate: 1429 passed, 0 failed. The CSP fix itself cannot be proven locally -- Playwright needs node_modules, which is not installed here -- so CI is the real verification for the 11 e2e assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(graph): address review findings on the opt-in engine Six findings from PR #74 review. Particles were assigned per relation with no regard for graph size, so the Cyber style with flow enabled created tens of thousands of animated particles on a large workspace. Capped against the existing large-graph signal. Community clustering added every link to the adjacency, so one sparse influences relation merged two unrelated topics into a single component and Community Islands gave them the same colour and force centre. Matched to the classic renderer's semantics. Hover changed hilite/hoverSet without invalidating the canvas, so with reduced motion on, flow off, or the simulation settled, highlight changes were invisible. The redraw is now requested. Show unlinked nodes never reached the engine: graphData() supplied degree-zero nodes while the engine's own filter still dropped them. Leaving the graph view before /graph or a lazy script resolved ran pause against a null renderer, so the pending callback could still create and start one against a hidden pane -- the rAF leak this PR fixed once already, by another route. The CSP gate parsed script tags case-sensitively, so an equivalent HTML spelling browsers load eagerly slipped past both the eager-load rejection and the existence check. Now uses the HTMLParser already in the file, which normalises case. 1441 passed, 0 failed (+12 tests). All asset gates pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(graph): rank communities, settle large galaxy graphs, follow the theme Three further findings from PR #74 review. Community IDs were assigned in raw payload order while graphRenderLegend() sorts communities by size and calls the largest "Cluster 1". Node colour indexes the palette by the ID itself, so whenever a smaller component appeared first the legend described one component with another's swatch. Components are now ranked by size before the IDs escape, matching the classic graphComputeCommunities(). Galaxy style held autoPauseRedraw(false) unconditionally, so a settled large graph repainted every node and link every frame forever. The starfield is the only paint force-graph cannot see; the classic path drops it past GPERF.large, so the same 600-node/2400-link signal now skips it and hands the redraw loop back to the vendor. Type colours resolved from hard-coded dark-theme constants, so switching to Light, Midnight, Solarized or Sepia moved the legend and controls while the canvas stayed dark. The engine cannot read CSS custom properties from a canvas, so the dashboard resolves --entity-* and supplies them through a new setThemeColors(), below style palettes and user overrides in priority. 1446 passed, 0 failed (+5 tests). ruff, externalize and manifest gates pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(licensing): make the outage-is-not-a-cancellation test actually test that The test raised CloudSessionError(..., status=503, transient=True), but that class takes only status -- transient belongs to CloudFeatureError. So the construction raised TypeError, the caller's broad except Exception swallowed it, and getattr(exc, "status", None) was None rather than 503. The assertion therefore passed while never exercising a transport failure at all, and could not have caught the regression it exists to prevent: loosening the 402 gate so any error clears a paying customer's access. Verified by mutation -- with the gate changed to any-truthy-status the test now fails with "an outage revoked a paying customer"; before this change it passed even with that mutation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(graph): close the remaining review threads on the opt-in engine Six findings from the PR #74 review, all cases where the opt-in renderer diverged from the classic path it is meant to replace. - Relation labels: the dashboard's Labels checkbox drives two label layers on the classic path, but the engine configured no linkCanvasObject, so ticking it painted entity names only and a relation could be read only by hovering it. Paint them, with the classic gates (zoom, dense-graph). - zoomToNode: graphFocus() treats a false return as "run the recovery path". The engine answered from raw.nodes, which keeps the coordinates force-graph left on a node, so an entity hidden by a scope filter or by the auto-collapsed cluster view still reported success and the camera moved to nothing. Expand a collapsed view, then verify against the data force-graph is actually holding. - Reseeding: render() called graphData() unconditionally with newly allocated arrays, so Style, Color by, Labels and Flow each reset the d3 alpha and restarted the layout. Reseed only when the visible set really changed, and invalidate the paint when it did not. - Cooldown: nothing overrode force-graph's 15s default simulation window, so a large store kept simulating (and repainting) far past the 1.1s/80 ticks the classic path allows. Mirror its size-dependent bounds. - Dense graphs: curvature and arrowheads stayed on past the classic GPERF.dense threshold, paying a bezier and a filled triangle per relation across thousands of links. - Community colours: the cluster legend swatches are CSS (.graph-cluster-N) encoding the Cyber palette, but the engine's Cyber and Solar palettes were ordered differently from dashboard.js, so on the default style the legend described each of the first clusters with another cluster's colour. Each is covered by a test that fails without the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(graph): reheat the simulation when a physics slider moves Under ?graph-engine=next the Repel/Link/Gravity/Size sliders appeared dead. dashboard.js::graphSet routes them through api.setSettings, which only asked render() for a reheat when `mode` was present. applyForces() writes the new charge / link / forceX-forceY / collide values straight into the simulation force-graph is already running, and a settled graph sits at alpha~0 — so the new forces were installed and nothing moved until the user found the Reheat button. `size` is affected too: it feeds d3.forceCollide. The classic branch of that same function does the opposite — it treats `repel|link|gravity|size` as a layout change and reheats unless the user asked for reduced motion. setSettings now matches that set (plus `mode`, which swaps the whole force arrangement); render() already carries the reduced-motion exemption, so it is honoured for free. Appearance-only settings (font, link width, label density, labels, flow) still must not reheat: restarting the layout because a label got bigger throws away the arrangement being read. The regression test drives the API against the force-graph stand-in and counts d3ReheatSimulation() calls per key. Pre-fix it reported {repel: 0, link: 0, gravity: 0, size: 0, mode: 1}. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(licensing): settle cached access on entitlement 402 * Add `engraphis connect --token` so a purchased client can actually connect `cloud_session.save_bootstrap()` is the only function that writes `~/.engraphis/cloud_session.json`, and it had zero production callers — only tests reached it. Meanwhile `docs/AGENT_CONNECT.md` and `.env.example` told customers to prefer that file. Nothing created it, so a paying customer had no supported way to connect a client and every paid feature was undeliverable. This adds the client half of the device-connect flow that the control plane already serves: - `engraphis/device_connect.py` — token validation, stable client identity, the POST to `/v1/devices/connect`, status-keyed error copy, and the call into `save_bootstrap`. - `scripts/connect.py` — the CLI, following `scripts/init.py` conventions (`main(argv=None) -> int`, argparse, no prompts). - `scripts/entry.py` — an `engraphis` front door that dispatches to the existing `engraphis-<verb>` mains, so the portal's `engraphis connect --token ...` is a runnable command. `engraphis-connect` is installed too. Credential handling: the connect token travels in the request body and nowhere else — never printed, logged, or persisted, and never quoted back in an error. Malformed or truncated tokens are rejected before any network call, so a bad paste costs no rate budget and consumes no token. Network access uses the repo's pinned opener with redirects blocked and an explicit timeout; provider bodies are never reflected into messages. A 401 (expired / already used / invalid, which the service deliberately makes indistinguishable) stays distinct from a 402 lapsed subscription, because the fixes differ. Client ids are minted-once random ULIDs in the owner-only state directory rather than a hardware fingerprint: a MAC- or hostname-derived id is a cross-account identifier the customer never agreed to ship, and it changes under exactly the conditions where stability was the point. The command verifies `cloud_session.configured()` after writing, and says so explicitly when only the compute endpoint is missing rather than half-reporting success. Docs updated: `docs/AGENT_CONNECT.md` and `.env.example` now describe `engraphis connect --token` as the real path to the session file. Tests: 43 new, covering the happy path writing a usable session, 401 printing actionable copy and writing nothing, 402 staying distinguishable, the token never appearing in stdout/stderr or any written file, and short/malformed tokens being rejected before the network. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Pre-flight session storage before redeeming the connect token `connect()` spent the single-use connect token on the POST and only then called `save_bootstrap()`. If the state directory had become unwritable, or `cloud_session.json` had been replaced by a symlink, a hard link or a directory, the write failed after the token was already consumed: the customer was left with a spent token and no session, and had to go back to the portal for a new one. Worse, `atomic_private_text` raises `UnsafeStateFile`, which is an `OSError` and not a `CloudSessionError`, so it escaped `connect()`'s handler entirely and surfaced as a raw traceback rather than actionable copy. `cloud_session.preflight_save()` now runs before the exchange. It lives beside `_session_path()`, `_refresh_lock()` and `_save()` because that module owns the paths and the atomic write it predicts; a private copy of those rules in `device_connect` would drift from the save it is meant to model. It reuses the existing primitives -- `private_file_stat` on the session leaf and on its refresh lock, plus the randomized sibling temp file `atomic_private_text` has to create -- and never opens, creates or replaces the session leaf, so an existing session survives a failed pre-flight untouched and a first connect is not left half written. `_refresh_lock()` picks up the extracted `_refresh_lock_path()` so the lock filename is not duplicated. `device_connect._preflight_session_storage()` translates the failure into a `DeviceConnectError` that names the path to fix and states that the token is still redeemable, and `connect()` reuses the returned path for the summary instead of rebuilding it from `_state_dir()`. Tests: 6 new, all asserting `opener.calls == []` -- the POST is never issued. Session leaf and refresh lock replaced by a directory, a genuinely hard-linked session, an uncreatable state directory (`ENGRAPHIS_STATE_DIR` under a regular file), a read-only mount, and a pre-flight that creates nothing and leaves no probe file behind. All six fail against the pre-fix code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address connect review: manifest compute endpoint, timeout and save races Three unresolved review threads on #75, plus one semantic conflict the merge with main could not see. `default_compute_url` now reads the manifest's `compute_plane` and falls back to `DEFAULT_COMPUTE_URL`. The review asked for the constant to be replaced outright, but the shipped `engraphis-commercial/v2` manifest declares only `control_plane`: reading the absent key alone resolves `""`, and `cloud_session.configured()` then rejects the session a production connect just saved, leaving a paying customer "connected" but unusable. Preferring the key removes the drift point the review named -- a manifest-only endpoint change needs no code change -- while the constant stays as the fallback that keeps today's manifest working. `_validated_timeout` refuses a non-finite or non-positive timeout. `argparse`'s `type=float` accepts `nan` and `inf`, which reach the pinned opener's deadline arithmetic and raise `ValueError`/`OverflowError` from inside `urllib`, neither caught by `post_connect`. That broke this module's contract that every failure is a `DeviceConnectError`, so `engraphis connect --timeout nan` printed a traceback instead of the command's error and exit code. Checked at the top of `connect()` so a bad flag is reported as a bad flag rather than masked by whatever the identity or storage pre-flight hits first, and again in `post_connect()` because it is public and owns the socket call. `connect()` now also translates `OSError` from `save_bootstrap`. The pre-flight added in aae7d93 closes the common case, but the state directory can still change between the pre-flight and the write, and `UnsafeStateFile` is an `OSError`, not a `CloudSessionError`, so it escaped as a traceback at the worst moment. The token is spent by then, so the copy says so instead of promising a retry with the same one. The `REGISTRATION` fixture no longer claims the cloud grants `export`. #76 removed that key from the plan->feature tables in both repos because the signed compliance export was never implemented; a fixture asserting the control plane returns it would re-establish exactly the drift that removal cleaned up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Report post-redemption connect failures instead of raising tracebacks Both paths fail *after* the control plane has consumed the single-use connect token, so a raw traceback left the customer unable to tell whether to retry or fetch a new token from the portal. `http.client.IncompleteRead` (a truncated chunked reply) subclasses `HTTPException`/`ValueError` -- neither `OSError` nor `URLError` -- so it escaped both of `post_connect`'s handlers. A third clause is added *after* the transport clause, because `RemoteDisconnected` is both a `ConnectionResetError` and a `BadStatusLine` and means the peer never answered: that one keeps the retryable 503 copy, and a test pins the ordering. `save_bootstrap` re-runs `validate_cloud_base_url` on both endpoints, which resolves the host. A resolver that dies mid-connect raises `CloudUrlUnresolved` and an endpoint that starts resolving to a rejected address raises a bare `ValueError`; both are `ValueError`, so neither the `CloudSessionError` nor the `OSError` handler in `connect()` covered them. Translated in `connect()` rather than by dropping the re-validation, which is still wanted for `save_bootstrap`'s other callers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Read the trial and the access state the cloud sends, and render them `/api/license` hardcoded `"is_trial": False` and `"trial": {"used": False}`. Those were the only assignments in the file, and the client read none of `status`, `trial_ends_at`, or `trial_duration_seconds` from anywhere, so three customer-visible states were wrong or unreachable: * the dashboard's `'TRIAL'` badge branch could never be taken — a trialist saw the same confident PRO/TEAM badge a subscriber sees; * `trial.used` never became true, so "Start hosted Pro/Team trial" was offered to everyone forever, including active subscribers and customers whose trial was already spent. `start_trial` refuses any organization that already holds an entitlement, so for every connected customer that button could only ever return a `TrialAlreadyConsumedError`; and * `cloud_access_active` was computed, returned, and rendered nowhere. After a trial expired or a subscription lapsed the client kept `plan="pro"` and cleared the feature list, so the customer got a confident PRO badge over rows of locked features with no explanation of what had happened. The client now reads what the control plane already sends on the calls it already makes. `cloud_session` persists `status`, `is_trial`, `trial_consumed`, and `trial_ends_at` from the registration/refresh handshake alongside the plan fields, under the same wire names so one parser serves the saved record, the entitlements read, and the compatibility cache. Every field stays individually optional: an older control plane omits them and the honest answer is "not a trial, none consumed", never an invented one. A billing denial keeps the trial facts — a lapse does not un-consume a trial or move its boundary — and drops the status it just contradicted. `/api/license` derives one `access_state` from that: active, trial, trial_expired, lapsed, or inactive. Each is a different thing to tell the customer and a different thing to ask them to do, and the dashboard now says which: * active trial — TRIAL badge, the trial's end date, no trial CTA; * spent trial — TRIAL ENDED, "your free trial has ended, your memories are still local and usable, the trial cannot be started again", subscribe; * paying — PRO/TEAM badge, no trial CTA (a subscriber who never trialled would still be refused one); * lapsed — PRO INACTIVE, why it lapsed when the cloud named a status, and update billing; * never — LOCAL CORE, the local engine is complete on its own, and the trial CTA — the one state it can actually be started in. `plan_source`/`plan_checked_at` were emitted for support and displayed nowhere; the panel now shows which rule produced the answer and when the cloud last confirmed it. No inline styles or handlers were added; both asset gates and `node --check` pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(connect): honor cloud-assigned compute endpoints * fix(graph): trim large graph render costs * fix(review): address open client release findings * fix: close final release hardening gaps * perf(graph): apply the classic renderer's large-graph guards to the opt-in engine The engine already computed `large` (>600 nodes / >2400 links, the classic `GPERF.large` signal) and used it for cooldown, alpha decay and the galaxy starfield, but two per-frame costs the classic path drops at that cutoff were still being paid: - `applyForces()` pinned `forceCollide().iterations(2)`. The second pass is another full quadtree traversal per node per tick, and a large store pays it on the initial layout and on every reheat. Now `iterations(large ? 1 : 2)`, matching `.iterations(GPERF.large?1:2)`. - Every `rich` node still got a glow: the galaxy halo, the solar corona *and* its sphere shading, and the cyber `shadowBlur`. A gradient is a fresh object per node and a blur is a convolution of the drawn shape, both per frame. All three are now gated on `!large`, with the flat `fillStyle = col` fallback the classic path uses for the solar sphere. Both are pinned by tests that drive the asset under Node and read the recorded force/canvas calls back, and both fail against the previous asset (601 collision iterations of 2; 607 gradients painted on a large solar graph). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(graph): exercise the opt-in engine in a real browser The Node harness in tests/test_graph_engine_asset.py drives the asset against a recording force-graph stand-in, which is right for the logic and proves nothing about a browser: no CSP, no real canvas, no vendor bundle, no <script> loading. The three defects this feature shipped and then fixed were all in that gap. This adds the browser half to the Playwright suite CI already runs (.github/workflows/ci.yml runs `npx playwright test`). Five checks, each pinning a claim this PR makes: - A dashboard page that never opens the graph fetches neither graph script and reports zero CSP violations. This is the actual reason both loads are lazy. - `?graph-engine=next` fetches both assets exactly once, registers EngraphisGraph, assigns GRAPH_ENGINE, hides the degree-zero entity, and puts non-uniform pixels on a sized canvas. - Moving the Repel slider after the layout has settled changes node coordinates. Verified by mutation: narrowing LAYOUT_KEYS back to ['mode'] fails this test, which is the dead-slider regression exactly as a user would have hit it. - The opt-in renderer adds no CSP violation the classic renderer does not already cause. Opening the graph is not CSP-clean under either: force-graph injects <style> elements that `style-src 'self'` blocks (4 of them, identical on both paths, one-time at attach). That is a pre-existing vendor behaviour, and confining it to the graph view is what the lazy loading buys. What this pins is that the new engine contributes nothing on top and touches no inline style attribute. - Without the flag the graph view stays on Classic and never fetches engraphis-graph.js. The force-graph instance is reached by intercepting the vendor global from an init script rather than by adding a test-only accessor to the engine's public API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix the same IncompleteRead escape in cloud_session._post_refresh Found while fixing the device-connect drain: `_post_refresh` carried the identical `except (OSError, ValueError)` guard around its error-body drain, and had no `http.client.HTTPException` clause at all -- it did not even import `http.client`. Two consequences, both reproduced as failing tests first: - A refresh reply that begins and then stops mid-body makes `HTTPResponse.read` raise `IncompleteRead`, which is not an `OSError`, so it escaped `_post_refresh` as a raw traceback. This is the token-refresh path every paid feature runs through, so the crash surfaced far from its cause. - `IncompleteRead` raised while draining an `HTTPError` body escaped the drain guard and replaced the status-specific copy (401 "connect this installation again", 403, 429) with a traceback. The drain guard is now the shared `_DRAIN_FAILURES` tuple, and a new `HTTPException` clause maps a truncated reply to the existing "temporarily unreachable" copy. That clause sits deliberately *after* the transport clause so `RemoteDisconnected` -- which is both a `ConnectionResetError` and a `BadStatusLine` -- keeps the behaviour it already had. In scope for this PR: it already modifies `cloud_session.py`, and `device_connect`'s drain comment cites this function as the same shape, which was only true of the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Say the token was spent on every post-2xx failure, and verify the session before printing success Second review round, same family as the first: a failure that can only happen after the control plane answered must never advise retrying a token that answer consumed. - `post_connect`'s oversized / unparseable / non-object body branches said only "invalid connect response". `urllib` raises `HTTPError` for every status >= 400 and `_NoRedirect` turns a 3xx into one, so reaching those branches means a 2xx -- the token is gone. They now carry the same portal guidance as the truncated-reply and missing-credential paths, via a shared `_SPENT_TOKEN_SUFFIX`. - `save_bootstrap` failing with `CloudSessionError` forwarded the bare message. That is how `_refresh_lock` reports a lock that vanished or turned unsafe after the pre-flight, and because it is a `CloudSessionError` rather than an `OSError` it bypassed the clause that explains the token was spent. It now keeps the cause *and* the guidance. - `scripts/connect.py` printed the summary before calling `cloud_session.configured()`. That call reads the session back and can raise, so a complete `--json` success object reached stdout and the command then exited 1 -- a consumer parsing stdout accepted a failed connect. The check now runs first; on failure stdout stays empty. All three reproduced as failing tests first (7 new cases). The oversized case carries an explicit `pytest.param(id=...)` because a 64 KiB parameter overflows the 32767-character limit on `PYTEST_CURRENT_TEST`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Separate connection failures from post-response failures, and fail the preflight when a probe cannot be removed Third review round. `OSError` means opposite things either side of `opener.open()`, and both call sites had them sharing one `try`. - `device_connect.post_connect` now opens and reads in separate `try` blocks. Once `open` returns, urllib has parsed a success status line, so the control plane answered and the single-use token is spent; a `TimeoutError` or `ConnectionResetError` *mid-body* was inheriting the connection-phase copy and telling the customer to retry a token that could not work. Failures before the status line keep the retry copy, which is still right for them. - `cloud_session._post_refresh` gets the same split, with sharper consequences. The rotated credential reaches disk only after the body parses, so a post-response failure leaves the *spent* credential stored. `_public_session_error` maps 503 to `transient=True`, and `CloudFeatureClient.run_job` acts on that by retrying — which resubmits the spent credential, and this module already documents that the control plane answers replay by revoking the whole credential family. Every post-response branch (truncated body, oversized, unparseable, non-object, incomplete credentials) now returns 409, the existing non-transient "connect this installation again" bucket. A test asserts the status still maps to `transient is False`, since that mapping is the thing that actually prevents the retry. This prefers a false "reconnect" over a replay: if the truncation preceded the server's commit the reconnect was unnecessary, but the opposite mistake revokes the whole family and forces the same reconnect from a worse state. - `cloud_session.preflight_save` no longer swallows a failed `os.unlink` of its probe. Creating a file and removing one are separate rights: a directory ACL granting add-file but denying delete let `mkstemp` succeed and `unlink` fail, and `atomic_private_text` finishes with `os.replace`, which needs exactly the right that just failed. The preflight passed on a directory the real save could not use, redeeming the token before failing, and littered a probe on every attempt — the precise drift the preflight exists to prevent. Ten new test cases; every one failed before its fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: close latest release review gaps * Require credential fields to actually be strings `str(response.get(key) or "").strip()` reads like a coercion but is not a validation. JSON arrays and objects arrive as Python `list`/`dict`, and `str()` renders their repr: str(["tok_abc"] or "").strip() -> "['tok_abc']" # truthy, non-empty So a control-plane reply carrying `"refresh_credential": ["tok_abc"]` passed the missing-credential check, `save_bootstrap` persisted the literal text `['tok_abc']`, `configured()` read it back as a usable session, and the CLI reported a successful connection -- while the next refresh submitted that junk and failed. By then the single-use connect token was spent, so the customer could not retry without a new one. Adds `cloud_session.text_field()`, which returns the value only when it is genuinely a string, and uses it at every untrusted-provider boundary: `save_bootstrap`, the rotation handling in `access_for_workspace`, and the pre-save check in `device_connect.connect` -- the last one via the same helper the writer uses, so the two cannot disagree about what counts as present. Identity fields (`installation_id`, `device_id`, `member_id`, `refresh_expires_at`) are dropped rather than stored as a repr, since the dashboard displays them verbatim. Six new cases covering list / dict / int / bool credentials, the writer boundary directly, and the identity fields; all failed before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: declare version 1.1.0 Bump the five places the client version is written, all of which `tests/test_packaging.py` requires to agree: `pyproject.toml`, the `PackageNotFoundError` fallback in `engraphis/__init__.py`, the commercial manifest that `scripts/check_commercial_manifest.py` cross-checks against pyproject, and the plugin/marketplace pair under `.claude-plugin/`. Editing the two `.claude-plugin/*.json` files invalidates their entries in `.claude-plugin/skill-assets.sha256`, which `test_packaging.py` verifies by hashing the files on disk, so those two digests are regenerated here. All six files are LF, as `.gitattributes` requires. 1.1.0 rather than 1.0.2: the unreleased work adds commands and changes the managed-compute consent default, which is a minor bump. Note that 1.0.1 was declared and dated in the changelog but never tagged or published — PyPI's latest is 1.0.0 — so 1.1.0 is the first release to actually carry it. No tag is pushed by this commit; publishing stays a separate, deliberate step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(graph): honor theme and validate lazy assets * fix(graph): restore readable entity labels * fix: absorb current release review heads * fix(connect): harden ambiguous device activation failures * feat(graph): serve renderer from v2 dashboard * fix(graph): keep v2 assets lazy and themed * fix: close remaining client review gaps * test: pin ambiguous refresh timeout handling * fix(graph): bound label rendering density --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Superseded by #80, which consolidated this change with the complete audited 1.1.0 client release and is now merged into main. |
The problem
engraphis/cloud_session.py::save_bootstrap()is the only function that writes~/.engraphis/cloud_session.json, and it had zero production callers — only testsreached it. Meanwhile
docs/AGENT_CONNECT.mdand.env.exampletold customers to preferthat file. Nothing created it, so a paying customer had no supported way to connect a
client and every paid feature was undeliverable.
The server half already exists (engraphis-cloud #25:
POST /v1/devices/connect). This isthe client half.
CLI surface
--token TOKEN-reads one line from stdin, keeping the token out of shell history.--control-url URLENGRAPHIS_CLOUD_CONTROL_URL).--compute-url URLENGRAPHIS_CLOUD_COMPUTE_URL, or the shipped endpoint).--workspace WORKSPACE_ID--label TEXTinstallation_labelshown in the account portal.--device-name TEXTdevice_name(default: hostname).--timeout SECONDS--jsonExit
0on success,1on a handled failure,2on argparse misuse.New entry points in
[project.scripts]:engraphis = "scripts.entry:main"— the front door, so the string the account portaldisplays is runnable as shown. It is a dispatcher, not a second CLI: it rewrites
sys.argvand calls the same
main()each existingengraphis-<verb>script calls, lazily, soengraphis connectnever imports the memory/embedding stack.engraphis-connect = "scripts.connect:main"— matches the existingengraphis-*naming.Implementation
engraphis/device_connect.py— token validation, client identity, the POST,status-keyed error copy, and the call into
save_bootstrap. The 200 body is fed tosave_bootstrapverbatim.scripts/connect.py— argparse CLI followingscripts/init.pyconventions(
main(argv=None) -> int, non-interactive, no prompts). No new CLI framework.scripts/entry.py— the verb dispatcher.engraphis/cloud_session.py—preflight_save()so storage faults are found beforethe token is spent, plus the
IncompleteReadfix in_post_refreshdescribed below.Credential handling
The connect token travels in the request body and nowhere else — never printed, never
logged, never persisted, and never quoted back in an error message (a rejected token is
still a secret). Malformed, truncated, or wrong-prefix tokens are rejected before any
network call, so a bad paste costs no rate budget and consumes no token.
Transport
build_pinned_https_openerwith a redirect-blocking handler and an explicit timeout,exactly as
cloud_session.pyandupdate_check.pydo — no barebuild_opener, norequests.HTTPErrorbodies are drained and closed inside the handler (a siblingexceptwould not cover a raise from the handler itself). Provider bodies are untrustedand never reflected into messages.
--timeoutis bounded, not merely checked for finiteness.socket.settimeout()convertsits argument into an absolute deadline, so a large but finite value raises
OverflowErrorfrom inside
urllib— verified on Windows, where1e9already fails with "timeout doesn'tfit into C timeval" and
1e10with "timestamp out of range for platform time_t". Amath.isfinite()check alone therefore still let--timeout 10000000000print a traceback.The ceiling is 3600s: far more than one interactive POST can need, far below the first
value any supported platform rejects.
Error mapping
401402<upgrade_url>" — kept distinct from401, because a lapsed subscription reported as a bad token sends the customer to the wrong page forever.403/404/422/429/503Nothing is written unless the exchange succeeded.
Failures after the control plane has answered are copy-critical, because a 2xx consumes
the single-use token as it is written.
urllibraisesHTTPErrorfor every status ≥ 400 and_NoRedirectturns a 3xx into one, so any fault pastopener.open()follows a successstatus. Every such path therefore says the token is gone and sends the customer to the portal
rather than advising a retry that would deterministically 401 — sharing one
_SPENT_TOKEN_SUFFIXso the copy cannot drift apart:refresh_credential;save_bootstrap(wrapped in
CloudSessionError, so it never reached theOSErrorclause that carried thewarning).
The CLI verifies
cloud_session.configured()before emitting any success output. Thatcall reads the session back and can raise, so while it ran last a complete
--jsonsuccessobject reached stdout and the command then exited 1 — a consumer parsing stdout accepted a
failed connect. On failure stdout is now empty and only stderr carries the message.
http.client.IncompleteReadis not anOSErrorWorth stating explicitly, because two comments in this PR previously asserted the opposite
and one guard was written on that false premise:
It is only an
HTTPException. Two consequences, both now fixed and both regression-tested:HTTPErrorerror-body drain was guarded withexcept (OSError, ValueError), whichcould never catch it. Because the drain runs inside an
exceptblock, the siblingexcept http.client.HTTPExceptionclause of the sametrywas unreachable for it too, soa truncated chunked error body replaced the status copy with a raw traceback.
engraphis/cloud_session.py::_post_refreshcarried the same guard and had noHTTPExceptionclause at all — it did not importhttp.client. That is the token-refreshpath every paid feature runs through, so a truncated refresh reply crashed far from its
cause. Fixed here since this PR already modifies that file.
A test now pins the real MRO so the false claim cannot come back.
OSErrormeans opposite things either side ofopener.open()IncompleteReadwas the symptom; this was the cause. Both call sites opened the connectionand read the body inside one
try, so aTimeoutErrororConnectionResetErrorraisedmid-body — after urllib had already parsed a success status line — was reported with the
connection-phase copy. Both now split the two phases:
post_connect: a body-read failure means the token is spent, so it reports thatinstead of "try again". A reset before any reply still gets the retry copy, since
RemoteDisconnectedgenuinely means nothing was consumed — pinned by its own test so thesplit cannot over-correct.
_post_refresh: sharper consequences. The rotated credential reaches disk only afterthe body parses, so a post-response failure leaves the spent credential stored.
_public_session_errormaps 503 totransient=True,CloudFeatureClient.run_jobacts onthat by resubmitting, and this module already documents that the control plane answers a
replayed credential by revoking the whole credential family. Every post-response branch
(truncated, oversized, unparseable, non-object, incomplete credentials) now returns 409,
the existing non-transient "connect this installation again" bucket, and a test asserts the
status still maps to
transient is False— that mapping, not the number, is what preventsthe retry. This deliberately prefers a false "reconnect" over a replay: the opposite
mistake revokes the whole family and forces the same reconnect from a worse state.
A coercion is not a validation
str(response.get(key) or "").strip()reads like it normalises a field, but JSON arrays andobjects arrive as Python
list/dictandstr()renders their repr:So
{"refresh_credential": ["tok_abc"]}passed the missing-credential check, was persistedas the literal text
['tok_abc'], read back byconfigured()as a usable session, andreported as a successful connection — while the next refresh submitted the junk, after the
single-use token was already spent.
cloud_session.text_field()now requires a genuinestring at every untrusted-provider boundary (
save_bootstrap, the rotation handling inaccess_for_workspace, and connect's pre-save check, which uses the writer's own helper sothe two cannot disagree). Identity fields are dropped rather than stored as a repr, since the
dashboard renders them verbatim.
preflight_savealso stopped swallowing a failedos.unlinkof its probe. Creating a fileand removing one are separate rights, and
atomic_private_textfinishes withos.replace,which needs the delete right that would have failed — so on an add-file-but-no-delete ACL the
preflight passed on a directory the real save could not use, redeeming the token before
failing. That is the exact drift the preflight exists to prevent.
Client identity
installation_client_id/device_client_idare minted once and persisted at~/.engraphis/client_identity.json(owner-only, honoursENGRAPHIS_STATE_DIR), using thepackage's own ULID minter —
inst_01K…/dev_01K….They are random rather than a hardware fingerprint on purpose: a MAC- or hostname-derived
id is a stable cross-account identifier the customer never agreed to ship, and it changes
under exactly the conditions (a new NIC, a rename) where stability was the point. Minted
once in the state directory gives the server what it needs — stable per installation — and
a second state directory is correctly a second installation.
Compute URL
DeviceRegistrationResponsecarries no compute endpoint, butcloud_session.configured()requires one, so connect would otherwise leave a customer "connected" but not configured.
Resolution is
--compute-url→ENGRAPHIS_CLOUD_COMPUTE_URL→ the shipped computeendpoint only when the control plane is also the shipped one. A custom or self-hosted
control URL gets no guess; the command then says exactly that instead of half-reporting
success.
configured()is checked after the write either way.Docs
docs/AGENT_CONNECT.mdand.env.exampleno longer point at a file nothing creates —both now document
engraphis connect --tokenas the real path, with the option table,the single-use/short-lived token semantics, and the
401vs402distinction.Verification
Offline gate on Windows, junit XML parsed from outside the repo. Both rows measured in this
environment, rebased onto the same
origin/main(73aa244):origin/main+102 tests, all passing, zero regressions.
python -m ruff check .→ all checks passed.eval.harnessonsample.jsonlandcodemem.jsonl→ recall@5 / hit@5 / answer-token-recallall 1.000;
eval.ablationunchanged (vector 0.6667, 1-hop 0.0, PPR 1.0). The absolutecollected count differs from CI's Linux number, so the delta is the meaningful figure.
New tests cover: happy path writes a session where
cloud_session.configured()is true andevery field lands correctly; the exact documented request shape (no
organization_id, noextra fields, optional fields omitted rather than sent empty); stable ids across reconnects;
401printing actionable copy and writing nothing;402staying distinguishable; per-statuscopy for 403/422/429/503/500; error bodies never reflected;
HTTPErrordrained and closed;the token absent from stdout, stderr, and every written file while present in the request
body; secrets absent from both the report and the
--jsonsummary; and eight malformed-tokenshapes rejected before the network via a poison opener that fails the test if dialled.
Post-review additions, each written as a failing test before the corresponding fix:
IncompleteRead's real MRO; a truncated error body still producing the status copy (on bothreadandclose, forIncompleteRead/LineTooLong/ConnectionResetError); the samefor
_post_refresh; oversized timeouts refused before the network by a poison opener, withthe ceiling asserted against a real
socket.settimeout()so it cannot drift into theoverflowing range; a credential-less 200 across all three shapes (key absent, empty,
whitespace); and two CLI tests asserting exit 1 with no
Tracebackin stderr. Second round:every unusable-2xx shape (unparseable / JSON string / JSON array / oversized) carrying the
spent-token copy; a refresh-lock race keeping both its cause and that copy; and an unusable
session producing empty stdout in both
--jsonand report mode, which rules out a partialsuccess object as well as a complete one. Third round: body-read failures after success
headers (
TimeoutError/ConnectionResetError/OSError) reporting the spent-token copywhile a pre-reply reset keeps the retry copy; post-response refresh failures returning 409
and mapping to
transient is False; and a preflight probe that cannot be deleted failingbefore the token reaches the wire (
opener.calls == []).Additionally smoke-tested end to end outside pytest, twice:
urllibpath —POST /v1/devices/connect,UA: Engraphis/1.0.1 device-connect, token present in the bodyand in no file or stream, and
cloud_session.configured()→True.IncompleteReadpath against a real socket rather than a mock: a loopback serversending
Transfer-Encoding: chunked, a chunk header promising 32 bytes, 17 bytes of body,then a clean FIN. Real
HTTPResponse.read()raises a realIncompleteRead(
isinstance OSError: False,isinstance ValueError: False), andconnect()returnsDeviceConnectError(status=401)with the portal copy and no traceback.🤖 Generated with Claude Code