Skip to content

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

Merged
madarco merged 49 commits into
feat/control-box-planfrom
feat/local-adoption
Jul 25, 2026
Merged

feat(control-box): local adoption — the laptop as a thin client of the control box#241
madarco merged 49 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.

Project Deployment Actions Updated (UTC)
agentbox-web Ready Ready Preview, Comment Jul 25, 2026 10:12am

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

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.
* feat(hub): headless API key for /api/v1 on a deployed control box

password-mode /api/v1 now accepts Authorization: Bearer <AGENTBOX_HUB_API_KEY>
in addition to the browser session cookie, so the CLI/tray/IDE can call a remote
control box's REST API headlessly (it gates /api/v1 only, never the UI pages).
The key is minted at 'control-plane setup'/deploy, recorded in control-plane.env,
and injected via docker-compose. Adds the resolveHubApiTarget() CLI seam.

Also lands the hub-API consolidation design doc + the CLI remote-support audit
appendix (docs/hub-api-consolidation-plan.md, docs/control-box-plan.md).

* feat(hub): mint AGENTBOX_HUB_API_KEY on redeploy too

deploy hetzner reuses the stored control-plane.env; ensure it carries a headless
/api/v1 key (mint + append if missing) so redeploying a pre-existing control box
also ships a hub that accepts the key, not just fresh setup runs.

* fix(cli): typed regex-capture access in ensureHubApiKeyInEnv

