Skip to content

Commit e25dd34

Browse files
codeslakeclaude
andcommitted
fix(launcher): age-gate the temp reaper; drop the publish-dir override
Two contract-level fixes from review of this PR. The reaper deleted every ccf.pem.* after a publish, which cannot distinguish an orphan from a CONCURRENT launcher's temp — one that has been written but not yet renamed. Deleting that makes the peer's renameSync throw a publish failure we caused, and leaves whichever launcher won first on disk rather than the current publisher's bytes. Name carries no provenance, so age is the signal: the write-to-rename window is one small write to the same directory, microseconds, and a minute of gate is four orders of magnitude of headroom. Reaping late costs nothing (nothing reads these); reaping early breaks a peer. CACHE_FIX_CA_TRUST_DIR is removed. The publish path had an override while the read path was a fixed name, so setting it made this launcher publish where no builder looks while still consuming the canonical bundle — silently dropping out of the contract while appearing to implement it. The two paths are halves of one rendezvous and must move together; CLAUDE_CONFIG_DIR already does that. The new test asserts both reaper outcomes across ONE launch: an old orphan is collected and a fresh sibling survives. Mutation-checked in both directions — removing the age gate reddens the survives assertion, removing the rm reddens the collected one. README now also states the boundary explicitly: the guard proves parses + carries us, never contains only approved writers. This is a cooperative convention among same-user processes, not a defense against a local attacker, who could equally replace ccf.pem or the CA dir. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1799d86 commit e25dd34

4 files changed

Lines changed: 73 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
### Fixed
66

7-
- **`--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 every PEM block in it parses **and** one of them is our own CA (compared by DER) — a bundle that is torn 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. New opt-in env var: `CACHE_FIX_CA_TRUST_DIR`. See [Coexisting with another MITM](README.md#coexisting-with-another-mitm-on-the-same-machine-ca-trustd).
7+
- **`--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 every PEM block in it parses **and** one of them is our own CA (compared by DER) — a bundle that is torn 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).
88

99
### Documentation
1010

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,27 @@ that case, and when no bundle exists at all, the launcher falls back to our own
132132
CA and behaves exactly as it did before any of this existed. **A host with no
133133
other MITM and no bundle builder sees no change.**
134134

135+
Both paths are fixed names under `<config>`, deliberately with no env override
136+
of their own. They are two halves of one rendezvous: a knob on either half alone
137+
lets a participant publish where no builder looks, or read a file no builder
138+
writes, while still appearing to implement the contract. `CLAUDE_CONFIG_DIR`
139+
already relocates the pair, and it moves both halves together.
140+
135141
Note the limit of what a consumer can check: intact, and carries my CA. Whether
136142
the bundle is *complete* — that no corporate root went missing — is the
137143
builder's guarantee, not something a reader can verify, because a reader has no
138144
previous state to compare against and a legitimately small bundle is
139145
indistinguishable from a narrowed one.
140146

147+
**This is a cooperative convention among same-user processes, not a trust
148+
boundary.** The check proves *parses, and carries us* — never *contains only
149+
approved writers*. Anyone who can write `<config>` can hand us a well-formed
150+
bundle holding our CA plus their own and it will be accepted, exactly as they
151+
could already have replaced `ca-trust.d/ccf.pem`, the CA dir, or this file. The
152+
contract defends against components accidentally untrusting each other, which is
153+
the failure that actually happens; it does not defend against a local attacker,
154+
who has simpler routes.
155+
141156
#### `CACHE_FIX_DOWNLOAD_REWRITE` breaks `claude update` — leave it off
142157

