feat(control-box): local adoption — the laptop as a thin client of the control box#241
Merged
Merged
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.
|
- 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
Owner
Author
|
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
Owner
Author
|
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
Owner
Author
|
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
Owner
Author
|
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
Owner
Author
|
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) |
Owner
Author
|
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) |
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.
* 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.