Grid attaches itself on daemon start, and only the backend may name the grid - #95
Merged
Merged
Conversation
A machine that signed in to the harness before grid existed has the `grid`
binary after an update but no grid credentials and no grid to point at, and
the login event that used to arrange that never fires again for an
already-signed-in account. The Local model picker then reads "No local models
on this account yet." with nothing the person can do but logout/login.
Reconcile the grid sign-in with the harness sign-in on every daemon start —
the path every update takes. New lib/gridAttach.ts:
- Cheap, offline gate: the account's grid name is globally unique (its suffix
hashes the account id), so this machine's `grid` knowing it locally proves
the machine is signed in as the right account with the grid present — the
converged path, which does nothing but publish the name.
- Otherwise hand the harness token to `grid login --harness` (overwriting a
different account's sign-in, by design) and ensure the private grid exists.
- Best-effort and non-blocking: nothing here can fail a daemon start.
BackendSocket: `grid_models_list` / retarget wait briefly (6s) for an in-flight
reconcile so the first picker after an update is not empty, under a hard 30s
ceiling so a stalled reconcile cannot degrade every grid RPC for the daemon's
life; `mintName` is bounded too.
Also fix machine_meta clobbering the grid name: a rename frame carries `{name}`
alone, and treating the absent key as null wiped the name a moment after it was
set. Absent now means unchanged; only an explicit null clears it.
Three corrections from an adversarial review of the previous commit. The daemon OUTLIVES the desktop app, and `harness start` against a live daemon returns without starting a new one. So "daemon start" can be days ago, and the skill's new "restart Harness and it will set itself up" was simply false: reopening the window re-runs nothing, and the app's own "Restart Harness" button restarts one agent, not the daemon. Say "sign in to Harness again" — the thing that actually forces the setup — and tell the agent not to suggest a restart. For the same reason a single attempt is not enough: a daemon coming up with the network typically finds the control plane unreachable, and the account would then have no grid until the next real restart. Retry on backend reconnect, which is the evidence the control plane is reachable again, capped at 5 attempts so a flapping link cannot sign in to grid repeatedly. Also stop `gridNamesLocal` reporting a FAILED registry read as an empty list. Conflating the two makes "this machine has no grids" a guess rather than a fact; it now throws, and the reconcile logs which happened before falling through to the hand-off that rewrites that registry.
…h the job Two of the three gaps left open by the auto-attach work. `handOffToGrid` spawned `grid login --harness` with no watchdog, alone among this codebase's `grid` calls. A control plane that accepts the connection and then answers nothing left the promise pending for good: a `harness login` that never returned, and a daemon reconcile that never settled. Give it the same treatment `gridExec` gives every other child — one SIGKILL, the timer cleared in `settle`, and the existing GRID_LOGIN_FAILED rather than a new code, so no caller changes. 60s rather than gridExec's 30s: this child makes two control plane round trips where the others make one, and it is the call a person is most likely to be watching. `timeoutMs` is injectable so the watchdog is testable in milliseconds instead of a minute. `harness grid login` signed in and stopped. An account whose private grid had never been created was then signed in to nothing, and the empty model picker that followed named no cause. Extract the mint-and-ensure half out of `attachGridToSignIn` and run it from both, so the sign-in's grid half and the explicit command cannot drift apart again. The command's `--json` result line is deliberately unchanged — it is a pinned contract, and the notes go to stderr where this command's other notes already go.
`machine_meta` carries this machine's display name and the account's private grid — the grid every agent on this computer is then pointed at. The daemon accepted it from any down-frame source, because `handleLocalFrame` let the transport default to `relay`: a frame from a process on this machine arrived at `dispatchDown` indistinguishable from the backend's own. Any process that could open the daemon's local port could therefore redirect the account's inference to a grid of its choosing, and a leftover test script doing exactly that by accident once cost hours to diagnose. Tag the local socket's frames `local` and refuse `machine_meta` from anything but `relay`. The p2p path already ran an allowlist that excludes this frame, so local was the only way in. No client sends `machine_meta`, so there is nothing to stay compatible with. `noteTerminalInputRoute`'s `transport === 'relay'` meant "not p2p" rather than "from the backend", so it now says that — otherwise the new third value would have silently stopped demoting local frames' p2p streams.
…used it `dispatchDown` already decided "did this come from a process on this machine" for four frame types, and derived it at DISPATCH time from `localClients.has(connId)`. Frames run through a per-connId queue, so a local client that disconnected between sending and being dispatched left that map lookup false — and its already-queued frames were then read as the BACKEND's, by every one of those four gates. The transport tag added for `machine_meta` is stamped at enqueue, by the caller that had just verified membership, so folding it into the same flag closes that window without a second mechanism: either half being true is local. It only ever widens what counts as local, and for all four gates the widened case is the safe direction. `machine_meta` keeps the stricter `transport !== 'relay'` rather than `!local`, now with a note saying why: it means "the backend link", so it still holds if the p2p allowlist ever widens, where "not local" would quietly admit p2p.
… its budget A review of the retry added in b9c6f05 raised two things, and they have one answer. The coordination — in-flight dedup, the attempt cap, the RPC ceiling, the reconnect wiring — was inline closures inside `runForeground`, a ~2800-line function in a 6000-line file, so nothing tested it. `reconcileGridAttach` was pulled into its own module to be testable; its scheduler deserved the same and now gets it as `createGridAttachRunner`, with an injected clock and seven tests covering overlap, retry, the cap, a rejected attempt and the ceiling. The cap was also a flat lifetime count of five, decremented by any call. But retries are driven by backend reconnects, and waking a laptop, changing network or toggling a VPN produces several within seconds — one moment of ordinary churn could spend the whole allowance and leave the feature silently dead for a daemon that then runs for days. Requests inside a one-minute window are now DEFERRED to the end of it rather than counted, so a burst costs one attempt and the opportunity is not lost either; the timer is unref'd. Reaching the cap now says so, once, instead of going quiet. Also make the hanging-child fake in the watchdog test ignore SIGTERM, which is the case SIGKILL is there for.
A security review of 6549e05 found its premise false one hop further back. That commit established "only the `relay` transport may send machine_meta", and the daemon cannot tell a frame the backend DECIDED on from one the backend RELAYED for a web client — both arrive on the same socket as `relay`. `webWs.ts`'s handleFrame blocks `__`-prefixed frames precisely because a web client must never forge a lifecycle notification, and validates the terminal and p2p namespaces. `machine_meta` is none of those, so it fell through to the verbatim `client.sendDown(frame)` at the bottom. Anything holding a valid access token for the account could therefore redirect that account's agents onto a grid of its choosing, over the network, with no access to the victim's machine at all — a strictly easier version of the local vector 6549e05 closed. Block it where the distinction still exists, next to the `__` rule. `machine_revoked` gets the same treatment, and it was worse: the daemon had no source check on it either, so any local process could send one frame over the loopback socket and make the daemon clear its stored SSO session and exit. Both frames are now refused from non-backend transports in the daemon too, through one `BACKEND_ONLY_DOWN_TYPES` rule rather than a per-branch check. Neither frame is sent by any client in this repository — only by adapterWs and MachineService — so refusing them costs nothing.
One conflict, in dispatchDown: main added the observer hand-off for harness sharing exactly where this branch replaced `const local` and added the BACKEND_ONLY_DOWN_TYPES guard. Resolved with the guard ABOVE the observer early-return. A genuine observer frame is `relay`, so the guard never intercepts one; placed below, a forged `observer:` connId on a local or p2p frame would be swallowed by harnessSharing.receive before reaching the guard, silently, and the invariant would then rest on three facts in other files. Two things checked before resolving, both against origin/main: - main added no new enqueueDown/dispatchDown call sites, so the transport tag still covers every entry; observer frames arrive over relay only, and harnessSharing.receive is a closed ladder that cannot reach machine_meta or machine_revoked. - main added three new gates that read `local` (encrypted/share/viewer types, orchestrator LOCAL_ONLY, viewer forwarding). They now inherit the race-free `transport === 'local' || localClients.has(connId)`, which is wider only inside the enqueue-to-dispatch window for a client that WAS verified local at enqueue. Same direction as the four original gates. cli.ts auto-merged cleanly; main's one addition near the grid-attach block (`viewerTargetProvider`) is a lazily-read callback, order-independent. Pre-existing on origin/main, not from this merge: cli/src/lib/e2ee/core.test.ts fails its sha256 pin because 6ef0f44 changed core.ts without re-pinning. Both files are byte-identical to origin/main here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A machine that signed in to Harness before grid existed has the
gridbinary after an update but no grid credentials and no grid to point at — the login event that used to arrange that never fires again for an already-signed-in account. The Local model picker read "No local models on this account yet." with nothing to do but logout/login.This PR moves grid setup from the login event to a reconcile on every daemon start (and on backend reconnect), so an existing user gets grid after an update with no re-login. Along the way it closes three security holes around the
machine_metaframe, one of which made the daemon-side fix insufficient on its own.What changes
Grid attaches itself (
cli/src/lib/gridAttach.ts, wired inrunForeground)grid lscontaining it proves "right account + grid exists" → converged, nothing to do. Anything else → hand the Harness token togrid login --harness(overwriting a different account's sign-in, by decision) and ensure the grid.grid_models_list/ retarget wait up to 6 s for an in-flight reconcile (under a 30 s ceiling) so the first picker after an update is not empty.harness grid loginnow also mints the name and ensures the grid (it used to sign in and stop). Its--jsonresult line is unchanged.handOffToGridgets a 60 s watchdog — it was the onegridcall with none.Security: only the backend may send
machine_meta/machine_revokedmachine_metanames the account's grid (where every agent on the machine runs);machine_revokedclears the session and exits. The daemon accepted both from any transport:handleLocalFramelet the transport default torelay, so a local-socket frame was indistinguishable from the backend's. Frames are now taggedrelay | local | p2pat enqueue and both types are refused unlessrelay.localflag (four other gates) was derived at dispatch time and raced a local client's disconnect; it now folds in the enqueue-time tag, closing that for all of them.webWs.tsforwarded any non-__/non-terminal/non-p2p frame from a web client verbatim to the daemon, and the daemon cannot tell "the backend decided" from "the backend relayed" — both arrive asrelay. Anything holding an account token could therefore forgemachine_metaover the network. Both types are now refused inwebWs.tsnext to the existing__rule. Neither is sent by any client in this repo.Bug fixes
machine_metawith{name}alone and the handler nulled the grid name — absent key now means unchanged.Test plan
cli: tsc clean; 2950+ vitest green. Remaining failures aredsh/*fs-watch timing (pass in isolation) and the pre-existinglib/e2ee/core.test.tssha256 pin broken upstream by 6ef0f44 —git diff origin/mainon both files is empty. Not touched here.backend: tsc clean (afterprisma generate); 442 vitest green.gridAttach.spec.ts(reconcile + runner, 19),gridHandoff.spec.tswatchdog (2),backendSocket.spec.tstransport gate / rename / probe wait (4),gridCommand.spec.tsgrid-login ensure (2),webWs.test.ts(2). Three gates mutation-tested: removing each fails exactly its test.existed(same account, credential removed),created(brand-new account → grid confirmedrunningon the control plane viagrid info),converged(account restored, no hand-off). Verified by the picker's own RPC over local-ws, not just logs. Procedure recorded in the repo owner's memory notes.Notes for release
POST /api/grid/namehas existed since Harness × grid: run agents on Local models, with web search and a managed grid runtime #71, and the backend's new refusal affects no client.ci.ymlhas been red on every PR since 2026-09-15 (same upstream pin); no branch protection. Merging with that known red is the repo's current practice — flagging rather than hiding it.🤖 Generated with Claude Code