Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
139 commits
Select commit Hold shift + click to select a range
fff0e86
proxy: survive a reload instead of cutting the response
codeslake Aug 4, 2026
ff73514
proxy: keep the reload survivable without widening what it can break
codeslake Aug 4, 2026
17ab7e7
test(reload): do not assert the co-bind row where the runtime cannot …
codeslake Aug 4, 2026
3bee2f7
proxy: let the guard say 'no opinion', and retire what it claimed bef…
codeslake Aug 4, 2026
e73674f
proxy: keep the reload from cutting sessions on every platform we run
codeslake Aug 4, 2026
43de0ff
proxy: serve a socket the supervisor holds, so a reload never unbinds…
codeslake Aug 4, 2026
e4c84bb
proxy: hold the advertised port so a proxy restart never strands a se…
codeslake Aug 4, 2026
5624339
test(reload): never derive a pid that could signal our own process group
codeslake Aug 4, 2026
94ebfd5
test(held-port): give the held-port cases their own file
codeslake Aug 4, 2026
70ee3bb
proxy: run supervised without a service manager, and clear the record…
codeslake Aug 5, 2026
5dde736
proxy: advertise run-service in --help
codeslake Aug 5, 2026
57780a9
test: stop the suite sleeping through what it could observe
codeslake Aug 5, 2026
5909670
test: bound the new concurrency by cores, not by nothing
codeslake Aug 5, 2026
8e3ee9e
fix(launcher): publish our CA when run-service starts the proxy, not …
codeslake Aug 5, 2026
9a925f6
test(reload): let the stream outlive the successor's boot, not a stop…
codeslake Aug 5, 2026
2cbc913
fix(launcher): run-service takes the port over instead of leaving old…
codeslake Aug 5, 2026
9ceeb88
fix(proxy): exit with the holder instead of orphaning a port nobody r…
codeslake Aug 5, 2026
1664a32
fix(proxy): put a holder back on the port when the old one dies
codeslake Aug 5, 2026
a9a17bd
fix(launcher): key holder detection on the subcommand, not the bin name
codeslake Aug 5, 2026
dd0f6e7
fix(launcher): stop the holder stealing accepts from the proxy it sup…
codeslake Aug 5, 2026
5c707cc
fix(launcher): read the incumbent's parent, and stop the backoff bein…
codeslake Aug 5, 2026
8e6b95f
test(held-port): assert what the holder guarantees, not what it cannot
codeslake Aug 5, 2026
f1c70f4
fix(launcher): make an upgrade actually upgrade
codeslake Aug 5, 2026
4f678c1
fix(launcher): decide upgrades by content hash, not by path
codeslake Aug 5, 2026
61506ab
test: guard that a green run means the cases were collected
codeslake Aug 5, 2026
5ccda37
test(reload): retry the final health probe past a fixture-stolen socket
codeslake Aug 5, 2026
b62db31
fix(proxy): refuse to start when the upstream is our own address
codeslake Aug 5, 2026
de30fa6
fix(proxy): stop the launching shell deciding a service upstream and …
codeslake Aug 5, 2026
bc02a8a
feat(health): report the port we bound and whether the upstream loops
codeslake Aug 5, 2026
ab02ef9
test(wrapper): bound the waits on a SIGTERMed child
codeslake Aug 5, 2026
de71ea3
docs(holder): record that never-letting-go measures worse in node
codeslake Aug 5, 2026
0988090
test: bound every wait on a child that was signalled
codeslake Aug 5, 2026
31d3bab
test: bound the last unbounded child wait, and guard the shape mechan…
codeslake Aug 5, 2026
4204838
feat(holder): restart the proxy onto a deploy, opt-in
codeslake Aug 5, 2026
9b7e6c3
fix(holder): the off switch has to cover the deploy watcher too
codeslake Aug 5, 2026
e516b2f
test(watcher): assert what the watcher did, not that a pid held still
codeslake Aug 5, 2026
cc5d1da
test: an exported switch must not change what the suite measures
codeslake Aug 5, 2026
e6c3d08
test: reap the detached successor the orphan case creates
codeslake Aug 5, 2026
4127b2e
test: pin the reaper as an assertion, not a comment
codeslake Aug 5, 2026
cd0f3de
test(held-port): assert the window the holder actually has, with its …
codeslake Aug 5, 2026
a89f932
fix(holder): an unreadable source must not look like an idle watcher
codeslake Aug 5, 2026
3d395d7
fix(forward-proxy): CONNECT never consulted the fallback chain, so ev…
codeslake Aug 6, 2026
04c7339
fix(holder): stop when nobody is left to stop us — 151 orphans, 9.17 GiB
codeslake Aug 6, 2026
9361658
fix(proxy): a killed holder left its proxy alive forever
codeslake Aug 6, 2026
7e645f4
fix(holder): hold the port without accepting on it, and never fork a …
codeslake Aug 6, 2026
113d687
fix(holder): the orphan guard killed the normal launch — opt in, not on
codeslake Aug 6, 2026
cf828bf
fix(proxy): a dead holder cost 138 requests because we exited before …
codeslake Aug 6, 2026
eadd943
fix(proxy): a handover successor is not the holder's child, and must …
codeslake Aug 6, 2026
0edb0f1
docs(proxy): prove the handover guard is reachable, instead of assuming
codeslake Aug 6, 2026
a82f9a3
fix(proxy): a log line could kill the process it was describing
codeslake Aug 6, 2026
8518357
fix(holder): take the port with SIGHUP, because SIGTERM means hand it on
codeslake Aug 6, 2026
e9c8e5b
fix(proxy): clear the socket-activation env before anything can spawn
codeslake Aug 6, 2026
26ca2ac
feat(holder): replace a holder by handing the socket on, not by dropp…
codeslake Aug 6, 2026
e82785b
fix(proxy): an orphaned proxy could never put a holder back
codeslake Aug 6, 2026
f7ce38d
test(sweep): remove the two temp dirs this fixture makes per call
codeslake Aug 6, 2026
08bf0ab
fix(proxy): clear the holder marker where it stops being true
codeslake Aug 6, 2026
f3f09b9
feat(holder): publish the holder's own bytes on /health
codeslake Aug 6, 2026
59f8280
fix(holder): hash our own bytes at load, not on first spawn
codeslake Aug 6, 2026
a8fd100
docs(holder): the fingerprint publish order buys the opposite of what…
codeslake Aug 6, 2026
a509bb9
fix(holder): stop keeping a gap listener whose listen() failed
codeslake Aug 6, 2026
4c25f39
test(server): reap the successors this file breeds, so npm test can exit
codeslake Aug 6, 2026
f6837d7
fix(proxy): a released port must not resurrect its own holder
codeslake Aug 6, 2026
4040988
docs(proxy): correct why the release guard exists — I got the cause w…
codeslake Aug 6, 2026
c4330f2
fix(proxy): the self-heal's exit condition could not answer on a mac
codeslake Aug 6, 2026
bec9f54
test(holder): make the release guard's case actually kill its mutation
codeslake Aug 6, 2026
e138a40
feat(holder): the held port CARRIES while no proxy is up, instead of …
codeslake Aug 6, 2026
fb49896
feat(holder): carry the held port even when there is no hop left
codeslake Aug 6, 2026
d937881
feat(holder): carry the port in the window a dead child leaves behind
codeslake Aug 6, 2026
eab1d70
feat(holder): keep the address carrying when CCF itself is turned off
codeslake Aug 6, 2026
3535e5e
fix(health): publish the hop CONNECTs actually leave through
codeslake Aug 6, 2026
9f95945
fix(holder): stop the standby relay from eating the port it exists to…
codeslake Aug 6, 2026
4f21483
fix(holder): ask again until the address is actually free, and stop l…
codeslake Aug 6, 2026
2c29e19
fix(holder): unblock the restart ladder, quieten the standby, and gua…
codeslake Aug 6, 2026
6163d86
perf(holder): cut the stall a request pays when the whole lineage dies
codeslake Aug 6, 2026
5f4d9c3
perf(holder): take the address the instant the holder is gone, not on…
codeslake Aug 6, 2026
7de8ada
fix(health): fingerprint the launcher LAYER, so a relay-only change i…
codeslake Aug 6, 2026
740d09f
fix(health): walk the launcher directory instead of naming its files
codeslake Aug 6, 2026
fa797e4
fix(holder): a surplus run-service must leave, not compete for the ad…
codeslake Aug 6, 2026
df8df11
fix(health): keep the suite's own stand-ins out of the layer fingerprint
codeslake Aug 7, 2026
596f570
test(held-port): keep the 503 body, so a red run names which of the t…
codeslake Aug 7, 2026
61028ac
test(held-port): stop the fixtures leaking a corp proxy's address int…
codeslake Aug 7, 2026
ee7608d
test(held-port): one definition of what counts as an outage, not four
codeslake Aug 7, 2026
535e695
test(handover): answer the split-request hop once, not once per read
codeslake Aug 7, 2026
8e25721
test: make the write-after-end shape impossible to paste back in
codeslake Aug 7, 2026
0a7e5bc
fix: a deploy that changes nothing, and a /health field that names th…
codeslake Aug 7, 2026
4a26fdd
fix: CACHE_FIX_REQUIRE_HOP guarded the tunnel and left the main path …
codeslake Aug 7, 2026
7bdb81b
fix: a fast upstream failure hung the client instead of answering 502
codeslake Aug 7, 2026
10210fa
fix: successorServing asked lsof a narrower question than it asked /proc
codeslake Aug 7, 2026
13d69f0
test: prove the client-abandon abort is load-bearing, after two tries…
codeslake Aug 7, 2026
e059200
fix: port 0 was made reachable without being made to work
codeslake Aug 7, 2026
5d09304
fix: the gap relay took one hop from the chain and called that the chain
codeslake Aug 7, 2026
b6da327
fix: an emptied chain kept publishing the hop it no longer had
codeslake Aug 7, 2026
e50460a
fix: keep the address serving when a probe, a parse, or a spawn fails
codeslake Aug 7, 2026
eafe0cc
test(gap-relay): stop the fixture racing its own child for the socket…
codeslake Aug 8, 2026
7379e65
comments: retire the claim that the pin reads our /health fields
codeslake Aug 8, 2026
fa46241
proxy: stop announcing a fault on every healthy start
codeslake Aug 8, 2026
a3ec338
fix(proxy): a lost stdout/stderr reader must not wedge the proxy
codeslake Aug 14, 2026
4b32eaa
fix(launcher): bound every shell-out, so a sick machine cannot hang t…
codeslake Aug 15, 2026
e588792
fix(proxy): answer the reviews, and make the guards able to fail
codeslake Aug 17, 2026
439a2e6
test: let the runner size its own parallelism, so Node 20 CI stops fl…
codeslake Aug 17, 2026
0d302eb
test: close three ways the parallelism guard could be walked past
codeslake Aug 17, 2026
7eb419c
fix(launcher): let a session's python clients verify the proxy we put…
codeslake Aug 18, 2026
1a06ee2
test: stop the deploy watcher losing its window to a neighbour's load
codeslake Aug 18, 2026
5eab3ca
test: share one launcher run between the two python-trust cases
codeslake Aug 18, 2026
811b78f
fix(launcher): never make our own CA the whole python trust world
codeslake Aug 18, 2026
2d50730
fix(launcher): prove the bundle subsumes the ambient store before nam…
codeslake Aug 18, 2026
19805fb
fix(proxy): give the minted CA a keyUsage, or strict verifiers refuse…
codeslake Aug 18, 2026
8d58a98
fix(proxy): report what the forced shutdown cut, and stop calling a h…
codeslake Aug 18, 2026
d530b8c
fix(launcher): never write a replace-class trust variable
codeslake Aug 18, 2026
af63d6c
fix(test,launcher): stop killing neighbouring test files, and stop sw…
codeslake Aug 18, 2026
fd75770
fix(launcher,test): gate the deploy announce, kill the proxy the case…
codeslake Aug 18, 2026
4be37eb
fix(proxy): stop the self-heal successor from being born mute
codeslake Aug 18, 2026
9f8c3bd
fix(proxy,relay): stop leaking a hop's password, and honour REQUIRE_H…
codeslake Aug 18, 2026
1fdd418
fix(relay): present a hop's credentials, and scrub the gate that now …
codeslake Aug 18, 2026
8d21fa4
fix: read the child-ready port for any address family, and /health's …
codeslake Aug 18, 2026
7150491
fix: fingerprint the whole proxy tree, not server.mjs alone
codeslake Aug 18, 2026
6b6bbbd
fix: hand the child the bind the holder actually holds
codeslake Aug 18, 2026
1ba9c28
fix(relay): read HTTP_PROXY as a hop, and route off the header block …
codeslake Aug 18, 2026
3ca7a44
fix: do not stamp direct_last when REQUIRE_HOP refused the dial
codeslake Aug 18, 2026
1109904
fix: a failed spawn must reach the restart ladder, and a failed fork …
codeslake Aug 18, 2026
0d1af01
fix: dial an https:// chain hop over TLS instead of in the clear
codeslake Aug 18, 2026
e3e16e9
refactor: one definition for the test process helpers, and for a URL'…
codeslake Aug 18, 2026
6ebc1f1
fix: a draining proxy must stop taking new work, not just new connect…
codeslake Aug 18, 2026
7a62d34
fix: spend the 5s outage budget only where something waits on our exit
codeslake Aug 18, 2026
f1a3877
test: assert the node behaviour 6ebc1f1's coverage silently depends on
codeslake Aug 18, 2026
f06d85b
docs: withdraw an unverifiable peer count from 6ebc1f1's rationale
codeslake Aug 18, 2026
4c6bad0
fix: close idle keep-alives ourselves, because Node 18 does not
codeslake Aug 18, 2026
4d71901
test: fold the clean-drain assertion into a case that already spawns …
codeslake Aug 18, 2026
617600c
fix: two handover defects that both end with nobody on the port
codeslake Aug 18, 2026
43bc9e7
fix: one chain had two definitions of a valid hop
codeslake Aug 18, 2026
e2bea8b
fix: an IPv6 bind never worked, in three places, and the last one was…
codeslake Aug 18, 2026
31e70c4
fix: a default macOS install grew its log forever
codeslake Aug 18, 2026
040ca82
fix: the stdio error handler reported a stderr fault by writing to st…
codeslake Aug 18, 2026
2eebfe2
fix: two globals made one embedded proxy answer for another
codeslake Aug 18, 2026
e549ed3
perf: N requests in flight meant N walks of the same chain
codeslake Aug 18, 2026
9a89238
fix: a retry ladder's error listener outlived the bind that succeeded
codeslake Aug 18, 2026
2a00e48
test: let cleanup see a fixture that has handed its listen on
codeslake Aug 18, 2026
0f1c137
fix(test): a 50ms window let the self-heal win a race the case blamed…
codeslake Aug 19, 2026
2d752ca
fix(test): a "dead" hop that any neighbour could take, and did
codeslake Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,22 @@

