Skip to content

Grid attaches itself on daemon start, and only the backend may name the grid - #95

Merged
kelvin1295 merged 9 commits into
mainfrom
feat/harness-grid
Sep 18, 2026
Merged

kelvin1295 merged 9 commits into
mainfrom
feat/harness-grid

Conversation

@kelvin1295

Copy link
Copy Markdown
Collaborator

Summary

A machine that signed in to Harness before grid existed has the grid binary 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_meta frame, one of which made the daemon-side fix insufficient on its own.

What changes

Grid attaches itself (cli/src/lib/gridAttach.ts, wired in runForeground)

  • Gate is the exact signal, not a heuristic: the account's grid name is globally unique (suffix hashes the account id), so this machine's local grid ls containing it proves "right account + grid exists" → converged, nothing to do. Anything else → hand the Harness token to grid login --harness (overwriting a different account's sign-in, by decision) and ensure the grid.
  • Runs once per daemon start and again on backend reconnect, throttled: requests inside a 60 s window are deferred to its end rather than counted, so a burst of reconnects (WiFi change, VPN, wake) costs one attempt instead of the whole budget. Gives up out loud after 5.
  • 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 login now also mints the name and ensures the grid (it used to sign in and stop). Its --json result line is unchanged.
  • handOffToGrid gets a 60 s watchdog — it was the one grid call with none.

Security: only the backend may send machine_meta / machine_revoked

  • machine_meta names the account's grid (where every agent on the machine runs); machine_revoked clears the session and exits. The daemon accepted both from any transport: handleLocalFrame let the transport default to relay, so a local-socket frame was indistinguishable from the backend's. Frames are now tagged relay | local | p2p at enqueue and both types are refused unless relay.
  • The pre-existing local flag (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.
  • Backend: webWs.ts forwarded 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 as relay. Anything holding an account token could therefore forge machine_meta over the network. Both types are now refused in webWs.ts next to the existing __ rule. Neither is sent by any client in this repo.

Bug fixes

  • A rename pushed machine_meta with {name} alone and the handler nulled the grid name — absent key now means unchanged.
  • The skill told users to "restart Harness" when grid was missing; the daemon outlives the app, so that did nothing. It now says sign in again.

Test plan

  • cli: tsc clean; 2950+ vitest green. Remaining failures are dsh/* fs-watch timing (pass in isolation) and the pre-existing lib/e2ee/core.test.ts sha256 pin broken upstream by 6ef0f44git diff origin/main on both files is empty. Not touched here.
  • backend: tsc clean (after prisma generate); 442 vitest green.
  • New unit tests: gridAttach.spec.ts (reconcile + runner, 19), gridHandoff.spec.ts watchdog (2), backendSocket.spec.ts transport gate / rename / probe wait (4), gridCommand.spec.ts grid-login ensure (2), webWs.test.ts (2). Three gates mutation-tested: removing each fails exactly its test.
  • Live on prod, real machine: all three reconcile branches — existed (same account, credential removed), created (brand-new account → grid confirmed running on the control plane via grid 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.
  • Two independent review passes (correctness, security) plus an adversarial verify; every finding addressed or explicitly accepted.

Notes for release

🤖 Generated with Claude Code

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.
@kelvin1295
kelvin1295 merged commit 3e7c333 into main Sep 18, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant