Uh oh!
There was an error while loading. Please reload this page.
Attach Claude Code by proxy so Remote Control keeps working (LLP 0231-0235) - #782
Conversation
…-0235) Claude Code disables Remote Control whenever `ANTHROPIC_BASE_URL` points anywhere other than api.anthropic.com. Attach repoints exactly that key, so attaching a machine costs the user Remote Control. It is a deliberate client-side gate, not a gateway bug, so no amount of improving the gateway reaches it. Proxy mode routes Claude Code through the gateway with `HTTPS_PROXY` plus a machine-local CA instead, leaving the base URL alone. The endpoint is then genuinely first-party, which also makes `ENABLE_TOOL_SEARCH` and the undocumented `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` unnecessary rather than merely unset, retiring the duty to re-verify an undocumented flag every release. Off unless `proxy_mode = true` is configured. Codex is unaffected and stays on base-URL attach, so both mechanisms coexist on one listener and one port. The aperture does not widen. A proxy sees all client egress, so two narrowings keep what is captured identical to today: - Only hosts a registered upstream names are decrypted; every other host is blind-tunnelled and never read. - Only paths an adapter's preset claims are recorded. Reusing the routing matcher would have been wrong: it accepts an `sk-ant-` bearer alone, which under a CONNECT is true of every request to the host, and a synthetic POST to an unrelated path was measured projecting 2 stored rows through that hole. Certificates are minted in-process (hand-rolled DER plus node:crypto): macOS ships LibreSSL rather than OpenSSL, Windows ships neither, and a certificate library is a large dependency for one CA and one leaf per host. The CA is name-constrained to the intercepted hosts (IPv4 and IPv6 excluded, or a leaked key would still vouch for an IP), trusted only by the attached client via its own settings file, never the system store, and deleted on detach. It lives in core rather than the gateway plugin because detach and uninstall must remove it with the plugin unloaded. Two failure modes get explicit answers, because a dead proxy breaks all of Claude Code's HTTPS rather than only its capture: attach refuses unless a CA proves proxy mode is actually running, and a listener that cannot intercept but may still have a client pointed at it serves blind tunnels so egress degrades to unrecorded-but-working. Supersedes rather than extends: LLP 0044 said the gateway records only traffic a client routes to it, and LLP 0016/0116 said it holds no secret-bearing code. Both are narrowed and argued in LLP 0231, with forward-refs on the affected docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rash on a mistyped upstream_proxy (#782) Four defects found reviewing the proxy-mode feature against LLP 0231-0235. 1. An empty routing table stranded a proxy-attached client's entire network. `launchListener` returned before the front door was ever considered, so a machine whose upstreams went away stopped binding at all - and a client attached in proxy mode has `HTTPS_PROXY` pointing at that port for ALL of its egress. LLP 0233#degrade-to-blind-tunnels already names the rule (a CA on disk means bind and blind-tunnel); only this route into it was missing. `startProxy` now accepts an empty routing table when, and only when, it is tunnel-only. 2. The damaged-marker detach branch reversed `HTTPS_PROXY` but left the CA. That branch already handles proxy markers, so it was leaving trusted signing key material behind on exactly the path where a user has least evidence anything was missed. The removal is now a shared helper both JSON branches call, with the same homeDir scoping. 3. A mistyped credential in `upstream_proxy` took the gateway down. `URL` leaves an invalid percent-escape in place and `decodeURIComponent` throws `URIError` on it, which propagated out of `compileConfig` and aborted the source start: the one outcome that compiler's contract exists to prevent. It now compiles to `undefined`, and the source reports an unusable `upstream_proxy` rather than silently connecting direct. 4. `upstream_proxy` had no test coverage at all, despite LLP 0231 recording that it has unit coverage. Added: URL compilation (including credentials and every malformed form), a real CONNECT hop through a corporate proxy carrying `Proxy-Authorization`, and a refusing proxy surfacing as 502. Also adds `@ref` annotations to `connect.js`, `tls/ca.js` and `tls/x509.js`, which carried the rationale as prose but nothing `/ref-check` or `/ref-story` could follow, and unglues a JSDoc block from the line above it in types.d.ts. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 14, 2026
Verdict: approve with fixes pushed, one design question left for youReviewed manually against LLP 0231-0235 (the
LLP numbering: clear0231-0235 are free everywhere. Checked Fixed (5d3e7eb)1. HIGH: an empty routing table stranded a proxy-attached client's whole network
Failure: a machine attached in proxy mode has Fix: probe for a CA before idling, and bind for blind tunnels when one is present ( 2. MEDIUM: the damaged-marker detach branch left the CA behind
Fix: 3. MEDIUM: a mistyped |
…f every report, and a terminated tunnel routes on the port it was trusted for (#782) Two defects from a second review pass over LLP 0231-0235. 1. A displaced `HTTPS_PROXY` was echoed, serialised and logged verbatim. The value proxy-mode attach takes over is far more likely to be corporate egress than a leftover, and such a URL routinely carries `user:pass@`. Attach printed it to stdout, put it in `--json` as `prev_value`, and pushed the same string into a `client.attach.malformed_block` log record, which an operator's own sink may ship off the machine; detach then printed and serialised it again as `restored_value`. Recording credentials is the one thing none of those surfaces may do. The userinfo now comes off every copy a human or a sink reads, via a shared `redactUrlUserinfo` beside the other display sanitisers in core's util. The copy on the marker stays verbatim, because it is the only backup the undo has: a test asserts the report is redacted, the marker is not, and detach still restores the user's own proxy byte for byte. `***@` rather than a bare strip, so a reader can still tell the value had credentials at all, and the host and port survive so the notice still names what was displaced. 2. A terminated tunnel resolved its upstream on the hostname alone. `interceptsHost` keys the trust decision on host AND port, deliberately, so that terminating `CONNECT host:8443` cannot end up forwarded to 443 (its own comment says so). `matchUpstreamByHost`, which decides where the decrypted request then goes, ignored the port, so the check was decoration: with two upstreams naming one host on different ports - an ordinary `upstreams` config, though no shipping preset does it - the request went to whichever entry sorted first, and was recorded under that entry's name and record anchor. The CONNECT port is now stamped alongside the host and both halves are matched, so a tunnel is routed to the entry that authorised it. A miss is impossible in practice, since this is only reached on a tunnel `interceptsHost` already matched, but the 502 that reports one now names the port too. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 15, 2026
Verdict: approve. Round 1's six fixes are real; two more found and fixed; three left for you, only one of which needs a decisionRound 2 of 2, reviewed Fixes for this round are pushed as c23e21d. Round 1's fixes: all six verified in the committed tree, none inertI walked each one rather than reading the diff, because the "plausible fix that is never reached" failure mode is the one that gets past a second pass. 1 (HIGH, empty routing table stranded a proxy-attached client) - real, and 2 (MEDIUM, damaged-marker detach left the CA) - 3 (MEDIUM, 4 (MEDIUM, 5 and 6 (LOW) - the six Fixed this round (c23e21d)1. MEDIUM: a displaced |
philcunliffe
commented
Aug 15, 2026
Closing: not ready. Live testing on a real machine found a blocker that the What works. Tested against real What does not. Remote Control - the entire point of this change - is only The POST succeeds, so the chat renders on the phone. The SSE stream fails with Root cause: certificate trust is split inside one process. In the same run, This invalidates the premise in LLP 0231: setting Why the obvious fixes are closed.
Ruled out by experiment, recorded so nobody re-derives them: WebSocket upgrade Branch |
…LLP 0236-0239) PR #782 closed because proxy mode silently broke Remote Control's inbound channel: Claude Code's SSE transport verifies TLS against Bun's default store, which NODE_EXTRA_CA_CERTS never reaches (LLP 0236, proven by live runs A-F). This lands the fix the experiments converged on: - Attach installs the CA as a user-domain trusted root in the login keychain - no sudo, macOS's own password dialog is the consent step - and degrades to a warning if refused (LLP 0237). - The CA becomes a ten-year credential constrained to the full static provider set (api.anthropic.com, api.openai.com, chatgpt.com), so one trust grant covers Codex later; detach keeps the CA and its trust, uninstall and the new `hyp detach --purge` remove them (LLP 0238). - NODE_USE_SYSTEM_CA=1 is delivered via `launchctl setenv` plus a login-time LaunchAgent, because no config file reaches Bun's boot-time trust store - settings.json delivery was proven too late (LLP 0239). Status reports intercept_hosts and the wider ca_permitted_hosts separately, so the trust grant stays inspectable. Also converts the LLP 0232-0235 anchor tags to headings so ref-check resolves the branch's existing @refs (16 broken refs repaired, none added). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
philcunliffe
commented
Aug 15, 2026
Reopening with the fix for the close reason. The close comment's root cause (Remote Control's inbound SSE stream verifies TLS against Bun's default store, which
The full working configuration was verified live end to end on the real Claude Code binary (runs C and D: 🤖 Generated with Claude Code |
The two old tests asserted the damaged branch deletes the CA, which dc9f9fe's lifecycle change (LLP 0238/0239: CA and trust survive detach, launchd env is released) made red on the pushed head. Replaces them with the working-tree version that asserts the new lifecycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ow (run G)
The run G acceptance test proved launchctl setenv never reaches new
windows of an already-running terminal app: windows inherit the app
process's pre-setenv environment, and terminal apps are single-process.
The old mid-attach line ("already-open terminals need a new window")
therefore pointed users at a step that cannot work, invisible to the
launchctl getenv check.
Attach now ends with a conditional final notice (proxy mode, launchd env
set, trust not refused): quit the terminal app completely and reopen it.
JSON output gains launchd_env_set for scripted callers. LLP 0239 gets a
provenance-tagged correction; the decision itself is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>philcunliffe
commented
Aug 15, 2026
Run G acceptance result: PASS (2026-08-14, macOS, real binary)The automated attach flow now delivers end to end what runs C-F proved by hand (isolated
One real finding, fixed in 4bb6400: fe8de94 additionally fixes the two damaged-marker detach tests that were red on Known follow-ups, not blockers: 🤖 Generated with Claude Code |
Conflict only in test/core/util-json-util.test.js, resolved keep-both: this branch's redactUrlUserinfo tests plus master's LLP 0225 sanitizeLabel/escapeForDisplay tests. The 15 local test failures after the merge (parquet pushdown area) reproduce identically on clean origin/master in this environment and are unrelated to the merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
philcunliffe
commented
Aug 15, 2026
What neutral was doing. Triage for PR #782 at head Why this is stuck rather than cleared. Two independent reasons, either one sufficient. 1. A substantial, security-critical body of work arrived after both review rounds and has never been reviewed. Both neutral review rounds ran at older heads (
Clearing the PR on two stale review rounds would present this work as reviewed when it is not. What would need reviewing: 2. Unresolved blocker from round 2: the CONNECT front door is an unauthenticated open forward proxy. Still true at head; none of the post-review commits touched the bind or auth path.
On the loopback default this is a preference. On a non-loopback bind it is an open relay that anyone on the network can tunnel arbitrary TCP through, a production security hole. Neutral deliberately did not harden it drive-by, because LLP 0114 Non-blockers (would ride in a follow-up issue if this were otherwise clearable):
Verified healthy at head:
Decision needed from the maintainer:
How to unstick: make the two calls above, then remove |
A CONNECT whose peer is not the machine itself is refused 403 before the target is parsed, blind tunnels included. The check is on the peer, not the bind: a non-loopback listen previously exposed only reverse-proxying to registered upstreams, and the front door made it an unauthenticated open relay to any host and port. Attach always writes http://127.0.0.1:<port> whatever the bind host, so a 0.0.0.0 install keeps working for its own client and loses nothing. Decision recorded in LLP 0233 "Loopback peers only" (review round 2, finding A, option 1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
philcunliffe
commented
Aug 15, 2026
Both calls made, unsticking. Finding 2 (open forward proxy): Option 1, fixed in 7a3bee2. The CONNECT front door now refuses any peer that is not loopback (127.0.0.0/8, Verified: front-door tests 13/13, Finding 1 (unreviewed keychain/launchd work): fresh neutral review round requested at the current head, covering Separately decided, deliberately out of scope for this PR: proxy mode will become the default for new Claude Code installs, possibly retiring base-URL attach for Claude entirely. That touches LLP 0044's consent model and the LLP 0100 first-sync privacy review, so it gets its own decision doc and PR after this merges. Removing 🤖 Generated with Claude Code |
…#782) The launchd delivery of NODE_USE_SYSTEM_CA=1 was reversed only by the marker-driven detach branch, which needs a settings file with an intact `mode: proxy` marker to read. A user who deleted ~/.claude/settings.json by hand, or whose marker was damaged past its `mode` field, ran `hyp daemon uninstall` and got the CA and its keychain trust removed while `NODE_USE_SYSTEM_CA=1` and its login LaunchAgent stayed behind, re-applied at every login on a machine HypAware was no longer installed on. The purge sweep that already ends the trust grant now releases the launchd environment too. Idempotent, so the ordinary path that already released it via the marker is unchanged, and best-effort like its neighbours: a launchctl hiccup becomes a line, never a failed uninstall. LLP 0239's Consequences already say the undo reverses the launchd pieces "on detach and uninstall"; this makes the code match. Review finding, neutral review round 3. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 15, 2026
Verdict: findings, none blockingRound 3, requested by the maintainer. Scope reviewed as a first review of Proxy mode becoming the default for new installs is out of scope and was not 1. The trust modelKey protection: good, verified by executionExercised on Linux against the real
Worth stating plainly because it is the part that actually changed: the Name constraints: really present, really enforced, and narrower than the docs claimPresent and correct, verified rather than read. The minted CA carries: Enforcement, against
So the legacy-CN case is caught too, and the IPv4/IPv6 exclusion closes the Lifecycle: no rotation path, and a re-mint fails silentlySee finding F3. Ten years is defensible for a root (roots are not subject Second machine: per-machine CA, per-machine grant, nothing shared. Lost key: Uninstall: one real residue path, now fixed
|
philcunliffe
commented
Aug 15, 2026
Triage at |
Uh oh!
There was an error while loading. Please reload this page.
Sole conflict: llp/0044's Extended-by header. Master (#782) added a second Extended-by line for LLP 0234; this branch appended LLP 0229 to the existing LLP 0086 line. Both kept. #782 changes no attach_probe manifest, so the probe-less set is unchanged (claude-desktop only) and LLP 0229's rule is unaffected.
RFC 0231 (proxy-mode capture) shipped in PR #782, but its realization is cited only through the spawned decisions 0232-0239, plus two prose mentions. The corpus therefore had no machine-readable edge from any realization back to the request, and neutral's coverage predicate reported 0231 as an uncovered request needing a design. The mode field on ClaudeAttachOptions is the one construct whose comment already cites the RFC itself (the two-transport surface, and why proxy exists at all), so this turns that prose 'See LLP 0231' into @ref LLP 0231#decision rather than minting a duplicate design doc or annotating a construct the spawned decisions already cover. Co-Authored-By: Claude <noreply@anthropic.com>
RFC 0231 (proxy-mode capture) shipped in PR #782, but its realization is cited only through the spawned decisions 0232-0239, plus two prose mentions. The corpus therefore had no machine-readable edge from any realization back to the request, and neutral's coverage predicate reported 0231 as an uncovered request needing a design. The mode field on ClaudeAttachOptions is the one construct whose comment already cites the RFC itself (the two-transport surface, and why proxy exists at all), so this turns that prose 'See LLP 0231' into @ref LLP 0231#decision rather than minting a duplicate design doc or annotating a construct the spawned decisions already cover. Co-authored-by: test <test@test.com> Co-authored-by: Claude <noreply@anthropic.com>
Why
Claude Code disables Remote Control whenever
ANTHROPIC_BASE_URLpoints anywhere other thanapi.anthropic.com. Attach repoints exactly that key, so attaching a machine costs the user Remote Control. This is a deliberate client-side gate, not a gateway bug, so no amount of improving the gateway reaches it.Proxy mode routes Claude Code through the gateway with
HTTPS_PROXYplus a machine-local CA instead, leaving the base URL alone. Because the endpoint is then genuinely first-party,ENABLE_TOOL_SEARCHand the undocumented_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URLbecome unnecessary rather than merely unset, retiring the standing duty to re-verify an undocumented flag every release.Off unless
proxy_mode = trueis configured. Codex is unaffected and stays on base-URL attach, so both mechanisms coexist on one listener and one port.The actual problem: aperture, not transport
A reverse proxy only receives what a client deliberately sends it. A proxy receives all client egress. Two narrowings keep what is captured identical to today:
sk-ant-bearer alone, which under aCONNECTis true of every request to the host. A synthetic POST to/api/eval/sdk-*carrying amessagesarray was measured projecting 2 stored rows through that hole.Capture parity was measured at 4 rows / 35 populated columns either way.
Certificates
Minted in-process (hand-rolled DER plus
node:crypto). Noopensslshell-out (macOS ships LibreSSL, Windows ships neither) and no new dependency. The CA is a ten-year credential name-constrained to the full static provider set (api.anthropic.com,api.openai.com,chatgpt.com— LLP 0238), with IPv4 and IPv6 excluded because RFC 5280 leaves an absent name form unrestricted and a leaked key would otherwise still vouch for an IP. It lives insrc/core/tls/rather than the gateway plugin because uninstall must remove it with the plugin unloaded.Trust is delivered two ways, and the second was forced by live testing (see below).
NODE_EXTRA_CA_CERTSin Claude's own settings file covers the main API client. But Claude Code is a Bun binary with two HTTP clients holding different trust stores (LLP 0236): Remote Control's inbound SSE transport verifies against the system store, whichNODE_EXTRA_CA_CERTSnever reaches. So on macOS, attach also installs the CA as a user-domain trusted root in the login keychain — nosudo, the native macOS password dialog is the consent step, refusal degrades to a warning with capture still working (LLP 0237) — and deliversNODE_USE_SYSTEM_CA=1vialaunchctl setenvplus a login LaunchAgent (LLP 0239). The earlier claim that the system trust store is never touched no longer holds on macOS; that escalation is the price of Remote Control actually working, and it is consented per machine through the OS's own dialog.The keychain grant is once per machine: detach keeps the CA and its trust so re-attach is silent, and only
hyp daemon uninstallorhyp detach --purgeremoves them (LLP 0238).Failure modes
A dead proxy breaks all of Claude Code's HTTPS, not just its model calls, so both directions get an explicit answer:
Review found two blockers, both fixed
An independent review pass reproduced both:
hyp initwritespath_prefix: "/"for the anthropic upstream, and operator config wins over the adapter preset, so reading the routing prefix as the record anchor meant the feature was dead on arrival. The record anchor now comes from the preset (record_prefix); routing stays the operator's. Every test that used a hand-written upstream missed this, so there is now one that asserts against the tablemergeUpstreamsreally compiles.502into an established TLS tunnel, becauseopenUpstreamcould call back twice. Latched, and the regression test was verified to fail without the fix.Also fixed from that pass: the missing IP name-constraint; a
permittedHostsbyte-scan that invented phantom hosts (~1 CA in 700) and regenerated the CA every boot for hosts it could not read; no key/cert match check on a stored CA; a base-URL attach on top of a proxy marker destroying the user's ownANTHROPIC_BASE_URL; detach resolving the CA from the ambient home rather thanhomeDir; and a re-attach swallowing a hand-editedHTTPS_PROXY.A second review round (see PR comments) fixed a displaced
HTTPS_PROXYcredential reaching stdout,--jsonand a log record (redactUrlUserinfo, on every display sink; the on-disk undo copy stays verbatim), and a terminated tunnel resolving its upstream on hostname alone where the trust decision was made on host and port.Live testing forced the trust-model change, then passed
The PR was briefly closed when a live run found Remote Control only half working: capture succeeded while the SSE inbound stream failed TLS verification forever, because of the split trust stores described above. The keychain + launchd design (LLP 0236-0239, commit dc9f9fe) is the fix; the full flow was then verified end to end on the real binary (run G): one password dialog on first attach, silent re-attach,
SSETransport: Connectedwith phone messages arriving while capture recorded in the same window, detach restoring settings byte-identical.Post-review hardening: the CONNECT front door answers loopback peers only
Review round 2's finding A, resolved as option 1 (7a3bee2): a
CONNECTfrom any peer that is not the machine itself is refused 403 before the target is parsed, blind tunnels included. The check is on the peer, not the bind, so a non-loopbacklistenkeeps working for its own client (attach always writeshttp://127.0.0.1:<port>) while no longer being an open relay for its network. Recorded in LLP 0233 ("Loopback peers only").Design docs
LLP 0231 (RFC) plus narrow decisions 0232-0235, with forward-refs added to 0016, 0044, 0045, 0114, 0116 and 0206. The live-testing pivot added LLP 0236 (research: the split trust stores) and decisions 0237-0239 (keychain trust, the long-lived full-provider CA,
NODE_USE_SYSTEM_CAvia launchd).This supersedes settled decisions rather than extending them, and the RFC argues each: LLP 0044 said "the gateway records only traffic a client actually routes to it", and LLP 0016/0116 said the gateway holds no secret-bearing code. LLP 0114's
#interception-acceptedthreat model is revisited by name, and its conclusion holds.Testing
7a3bee2: 4165/4168 pass (2 skipped);npm run typecheckclean;npm run build:typesclean.a query whose heap growth exceeds the execution budget...) is GC-sensitive under full-suite load and passes in isolation with and without this branch's changes.claude_attach_detach,client_attach_idempotent,client_attach_on_join,gateway_claude_capture,gateway_codex_capture,daemon_foreground_start_stop,status_diagnostics,hypignore_capture_drop. (walkthrough_picker_to_first_querywas already red on master.)permitted subtree violationnegative, the CA lifecycle, the CONNECT front door (terminate, blind tunnel, mid-tunnel reset, shutdown), proxy-mode recording gates, keep-alive across a reused tunnel, and proxy attach/detach incl. mode migration in both directions.Not done
upstream_proxycorporate-proxy chaining has unit coverage but no real enterprise-proxy test, and it currently accepts anhttps:proxy URL it would dial in cleartext (review round 2 finding B — follow-up).hyp statustrust-state surfacing has helpers but is unwired, and a future CA rotation re-prompts and strands the prior same-name keychain entry (noted in the run G comment).Decided, deferred to its own PR: proxy mode becomes the default for new Claude Code installs, possibly retiring base-URL attach for Claude entirely. That touches LLP 0044's consent model and the LLP 0100 first-sync privacy review, so it gets its own decision doc after this merges.
🤖 Generated with Claude Code