noUncheckedIndexedAccess: the capture group is string|undefined; guard via
optional chaining instead of indexing after a truthiness check.
… subset) (#246)

* feat(hub): reverse-adoption — drive Store-registered-only boxes

Extract the registration->BoxRecord mapper into @agentbox/relay
(registrationToBoxRecord, shared with the PC's hub adopt) and add
hydrateRegisteredBox to the hub backend: on a local-state miss the
control box reconstructs + persists a drivable record from its Store
registration, so /api/v1 lifecycle, git, services, and REAL destroy
work on PC-created/registered boxes (not just a state reap).

* feat(cli): HubApiClient + control-plane boxes/prompts speak /api/v1

Add the URL-swappable /api/v1 client (the same surface the tray+web
use) and move the control-plane 'boxes' + 'prompts' command group onto
it: 'boxes list' + new start/stop/pause/resume + a real 'boxes rm'
(destroy the cloud resource, not just reap), and prompts list/answer
via /api/v1/approvals. Reverse-adoption (prior commit) lets these drive
every box on the control box, including PC-created/registered ones.

* docs: reverse-adoption + /api/v1 CLI consolidation (Phase 1+2 subset)

Update the hub-API consolidation plan (Phase 1 reverse-adoption DONE;
Phase 2 clean subset shipped, ls/create deferred with rationale), the
control-box backlog, and deployed-hub.mdx (control-plane boxes now speak
/api/v1: lifecycle subcommands + real destroy).
… the hub config (#247)

* feat(hub): route cloud box creation to the control box by default

When a control box is configured, cloud creates (agentbox create, and
foreground claude/codex/opencode) default to being built ON it (cloud.viaHub,
on by default) so the box keeps running with the laptop off; docker/remote-docker
stay local and --local / cloud.viaHub=false force local. Adds `agentbox hub target`
(the local-or-remote seam the tray follows) and lets /api/events accept the
headless Bearer key so a remote hub's SSE works.

* docs: cloud.viaHub default, hub target, tray follows the hub config
* feat(hub): run background -i cloud jobs fully on the control box

The hub create worker now starts the agent detached (seed prompt) after creating
the box, so agentbox claude/codex/opencode -i on a cloud provider runs on the
control box (laptop off). Shares the detached-start orchestration into
@agentbox/sandbox-cloud; threads prompt/agentArgs through the worker; a failed
agent-start fails the job but keeps the box id. CLI -i defaults to the hub when
configured (--local opts out).

* test: startDetachedCloudAgent + agent-start-failure job handling

Unit tests for the shared detached-start orchestration (running/paused/missing/
verify-fail/resume) and drainOneCreateJob mapping an agentStartError to a failed
job that keeps the box id. Drop the now-unused Provider import.

* docs: background -i now runs on the control box
The create worker clones with a leased token and LFS reuses it over HTTPS (the
standard CI path) — but the box's git can carry an insteadOf rewrite pushing
github URLs to SSH, so git-lfs resolved an SSH batch endpoint and hard-failed the
create at smudge (ssh_askpass / Host key verification failed). Shared
cloneRepoWithLfs now clones with GIT_LFS_SKIP_SMUDGE=1 (never fails on LFS), then
git lfs pull with lfs.url FORCED to the authed HTTPS endpoint — the embedded
x-access-token userinfo authenticates the fetch and dodges the insteadOf rewrite,
so the real bytes come down over HTTPS. Best-effort + non-interactive + timed;
falls back to pointers with a log line. Used by both the hub resident worker and
the laptop control-plane worker. Empirically validated the forced-HTTPS pull
fetches a 512 KB LFS object where a plain clone left a 131-byte pointer.
…te-hub gaps (#250)

* refactor(cli): fold control-plane commands into the one hub group

AgentBox is unreleased, so this is a clean rename (no aliases): the hidden
`agentbox control-plane *` group is gone and its subcommands
(setup/deploy/set-url/unset-url/add/worker/credentials/secrets/project/custody/
boxes/prompts) are now surfaced under the single `agentbox hub` group. The one
hard collision, `status`, is unified by target — `agentbox hub status` reports
the configured remote control box (reachability + box/event counts) when set,
else the local hub process.

The on-disk `~/.agentbox/control-plane/` dir + `control-plane.env`, the
`relay.controlPlaneUrl` config key, the `'control-plane'` SyncTopology enum, and
the `apps/cli/src/control-plane/` source folder are intentionally untouched
(none are the CLI verb). Docs (deployed-hub/cli/configuration/api + internal
plans) updated; hub-api-consolidation Phase 5 marked done with the remaining
items (destroy-reap, web-UI seed overlay, ls -g/create --via-hub onto /api/v1)
documented as next.

* fix(hetzner): migrate Daytona JWT org id + endpoint overrides to the control box

The deploy-time provider-secret allowlist (PROVIDER_SECRET_KEYS) carried
DAYTONA_ORG_ID, but the key written + read everywhere is DAYTONA_ORGANIZATION_ID
— so a JWT-mode Daytona setup landed on the control box with the token but no org
id and failed at create (while the hub "configured" badge still showed green).
Fix the key, and also carry the endpoint/region overrides the provider env-loaders
honor (HCLOUD_ENDPOINT, E2B_DOMAIN, DAYTONA_API_URL/TARGET) so a custom-endpoint
provider keeps working remotely. Extract filterProviderSecrets (pure) + export the
allowlist, and add a sync test that pins the contract against the env-loader keys
(guards this class of drift). Docs: correct the deployed-hub key list, note the
bake-record sharing + the fingerprint-match caveat, and file the control-box
self-bake gap (the real fresh-create blocker) in the local-adoption backlog.

* fix(hub): install git-lfs + stage the e2b attach-helper in the control box

Two packaging gaps blocked a hub-routed cloud `-i` create on a freshly deployed
control box (found via live e2e):

- The hub container installed git + openssh-client but NOT git-lfs, so the
  resident worker's `git lfs pull` (the #249 forced-HTTPS LFS fetch) failed with
  "git: 'lfs' is not a git command", left pointers, and the in-box checkout then
  hard-failed on the smudge filter. Add git-lfs to the image apt install.
- The standalone hub build never staged packages/sandbox-e2b/dist/attach-helper.cjs
  next to the bundle, so `resolveAttachHelperPath` missed it and a hub-created e2b
  box came up but its `-i` agent-start failed ("e2b attach helper not found"). Copy
  it into dist-standalone/apps/hub in build-standalone.mjs.

Both verified live: with them in place a fresh control box created an e2b box from
an LFS repo, materialized the 512KB LFS object over HTTPS, started claude detached,
and the agent wrote the expected file (ground-truth).

* docs(local-adoption): note box.claudeInstall not migrated is the common bake-mismatch trigger

* fix(hub): reap the control-box registration on destroy/prune

`ControlPlaneAdminClient.reapBox()` and its `DELETE /remote/boxes/:id` route
both existed but had no call sites, so a box destroyed from the PC left its
registration + custody SSH keys behind as a ghost in `agentbox ls`, the hub UI
and the tray. Wire it into `destroy` (best-effort: an unreachable control box
warns and points at `hub boxes rm`, never fails the local destroy) and into
`prune --provider <cloud>`, which maps the orphan sandbox ids it deleted back
to registrations via the registry.

Also fixes a pre-existing bug this uncovered: `pruneBoxes` judged every record
by `docker inspect`, and a cloud box's container is the synthetic
`cloud:<sandboxId>`, so a plain `agentbox prune` deleted the state record of
every live cloud box — and with `--all`, its per-box dir holding the private
SSH key. Guard on provider, as `listBoxes` already does.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* fix(hub): point the PC's approval surfaces at the box's own relay

Every host-side approval path was pinned to http://127.0.0.1:8787, so a box
created against a control box — whose host-action approvals live on THAT plane —
showed nothing: the attach footer stayed silent, `agent approvals` reported
"nothing pending", and the dashboard's sidebar marker never lit. The tray or web
UI was the only way to unblock such a box.

The server half already worked (`adminGateAllows` admits a non-loopback caller
bearing the admin token), so this is client-side only: `subscribePrompts` /
`postAnswer` take an optional bearer, and a shared `resolveBoxPromptSource`
resolves each box's plane from its own record (`cloud.controlPlaneUrl`, which
outlives a config change) before falling back to config. The dashboard resolves
per box rather than once globally, since its sidebar spans both kinds.

`agent approve` takes an id with no box to resolve from, so it tries the local
relay and then the control box; ids are UUIDs, so that's unambiguous. Where we
can't authenticate to a known plane we say so instead of reporting an empty list.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* feat(hub): dashboard lists hub boxes like `agentbox ls` does

`mergeHubBoxes` had exactly one consumer (`list.ts`), so a box created on the
control box showed up in `agentbox ls` but was absent from the dashboard TUI.
Lift the hub-fetch + merge into a shared `listBoxesMerged()` and read it from
both, so "what boxes exist" has one answer.

Selecting a row that exists only on the control box adopts it first (reusing the
bounded `tryAutoAdopt`), since every panel and action downstream needs a local
record; if the control box can't be reached the row degrades to a read-only
placeholder naming `hub adopt` rather than "box not found".

Verified against a stub control box: the pre-change source (`listBoxes()`)
returns [] where the merged listing renders `hub-demo` in the sidebar.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* feat(hub): surface the control box's create queue from the PC

With a control box configured, background `-i` cloud runs are enqueued on the
plane's `create_jobs` queue, but `agentbox queue list` reads only the local
`~/.agentbox/queue/` — so the jobs you just submitted were invisible. The queue
was addressable by job id alone: `Store` had enqueue/get/claim/complete but no
listing, and `/remote/boxes` handled only POST and per-id GET/DELETE.

Adds `Store.listCreateJobs` (sqlite/postgres/memory + write-through pass-through,
covered by the conformance suite so no backend drifts), `GET /remote/boxes`
behind the same admin gate, an `agentbox hub jobs list|show`, and a control-box
block appended to `queue list`. Kept as its own block rather than merged into the
local table: different queue, different shape, and the table's fixed columns are
relied on for grepping — `cancel`/`clear` stay local-only and say so.

Bounded at 1.5s like `fetchHubListing`, degrading to a note. A control box
predating the route answers 405, which is reported as "redeploy the hub" rather
than as an outage.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* docs(hub): correct the CLI remote-hub audit + document the new surfaces

The audit appendix listed two gaps that were already closed (`--via-hub` for the
agent commands; the web UI driving PC-registered boxes) and missed the attach
footer entirely, which marked `attach`/`shell` as plainly "Local". Rewrite the
affected rows and split the gap list into closed / previously-mis-recorded /
still-open, with the fork-stays-local reason spelled out so it isn't re-filed as
a gap.

Public docs: `hub jobs`, the hub queue in `queue list`, `destroy` reaping the
control box, and a new "Approvals reach you wherever you are" section covering
the footer + `agent approvals` against a hub box — including that approval is
not execution (a host-filesystem action still needs the laptop up).

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* fix(hub): address bugbot findings on the remote-hub gap closures

- **Dashboard missed hub prompt relays.** `relaySourceFor` resolved each box
  from local `listBoxes()`, but the sidebar now includes rows that exist only on
  the control box — exactly the ones whose approvals are remote. They fell back
  to the laptop loopback relay, so their approval marker never lit until they
  were adopted. Resolve from the merged listing instead; `resolveBoxPlane` now
  takes the narrower `PlaneAddressable` so a registration-only row works.
- **`agent approve` misreported a missing token.** On a local 404 with a control
  box configured but no admin token, the remote attempt was skipped and it still
  printed "already resolved (or expired)" — a control box we can't ask is not
  evidence the prompt is gone. It now says which, and exits non-zero.
- **`listHubJobs` had no array guard.** A 200 whose body isn't the expected shape
  (a proxy interstitial, a hub mid-upgrade) made callers iterate `undefined`.

Also fixes a leak found while self-reviewing the same code: the pending-slot
sentinel in `syncPromptSubscriptions` was a shared singleton, so two resolvers
racing for one box (it leaves and re-enters the list mid-resolve) both matched
the slot — the loser then deleted the winner's live stream from the map, leaking
an SSE connection nothing could close and re-subscribing every poll after. Token
is now per-attempt, and a bailing attempt only clears a slot that is still its
own. Regression test fails against the old sentinel.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* fix(hub): address bugbot round 2 — dashboard reap, adopt wording, job-id prefix

- **Dashboard destroy skipped the hub reap.** `agentbox destroy` reaps the
  control-box registration, but the TUI's `destroyBoxAction` went straight to
  `provider.destroy`, leaving exactly the ghost the CLI fix removes. Same reap
  now runs on the cloud branch there.
- **`hub jobs show` couldn't take the id you can see.** `jobs list` and the
  `queue list` block print an 8-char prefix, but `show` passed it to the by-id
  route, which matches full UUIDs — so copying an id from the listing 404'd.
  `show` now falls back to resolving a prefix, and reports ambiguity rather than
  guessing.
- **Adopt-failure placeholder asserted a cause it hadn't established.** It always
  blamed an unreachable control box, but `tryAutoAdopt` also returns null when
  the hub doesn't know the box or has no admin token. Reworded to point at
  `hub adopt`, which reports the real reason.

Prefix resolution verified against a stub control box: copied 8-char prefix
resolves, ambiguous prefix lists the candidates, full UUID unchanged, bogus id
still reports not-found.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* fix(hub): address bugbot round 3 — approvals degrade, dashboard reports the reap

- **A remote relay error took down `agent approvals` entirely.** `gatherApprovals`
  awaited the mailbox fetch before merging the in-TUI rows, and that fetch throws
  on any non-OK response. That was tolerable when it was a loopback call; making
  it a WAN request to the control box turned any blip there into a command
  failure that also hid the plan/question/permission rows — which come from the
  box's own status and are entirely independent. The relay half now degrades:
  its rows are reported as missing (never as absent), the in-TUI rows still
  render, and both text and --json exit 1 so a caller can tell "couldn't read the
  mailbox" from "nothing pending". `--json` keeps its array shape on stdout (an
  orchestration contract) with the warning on stderr.
- **The dashboard ignored the reap outcome.** It called `reapOnControlBox` but
  discarded the result, so a cloud box destroyed from the TUI could vanish
  locally while its hub registration survived, with no hint — the CLI warns here.
  `destroyBox` may now return a short note the confirmation flash appends, since
  the TUI owns the screen and an action's own `log.warn` would be invisible.

Verified against a down control box: `approvals` warns and still returns,
exit 1 in both modes, and `--json` stdout stays a parseable array.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* fix(hub): a dead prompt stream must not read as "no approvals"

Same class as the round-3 finding, found auditing for it: `subscribePrompts`
retries transient drops but bails permanently on a non-200, and `run.ts` never
wired `onError`. That was harmless while the URL was always loopback — a non-200
there is near-impossible. Now a hub box streams from the control box with an
admin bearer, so a stale token answers 401, the client closes for good, and the
attach footer sits silent for the whole session while the box is blocked
waiting on an approval. Exactly the failure this change set out to remove.

Surface it in the alert band (pointing at `hub prompts`) and pass it to the
caller's error sink so it is diagnosable afterwards.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* test(hub): pin that a non-200 SSE response raises onError

The attach footer now depends on this contract: the client gives up permanently
on a non-200 (a control box rejecting a stale admin token with 401), so without
onError the footer would sit silent all session and look like "no approvals".

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* fix(hub): dashboard surfaces a degraded approvals channel, like attach does

Bugbot round 4 — both findings the dashboard's half of fixes already made for
the CLI/attach paths:

- **A permanently-failed prompt stream was invisible.** The compositor's
  `onError` was a no-op whose comment ("already reconnects with backoff") is
  false for a non-200: the client gives up for good, so a hub box whose control
  box rejects a stale token showed no sidebar marker and no rows, silently. It
  also left the dead slot in `promptStreams`, so `syncPromptSubscriptions`
  (which skips boxes already present) never reopened it for the life of the
  dashboard. Now the slot is released and the failure surfaces in the alert
  band — but reopening is throttled to 60s, since retrying a 401 on the 1s poll
  cadence would be a request per second at the control box.
- **`relaySourceFor` dropped `unauthenticatedPlane`.** `attachRelayOptions`
  warns when a box's plane is known but unauthenticated; the dashboard silently
  subscribed to loopback instead. The resolver now forwards a warning, shown in
  the same band (a TUI owns the screen, so a stderr note would be wrong here).

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
…ode (#251)

* fix(hub): a control box can use a bake made in either claudeInstall mode

`prepare` folds `box.claudeInstall` into the stored fingerprint, but that key
lives in the PC's config.yaml — a different file from secrets.env, so the deploy
allowlist never carried it and the control box fell back to the built-in
`native`. A PC set to `npm` therefore pushed an npm-fold record that the control
box's native-fold fingerprint never matched, `hydratePreparedFromCustody`
rejected it, and every cloud create failed with "run `agentbox prepare` first" —
on an identical build context. Live-confirmed 2026-07-24.

The record stores only the folded fingerprint, never the mode, and the fold is
one-way — so the mode can only be inferred. There are exactly two and `native`
is the identity fold, so one context hash yields both candidates:
`matchClaudeInstallFingerprint` returns which mode a stored fingerprint came
from. Adopting either is right — the two bases differ only in how Claude Code
was installed and are functionally equivalent, whereas rejecting means failing
every create.

Applied to both consumers, deliberately: hydrate alone would fix creates while
`/settings` kept reporting the adopted base as stale and demanding a re-bake, so
`providerBaseFreshness` now probes in native (the raw context hash, from which
the npm fold derives) and matches the same way.

Also migrates the key at deploy, so a control box that bakes its OWN base uses
the operator's mode — which matters here specifically: `npm` exists because the
native installer intermittently Cloudflare-403s datacenter egress IPs, and a
control box is one. The migration merges into the VPS's existing config rather
than regenerating it: the hub writes that same file itself
(`box.remoteDockerHost` from Settings), so a fresh file would drop its keys on
every redeploy. `mergeConfigYaml` is the pure helper for that; `setConfigValue`
is refactored onto the same parse so there is one merge implementation.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* docs(hub): correct the self-bake backlog item + document the claudeInstall migration

The backlog filed control-box self-bake as needing "the provider build machinery
+ queue integration on the control box". That machinery already ships —
`prepareProvider` enqueues a prepare job, `/api/v1/providers/:id/prepare` exposes
it, Settings has a bake button, and the web-UI create is already two-phase. Only
the worker path is missing it, which is a far smaller change; leaving the note as
written would get this re-scoped wrongly later. Also records a workaround that
works today (bake from Settings, or create the first box from the web UI) in
place of "until the control box can self-bake".

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp

* test(hub): pin the no-trailing-newline body the deploy actually reads

Surfaced while verifying the merge against the live control box: `sshExec` runs
through execa, which strips the final newline, so the body handed to the merge
legitimately lacks one.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
@madarco
madarco merged commit bf332d3 into feat/control-box-plan Jul 25, 2026
4 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant