fix(hub,cli): loopback-gate custody byte-read; repoint box-scoped cmds onto owning hub - #291
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Owner
Author
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit a26c454. Configure here.
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.
Three tracked follow-up fixes found while reviewing merged
/api/v1consolidation 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:
0.0.0.0(it had to: docker boxes must reach the embedded relay athost.docker.internal:8787).Together,
GET /api/v1/custody/<path>returned real credential bytes — agent creds,.envfiles, 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.jsonwith only the hub token returned a base64datafield.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).offis unchanged (whole API open).custodyByteReadAuthorized(apps/hub/lib/custody-auth.ts) gains anisLoopbackarg: token profile returns it, password unchanged, off unchanged.PEER_LOOPBACK_HEADER(apps/hub/lib/peer.ts).server.ts'suiHandlerstamps 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).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):
x-agentbox-peer-loopback: 1GET /custodyover LAN IPTests: kept Step 10's two-tier tests, added a non-loopback-refused case in
custody-auth.test.tsand a newpeer.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
withOwningHubStep 7 shipped
services,services restart,url,screen, andstatus --set-name(rename) on plainwithHubClient({})(default target resolution). Step 5 later addedboxOwningHubIsLocal/withOwningHubprecisely because default resolution is wrong for a box-scoped op: withrelay.controlPlaneUrlset, 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 freshprovider === 'docker'checks —withOwningHub/boxOwningHubIsLocalare the single ownership predicate.url/screenreturn a value, so they capture the payload URL via a closure variable (the op returnsvoid) and fall through to the provider path onnull/not-found.Classification (as requested):
withOwningHub:services list,services restart,url,screen,status --set-name/--clear-name(rename). All five.withHubClient({}): none in Step 7's set. (The precedent — Step 9'scheckpoint lsandprune --provider— was already correct and untouched.)Verified end-to-end (fails-before / passes-after): with
relay.controlPlaneUrlpointing at a separate non-owning control box (a second hub) and a docker box present, and the machine not exposed:url fix2smoke --printbox not found(exit 2)http://fix2smoke.localhost:1355(exit 0)status fix2smoke --set-name xbox … not found(exit 2)renamed …(exit 0)services fix2smokeI proved the wrong-hub routing directly too: the control box returns
404for the docker box's id while the local hub returns200. Plain local-hub mode (nocontrolPlaneUrl) is unregressed — all three still succeed.Fix 1 —
prepare --name: already wired, added coverageThe review flagged the
-n, --nameflag as never re-added, but it is present (commands/prepare.ts) and threaded end to end:opts.name→runPrepare→runPrepareViaHub→bakeViaHub→HubApiClient.prepareProvider({name})→ request body →parseProviderPrepare→enqueuePrepareJob→QueueJobPrepare. Step 1 (#283) landed the flag and the plumbing together. Since a Daytona bake can't run in a unit test, addedpackages/relay/test/queue-prepare-name.test.tsassertingenqueuePrepareJob({name})landsprepare.nameon the persisted job manifest. No other change.Verification summary
pnpm typecheck— 31/31 green.apps/web/content/docs/api.mdxcustody row updated (localhost byte-read is now loopback-only).Docs/plan:
docs/hub-api-single-path-plan.mdticked 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.custodyByteReadAuthorizedrequiresisLoopbackfor the token profile;server.tsstamps a trustedx-agentbox-peer-loopbackheader 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,statusrename) switch from defaultwithHubClienttowithOwningHub, so docker boxes hit the local owning hub instead of a configured remote control box (not_found).services/statuscallreportBoxNotOnAnyHubwhen appropriate.Docs/tests: API docs and plan doc updated;
custody-auth+peerunit tests; relay test thatprepare --namepersists on the job manifest.Reviewed by Cursor Bugbot for commit a26c454. Configure here.