Skip to content

fix(hub,cli): loopback-gate custody byte-read; repoint box-scoped cmds onto owning hub - #291

Merged
madarco merged 1 commit into
feat/cli_api_consolidationfrom
agentbox/fixups-name-routing
Jul 30, 2026
Merged

fix(hub,cli): loopback-gate custody byte-read; repoint box-scoped cmds onto owning hub#291
madarco merged 1 commit into
feat/cli_api_consolidationfrom
agentbox/fixups-name-routing

Conversation

@madarco

@madarco madarco commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Three tracked follow-up fixes found while reviewing merged /api/v1 consolidation work (docs/hub-api-single-path-plan.md). Base: feat/cli_api_consolidation.

Fix 3 (priority) — SECURITY: loopback-gate the custody byte-read on the token profile

An emergent issue from two independently-correct merges — not a defect in either alone:

  • Step 2 made the localhost hub bind 0.0.0.0 (it had to: docker boxes must reach the embedded relay at host.docker.internal:8787).
  • Step 10 let the hub token alone authorize custody byte-reads on the token profile, on the premise that a local hub is a single trusted machine — true only while the hub was loopback-only.

Together, GET /api/v1/custody/<path> returned real credential bytes — agent creds, .env files, and per-box SSH private keys — to anyone on the LAN who obtains the hub token once. And the hub prints its open URL with ?token=…, so that token lands in terminal scrollback and browser history. Confirmed on this host: curl http://<LAN-IP>:8787/api/v1/custody/agents/claude/.credentials.json with only the hub token returned a base64 data field.