## [Unreleased]

### Changed

- **`CACHE_FIX_REQUIRE_HOP=1` now covers the relayed `/v1/messages` route, not just the two `CONNECT` paths.** With the variable set and no chain hop reachable, that route previously dialled `api.anthropic.com` directly — carrying the caller's API key past the boundary the variable exists to enforce, while the `CONNECT` paths correctly refused. It now answers `502` instead, matching them. This is a new user-visible outcome on the primary route: an operator who sets the variable, configures no fallbacks and has no reachable proxy will see requests refused where they previously succeeded, which is what the flag asks for. Unset (the default) nothing changes. Hosts exempted by `NO_PROXY` stay exempt — that is an operator saying "this one is direct on purpose". Three egress sites still do not consult the variable: `storageAgent()`, the update-channel probe, and `fallbackToOrigin()`. The first two issue our own requests and carry no client headers. The third forwards the client's headers verbatim to `downloads.claude.ai` on the opt-in download-rewrite path — it is enumerated here rather than claimed harmless.

### Fixed

- **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan.

- **A dead log reader no longer kills the supervisor or the relay.** The proxy gained an EPIPE swallower after a measured 27-minute outage; the launcher's holder and the gap relay share the same pipe and never got one, so the same killed reader took down the process whose whole job is to put the proxy back. Both now install stream `'error'` listeners, as does the proxy in reverse mode, where its own guard had been attached only when forward mode was active. The interactive wrapper deliberately keeps the old behaviour — a foreground producer whose consumer dies should end.

