feat(control-box): local adoption — the laptop as a thin client of the control box#241
feat(control-box): local adoption — the laptop as a thin client of the control box#241madarco wants to merge 42 commits into
Conversation
…ption phase 1) BoxRegistration now carries the adoption material a PC needs to rebuild a hub-created box's BoxRecord: publicHost (VPS IP, also refreshed on resume via CloudHandle/backend.get), image, webPort, agent, projectSlug. New `agentbox hub adopt <box>` reconstructs the local record from that registration, pulls the per-box SSH keys from custody, and links the box to a local clone of its repo by normalized origin URL (rewriting the control box's temp hostMainRepo to the local path). Idempotent. `recover` now falls back to custody for a missing per-box key instead of hard-failing. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
…al-adoption phase 2) With a control box configured, `agentbox ls` merges its registry with local boxes: hub-created boxes show as `on hub` rows, adopted ones render from local state, and a local cloud record the control box no longer has is flagged an orphan. Project-scoped ls matches un-adopted hub boxes by origin URL. The listing is cached to ~/.agentbox/hub-boxes-cache.json for the offline path. resolveBoxOrExit now auto-adopts on a by-name miss, so attach/cp/url/screen/ destroy all work against a hub box with no manual adopt step. Reachability is probed with a socket we own before fetching: AbortSignal only rejects the fetch promise, while undici holds the connecting socket until its 10s connectTimeout — which kept `ls` alive ~9s after it had printed. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
…ion phase 3) A hub-created box clones the repo with a leased token, which by definition can't carry the user's untracked files or .env/secrets. PC creates (and the new `agentbox control-plane project push`) now store that seed material under custody projects/<slug>/seed/, and the create-worker overlays it onto its fresh checkout — clone wins on conflicts, and the job log records the seed's age and captured commit. Custody PUTs get their own body cap (relay.custodyMaxBodyBytes, default 32 MiB) so seed tars fit without widening the relay's 1 MiB cap for /rpc and /events. The tar is gzipped via zlib rather than `tar -z`: gzip stamps the current time into its header, which gave an unchanged tree a new sha256 every run and defeated the hash-skip on the seed's largest blob. Live-verified against the deployed control box: push, hash-skip (unchanged tree re-uploads only the manifest), and cleanup. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
A cloud base is a provider-side snapshot any machine with the API key can boot, but the record of it was per-machine — so a base baked on the control box still looked unbaked on the PC, and both sides re-baked the same thing into different snapshots. Bake records now sync through a new custody `prepared/` scope. Adoption is fingerprint-match-wins: a shared record is adopted only when its base.contextSha256 equals the fingerprint this CLI computes, so a bake from a different build context is ignored rather than booted. `prepare` (CLI and the hub's queued bake) pulls before baking and pushes after; `control-plane deploy hetzner` seeds the new box with the PC's records. Offline-safe throughout, and a control box predating the scope answers 400, treated as "nothing shared". Verified against the real relay HTTP surface: prepared/ round-trips, unknown scopes are still rejected, and a 4MB seed tar passes the custody-scoped body cap. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
…hared bakes Rewrites the deployed-hub "adoption gap" section as the thin-client model that now exists: ls shows hub boxes, by-name use auto-adopts, and project seed material makes web-UI creates complete. Adds `hub adopt` + `control-plane project push` to the CLI reference, `relay.custodyMaxBodyBytes` to the config table, and marks the local-adoption / deploy-prepared backlog entries done in control-box-plan.md. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
- Seed slug mismatch: `control-plane project push` still used its own slugger (first two path segments, unsanitized) while the create path and hub worker use projectSlugFromOriginUrl (last two, sanitized). For a nested remote like gitlab.com/group/subgroup/repo a push landed under `group__subgroup` while the worker read `subgroup__repo` — the seed was invisible to hub creates. Both now share the one helper; a test pins the derivation. - Doubled timeout budget: auto-adopt ran hostReachable for its full timeout and then re-armed the same timeout around the adopt, so the worst case was ~2x the documented ceiling. Both auto-adopt and the ls listing now spend down a single budget. - False not-found: when the adopt lost the race, tryAutoAdopt returned null while the adoption could still complete and write state.json — "no box matches <ref>" for a box that now existed. One shared AbortSignal bounds the whole adoption instead, so it either completes and is returned, or aborts. Also fixes a slug inconsistency the new test caught: `new URL` percent-encodes the pathname while the scp-like parse doesn't, so one repo spelled two ways produced two slugs (`re-20po` vs `re-po`). Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
- Unreachable hub marked every local cloud box `orphan`: fetchHubListing returns an EMPTY registration list (not null) when the control box is unreachable with no cache, and merge read that as authority for absence. A stale listing now never tags orphans — absence only means something in a listing we received. - Seed cap ignored its own config key: the tar ceiling was hardcoded while the logs told users to raise `relay.custodyMaxBodyBytes`. Both callers now pass the effective value, and the blob ceiling is derived from it. - Nested env seed exceeded the custody path limit: env files were one entry each under `seed/env/<rel>`, so a monorepo `apps/web/.env.local` needed 7 segments against custody's 6 and the push failed for exactly the layouts that need seeding most. They now ride a single `env.tar.gz`, which also preserves nesting, modes and odd filenames for free. Regression tests cover all three. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
- Hub creates dropped the agent: the create job carried `agent` and the registration had a slot for it, but the worker never passed it to provider.create — so every hub-created box registered without one and adopted with no `lastAgent`. Threaded through both worker implementations. - `list --watch` refetched the hub every redraw: at the default 2s interval that probed + fetched the control box 30x a minute per viewer, and each redraw waited on the network. A 10s in-process memo now serves redraws (box membership changes on human timescales); a one-shot `ls` is a fresh process and still fetches. Verified: ~5 redraws over 9s now make 1 request, not 5. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
…ound 4)
Adoption built `identityFile` as `${boxSshDirForProvider(...) ?? ''}/id_ed25519`,
so a provider that reports a publicHost but mints no per-box keypair (a plugin,
or a future backend) would get the absolute path `/id_ed25519` written into its
record and into the generated ~/.agentbox/ssh/config. Latent today — only the
SSH providers set publicHost — but free to get right.
The identity is now set only when a key dir actually resolves; otherwise the
host/user are still recorded and ssh falls back to its normal key resolution.
Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
…ound 5) Adoption resolved the registration (by id, name, OR sandbox id) and then handed the RAW ref to pullBoxSshKeys, which re-resolved it matching only id/name. A sandbox-id ref therefore found nothing there, lost `provider`, and wrote the keys to the un-namespaced default dir — while the record's identityFile used the provider-namespaced path. DigitalOcean broke; hetzner passed only because its dir is un-namespaced anyway. The download now takes the already-resolved provider + key (no second registry fetch, no chance to resolve differently), and pullBoxSshKeys matches sandbox ids too so `hub pull <sandboxId>` is consistent. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_89479b7f-cf80-46f8-b573-30ae1bf89dd2) |
|
bugbot run |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_4a0963b7-eb7d-4315-865a-2e1849750845) |
|
bugbot run |
tryAutoAdopt returned null when there was no admin token, which the shift path reads as "definitely not a hub box" — so a tokenless setup could still run `agentbox claude <hub-box>` in the wrong local box with no warning, the same failure this PR closed for an unreachable hub. It now returns 'unreachable' when a control box IS configured but we lack a token (we could not ask), and null only when none is configured (nothing to ask). Warning text no longer says "unreachable" for what may be an auth problem. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
adoptHubBox reused an existing local record when the NAME matched, so adopting a hub box called `foo` would overwrite an unrelated local docker box called `foo` with cloud fields — corrupting the record for a different box, after which commands target the wrong one. Names aren't unique across providers; identity is the sandbox id or box id. Matching is now identity-only. Test: a local docker box sharing the name is left untouched and both records coexist. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
By-name resolution is local-first by design; asking the control box on every by-name hit would put a round-trip in front of every command to catch a case that needs the same explicit --name picked twice. The shadowed box stays addressable by sandbox/box id (incl. prefixes). Also notes the pre-existing first-match findBox name lookup this sits on top of. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 25484f1. Configure here.
Live e2e complete — and it earned its keepDeployed a fresh control box from this branch onto a real Hetzner VPS and ran the full goal scenario against it with an e2b box (all infra torn down after; the PC's control-plane config restored). Proven live:
Two real bugs only the live run could find:
Bugbot then found 9 more on top of those (fingerprint install-mode, adopt-without-keys, ref-prefix consistency across adopt/pull, missing-token = "couldn't ask" not "no such box", and a same-name adopt clobbering an unrelated local docker box). All fixed with regression tests; one (local name shadows a hub box) documented as a deliberate local-first limitation. Bugbot is now clean and CI green. Details of what is and isn't proven live are in |
The hub's getData() read only the VPS's local state.json, so the web UI, the /api/v1/boxes REST endpoint, and therefore the tray showed only boxes the control box created itself — a box registered from a PC sat in the Store (control-plane boxes list + /healthz both see it) but never appeared in the control box's own UI. The exact mirror of the PC-side gap this PR fixes with `ls -g`. getData() now merges Store registrations not present in local state as box rows (mapRegistrationToBox), deduped by box id and sandbox id. One source feeds all three consumers, so the tray needs no change to see them. Lifecycle actions on these rows (the control box has no local record to drive) are a follow-up. Found while live-testing the deployed control box: a PC `agentbox claude` box was registered but invisible in the web UI. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
…nders them The first pass added Store-registered boxes to getData's box list, but the dashboard groups strictly by projectId and listProjects only builds projects from local box roots — so a PC box was COUNTED (the header's box number went up) but rendered under no project card, i.e. invisible. getData now also emits a synthetic project per registered box whose project key isn't already present, sharing the exact id the box row uses (registrationProjectKey). Verified end-to-end against a local hub: the injected registered box shows in /api/v1/boxes and its project in /api/v1/projects with matching ids. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
The synthetic project for a registered box keyed on repo identity (projectSlug), so two folders sharing one git origin merged into a single project card — unlike `agentbox ls`, which groups by folder. A user with two copies of a repo saw both boxes under one project. Key on the box's host folder instead (worktrees[].hostMainRepo, the PC path it was created from) via the same hashProjectPath the local project id uses. Two folders of one repo now stay separate, folder basename is the project name, and the id matches the box's own local project — so adopting it on the PC lands it in the same card rather than a duplicate. Falls back to repo identity only when there's no host folder. Verified locally: two same-origin boxes with different host folders resolve to distinct projects whose ids match the local folder-project ids. Claude-Session: https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
ensureTunnel opened the ControlMaster without vpsUser, so ssh defaulted to the host login user (e.g. marco@) instead of the box user (vscode). Every host-side VPS create failed with 'Permission denied (publickey)'; it only worked from inside a box whose ambient user happened to match. Verified live: hetzner + DO creates now boot and are usable.
…ntime Each provider's findStagedCliRuntimeRoot only probed next to its own bundle (<dist>/../runtime). That works for the CLI and a published install, but the source-deployed control-box hub bundle lives under apps/hub/dist-standalone/**, far from the staged tree at apps/cli/runtime, so it silently fell back to hashing packages/** source — a different base fingerprint than the CLI baked with. hydratePreparedFromCustody then rejected every PC-baked record as 'a different build context' and the box never recognized shared bakes. Add an AGENTBOX_RUNTIME_ROOT override (shared resolveStagedRuntimeRoot helper in sandbox-core; daytona's Dockerfile-context + CLAUDE.md resolvers honor it too) and set it in the hub image to /repo/apps/cli/runtime. The hub now hashes the same staged tree the CLI does, so custody bake records match. No behavior change when the env is unset (CLI/published still resolve via the SELF-relative path).
The /settings + tray freshness check (providerBaseFreshness) read only local prepared-state, which is empty on a control box — bakes live in custody and were hydrated only at create time. So every provider showed 'needs baking' even when custody held a matching record. Hydrate from custody in the freshness path too (same fingerprint-match-wins policy, reused via the extracted ./prepared-hydrate helper), so /settings reflects shared bakes. No-op on a local hub or when custody has no matching record.
Every provider's Provider.baseFingerprint was () => currentXBaseFingerprintLive() — dropping the claudeInstall arg, so it always computed the 'native' fold. The custody-hydration path (create + settings freshness) passes the machine's claudeInstall to decide if a shared bake matches; with the arg dropped, an npm-baked record (box.claudeInstall=npm) never matched the native-computed fingerprint, so it was always rejected as 'a different build context' and the provider showed 'needs baking'. Forward the arg.
Closes the local-adoption gap (
docs/local-adoption-plan.md, the plan doc on the base branch): with a control box configured, the laptop becomes a thin client — the control box is the source of truth for cloud boxes, and local state is a cache of the ones you actually drive.Before this, a box created from the control box (web UI /
--via-hub) had no local record, soagentbox lscouldn't show it andattach/cp/url/screencouldn't resolve it by name. Boxes could only be driven from the side that created them.Four phases, one commit each — reviewable in order.
Phase 1 — registration enrichment +
hub adopt(26a86db)BoxRegistrationgains the material a PC needs to rebuild a box's record:publicHost(the VM IP, also refreshed on resume viaCloudHandle/backend.get),image,webPort,agent,projectSlug. Newagentbox hub adopt <box>reconstructs the localBoxRecord, pulls the box's SSH keys from custody, and links it to a local clone by normalized origin URL — rewriting the control box's temphostMainRepoto the local path (this closes the deferred hostMainRepo-rewrite-on-PC-adoption item). Idempotent.recovernow falls back to custody for a missing per-box key instead of hard-failing.Phase 2 — thin-client
ls+ auto-adopt (86537d2)lsmerges the control box's registry with local boxes: hub-only boxes render ason hub, adopted ones render from local state, and a local cloud record the control box no longer has is surfaced asorphan(never silently pruned). Cached to~/.agentbox/hub-boxes-cache.jsonfor the offline path. One hook inresolveBoxOrExitauto-adopts on any by-name miss, soattach/cp/download/url/screen/destroyall work with no per-command code.Phase 3 — project seed material (b1da321)
The worker clones with a leased token, which by definition can't carry untracked files or
.env/secrets. PC creates (and the newagentbox control-plane project push) store those under custodyprojects/<slug>/seed/; the worker overlays them onto its fresh checkout — clone wins on conflicts, and the job log records the seed's age + captured commit. Custody gets its own body cap (relay.custodyMaxBodyBytes, default 32 MiB) so seed tars fit without widening the relay's 1 MiB cap for/rpcand/events.Phase 4 — shared bake state (d86149e)
A cloud base is a provider-side snapshot any machine with the API key can boot, but its record was per-machine — so both sides re-baked the same thing into different snapshots. Bake records now sync through a new custody
prepared/scope, adopted only on fingerprint match (base.contextSha256equals what this CLI computes), so a bake from a different build context is ignored rather than booted.prepare(CLI + the hub's queued bake) pulls before baking and pushes after;control-plane deploy hetznerseeds a new box with the PC's records.Two bugs the live testing caught
Both would have survived unit tests, and are worth a look during review:
lsheld the shell ~9s when the control box was unreachable.AbortSignalrejects the fetch promise, but undici keeps the connecting socket until its own 10sconnectTimeout— output appeared at 1.5s, the process exited at 10.9s. Fixed by probing reachability with a socket we own and destroy (hostReachableinhub-list.ts) → 1.9s.tar -zstamps the current time into the gzip header, so an unchanged tree hashed differently every run and re-uploaded the seed's largest blob on every create. Fixed by gzipping viazlib(MTIME=0). Pinned by a regression test.Verification
pnpm build,pnpm typecheck,pnpm lintclean; 2910 tests pass (+38 new).apps/webdocs site builds.ls -greachable (0.7s) and unreachable (1.9s, degraded note, no hang);control-plane project push→ seed landed in custody, and an unchanged re-push uploaded only the manifest. Smoke data cleaned up afterwards.startRelayDaemon:prepared/round-trips, an unknown scope is still rejected (400), and a 4 MB seed tar passes the custody-scoped cap.Not yet verified live (needs a hub-created box)
Tracked at the end of
docs/local-adoption-plan.md: adopting a real web-UI-created box, a web-UI create consuming seed material, and a control-box bake adopted by the PC.Docs
deployed-hub.mdx's "adoption gap" section is rewritten as the thin-client model;hub adopt+control-plane project pushadded to the CLI reference;relay.custodyMaxBodyBytesadded to the config table; thecontrol-box-plan.mdbacklog entries (local-adoption, deploy-prepared) marked done.https://claude.ai/code/session_01HvL78fw2hwN8m4F5TADV8R
Note
Medium Risk
Touches core box resolution, control-plane create/register paths, and custody body limits; mistakes could mis-route commands or leave hub boxes without keys/seed, but behavior is mostly best-effort with tests and live verification noted in the PR.
Overview
With a control box configured, the PC now treats it as the source of truth for cloud boxes instead of only local
state.json.Visibility and adoption.
agentbox lsmerges the control box registry with local boxes (on hub,orphan, stale-cache notes).agentbox hub adoptandresolveBoxOrExitauto-adopt on a by-name miss soattach/cp/url/etc. work for web-UI boxes. Box resolution adopts hub refs before the “shift” path soagentbox claude <hub-box>does not land in the wrong local box.Custody and creates. Registrations carry adoption fields (
publicHost,image,webPort,agent,projectSlug).agentbox control-plane project pushuploads untracked + env seed; create workers overlay seed after clone (clone wins). Newprepared/custody scope syncs bake records (fingerprint match);prepareand deploy seed/pull.relay.custodyMaxBodyBytes(32 MiB default) applies only to custody PUTs. PC creates load the control-plane env file so registration and seed push are not skipped.Reliability. Hub calls use socket reachability probes and tight timeouts so unreachable control boxes do not hang
lsor adopt (~9s undici connect issue).recovercan pull missing Hetzner keys from custody.Reviewed by Cursor Bugbot for commit 62f6da5. Configure here.