Skip to content

feat(control-box): local adoption — the laptop as a thin client of the control box#241

Open
madarco wants to merge 42 commits into
feat/control-box-planfrom
feat/local-adoption
Open

feat(control-box): local adoption — the laptop as a thin client of the control box#241
madarco wants to merge 42 commits into
feat/control-box-planfrom
feat/local-adoption

Conversation

@madarco

@madarco madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner

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, so agentbox ls couldn't show it and attach/cp/url/screen couldn'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)

BoxRegistration gains the material a PC needs to rebuild a box's record: publicHost (the VM IP, also refreshed on resume via CloudHandle/backend.get), image, webPort, agent, projectSlug. New agentbox hub adopt <box> reconstructs the local BoxRecord, pulls the box's SSH keys from custody, and links it to a local clone by normalized origin URL — rewriting the control box's temp hostMainRepo to the local path (this closes the deferred hostMainRepo-rewrite-on-PC-adoption item). Idempotent. recover now falls back to custody for a missing per-box key instead of hard-failing.

Phase 2 — thin-client ls + auto-adopt (86537d2)

ls merges the control box's registry with local boxes: hub-only boxes render as on hub, adopted ones render from local state, and a local cloud record the control box no longer has is surfaced as orphan (never silently pruned). Cached to ~/.agentbox/hub-boxes-cache.json for the offline path. One hook in resolveBoxOrExit auto-adopts on any by-name miss, so attach/cp/download/url/screen/destroy all 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 new agentbox control-plane project push) store those under custody projects/<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 /rpc and /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.contextSha256 equals 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 hetzner seeds 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:

  • ls held the shell ~9s when the control box was unreachable. AbortSignal rejects the fetch promise, but undici keeps the connecting socket until its own 10s connectTimeout — output appeared at 1.5s, the process exited at 10.9s. Fixed by probing reachability with a socket we own and destroy (hostReachable in hub-list.ts) → 1.9s.
  • The seed's hash-skip never worked. tar -z stamps 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 via zlib (MTIME=0). Pinned by a regression test.

Verification

  • pnpm build, pnpm typecheck, pnpm lint clean; 2910 tests pass (+38 new). apps/web docs site builds.
  • Live against the deployed control box: ls -g reachable (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.
  • Live against a real 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.

Note: the currently deployed control box answers 400 for prepared/ — it predates the scope, which is exactly the back-compat case the pull treats as "nothing shared". It needs a redeploy to serve the new scope.

Docs

deployed-hub.mdx's "adoption gap" section is rewritten as the thin-client model; hub adopt + control-plane project push added to the CLI reference; relay.custodyMaxBodyBytes added to the config table; the control-box-plan.md backlog 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 ls merges the control box registry with local boxes (on hub, orphan, stale-cache notes). agentbox hub adopt and resolveBoxOrExit auto-adopt on a by-name miss so attach/cp/url/etc. work for web-UI boxes. Box resolution adopts hub refs before the “shift” path so agentbox 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 push uploads untracked + env seed; create workers overlay seed after clone (clone wins). New prepared/ custody scope syncs bake records (fingerprint match); prepare and 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 ls or adopt (~9s undici connect issue). recover can pull missing Hetzner keys from custody.

Reviewed by Cursor Bugbot for commit 62f6da5. Configure here.

madarco added 5 commits July 17, 2026 09:23
…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
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agentbox-web Skipped Skipped Jul 17, 2026 9:00pm

Request Review

Comment thread apps/cli/src/commands/control-plane.ts
Comment thread apps/cli/src/control-plane/auto-adopt.ts Outdated
Comment thread apps/cli/src/control-plane/auto-adopt.ts Outdated
- 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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread apps/cli/src/commands/list.ts Outdated
Comment thread packages/sandbox-cloud/src/custody-seed.ts Outdated
Comment thread packages/sandbox-cloud/src/custody-seed.ts Outdated
- 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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread packages/relay/src/create-worker.ts
Comment thread apps/cli/src/commands/list.ts
- 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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread apps/cli/src/control-plane/hub-adopt.ts Outdated
…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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread apps/cli/src/control-plane/hub-adopt.ts Outdated
…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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread apps/cli/src/control-plane/auto-adopt.ts Outdated
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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread apps/cli/src/control-plane/hub-adopt.ts
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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

Comment thread apps/cli/src/box-ref.ts
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
@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@madarco

madarco commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Live e2e complete — and it earned its keep

Deployed 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:

  • deploy serves the new prepared/ scope and auto-seeded the PC's bake records into custody
  • a PC create registers with full adoption material (image, webPort, projectSlug)
  • with the local record deleted, ls -g shows the box as on hub; url <name> auto-adopts it and opens the real URL; shell <name> runs a command in it
  • adopting from the box's own repo links projectRoot and rewrites hostMainRepo
  • laptop-off push: with :8787 verified dead, an in-box git push landed the exact commit on GitHub (confirmed by git ls-remote, not exit code)

Two real bugs only the live run could find:

  1. The create path never loaded the control-plane env, so in a normal shell the admin token was unset and every PC create silently skipped registration — the box came up topology: control-plane but the plane never heard of it, and its push had no token to lease with.
  2. The bake records the deploy seeds into custody were never consulted at create, so a fresh control box failed every create with "run prepare first" while a good record sat in its custody.

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 docs/local-adoption-plan.md.

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
madarco added 3 commits July 17, 2026 19:31
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.
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