- **A failed `SIGUSR2` handover recovers instead of leaving the port unowned.** `spawn()` reports fork-pressure failures (`EAGAIN`) as an asynchronous `'error'`, which was unhandled — an uncaughtException that killed the holder after it had already killed its standby. The holder now leaves only once the successor has actually started, and both failure routes restore the standby and re-arm the restart ladder.

- **The gap relay no longer surrenders a live socket on a transient error, and refuses to start blind.** Its server-error handler exited on *any* error, including an accept-time `EMFILE` on a socket it was still serving — the moment the address is most needed. It now keeps the socket when still listening. Separately, the standby refused to arm if `CACHE_FIX_STANDBY_PARENT` was missing rather than silently falling back to `process.ppid`, which compares 1 against 1 for ever and holds a listening socket that answers nothing.

- **`openssl` is bounded.** Certificate minting shelled out with no timeout, inside `startProxy()` before the proxy listens and while holding the CA-generation lock, so one wedged call stalled every sibling waiting on that lock.

- **`--remote-control` no longer clobbers another component's `NODE_EXTRA_CA_CERTS`.** That variable takes exactly one file, so on a host where something else also MITMs `api.anthropic.com` (a corporate agent, an account-pinning proxy) the last writer won and every other CA was silently untrusted — measured breaking Remote Control inbound. The launcher now publishes its own CA to `${CLAUDE_CONFIG_DIR:-~/.claude}/ca-trust.d/ccf.pem` (own filename only, never a sibling's, rewritten every launch, atomically via temp + `rename`) and reads a merged `ca-trust.pem` if one exists. It never writes the merged bundle: merging needs ambient corporate-root discovery, which is environment-specific and belongs outside this repo. The bundle is used only when node, handed that file, actually verifies our proxy's leaf — a bundle that is damaged or predates our publish is worse than none, since it makes the client distrust the very proxy it is routed through. On a host with no other MITM and no bundle, behavior is byte-identical to before. Both paths are fixed names under the config dir with no env override: they are two halves of one rendezvous, so a knob on either half alone would let a participant drop out of the contract while appearing to implement it. See [Coexisting with another MITM](README.md#coexisting-with-another-mitm-on-the-same-machine-ca-trustd).

