diff --git a/AGENTS.md b/AGENTS.md index d87b9819..a4c44682 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,14 @@ Written acceptance procedures: Proves Desktop traffic reaches `ai_gateway_messages` by both the live gateway route and the `~/.codex/sessions` backfill route, and is attributable via `entrypoint`. See `docs/ACCEPTANCE.md`. +- `claude_proxy_capture`: opt-in/manual, needs a real Mac. Proves proxy-mode + attach against a real installed daemon and login keychain: attach writes + only `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS`, the keychain trust dialog + names every intercepted provider host, `NODE_USE_SYSTEM_CA` reaches the + launchd environment, a Claude Code session lands rows in + `ai_gateway_messages` while Remote Control's inbound channel keeps + working, and detach, purge and uninstall each remove exactly their own + residue. See `docs/ACCEPTANCE.md`. Good acceptance smoke candidates (no written procedure yet): diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index cbacae8f..941a94bf 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -515,6 +515,349 @@ procedure checks, R11 in particular), [LLP 0172](../llp/0172-openclaw-two-lane-c --- +## `claude_proxy_capture` + +**What it proves:** that proxy-mode attach ([LLP 0231](../llp/0231-proxy-mode-capture.rfc.md), +design [LLP 0245](../llp/0245-proxy-mode-capture.design.md)) works end to end +against a real, installed daemon and a real macOS keychain: attach writes +only the two proxy-mode environment keys into `~/.claude/settings.json`, the +keychain trust dialog names every intercepted provider host, the launchd +environment carries `NODE_USE_SYSTEM_CA`, a live Claude Code session lands +rows in `ai_gateway_messages` while Remote Control's inbound channel keeps +working (the reason this design exists at all), `hyp status` reports the +trust state, and detach, purge and uninstall each remove exactly what they +are supposed to and nothing more. + +**What it does not prove:** anything about the Codex adapter (Codex stays on +base-URL attach and is out of this design's scope), anything about +`upstream_proxy` field-testing against a corporate egress proxy (an open +item, not covered here), the exact Bun trust-store behaviour underlying +[LLP 0236](../llp/0236-claude-code-split-trust-stores.research.md) (its +canary caveat is a standing risk this procedure can only observe the +symptom of, not the cause), or fleet forwarding. It also proves nothing +about base-URL attach, which is the default and is already exercised by +`hyp smoke gateway_claude_capture` and `hyp smoke client_attach_idempotent`. + +**Requires:** + +- A real Mac (the keychain trust dialog and `launchctl` env delivery are + Darwin-only; see [LLP 0237 #darwin-only](../llp/0237-attach-trusts-ca-in-login-keychain.decision.md#darwin-only)). + Do not attempt this on Linux; the design states Remote + Control inbound is unsupported there under proxy mode and there is no + dialog or launchd table to check. +- Claude Code installed and signed in, with Remote Control reachable from a + second device (the mobile app or web) so step 4 has something to pair + against. +- HypAware installed from the package under test, with no prior HypAware CA + trusted in the login keychain (a machine that has run this procedure + before will not see the dialog again in step 2; that is expected, not a + failure, per + [LLP 0238 #ca-survives-detach](../llp/0238-long-lived-ca-full-provider-constraints.decision.md#ca-survives-detach)). + If you need a clean first-trust observation, run `hyp detach claude --purge` + **and then `hyp daemon restart`**, and confirm + `~/.hyp/hypaware/tls/ca-cert.pem` is back before you start: purge deletes + the CA, the running daemon holds its own copy in memory and never re-mints, + and step 2 reads that file to decide which mode to attach in. Attaching + against the gap silently produces a base-URL attach. + +**Related:** [LLP 0231](../llp/0231-proxy-mode-capture.rfc.md) (the request), +[LLP 0245](../llp/0245-proxy-mode-capture.design.md) (the design this +procedure gates, sections 1, 4 and 6), +[LLP 0237](../llp/0237-attach-trusts-ca-in-login-keychain.decision.md) +(keychain trust), [LLP 0238](../llp/0238-long-lived-ca-full-provider-constraints.decision.md) +(CA and trust survive detach), [LLP 0239](../llp/0239-node-use-system-ca-via-launchd.decision.md) +(the launchd delivery and its terminal caveat). + +### Steps + +1. Turn proxy mode on and do a **real** daemon install and start (not + `hyp daemon foreground`; this procedure exists specifically to exercise + the real launchd service and the real keychain, which a foreground dev + run never touches): + + ```sh + cp ~/.hyp/hypaware-config.json ~/.hyp/hypaware-config.json.pre-acceptance + jq '.plugins |= map(if .name == "@hypaware/ai-gateway" + then .config.proxy_mode = true else . end)' \ + ~/.hyp/hypaware-config.json > /tmp/hypaware-config.json \ + && mv /tmp/hypaware-config.json ~/.hyp/hypaware-config.json + hyp daemon install + hyp daemon start + hyp daemon restart # reload the config edit above + hyp status + ls ~/.hyp/hypaware/tls/ca-cert.pem + ``` + + The backup copy is how you put your machine back afterwards; nothing below + restores the original `proxy_mode` for you. The `restart` is not + redundant: a daemon that was already installed and running does not re-read + the config on `install`/`start`, so without it the CA check at the end of + this step fails for a reason that has nothing to do with the feature. + + Pass condition: `hyp status` shows the daemon running and, under + `sources:`, the gateway source `[started]`, and the CA file exists. Check + all three: the `jq` edit is a silent no-op if the config has no + `@hypaware/ai-gateway` entry, and on a centrally managed host a local + `proxy_mode` write loses the LLP 0031 layer merge outright. The CA is the + only proof that proxy mode is actually being *served*, and step 2 reads + exactly that file to decide which mode to attach in + ([LLP 0245 #claude-attach](../llp/0245-proxy-mode-capture.design.md#claude-attach), + realizing [LLP 0232 #proxy-attach-preflight](../llp/0232-claude-attaches-by-proxy.decision.md#proxy-attach-preflight)). + With no CA, `hyp attach claude` does not refuse, + it quietly attaches in base-URL mode instead, and every step below would + then be measuring the wrong feature. + +2. Attach Claude Code and watch for the keychain dialog: + + ```sh + hyp attach claude + ``` + + A macOS password/consent dialog should appear during this command on a + machine with no prior HypAware trust, naming **HypAware Local CA** as the + certificate and offering to add it as a trusted root. Read the dialog + text: it must name (or the surrounding attach output must state) all + three hosts the CA is constrained to, `api.anthropic.com`, + `api.openai.com`, and `chatgpt.com`, not only the one this attach is for + ([LLP 0238 #full-provider-constraints](../llp/0238-long-lived-ca-full-provider-constraints.decision.md#full-provider-constraints): + one certificate, one dialog, every provider, + and the grant must be informed about all of them). Approve it. + + Confirm the settings write: + + ```sh + cat ~/.claude/settings.json | jq '.env' + ``` + + Pass condition: `env` carries exactly `HTTPS_PROXY` and + `NODE_EXTRA_CA_CERTS` and nothing else HypAware manages for proxy mode. + In particular `ANTHROPIC_BASE_URL`, `ENABLE_TOOL_SEARCH`, and + `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` must be **absent** (or, if this + machine previously ran base-URL attach, must have been released by the + mode migration, not merely left stale) + ([LLP 0232 #mode-migration](../llp/0232-claude-attaches-by-proxy.decision.md#mode-migration)). + `NODE_EXTRA_CA_CERTS` must point at + `~/.hyp/hypaware/tls/ca-cert.pem` (or + `$HYP_HOME/hypaware/tls/ca-cert.pem`). + +3. Confirm the launchd environment, respecting the terminal caveat: only + processes launchd starts **after** the `setenv` see the variable, and a + terminal app is single-process, so a window that predates step 2 will not + see it even though it is genuinely set + ([LLP 0239 #terminals-predating-attach](../llp/0239-node-use-system-ca-via-launchd.decision.md#terminals-predating-attach)). + Read attach's own final output line first: + it states this caveat explicitly. Then, in the **same terminal window** + you ran step 2 in (do not open a new window yet): + + ```sh + launchctl getenv NODE_USE_SYSTEM_CA + ``` + + Pass condition: `1`. This reads launchd's table directly, so it passes + regardless of the terminal caveat above; the caveat only matters for + whether a *shell's own* `$NODE_USE_SYSTEM_CA` is set, which this command + does not test. Now **fully quit and reopen your terminal application** + (closing the window is not enough; the app itself must relaunch) and + confirm the shell environment a freshly-launched Claude Code process + would actually inherit: + + ```sh + echo $NODE_USE_SYSTEM_CA + ``` + + Pass condition: `1`, only after the full quit-and-reopen. A pre-existing + window still printing empty here is the expected, documented gap, not a + failure; do not file it. + +4. Hold a Claude Code session and confirm both halves of the RFC's point: + capture works, and Remote Control still does too. + + ```sh + hyp query sql "select count(*) from ai_gateway_messages" + ``` + + Note the count, then **fully quit and reopen Claude Code** (it reads + `settings.json` at launch) and hold a short conversation: send at least + one message and let it answer. + + ```sh + TODAY=$(date -u +%Y-%m-%d) + hyp query sql " + select entrypoint, client_name, count(*) n, max(message_created_at) last_seen + from ai_gateway_messages + where date >= '$TODAY' + group by 1, 2 + order by last_seen desc" + ``` + + Pass condition: a new row whose `client_name` names Claude, `entrypoint` + is populated, and `last_seen` is inside the last few minutes, with the + total count grown against the value noted above. + + Now, without quitting Claude Code again, pair the second device to + Remote Control (or, if already paired from before this procedure, send + one instruction from it) and confirm it drives this session. Pass + condition: Remote Control connects and controls the session normally, + with no "Remote Control is only available when using Claude via + api.anthropic.com" refusal. This is the one observation the whole design + exists to produce: base-URL attach would have failed this step. + +5. Confirm `hyp status` reports the trust state: + + ```sh + hyp status + hyp status --json | jq '.proxy_trust' + ``` + + Pass condition: the text output has a `proxy trust:` block naming the CA + fingerprint, `login keychain: trusted`, and + `launchd env: NODE_USE_SYSTEM_CA=1 set`; the JSON + carries the same three facts as `ca_fingerprint`, `ca_trusted: true`, and + `launchd_env_set: true` + ([LLP 0245#status](../llp/0245-proxy-mode-capture.design.md#status), + `ProxyTrustReport`). + +6. Detach and confirm the CA and its trust survive, per + [LLP 0238 #ca-survives-detach](../llp/0238-long-lived-ca-full-provider-constraints.decision.md#ca-survives-detach): + + ```sh + hyp detach claude + cat ~/.claude/settings.json | jq '.env' + launchctl getenv NODE_USE_SYSTEM_CA + hyp status --json | jq '.proxy_trust' + security find-certificate -c "HypAware Local CA" ~/Library/Keychains/login.keychain-db + ``` + + Pass condition: `env` no longer carries `HTTPS_PROXY` or + `NODE_EXTRA_CA_CERTS` (restored from the marker's `prev_env`, or removed + if none existed); `launchctl getenv` now prints nothing (the launchd + environment and its LaunchAgent are removed, since that half is + recreatable for free and follows the attach per + [LLP 0239#launchctl-setenv](../llp/0239-node-use-system-ca-via-launchd.decision.md#launchctl-setenv)); + but `hyp status --json`'s `.proxy_trust.ca_trusted` is still `true` and + the `security find-certificate` call still finds the CA. Re-attaching + after this point should show no keychain dialog, proving the trust grant + really did survive: + + ```sh + hyp attach claude + ``` + +7. Purge, then uninstall, and confirm every artifact this design created is + gone: + + ```sh + hyp detach claude --purge + security find-certificate -c "HypAware Local CA" ~/Library/Keychains/login.keychain-db + ls ~/.hyp/hypaware/tls/ 2>&1 || echo 'tls dir gone' + ``` + + Pass condition: `security find-certificate` now fails to find the + certificate, and the `tls/` directory under the state root is gone or + empty of key material. + + ```sh + hyp daemon uninstall + ls ~/Library/LaunchAgents/ | grep hyperparam || echo 'no hypaware launchd residue' + ``` + + Pass condition: no HypAware daemon or `node-system-ca` LaunchAgent plist + remains under `~/Library/LaunchAgents`, and `hyp status` (if you still + have the binary) reports no running daemon. + + Then, if this is your working machine, re-install and re-attach so you do + not silently leave it uncaptured: + + ```sh + hyp daemon install + hyp daemon start + ls ~/.hyp/hypaware/tls/ca-cert.pem # wait for this before attaching + hyp attach claude + cp ~/.hyp/hypaware-config.json.pre-acceptance ~/.hyp/hypaware-config.json + ``` + + Do not skip the `ls`. The gateway mints the CA after it boots and + `hyp attach claude` does not wait for it, so an attach fired immediately + after `start` can find no CA and silently write a base-URL attach, + leaving your working machine with Remote Control broken. Restore the + config backup last, and `hyp daemon restart` after it if you want the + machine back on its original mode. + +### If it fails + +- `hyp attach claude` reports a base-URL attach, or writes + `ANTHROPIC_BASE_URL` instead of the two proxy keys, and no dialog appears: + the gateway is not serving proxy mode, so there is no CA and attach fell + back to base-URL rather than refusing. Check that step 1's + `proxy_mode: true` edit actually landed and that `hyp daemon restart` (or a + fresh `hyp daemon start`) ran after it, then re-check + `~/.hyp/hypaware/tls/ca-cert.pem`. Do not treat the silent fallback as a + pass: it is the whole reason step 1 checks for the CA file. The one hard + refusal on this path (`CA_MISSING`) fires only when proxy mode was already + selected and the certificate became unreadable between the probe and the + write. +- No dialog in step 2 on a machine that has never run this procedure: + confirm no earlier HypAware install already trusted a CA under this same + login keychain (`security find-certificate -c "HypAware Local CA" ...`); + if one exists, `hyp detach claude --purge` first for a clean observation, + then `hyp daemon restart` and confirm `~/.hyp/hypaware/tls/ca-cert.pem` is + back before re-attaching. Purge deletes the CA and never touches config, + and the running daemon keeps serving the copy it loaded at boot rather than + re-minting, so a `hyp attach claude` in that window finds no CA and quietly + writes a base-URL attach instead of raising the dialog again. That reads + identically to "still no dialog", so check the CA file rather than the + symptom. A refused or dismissed dialog is not a failure on its own: + attach still completes and states plainly that Remote Control inbound will not work + ([LLP 0237 #attach-anyway-on-refusal](../llp/0237-attach-trusts-ca-in-login-keychain.decision.md#attach-anyway-on-refusal)); + re-running `hyp attach claude` retries the dialog. +- Step 3's `echo $NODE_USE_SYSTEM_CA` is empty even after a full quit and + reopen of the terminal application: confirm you quit the *application*, + not just the window (window managers and some terminal emulators keep the + process alive across "close window"); confirm `launchctl getenv` (which + does not have this caveat) shows `1` first to isolate whether the launchd + side or the terminal side is the gap. +- Step 4 shows new rows but Remote Control still refuses: check + `~/.claude/settings.json` for a lingering `ANTHROPIC_BASE_URL` (mode + migration should have released it; a stale value here means migration + regressed), then check `hyp status --json | jq '.proxy_trust.ca_trusted'` + for `false` (an untrusted CA is exactly the state that leaves capture + working and Remote Control inbound broken, per + [LLP 0245#failure-modes](../llp/0245-proxy-mode-capture.design.md#failure-modes)). + If both are clean, and the refusal names the *account* rather than the base + URL ("Remote Control environments are not available for your account"), it + is the absolute-form regression, not the trust story: the bridge client + sends absolute-form requests straight at the proxy port and the gateway + must route them by the host the request line names + ([LLP 0246](../llp/0246-remote-control-absolute-form-requests.issue.md), + [LLP 0247](../llp/0247-absolute-form-third-front-door.decision.md)). + Otherwise this may be the LLP 0236 canary: an upstream Bun + behaviour change silently breaking Remote Control's trust-store lookup is + out of this design's control; file it against claude-code#75050 rather + than against this procedure. +- Step 4 shows no new rows at all: confirm `proxy_mode_error` is absent from + the gateway source's details in the daemon status file, which is where the + source publishes it (`hyp daemon status --json | jq '.sources[] | + select(.plugin == "@hypaware/ai-gateway") | .details'`; note `hyp status + --json`'s own `.sources` carries only name, plugin and state, not the + details block). A CA-preparation failure at boot degrades the gateway to + reverse-proxy-only, so an attached proxy-mode client's traffic never gets + terminated. Then confirm you fully quit and reopened Claude Code so it + re-read `settings.json`. +- Step 6 finds the CA no longer trusted, or the `tls/` directory gone, right + after a plain `hyp detach claude` (no `--purge`): this is the LLP 0238 + regression to watch for specifically, since keeping the CA and trust + across detach is the entire point of that decision; do not treat "detach + cleaned up everything" as a pass here the way it would for the settings + keys. +- Step 7 still finds the certificate after `hyp detach claude --purge`: + confirm `purgeProxyTrustResidue` actually ran (its output names the + keychain and CA removal explicitly); a permission prompt cancelled + mid-purge can leave the keychain entry behind while still removing the CA + files on disk, which is worth filing as its own gap rather than assuming + the whole purge silently no-op'd. + +--- + ## Other candidates `CLAUDE.md` lists further acceptance candidates that have no written diff --git a/llp/0231-proxy-mode-capture.rfc.md b/llp/0231-proxy-mode-capture.rfc.md index 8fe82706..21a8ca75 100644 --- a/llp/0231-proxy-mode-capture.rfc.md +++ b/llp/0231-proxy-mode-capture.rfc.md @@ -7,6 +7,7 @@ **Date:** 2026-08-14 **Related:** LLP 0016, LLP 0044, LLP 0045, LLP 0049, LLP 0066, LLP 0086, LLP 0114, LLP 0116, LLP 0176, LLP 0192, LLP 0206 **Spawns:** LLP 0232, LLP 0233, LLP 0234, LLP 0235 +**Designed-by:** LLP 0245, proxy-mode capture technical design > Claude Code disables **Remote Control** whenever `ANTHROPIC_BASE_URL` points > anywhere other than `api.anthropic.com`. Attach repoints exactly that key, so diff --git a/llp/0245-proxy-mode-capture.design.md b/llp/0245-proxy-mode-capture.design.md new file mode 100644 index 00000000..7d16d282 --- /dev/null +++ b/llp/0245-proxy-mode-capture.design.md @@ -0,0 +1,336 @@ +# LLP 0245: Proxy-mode capture for Claude Code, technical design + +**Type:** design +**Status:** Active +**Systems:** Gateway, Sources, Config, Plugins, Privacy, Core, Daemon +**Generated-by:** neutral +**Related:** LLP 0231, LLP 0232, LLP 0233, LLP 0234, LLP 0235, LLP 0236, +LLP 0237, LLP 0238, LLP 0239, LLP 0242, LLP 0243, LLP 0244, LLP 0246, +LLP 0247, LLP 0044, LLP 0045, LLP 0114, LLP 0192, LLP 0206 + +> Technical design for the proxy-mode capture stack the accepted RFC +> LLP 0231 asked for: Claude Code routed through the gateway with +> `HTTPS_PROXY` and a machine-local CA instead of a repointed +> `ANTHROPIC_BASE_URL`, with the aperture narrowed so the rows recorded are +> exactly the rows the reverse proxy already records. Named files, function +> seams, data flow, failure modes, and what the tests prove. + +Coverage anchor: + +`@ref LLP 0231: proxy-mode capture for Claude Code; the CONNECT front door, the routing-table intercept set and path-anchor recording aperture, the in-process name-constrained CA, the macOS keychain-trust and launchd-env delivery, and the proxy-mode Claude attach this document designs are LLP 0231's realization` + +## 0. Scope and code status {#scope} + +LLP 0231 is the accepted RFC; LLP 0232 through 0235 are its spawned +decisions, and LLP 0236 through 0239 are the research and follow-on +decisions that corrected the trust story after live testing. This document +is the implementation design that binds those decisions to the tree. + +The design is realized on `master` by three commits: `fa701a7e` (#782, the +transport, aperture, CA and attach), `d0f7c4ad` (#792, the status and trust +reporting surface), and `04330abb` (#794, the LLP 0242-0244 rollout that +turns proxy mode on). +File paths and function names below are verified against that tree; the +tests named in section 7 exist and gate it. What this document adds to the +corpus is the request-level design of record: the one place the whole +mechanism is laid out end to end, with the request (LLP 0231) `@ref`'d +above. + +This change set deliberately excludes who *turns proxy mode on*. Fresh +install composition and the existing-install migration are LLP 0242's +problem, settled by LLP 0243 (the picker row composes `proxy_mode`) and +LLP 0244 (attach offers the migration), and already landed on `master`. +Their own design of record belongs to that change set, not this one. + +## 1. Data flow, end to end {#data-flow} + +1. Attach writes `env.HTTPS_PROXY = http://127.0.0.1:` and + `env.NODE_EXTRA_CA_CERTS = /tls/ca-cert.pem` into + `~/.claude/settings.json`. The base URL is untouched, so Claude Code's + first-party predicate stays true on its own terms (LLP 0232). +2. Claude Code opens `CONNECT api.anthropic.com:443` against the gateway's + one listener. The CONNECT front door checks the peer is loopback, + answers `200 Connection Established` on the raw socket, and only then + wraps the socket in TLS with a leaf minted for the target host + (LLP 0233). +3. If no registered upstream names the target host and port, the socket is + a blind tunnel: bytes piped, nothing decrypted, nothing recorded + (LLP 0234). +4. An intercepted socket re-enters the same HTTP server via + `server.emit('connection', tlsSocket)`, stamped with the CONNECT + authority. The ordinary request handler runs; routing proceeds as for + reverse-proxy traffic. +5. An exchange is recorded only when the request path matches the + upstream's `record_prefix`, which is the adapter preset's declared + `path_prefix` (`/v1/messages` for Anthropic). Unmatched paths are + forwarded faithfully and never buffered (LLP 0234). +6. Recorded exchanges take the existing recorder, projector and cache + write path unchanged; capture parity with base-URL mode was measured at + 4 rows / 35 populated columns for the same prompt (LLP 0231). + +Two more macOS-only deliveries make the attach complete: the CA is +installed as a user-domain trusted root in the login keychain (LLP 0237), +and `NODE_USE_SYSTEM_CA=1` is set in the launchd user environment with a +login LaunchAgent to re-apply it (LLP 0239). Both exist because Claude +Code verifies TLS against two different stores (LLP 0236): the main client +honours `NODE_EXTRA_CA_CERTS`; the Remote Control SSE transport honours +only the keychain, merged in at process boot by that variable. + +## 2. Core TLS toolkit: `src/core/tls/` {#core-tls} + +The CA lives in core, not in the gateway plugin, because `hyp detach` and +`hyp daemon uninstall` must be able to remove it with no plugin loadable +(LLP 0235). + +- `src/core/tls/x509.js`: minimal DER emitter signed with `node:crypto`, + scoped to EC P-256 / ECDSA-SHA256 / UTCTime and the fixed extension set + interception needs. Exports `generateKeyPair()`, `mintCertificate()`, + `derToPem()`, and `readNameConstraints()`. Constraints are encoded with + **implicit** tags for `permittedSubtrees [0]` / `excludedSubtrees [1]` + (wrapping instead of replacing the `SEQUENCE OF` tag silently voids the + constraint), all IPv4 and IPv6 space is excluded (a dNSName-only + constraint leaves IP identities unrestricted per RFC 5280 4.2.1.10), and + read-back is a structural DER walk, not a byte scan (LLP 0235). +- `src/core/tls/ca.js`: the CA lifecycle. Exports + `INTERCEPT_PROVIDER_HOSTS` (the reviewed full-provider constant: + `api.anthropic.com`, `api.openai.com`, `chatgpt.com`, per LLP 0238), + `defaultStateRoot()`, `caPaths()` (the `tls/` directory in the state + root), `fingerprint()`, `ensureLocalCa()` (mint or reuse; key written + mode 0600; ten-year validity; regenerates when the stored key does not + match the stored certificate, which concurrent daemon starts can + produce; renewal roll kept for eventual expiry, deliberately longer than + a leaf's lifetime), `createLeafStore()` (per-host leaves minted in + memory, never written to disk), `readLocalCaInfo()`, + `waitForLocalCa()` (polling used by attach-side flows), and + `deleteLocalCa()` (uninstall and explicit purge only; detach keeps the + CA per LLP 0238). +- `src/core/tls/darwin_trust.js`: keychain trust. Exports + `CA_COMMON_NAME` (`HypAware Local CA`), `loginKeychainPath()`, + `isCaTrusted()` (read-only `security verify-cert` probe, making the + install idempotent and the password dialog once-per-machine), + `installCaTrust()` (`security add-trusted-cert -r trustRoot`, user + domain, no sudo; the native macOS dialog is the consent moment), and + `removeCaTrust()` (LLP 0237). +- `src/core/daemon/launchd_env.js`: boot-environment delivery. Exports + `ENV_VAR_NAME` (`NODE_USE_SYSTEM_CA`), `ENV_VAR_VALUE`, + `ENV_AGENT_LABEL` (`com.hyperparam.hypaware.node-system-ca`), + `envAgentPlistPath()`, `buildEnvAgentPlist()`, `installLaunchdEnv()` + (`launchctl setenv` plus the login LaunchAgent), `removeLaunchdEnv()`, + and `isLaunchdEnvSet()` (`launchctl getenv`, feeding status). Unlike the + CA, these follow the attach: they are recreatable for free, so detach + removes them (LLP 0239). + +## 3. Gateway front door: `@hypaware/ai-gateway` {#front-door} + +`hypaware-core/plugins-workspace/ai-gateway/src/connect.js`: + +- `attachConnectFrontDoor(opts)` installs the `connect` handler on the + **existing** HTTP server; there is no second port and every status and + discovery surface keeps meaning one thing (LLP 0233). The two mechanical + constraints it carries are load-bearing: the TLS socket offers + `http/1.1` only in ALPN (an h2 negotiation would hang against the + HTTP/1.1 server), and the `200 Connection Established` plus any early + bytes are written to and pushed back onto the raw socket **before** the + TLS wrap. +- `isLoopbackAddress()` gates the peer, not the bind: a non-loopback + `CONNECT` is refused `403` before the target is parsed, blind tunnels + included, so a `listen = "0.0.0.0"` install is never an open relay + (LLP 0233 #loopback-peers-only). +- `CONNECT_HOST` / `CONNECT_PORT` symbols stamp the terminated socket with + the CONNECT authority, the only new fact the request path needs; + `connectHostOf()` / `connectPortOf()` read them back. +- `openUpstream()` dials the destination directly or through a configured + `upstream_proxy` chain; `parseAuthority()` parses the CONNECT target. + +`hypaware-core/plugins-workspace/ai-gateway/src/proxy.js`: + +- `startProxy(opts)` boots the listener; with `proxy_mode` on and nothing + to route it starts tunnel-only and says so, and with a CA on disk but + interception unavailable it degrades to blind-tunnel-only rather than + refusing CONNECT, because an attached client's whole egress arrives here + (LLP 0233 #degrade-to-blind-tunnels). +- `interceptsHost(upstreams, host, port)` and + `matchUpstreamByHost(upstreams, host, port)`: the intercept set is + derived from the routing table and keyed on host **and** port; nothing + configures it separately (LLP 0234). +- `shouldRecordProxyExchange(upstream, pathname)`: the recording anchor is + `recordPrefix ?? prefix`, and an anchor of `/` or empty records nothing; + failing closed is the default, so a routing prefix of `/` can never read + as record-everything. Note the fallback: an upstream with no + `record_prefix` records under its routing prefix, which is why the + `source.js` merge below matters. The `hyp init` preset writes + `path_prefix = "/"`, so without the merge the anchor was `/`, the + fail-closed guard suppressed every request, and the default install + recorded nothing at all. The routing matcher is deliberately not reused: the + Anthropic route matcher accepts an `sk-ant-` bearer alone, which under a + proxy is true of every request to the host and measurably reopened the + aperture (LLP 0234 #recording-is-opt-in-per-path). +- `compileUpstreams()` carries `record_prefix` onto the compiled entry; + `createChainedAgent()` chains the intercepted leg through + `upstream_proxy`. + +`hypaware-core/plugins-workspace/ai-gateway/src/source.js` merges each +adapter preset's declared `path_prefix` (and `provider`) onto the merged +upstream entry as `record_prefix`. Operator config still wins the *routing* +question, but the record anchor belongs to the adapter that registered the +preset: an operator writing `path_prefix = "/"` is saying "route everything +on this host here", not "record everything on this host", and the +fail-closed guard turns that into recording nothing at all on a default +install (LLP 0234). It also owns the gateway's own status details: +`proxy_mode`, `ca_fingerprint`, `ca_not_after`, `ca_cert_path`, +`ca_permitted_hosts`, `intercept_hosts`, and `proxy_mode_error` when CA +preparation failed while the gateway kept reverse-proxying. + +`hypaware-core/plugins-workspace/ai-gateway/src/config.js` reads the +switch: `proxy_mode` is on only when the config field is literally `true` +(LLP 0233 #proxy-mode-is-explicit), and compiles `upstream_proxy`. + +One front door post-dates this design's audit and is described here only so +the map is complete: Claude Code's Remote Control bridge sends absolute-form +plaintext requests straight at the proxy port rather than tunnelling them, +and until LLP 0246/0247 (landed as #797) the gateway routed them by pathname +alone and answered a local 404 that the client misread as an account +limitation. Absolute-form is now a third front door, routed by the host the +request line names through `matchUpstreamByHost` and recorded under the same +per-path anchor. Nothing above changes; it is the reason the acceptance +procedure's Remote Control step passes on current `master`, so a reader +diagnosing that step needs LLP 0247 as well as LLP 0237 and LLP 0239. + +## 4. Claude attach: `@hypaware/claude` {#claude-attach} + +`hypaware-core/plugins-workspace/claude/src/settings.js`: + +- Exports `MODE_PROXY` / `MODE_BASE_URL`; `attach()` takes the mode and + writes, for proxy mode, exactly `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` + (`PROXY_MODE_ENV_KEYS`). `HTTP_PROXY` and `NO_PROXY` are never written; + `ENABLE_TOOL_SEARCH` and `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` are + not needed and not written (LLP 0232). +- A displaced pre-existing `HTTPS_PROXY` is backed up to the marker's + `prev_env`, warned about once with userinfo redacted, and the warning + names `upstream_proxy` as the remedy for a corporate egress proxy. +- The marker (`value[MARKER_KEY]`) records `mode`, `managed.env`, + `managed.hooks`, `prev_env`, and keeps `prev_base_url` as its own field + so markers written by earlier versions still restore. `mode` is what + tells the plugin-agnostic undo that proxy residue exists. +- `releaseUnmanagedKeys()` implements mode migration: switching modes + restores-or-removes keys the new mode no longer manages, so migrating to + proxy never strands a live `ANTHROPIC_BASE_URL` pointing at the gateway + (LLP 0232 #mode-migration). +- The session-start hooks, `cwd` attribution and the settlement sidecar + ride the same `env`-block write and are untouched (LLP 0231). + +`hypaware-core/plugins-workspace/claude/src/index.js`: + +- The proxy-attach preflight is `readLocalCaInfo()`, and what it decides is + the *mode*: a CA on disk means `MODE_PROXY`, no CA means the attach falls + back to base-URL mode. The CA's existence proves the gateway is actually + serving the mode, and a proxy attach against a dead gateway would break + all of Claude Code's HTTPS (LLP 0232 #proxy-attach-preflight). Mode is + read from what the daemon is doing, never from what config asks for. + Recorded precisely because LLP 0232 states this twice and the two + statements do not say the same thing: "a missing CA is a refusal + (`markActionRefused`), not a warning" reads absolutely, while the next + paragraph settles "proxy mode when a CA exists and base-URL mode + otherwise". The tree implements the second. The only `markActionRefused` + on this path is `CA_MISSING` in `settings.js`, which fires when proxy + mode was already selected and the certificate has since become + unreadable, i.e. the race between the probe and the write. +- On Darwin the attach then runs the trust and launchd steps: keychain + probe/install (dialog names all permitted hosts, per LLP 0238), and + `installLaunchdEnv()`. A refused dialog degrades politely: attach + completes, prints exactly what will not work (Remote Control inbound), + and re-running attach retries (LLP 0237 #attach-anyway-on-refusal). On + other platforms the attach states Remote Control inbound is unsupported + under proxy mode (LLP 0237 #darwin-only). The final attach line carries + the corrected terminal caveat: only processes launchd starts after the + `setenv` see the variable, so a terminal app must be fully quit and + reopened (LLP 0239 #terminals-predating-attach). + +Detach and uninstall run the plugin-agnostic, disk-driven undo +(`src/core/config/client_detach_disk.js`, `detachClientFromDisk()`): +restore every managed env key from `prev_env` (falling back to +`prev_base_url`), strip managed hooks, remove the launchd env and its +LaunchAgent, and delete the marker. The CA and its keychain trust +*survive* detach so re-attach is silent; `hyp daemon uninstall` and +`hyp detach claude --purge` (`purgeProxyTrustResidue()` in +`src/core/commands/clients.js`) remove them (LLP 0238 +#ca-survives-detach). The state root for the undo is resolved from the +caller's `homeDir`, never the ambient one, so a sandboxed undo cannot +delete a different install's key material. + +## 5. Status surface {#status} + +Two surfaces, and they carry different things. + +`hyp status` reports the trust half: `src/core/commands/status.js` renders +the `proxy trust:` block from `ProxyTrustReport` +(`src/core/daemon/types.d.ts`), which is exactly three facts: the CA +fingerprint, keychain trust state, and whether `NODE_USE_SYSTEM_CA` is live +in the launchd environment (`launchctl getenv`). Trust and launchd state are +tri-state, because "the probe could not run" is not the claim "not trusted". +"The dialog was cancelled last month" is diagnosable without re-running +attach (LLP 0237). + +The aperture half lives in the gateway source's own status details +(section 3): `proxy_mode`, `ca_not_after`, `ca_cert_path`, +`ca_permitted_hosts`, `intercept_hosts` and `proxy_mode_error`, readable +without grepping a boot log (LLP 0233). Note that `hyp status --json` maps +each source to name, plugin and state only and drops the details block, so +those fields are read from the daemon status file +(`hyp daemon status --json`), not from `hyp status`. + +## 6. Failure modes {#failure-modes} + +- **Dead gateway behind a proxy attach**: the worst mode the feature can + have. The CA-existence preflight covers the case it was designed for, a + machine that never ran proxy mode: no CA, so attach writes a base-URL + attach instead. It does not cover a machine that ran proxy mode and then + stopped the daemon: the CA outlives the process (LLP 0238), and when a + `listen` is configured `hyp attach` resolves the endpoint from config + rather than from a live bind, so `HTTPS_PROXY` can still be written at a + dead port. Residual, not closed; the acceptance procedure's step 1 + therefore proves the daemon is up before attaching. +- **CA preparation fails at boot**: gateway still starts and still reverse + proxies; `proxy_mode_error` reported; degraded blind-tunnel CONNECT + keeps an already-attached client's egress working. +- **Trust dialog refused / non-interactive attach**: capture works, Remote + Control inbound does not; stated, retryable. +- **Squatter on the fixed port**: revisited and accepted; the CA key is + 0600 in the state root, inside the same-user boundary LLP 0114 already + conceded. Mutual auth deliberately not added. +- **Shutdown with live tunnels**: hijacked CONNECT sockets are destroyed + by `stop()` itself; `server.close()` no longer knows about them. +- **Key/cert interleave from concurrent mints**: detected by the explicit + key-matches-certificate check; regenerated instead of failing every + handshake for ten years. +- **Upstream Bun behaviour change** (LLP 0236's canary caveat): first + symptom is inbound Remote Control silently failing; the durable fix is + upstream (claude-code#75050) and out of this design's control. + +## 7. What the tests prove {#tests} + +Traditional (root `test/`): `test/core/tls-x509.test.js` (constraint +encoding, including a minted leaf for another host failing the handshake +with `permitted subtree violation`, and structural read-back), +`test/core/tls-ca.test.js` (0600, reuse, key/cert mismatch regeneration, +wait/delete), `test/core/tls-darwin-trust.test.js` (probe-first +idempotence, refusal path), `test/plugins/ai-gateway-connect-front-door.test.js` +(loopback refusal, blind-tunnel fidelity, ALPN and early-byte ordering), +`test/plugins/ai-gateway-proxy-mode.test.js` and +`ai-gateway-proxy-routing.test.js` (the negatives that define the +aperture: a side-channel path is proxied faithfully and starts no +exchange; an Anthropic bearer on an unmatched path cannot reopen it; an +anchor of `/` or empty records nothing; interception keyed on host and +port), +`test/plugins/claude-settings-proxy-attach.test.js` (two-key write, marker +`mode`/`prev_env`, displaced-proxy backup and one-time redacted warning, +mode-migration key release), and `test/core/status-proxy-trust.test.js` +(the LLP 0237/0239 reporting). The `security` / `launchctl` seams refuse +under the test runner and are shimmed in hermetic flows, per the LLP 0181 +rule restated in LLP 0244. + +Hermetic smokes: `gateway_claude_capture`, `claude_attach_detach`, +`client_attach_idempotent` exercise the wiring in a temp `HYP_HOME`. +The written acceptance procedure in `docs/ACCEPTANCE.md` remains the +manual gate for real-daemon, real-keychain behaviour; hermetic runs must +never touch the host keychain or launchd table. diff --git a/llp/0248-proxy-mode-capture.plan.md b/llp/0248-proxy-mode-capture.plan.md new file mode 100644 index 00000000..0d6c16f2 --- /dev/null +++ b/llp/0248-proxy-mode-capture.plan.md @@ -0,0 +1,103 @@ +# LLP 0248: Proxy-mode capture, implementation plan + +**Type:** plan +**Status:** Active +**Related:** LLP 0245, LLP 0231 +**Generated-by:** neutral + +> Executable plan for the `proxy-mode-capture` change set, refining design +> LLP 0245 (which covers RFC LLP 0231). The plan's headline finding is that +> the design is already realized on `master`; the tasks below close the two +> gaps the design-versus-tree audit actually found, and nothing else. + +## Audit: what is already built on `master` {#audit} + +LLP 0245 section 0 states the design is realized by `fa701a7e` (#782, the +transport, aperture, CA and attach) and `d0f7c4ad` (#792, status and trust +reporting), and this branch's merge base (`04330abb`, #794, the LLP +0242-0244 rollout) already contains both. The audit for this plan verified +every file, symbol and test the design names against that tree: + +- **Core TLS toolkit** (design section 2): `src/core/tls/x509.js` + (`generateKeyPair`, `mintCertificate`, `derToPem`, + `readNameConstraints`), `src/core/tls/ca.js` (`INTERCEPT_PROVIDER_HOSTS` + with exactly the three LLP 0238 hosts, `defaultStateRoot`, `caPaths`, + `fingerprint`, `ensureLocalCa`, `createLeafStore`, `readLocalCaInfo`, + `waitForLocalCa`, `deleteLocalCa`), `src/core/tls/darwin_trust.js` + (`CA_COMMON_NAME`, `loginKeychainPath`, `isCaTrusted`, `installCaTrust`, + `removeCaTrust`), and `src/core/daemon/launchd_env.js` (all eight + exports). All present as designed. +- **Gateway front door** (section 3): + `hypaware-core/plugins-workspace/ai-gateway/src/connect.js` + (`attachConnectFrontDoor`, `isLoopbackAddress`, `CONNECT_HOST` / + `CONNECT_PORT` symbols and readers, `openUpstream`, `parseAuthority`), + `proxy.js` (`startProxy`, `interceptsHost`, `matchUpstreamByHost`, + `shouldRecordProxyExchange`, `compileUpstreams`, `createChainedAgent`), + `source.js` (`record_prefix` merge from the preset `path_prefix`, and the + full status surface: `proxy_mode`, `ca_fingerprint`, `ca_not_after`, + `ca_cert_path`, `ca_permitted_hosts`, `proxy_mode_error`), and + `config.js` (`proxy_mode` strictly-true switch, `upstream_proxy`). All + present. +- **Claude attach** (section 4): + `hypaware-core/plugins-workspace/claude/src/settings.js` (`MODE_PROXY` / + `MODE_BASE_URL`, `PROXY_MODE_ENV_KEYS` of exactly `HTTPS_PROXY` and + `NODE_EXTRA_CA_CERTS`, marker `mode` / `prev_env`, mode-migration key + release), `index.js` (CA-existence preflight via `readLocalCaInfo`, + Darwin trust and launchd steps), the disk-driven undo in + `src/core/config/client_detach_disk.js` (`detachClientFromDisk`), and + `purgeProxyTrustResidue` in `src/core/commands/clients.js`. All present. +- **Status surface** (section 5): `ProxyTrustReport` in + `src/core/daemon/types.d.ts`, populated by `src/core/daemon/status.js` + and rendered by `src/core/commands/status.js`. Present. +- **Tests** (section 7): every named traditional test exists and passes + (120 tests across `test/core/tls-x509.test.js`, `tls-ca.test.js`, + `tls-darwin-trust.test.js`, `status-proxy-trust.test.js`, + `test/plugins/ai-gateway-connect-front-door.test.js`, + `ai-gateway-proxy-mode.test.js`, `ai-gateway-proxy-routing.test.js`, + `claude-settings-proxy-attach.test.js`, plus the rollout-era + `test/core/attach-proxy-migration.test.js` and + `test/core/gateway-proxy-enable.test.js`). + The `gateway_claude_capture` hermetic smoke runs green; + `claude_attach_detach` and `client_attach_idempotent` flows exist. +- **Annotations**: sixteen source files already carry `@ref LLP 0232` + through `@ref LLP 0239` annotations at the seams the design describes. + +**No task below rewrites any of that.** A worker who believes a section 2-5 +mechanism is missing should re-read this audit and the tree before writing +code; the correct output for already-built scope is no diff. + +## Gaps the audit found {#gaps} + +Two, both documentation: + +1. **The manual acceptance gate the design cites does not exist.** Design + section 7 says "the written acceptance procedure in `docs/ACCEPTANCE.md` + remains the manual gate for real-daemon, real-keychain behaviour", but + `docs/ACCEPTANCE.md` contains only `codex_desktop_capture` and + `openclaw_capture`. Nothing written covers proxy-mode attach on a real + Mac: the keychain trust dialog, the launchd environment, Remote Control + surviving attach (the whole point of RFC 0231), or purge. Hermetic + smokes shim the `security` / `launchctl` seams (LLP 0181 rule restated + in LLP 0244), so only a written manual procedure can gate that + behaviour. +2. **The request has no forward-ref to its design of record.** LLP 0231's + header carries `Related:` and `Spawns:` but nothing pointing at LLP + 0245, so the coverage edge is discoverable only from the design side. + The corpus convention is to append a forward-ref to the covered doc; + forward-refs are among the trivial editorial edits an Accepted doc + still admits. + +## Out of scope {#out-of-scope} + +Who turns proxy mode on (fresh-install composition, existing-install +migration) is LLP 0242's problem, settled by LLP 0243 and LLP 0244 and +already landed on `master` as #794 (`04330abb`, this branch's merge base); +it belongs to its own change set either way. Codex stays base-URL +(RFC 0231). The +`upstream_proxy` field-testing question and claude-code#75050 are recorded +open items, not tasks. + +## Tasks + +- id: T1 branch: task/proxy-mode-capture/T1 deps: [] complexity: 3 -- Write the missing manual acceptance procedure `claude_proxy_capture` in docs/ACCEPTANCE.md, in the same shape as the existing `codex_desktop_capture` and `openclaw_capture` entries (what it proves, what it does not prove, prerequisites, exact commands, pass condition, an "If it fails" section). Opt-in/manual, needs a real Mac. It must cover, from LLP 0245 sections 1, 4 and 6: real `hyp daemon install`/start, `hyp attach claude` in proxy mode writing only HTTPS_PROXY and NODE_EXTRA_CA_CERTS into ~/.claude/settings.json, the macOS keychain trust dialog naming all INTERCEPT_PROVIDER_HOSTS, NODE_USE_SYSTEM_CA visible via `launchctl getenv` with the fully-quit-and-reopen-terminal caveat (LLP 0239), a Claude Code session producing rows in ai_gateway_messages attributable via entrypoint while Remote Control inbound still works (the RFC's whole point), `hyp status` reporting the ProxyTrustReport fields, detach restoring env keys while the CA and keychain trust survive (LLP 0238), and `hyp detach claude --purge` plus `hyp daemon uninstall` removing CA, trust and launchd residue. Also add `claude_proxy_capture` to the written-procedures list in CLAUDE.md's Smoke Test Model section (currently lists only codex_desktop_capture). Do NOT touch any code: the mechanisms are all built and green on master (see #audit). Verification is `npm test` still green and the two docs reading consistently; no new automated tests. Prose rules apply: no em dashes anywhere. +- id: T2 branch: task/proxy-mode-capture/T2 deps: [] complexity: 1 -- Append the design-of-record forward-ref to the request: in llp/0231-proxy-mode-capture.rfc.md's metadata header add a line `**Designed-by:** LLP 0245` directly after the `**Spawns:**` line (`Designed-by` is the corpus's existing name for this forward-ref relation; see llp/0044). This is a trivial editorial forward-ref, explicitly permitted on an Accepted doc; change nothing else in the file (no body edits, no status change). Check llp/0232 through llp/0239 headers and confirm they need no equivalent edit (0232, 0233 and 0235 already carry Extended-by/Superseded-by lines; the design cites all of them from its own side, and per-decision back-refs from code already exist). Verification: `npm test` untouched and green, and a grep shows exactly one new line added under llp/.