PRD: Claude forward_host_credentials #326
Reference in New Issue
Block a user
Delete Branch "claude-forward-host-credentials"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #325.
PRD: docs/prds/prd-new-claude-forward-host-credentials.md
Summary
forward_host_credentials: truesupport foragent_provider.template: claude, mirroring the existing Codex flow (PRD 0029).bot_bottle/contrib/claude/claude_auth.pyreads the host Claude OAuth session key from~/.claude.json(oauthAccount.sessionKey) and validates presence and expiry.provisioned_env(egress sidecar environment) — agent receives onlyCLAUDE_CODE_OAUTH_TOKEN=egress-placeholder.CLAUDE_HOST_CREDENTIAL_TOKEN_REFconstant toegress.pyandCLAUDE_HOST_CREDENTIAL_HOSTStoagent_provider.py.forward_host_credentialsnow accepted forclaudeandcodex; setting bothauth_tokenandforward_host_credentialson the same bottle is rejected.--remote-controldiagnosis (Agent 4, live)Diagnosed why a bottle started with
forward_host_credentials+--remote-controlnever reaches the Claude mobile/desktop app. Verified against the runningbot-bottle-agent-4(claude image) and its egress sidecar.Root cause:
forward_host_credentialsforwards a bare access token, not the login session — an auth-mode mismatch, not a missing entitlement.What checks out
claude --dangerously-skip-permissions --remote-control --append-system-prompt-file …api.anthropic.com;GET /api/claude_code/policy_limits → 200 OKproves the credential authenticates.bridge.claudeusercontent.comis one of the 5 loaded routes in/etc/egress/routes.yaml(allowlisted, DLP off). Not the problem.What's actually wrong
claude_auth.py::claude_host_access_token()extracts onlyclaudeAiOauth.accessTokenand discardsrefreshToken/expiresAt/scopes/subscriptionType/rateLimitTier.agent_provider.py(~181–199) then setsCLAUDE_CODE_OAUTH_TOKEN="egress-placeholder"in the guest, has egress swap the real bearer onapi.anthropic.comonly, and never writes~/.claude/.credentials.jsoninto the guest (confirmed absent).So the guest runs in
CLAUDE_CODE_OAUTH_TOKENmode — the headless/CIclaude setup-tokenpath — a bare bearer with no session envelope. Live egress log:/api/claude_code/settings404s for bare-token mode, so Claude aborts before opening the bridge WebSocket (0server connect bridge.claudeusercontent.com, 0 live:443connections). On the host, Claude reads the fullclaudeAiOauthobject (session) → settings returns 200 → remote control works. Same credential, different mode.Two structural reasons the current design can't do remote control
claudeAiOauthlogin session with scopes/subscription, which is what the settings endpoint gates on.api.anthropic.com— the remote-control WebSocket goes tobridge.claudeusercontent.comand carries its own session-derived auth. The egress bearer-swap (anthropic-only) can never authenticate the bridge, so the real credential has to live inside the guest.Fix direction (with tradeoff)
Reconstitute the host login session in the guest: write the full
claudeAiOauthobject to guest~/.claude/.credentials.json(or the guest keychain), and stop forcingCLAUDE_CODE_OAUTH_TOKEN(the env var likely overrides the session file).Tradeoff: this reverses the deliberate "keep the token out of the guest, inject at egress" custody posture — the real access and refresh tokens would then sit on the guest filesystem. Remote control is fundamentally incompatible with egress-only custody, because the bridge WS auth originates in the guest.
@didericis-claude Different potential solution: could provide a
--remoteoption when starting a bot-bottle that works generically across different agent TUIs and forwards the session to a mobile client.This could be something very basic, like something that just renders piped ansi in a read only format, and then takes a text box that we then pipe in to the bottle. This could be another paid incentive to allow hacker types that like self hosting to more easily and securely act with TUIs (including for experimental models that may not have a great mobile experience yet) that run on their hardware. Thoughts?
I think this is the better direction, and the diagnosis above is actually the argument for it. Vendor-native remote-control forces two things we don't want: the real credential has to live in the guest, and the session has to route through the vendor's bridge (
bridge.claudeusercontent.comfor Claude, and whatever Codex/others use). A bot-bottle-native--remotesidesteps both — the agent session never leaves the box, the credential stays under our custody model, and it works for any TUI including experimental models with no mobile story. That's a cleaner custody story than the native path, not just a broader one.A few design notes from thinking it through:
Input model is the one fork that matters. A read-only ANSI render + a line-buffered text box is enough to submit prompts, but TUIs like Claude/Codex need real key events for the interactive bits — approval prompts (y/n), arrow-key menus, Ctrl-C, ESC. If the mobile client can only push a line of text, you can drive the happy path but not approve a tool call or cancel a run, which is exactly what you'd want a phone for. I'd go straight to a full interactive terminal (xterm.js in a webview forwards keystrokes) — it's barely more work than the read-only version and avoids painting ourselves into a corner. Read-only can be a mode, not the ceiling.
Attach without disrupting the agent. Run the agent under a PTY broker from launch (tmux-style, or a small custom pty multiplexer) so the mirror can attach/detach and survive mobile reconnects without restarting the agent. This also gives us scrollback replay on connect for free.
Transport is the hard part, and it's the paid wedge. Bottles have no inbound path — everything is outbound through egress. So
--remoteneeds an outbound-initiated reverse tunnel to a rendezvous the phone can reach. That relay is exactly the hosted (or self-hostable) control-plane piece: it's what the vendor bridge does for them, and owning it is the natural thing to charge for. It can likely ride the channel thesupervisesidecar already opens rather than being a whole new plane.Monetization: agreed, and it fits the existing framing. This is the control plane (phone-control), distinct from custody/vault. The self-hosting-hacker angle is real — "drive any TUI agent running on my own hardware, from my phone, securely, including models with no app" is a sharp pitch, and it's differentiated precisely because it's vendor-agnostic and custody-preserving.
Two things I'd want to pin down before building:
My lean: full interactive xterm mirror over a dumb E2E relay as the MVP, structured/native controls layered on later for the top agents.
@didericis-claude Am thinking full xterm as a fallback, but a dumb read only interface as the primary UX.
I think we can for the most part get away with the following:
The interactive bits are I think almost always solvable via arrow keys. As a fallback can go into full terminal/interactive mode where keystrokes get piped to the terminal. Don't really want to maintain more structured controls that require parsing more of the agent input/output, as we end up competing with harness providers and any mobile solutions specific to that harness. Our selling point should be general applicability.
RE trust boundary, yes, should be e2e encrypted.
Agreed on dropping structured controls — that's the right call for the reason you gave. The moment we parse agent semantics we're on the harness vendor's turf, we break on their next release, and we've traded the one thing that's actually defensible (works with anything that speaks a PTY) for a UX race we'd lose. Generality is the moat; keep it dumb.
One architectural sharpening on "read-only primary, xterm fallback": I don't think those are two renderers — I think it's one renderer, two input layers, and that's simpler than it sounds.
The reason: agent TUIs (Claude, Codex) aren't line-oriented output — they're full-screen ANSI apps with cursor addressing, redraws, spinners, boxes, a status line that rewrites itself. A truly "dumb" text render that just appends the output stream will show escape-sequence garbage and redraw artifacts for these. To display them correctly at all you need a terminal emulator (xterm.js). So:
\x1b[A/\x1b[B, enter =\r, and I'd add esc (\x1b), ctrl-c (\x03), and tab to your arrows — approvals and cancels need those, and that set covers virtually every Claude/Codex interactive prompt.The nice part: primary and fallback send the same thing over the wire — key bytes on the E2E channel. "Full terminal mode" isn't a second code path, it's the same pipe with a different on-screen keyboard. One renderer, one wire protocol, one thing to maintain. Still 100% general, zero output parsing.
So I'd frame it as: one xterm mirror + a curated key bar (text + arrows + enter/esc/ctrl-c/tab), with a "raw keyboard" escape hatch. Read-only is then just "don't render the input bar" — a view permission, not a separate mode.
On E2E: 👍. That makes the relay a dumb encrypted pipe that never holds plaintext, which is the strong version of the custody story. The one real crypto design question it raises is pairing — how the phone and the bottle agree on the shared key without the relay ever seeing it. Cleanest is a pairing handshake at launch (QR or short code shown by the bottle/dashboard) that establishes the key out-of-band; the relay only ever routes ciphertext between paired endpoints. Worth deciding early since it shapes the transport.
Can't we just do public key exchange? Holepunch provides primitives for establishing e2e encrypted tunnels, which is one of the reasons I wanted to use their stack.
Yeah — and if we're on the Holepunch stack that's strictly better than a relay we operate, so let me walk back the "relay routes ciphertext" framing. With HyperDHT the transport already is the answer: peers are Ed25519 keypairs,
connect(remotePublicKey)gives you a Noise-encrypted stream, and the DHT only coordinates the holepunch — when a direct P2P path can't be punched it falls back to routing through DHT nodes, but that path is still Noise E2E, so no intermediary ever sees plaintext. That satisfies the custody requirement by construction, and there's no server chokepoint for us to run.The thing I'd flag is that public-key exchange doesn't remove the pairing step — it's the safe form of it. Two sub-problems remain, and they're both good news:
And the nice part: Holepunch already ships a primitive for this —
blind-pairing/ invite codes, the same mechanism Keet uses to add a device to a room without a central server. So we probably don't hand-roll the handshake at all; the invite-code → key-exchange → allowlist flow is off the shelf. That's a good chunk of the reason to be on their stack, agreed.One architectural consequence worth naming: pure P2P means there's no hosted pipe for us to meter, which kills the "charge for the relay" idea I floated earlier — but that's fine, arguably good. "We literally cannot see or route your session" is a stronger custody pitch than owning the wire, and it pushes the paid wedge back where it belongs: the control plane (device management, pairing UX, fleet/multi-bottle, the app itself), not the bytes.
RE authorization, we could gate that by the paid control plane. So firewall would just ask the trusted relay from the paid subscription service about whether a given public key were authorized. Would also do two things: 1) means the user doesn't need to be physically next to the host to pair/can launch remotely 2) provides audit trail for authorized pairs without revealing what gets passed through.
This is the right move, and it fixes the gap I left in my last comment. I said pure P2P leaves the control plane with no data-plane role — but you've found its actual role: it's the authorization authority, not the pipe. The session stays E2E P2P (control plane never sees a byte of it), while the control plane governs who is allowed to connect. That's the control-vs-custody split applied cleanly — control plane authorizes, custody stays local — and both of your benefits fall out of it: remote pairing (authorize a device from the dashboard without being at the host) and a metadata-only audit trail (who paired with what, when — never the contents).
One sharpening on the mechanism, because there's a failure mode to avoid: don't make the firewall a synchronous online oracle that phones home on every incoming connection. If it does, remote access dies whenever the control plane is unreachable, the sub lapses, or their service blips — you've coupled a P2P feature to a hard SaaS dependency.
Better: the control plane issues a signed, short-lived capability rather than answering per-connect. Pairing = the control plane signs a grant
{device_pubkey, bottle_id, exp}with a key the bottle has pinned. The device presents that grant in the Noise handshake payload; the firewall verifies the signature offline against the pinned control-plane pubkey. That gets you all four properties at once:And keep the local allowlist as the base tier underneath it: the firewall consults local pinned keys first, then a valid signed grant. That way the free / fully-self-hosted path still works offline with no subscription (pair by pasting a pubkey), and the control-plane authority is a paid upgrade that adds remote pairing + fleet + audit — which lines up with the single-run-free / cross-run-and-fleet-paid split.
Net: control plane as the signing/audit authority over a data plane it can't read. That's the defensible version of the wedge — you're selling authorization and provenance, not bytes.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.