- **The `ca-trust.pem` guard now ASKS node's loader instead of predicting it.** The previous guard modelled the loader in a regex — base64 quanta, padding position, dash runs in markers, which of ten whitespace characters openssl tolerates. It took five review rounds and was still wrong in *both* directions on a real bundle: it accepted one node loads nothing from (overlapping `BEGIN` markers on one line, measured 0 certificates loaded and a failed handshake) and refused one node loads fine (a non-certificate block whose body contains a line-start `-----BEGIN ` line, measured 1 certificate and a successful handshake). The rule it was reaching for is not expressible from outside: an identical tear is recovered or fatal depending only on whether its truncated body happens to be complete DER, which is a question about bytes no parser can answer. The launcher now spawns a child with `NODE_EXTRA_CA_CERTS` set from birth, has it stand up a TLS server holding our leaf, and connects to it — the same thing the session will do. One spawn, ~25 ms over a bare `node -e ''` (measured, 40 interleaved pairs: 17.3 ms bare, 42.4 ms probed) — ~8% of a ~520 ms launch, of which ~493 ms is the proxy coming up, and very nearly all of the CA work. Once per launch, on a path that already forks node for the proxy. Deliberately a handshake rather than `tls.getCACertificates`, which does not exist before node v22.15 while this package declares `engines: >=18`: on those runtimes an API probe cannot answer at all, and a guard that returns "cannot tell" on every ordinary host is not a guard. Three outcomes now, never two — `ok`, `not ok`, and `unknown` for a probe that could not be run, because answering "unusable" when you could not ask drops every corporate root on a machine whose bundle was fine. And a refused merge no longer costs the other publishers their CAs: the launcher rebuilds from the `ca-trust.d/` files that still work, measured on this box (ours plus one peer): 1 certificate under the old fallback versus 2 under the rebuild, and 1 versus 3 on a three-publisher host — one certificate per surviving publisher. The decision lives in `bin/ca-trust.mjs` so the tests drive the shipped code: it was previously inline in a top-level script with a hand-copied twin in the test file, and mutating the real one left the entire suite green.
Expand Down
Loading
Loading