143158
`CACHE_FIX_DOWNLOAD_REWRITE=on` reads like a pure performance knob. It is not:
@@ -311,7 +326,6 @@ All proxy settings are controlled via environment variables. Set them before sta
311326
| `CACHE_FIX_PROXY_UPSTREAM` | `https://api.anthropic.com` | Upstream URL. Change to chain another proxy (e.g. `http://localhost:8080`) |
312327
| `CACHE_FIX_FORWARD_PROXY` | unset | Set to `on` for forward-proxy mode (HTTP CONNECT + selective MITM of the upstream host) so the client points `HTTPS_PROXY` at the proxy instead of `ANTHROPIC_BASE_URL`, keeping Remote Control enabled. See [Forward-proxy mode](#forward-proxy-mode-keeps-remote-control-working). |
313328
| `CACHE_FIX_CA_DIR` | `~/.claude/cache-fix-ca` | Directory for the forward-proxy CA/leaf cert (generated once on first start). The client trusts `ca.pem` via `NODE_EXTRA_CA_CERTS`. |
314-
| `CACHE_FIX_CA_TRUST_DIR` | `$CLAUDE_CONFIG_DIR/ca-trust.d` | Where `--remote-control` publishes our CA as `ccf.pem` so another MITM on the same host can merge it. See [Coexisting with another MITM](#coexisting-with-another-mitm-on-the-same-machine-ca-trustd). |
315329
| `CACHE_FIX_PROXY_TIMEOUT` | `600000` | Request timeout in milliseconds |
316330
| `CACHE_FIX_EXTENSIONS_DIR` | `proxy/extensions/` | Directory for extension `.mjs` files |
317331
| `CACHE_FIX_EXTENSIONS_CONFIG` | `proxy/extensions.json` | Extension configuration file |

bin/claude-via-proxy.mjs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fork, spawn } from "node:child_process";
44
import { fileURLToPath } from "node:url";
55
import { dirname, resolve, join } from "node:path";
66
import { homedir } from "node:os";
7-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
88
import { X509Certificate, randomUUID } from "node:crypto";
99
import http from "node:http";
1010

@@ -223,8 +223,15 @@ if (remoteControl) {
223223
// We write EXACTLY ONE path, ca-trust.d/ccf.pem, and never a sibling's.
224224
// Rewritten every launch, not once: the proxy regenerates its CA whenever
225225
// caDir is wiped, and a stale pem would advertise a key nothing signs with.
226+
//
227+
// Fixed name, no env override, for the same reason the merged bundle below has
228+
// none: both halves are a rendezvous, and a knob on one half only lets this
229+
// launcher publish where no builder is looking while still consuming the
230+
// canonical bundle — dropping out of the contract while appearing to implement
231+
// it. Relocating the pair is what CLAUDE_CONFIG_DIR already does, and it moves
232+
// both sides together.
226233
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
227-
const caTrustDir = process.env.CACHE_FIX_CA_TRUST_DIR || join(configDir, "ca-trust.d");
234+
const caTrustDir = join(configDir, "ca-trust.d");
228235
try {
229236
mkdirSync(caTrustDir, { recursive: true });
230237
const ours = readFileSync(caPem);
@@ -257,8 +264,22 @@ if (remoteControl) {
257264
// Reap temps orphaned by a kill between the write and the rename. They do
258265
// not match a *.pem glob so a builder ignores them, but nothing else would
259266
// ever remove them.
267+
//
268+
// Age-gated, because a temp is indistinguishable from an orphan by name: a
269+
// CONCURRENT launcher has its own ccf.pem.<pid>.<uuid> on disk in the window
270+
// between its writeFileSync and its renameSync, and deleting that makes its
271+
// rename throw a publish failure we caused. The window is one small write to
272+
// the same directory, microseconds; a minute is four orders of magnitude of
273+
// headroom and still collects the orphan on the next launch. Deleting late
274+
// costs nothing — nothing reads these — while deleting early breaks a peer.
275+
const orphanAgeMs = 60_000;
260276
for (const f of readdirSync(caTrustDir)) {
261-
if (f.startsWith("ccf.pem.")) try { rmSync(join(caTrustDir, f)); } catch { /* raced */ }
277+
if (!f.startsWith("ccf.pem.")) continue;
278+
const tmpPath = join(caTrustDir, f);
279+
try {
280+
if (Date.now() - statSync(tmpPath).mtimeMs < orphanAgeMs) continue;
281+
rmSync(tmpPath);
282+
} catch { /* raced: someone renamed or removed it first */ }
262283
}
263284
} catch (e) {
264285
// Non-fatal: publishing is how OTHERS trust us. This session only needs its

test/proxy-wrapper.test.mjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fork } from "node:child_process";
44
import { fileURLToPath } from "node:url";
55
import { dirname, resolve, join } from "node:path";
66
import { tmpdir } from "node:os";
7-
import { closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7+
import { closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, utimesSync, writeFileSync } from "node:fs";
88
import http from "node:http";
99

1010
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -467,6 +467,38 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
467467
assert.ok(begins >= 1, "published pem must contain at least one certificate");
468468
});
469469

470+
it("--remote-control reaps an old orphan temp but leaves a concurrent publisher's fresh one alone", async () => {
471+
// The reaper cannot tell an orphan from a live temp by NAME — both are
472+
// ccf.pem.<pid>.<uuid>. A second launcher publishing at the same moment has
473+
// written its temp and not yet renamed it; deleting that makes ITS
474+
// renameSync throw a publish failure we caused, and leaves whichever
475+
// launcher won first on disk rather than the current publisher's bytes.
476+
// Age is the only signal available: the write-to-rename window is one small
477+
// write to the same directory, so anything older than the gate is genuinely
478+
// abandoned and anything younger may be in flight.
479+
//
480+
// Both fixtures exist in the same directory across one launch, so the test
481+
// fails if the reaper is unconditional (fresh one dies) OR absent (old one
482+
// survives) — one launch, two opposite outcomes.
483+
const configDir = mkdtempSync(join(tmpdir(), "cfftrust-"));
484+
const trustDir = join(configDir, "ca-trust.d");
485+
mkdirSync(trustDir, { recursive: true });
486+
const stale = join(trustDir, "ccf.pem.99999.aaaaaaaa-orphan");
487+
const fresh = join(trustDir, "ccf.pem.99998.bbbbbbbb-inflight");
488+
writeFileSync(stale, "# abandoned by a kill between write and rename\n");
489+
writeFileSync(fresh, "# a concurrent launcher's temp, not yet renamed\n");
490+
// Backdate past the gate. Real time cannot be used — the gate is a minute and
491+
// a test may not sleep for one.
492+
const longAgo = new Date(Date.now() - 3600_000);
493+
utimesSync(stale, longAgo, longAgo);
494+
495+
const { code, err } = await runWrapper('process.stdout.write("OK\\n")', { CLAUDE_CONFIG_DIR: configDir });
496+
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${err}`);
497+
498+
assert.ok(!existsSync(stale), "a temp older than the gate is abandoned and must be reaped");
499+
assert.ok(existsSync(fresh), "a temp younger than the gate may belong to a live publisher and must survive");
500+
});
501+
470502
it("--remote-control ignores a merged bundle that does NOT contain our own CA", async () => {
471503
// The dangerous case, and worse than falling back: the bundle exists and is
472504
// non-empty, so a size-only gate accepts it — but it was built BEFORE we

0 commit comments

Comments
 (0)