Fix: peer-gate the byte-read to loopback on the token profile, exactly the way /admin/* already is (adminGateAllows). The password profile is unchanged (still requires the admin token; behind Caddy every request looks loopback anyway). off is unchanged (whole API open).

  • custodyByteReadAuthorized (apps/hub/lib/custody-auth.ts) gains an isLoopback arg: token profile returns it, password unchanged, off unchanged.
  • The loopback verdict rides a trusted header PEER_LOOPBACK_HEADER (apps/hub/lib/peer.ts). server.ts's uiHandler stamps it from the real socket peer after deleting any client-supplied copy, so a remote caller can't forge it (the custom server owns the socket).
  • The manifest/list route is untouched — it correctly returns path/size/sha256/mode/updatedAt only.

Nothing legitimate breaks: docker boxes never byte-read custody (they use /rpc); the PC reads custody over loopback; a remote PC pulling from a control box is the password-profile admin-token path.

Verified live (from the committed build):

call pre-fix post-fix
byte-read over loopback (127.0.0.1) + hub token data data (unchanged)
byte-read over LAN IP (172.17.0.2) + hub token data (LEAK) HTTP 401
byte-read over LAN IP + forged x-agentbox-peer-loopback: 1 HTTP 401 (unspoofable)
manifest GET /custody over LAN IP 200 (metadata) 200 (metadata) — unaffected

Tests: kept Step 10's two-tier tests, added a non-loopback-refused case in custody-auth.test.ts and a new peer.test.ts. Plan-doc note added under Step 10, plus a Step 12 guard so the peer gate isn't undone when hub binding is revisited.

Fix 2 — repoint Step 7's box-scoped commands onto withOwningHub

Step 7 shipped services, services restart, url, screen, and status --set-name (rename) on plain withHubClient({}) (default target resolution). Step 5 later added boxOwningHubIsLocal/withOwningHub precisely because default resolution is wrong for a box-scoped op: with relay.controlPlaneUrl set, a docker/remote-docker box's op goes to the remote hub that never owned it → not_found.

Repointed all five onto withOwningHub(box, op). No fresh provider === 'docker' checks — withOwningHub/boxOwningHubIsLocal are the single ownership predicate. url/screen return a value, so they capture the payload URL via a closure variable (the op returns void) and fall through to the provider path on null/not-found.

Classification (as requested):

  • Box-scoped → withOwningHub: services list, services restart, url, screen, status --set-name/--clear-name (rename). All five.
  • Project/fleet-scoped → left on withHubClient({}): none in Step 7's set. (The precedent — Step 9's checkpoint ls and prune --provider — was already correct and untouched.)

Verified end-to-end (fails-before / passes-after): with relay.controlPlaneUrl pointing at a separate non-owning control box (a second hub) and a docker box present, and the machine not exposed:

command pre-fix binary post-fix binary
url fix2smoke --print box not found (exit 2) http://fix2smoke.localhost:1355 (exit 0)
status fix2smoke --set-name x box … not found (exit 2) renamed … (exit 0)
services fix2smoke unreachable (exit 1) live service table (exit 0)

I proved the wrong-hub routing directly too: the control box returns 404 for the docker box's id while the local hub returns 200. Plain local-hub mode (no controlPlaneUrl) is unregressed — all three still succeed.

Fix 1 — prepare --name: already wired, added coverage

The review flagged the -n, --name flag as never re-added, but it is present (commands/prepare.ts) and threaded end to end: opts.namerunPreparerunPrepareViaHubbakeViaHubHubApiClient.prepareProvider({name}) → request body → parseProviderPrepareenqueuePrepareJobQueueJobPrepare. Step 1 (#283) landed the flag and the plumbing together. Since a Daytona bake can't run in a unit test, added packages/relay/test/queue-prepare-name.test.ts asserting enqueuePrepareJob({name}) lands prepare.name on the persisted job manifest. No other change.

Verification summary

  • pnpm typecheck — 31/31 green.
  • Unit tests: hub (107), relay (419 + the new prepare-name test), CLI (1228).
  • Security fix + Fix 2 exercised end-to-end as above (ground truth, not exit codes).
  • apps/web/content/docs/api.mdx custody row updated (localhost byte-read is now loopback-only).

Docs/plan: docs/hub-api-single-path-plan.md ticked the Step 9 follow-up (Fix 2), added the emergent Step 2+10 note + Step 12 guard (Fix 3), and a Step 1 note (Fix 1).

https://claude.ai/code/session_019SbBaZ96vZ35ZNfxAFxtZG


Note

High Risk
Changes custody authorization and hub routing for commands that touch credentials and box state; security fix is intentional but must stay fail-closed on LAN peers.

Overview
Security: On a local hub (token auth), GET /custody/{path} byte-reads are now loopback-only. custodyByteReadAuthorized requires isLoopback for the token profile; server.ts stamps a trusted x-agentbox-peer-loopback header from the real socket peer (client forgery stripped). Password-profile control boxes still need the admin token; metadata list/PUT/DELETE unchanged.

CLI routing: Box-scoped commands (services, url, screen, status rename) switch from default withHubClient to withOwningHub, so docker boxes hit the local owning hub instead of a configured remote control box (not_found). services/status call reportBoxNotOnAnyHub when appropriate.

Docs/tests: API docs and plan doc updated; custody-auth + peer unit tests; relay test that prepare --name persists on the job manifest.

Reviewed by Cursor Bugbot for commit a26c454. Configure here.

…s onto owning hub

Three tracked follow-up fixes found reviewing merged /api/v1 consolidation work.

SECURITY (emergent Step 2 + Step 10): the custody byte-read `GET /api/v1/custody/<path>`
returned credential bytes (agent creds, .env, per-box SSH PRIVATE keys) to any LAN
caller holding the hub token on the token profile. Step 2 made the localhost hub bind
0.0.0.0 (docker boxes must reach the embedded relay); Step 10 let the hub token alone
authorize byte-reads on the premise of a single trusted machine — true only while the
hub was loopback-only. Now peer-gated to loopback on the token profile, the same way
/admin/* is (adminGateAllows): custodyByteReadAuthorized gains an isLoopback arg (token
profile returns it; password unchanged = admin token; off unchanged). The verdict rides
a trusted header (PEER_LOOPBACK_HEADER) that server.ts's uiHandler stamps from the real
socket peer after stripping any client-supplied copy (unspoofable). Manifest/list route
unaffected. Kept the two-tier tests, added a non-loopback-refused case + peer.test.ts.

Fix 2: repoint Step 7's box-scoped commands (services, services restart, url, screen,
status --set-name rename) from plain withHubClient({}) onto withOwningHub(box, op) so a
docker box under a configured remote hub reaches its LOCAL owning hub instead of failing
not_found. No fresh provider === 'docker' checks. Project/fleet-scoped calls left on
withHubClient.

Fix 1: prepare --name was already present and wired end to end; added
queue-prepare-name.test.ts asserting the value lands on the persisted QueueJobPrepare
manifest (a Daytona bake can't run in a unit test).

Docs: api.mdx custody row + plan-doc notes (incl. a Step 12 guard so the peer gate
isn't undone when hub binding is revisited).

Claude-Session: https://claude.ai/code/session_019SbBaZ96vZ35ZNfxAFxtZG
@vercel

vercel Bot commented Jul 30, 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 Jul 30, 2026 4:19am

Request Review

@madarco

madarco commented Jul 30, 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 a26c454. Configure here.

@madarco
madarco merged commit ea76829 into feat/cli_api_consolidation Jul 30, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 30, 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