-
-
Notifications
You must be signed in to change notification settings - Fork 35
Expand file tree
/
Copy pathproxy-wrapper.test.mjs
More file actions
1826 lines (1678 loc) · 106 KB
/
Copy pathproxy-wrapper.test.mjs
File metadata and controls
1826 lines (1678 loc) · 106 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// A private TMPDIR for this file, because the launchers spawned below write
// under os.tmpdir(). First, so nothing reads one before it is set.
import "./file-tmpdir.mjs";
import { after, describe, it } from "node:test";
import assert from "node:assert/strict";
import { withDeadline, exitWithin } from "./child-deadline.mjs";
import { fork, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve, join } from "node:path";
import { tmpdir, availableParallelism } from "node:os";
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
import http from "node:http";
import tls from "node:tls";
// The keep-the-merge branch needs a census that can say YES, and
// `tls.getCACertificates` arrived in v22.15 while `engines` allows >=18. Below
// that the census answers `null` for every healthy bundle, so the branch cannot
// fire and the two rows that assert it would fail describing a defect that is
// not there. Asked of the runtime, not of its version string.
const canCountCAs = typeof tls.getCACertificates === "function";
// The launcher's own trust question, asked the same way it asks it.
import { bundleUsable } from "../bin/ca-trust.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WRAPPER_PATH = resolve(__dirname, "../bin/claude-via-proxy.mjs");
const SERVER_PATH = resolve(__dirname, "../proxy/server.mjs");
// Every temp dir this file makes, removed once at the end.
//
// Registered centrally rather than rmSync'd per test: forward mode mints an RSA
// CA and leaf inside each config dir, so a leak is not an empty directory, it is
// private key material. Measured before this: one `node --test` of this file
// left 38 dirs behind, and a /tmp that had accumulated 1954 of them held 432
// ca.key / leaf.key files. Per-test cleanup would also skip exactly the runs
// that matter — a failing test throws before its own rmSync — and every future
// test would have to remember. after() runs on pass and on fail.
const tempDirs = [];
function tempDir(prefix) {
const d = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(d);
return d;
}
after(() => {
for (const d of tempDirs) {
try { rmSync(d, { recursive: true, force: true }); } catch { /* already gone */ }
}
});
describe("proxy server lifecycle", () => {
it("starts and responds to health check", async () => {
const proxyProc = fork(SERVER_PATH, [], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: { ...process.env, CACHE_FIX_PROXY_PORT: "0", CACHE_FIX_PROXY_BIND: "127.0.0.1" },
});
let port;
await new Promise((resolve, reject) => {
let output = "";
proxyProc.stdout.on("data", (chunk) => {
output += chunk.toString();
const match = output.match(/:(\d+)/);
if (match) { port = parseInt(match[1], 10); resolve(); }
});
proxyProc.on("error", reject);
proxyProc.on("exit", (code) => {
if (!port) reject(new Error(`Proxy exited (code ${code}) before ready`));
});
setTimeout(() => reject(new Error("Proxy startup timeout")), 10000);
});
const res = await new Promise((resolve, reject) => {
http.get(`http://127.0.0.1:${port}/health`, resolve).on("error", reject);
});
assert.equal(res.statusCode, 200);
proxyProc.kill("SIGTERM");
await exitWithin(proxyProc, 30_000, "the proxy never exited after SIGTERM");
});
it("shuts down cleanly on SIGTERM", async () => {
const proxyProc = fork(SERVER_PATH, [], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: { ...process.env, CACHE_FIX_PROXY_PORT: "0", CACHE_FIX_PROXY_BIND: "127.0.0.1" },
});
await new Promise((resolve) => {
proxyProc.stdout.on("data", (chunk) => {
if (chunk.toString().includes("listening")) resolve();
});
setTimeout(resolve, 2000);
});
proxyProc.kill("SIGTERM");
// Bounded, because `node --test` defaults to NO test timeout at all: a
// child that does not honour SIGTERM makes this await block forever, the
// case never fails, and the CI job idles to GitHub's 360-minute default —
// observed on run 31018228595, where node 22 finished in 39 s while 18 and
// 20 sat `in_progress` past 80 minutes with nothing reported as failed.
// 30 s is 6x the proxy's own 5 s shutdown grace, so a slow-but-honest drain
// still passes; only a child that is never going to exit trips it.
const code = await withDeadline(
new Promise((resolve) => proxyProc.on("exit", (c) => resolve(c))),
30_000, proxyProc, "the proxy never exited after SIGTERM");
assert.equal(code, 0);
});
});
function cleanEnv(overrides) {
const env = { ...process.env };
// Strip from the BASE, then apply overrides, so a test that deliberately sets
// one of these still gets it. An ambient NO_PROXY on the developer's shell
// otherwise reaches the child and the merge assertions read the host's value
// instead of the fixture's — the lowercase-no_proxy case fails that way on any
// machine that exports NO_PROXY.
// CACHE_FIX_CA_PROBE_UNANSWERABLE is here for the same reason, from the other
// direction: it is a seam ONE test sets, and a leak would make every later
// test's CA probe answer "could not ask" — silently turning the assertions
// that follow into measurements of the fallback rather than of the guard.
// The TRUST vars are here for the third reason, and it is the sharpest one:
// OUR OWN PRODUCT SETS THEM. A machine running the launcher exports
// SSL_CERT_FILE / REQUESTS_CA_BUNDLE / NODE_EXTRA_CA_CERTS into the developer's
// shell, so the cases that assert "this must stay UNSET" or "this must point at
// the bundle WE built" were reading the host's wiring instead of the fixture's.
// Measured 2026-08-18 at 6d20f0c: 7 of 44 red on a developer machine, green on
// CI, entirely because CI's shell has no trust wiring. All three were set —
// one to a chained proxy's bundle, one to the distro store, one to the merged
// ca-trust.pem this launcher itself publishes.
// A suite that only passes on a machine that does NOT run the thing under test
// is not testing the thing under test.
for (const k of ["CACHE_FIX_PROXY_PORT", "CACHE_FIX_PROXY_UPSTREAM", "NO_PROXY", "no_proxy",
"CACHE_FIX_CA_PROBE_UNANSWERABLE",
"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS",
"CURL_CA_BUNDLE"]) delete env[k];
env.CACHE_FIX_PROXY_BIND = "127.0.0.1";
// A config dir per invocation, by DEFAULT — not opt-in per test. Forward mode
// publishes our CA into <config>/ca-trust.d/ccf.pem, so any test that forgot to
// set this published a throwaway temp CA over the developer's REAL
// ~/.claude/ca-trust.d/ccf.pem. Measured: one run of the CACHE_FIX_CA_DIR test
// took the host's pem from 5dc414fc to 3773c611, leaving the machine's merged
// bundle advertising a CA nothing signs with — precisely the failure this
// feature exists to prevent. It also silently poisons the suite itself: two
// cases were reading the host's real merged bundle instead of a fixture.
env.CLAUDE_CONFIG_DIR = tempDir("cffcfg-");
return { ...env, ...overrides };
}
const NODE = process.execPath;
// Fork the wrapper in forward mode, collect the child's output, resolve when it
// exits. Two ca-trust tests below run the wrapper TWICE against one config dir
// (first launch publishes our CA, second reads the bundle built from it), which
// is what makes a named helper worth it over the inline fork the older tests use.
// `close`, not `exit`: exit fires when the process is gone, close when its
// stdio has also been drained. Measured under the concurrency this file now
// runs at — an exit-resolved run came back with bytesRead=0 and
// readableEnded=false while the child had provably run and written, so the
// assertion read an empty string the child had in fact produced. One helper
// because that judgement was previously repeated at fifteen call sites, which
// is why correcting it had to touch all fifteen.
const waitClose = (p) => new Promise((res) => {
const t = setTimeout(() => { p.kill("SIGTERM"); res(null); }, 15_000);
p.on("close", (c) => { clearTimeout(t); res(c); });
});
// LIVES HERE, NOT IN bin/. Its only caller is the fixture below, and
// production stopped having one when the launcher's replace-class write was
// deleted — shipping it in bin/ made it dead API on every install. Moved
// rather than deleted: the fixture has to build its bundle the way the real
// builder does, ambient store first, and inlining platform detection at the
// call site is what this function exists to avoid.
// Where this platform keeps the trust store an unset SSL_CERT_FILE falls back
// to. Returns null when there is no FILE to compare against — macOS keeps it in
// the keychain, which is not enumerable this cheaply, and "cannot name it" must
// read as "cannot prove", never as "nothing to lose".
function ambientStorePath() {
// NOT process.env.SSL_CERT_FILE: that is the CLIENT's value, which the caller
// already compares against separately. Reading it here would compare a value
// to itself and always answer yes.
for (const p of [
"/etc/ssl/certs/ca-certificates.crt", // debian, ubuntu, most containers
"/etc/pki/tls/certs/ca-bundle.crt", // rhel, fedora
"/etc/ssl/cert.pem", // alpine, AND macOS: the system roots
// exported in OpenSSL form. Measured 128
// certs on two Macs — so macOS is provable
// here, not a platform we have to skip.
]) {
if (!p) continue;
try { if (statSync(p).size > 0) return p; } catch { /* next */ }
}
return null;
}
async function runWrapper(script, overrides) {
const p = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, ...overrides }),
});
let out = "", err = "";
p.stdout.on("data", (c) => { out += c.toString(); });
p.stderr.on("data", (c) => { err += c.toString(); });
return { code: await waitClose(p), out, err };
}
// Concurrent, but BOUNDED BY CORES: each case boots a real proxy under its own
// 10s startup budget, and unbounded concurrency blew that budget on the CI
// runner — measured, "Proxy failed to start within 10s" on every node, while a
// 48-core box passed every time. Serial, the file pays the sum of the waits; at
// half the cores it pays close to the longest one without starving any boot.
//
// availableParallelism(), NOT cpus().length — see the same bound in
// proxy-held-port.test.mjs for the measurement, for what this does and does not
// fix, and for why the runner's core count is deliberately not claimed. Short
// version: cpus() counts the machine and ignores this process's CPU affinity,
// so under `--cpuset-cpus=0,1` it reports 48 and this bound stops bounding
// anything. No change on CI, where there is no mask and both calls agree.
//
// THE FLOOR IS 1. `Math.max(2, ...)` used to defeat the halving on exactly the
// machines it exists for — floor(2/2) is 1, so a two-core runner computed 2 and
// booted two real proxies at once. Identical on any box with 4+ cores, which is
// why it survived every local run. Arithmetic, not a CI fix: it was proposed as
// one and suite-collection.test.mjs records that hypothesis REJECTED.
const CONCURRENCY = Math.max(1, Math.floor(availableParallelism() / 2));
describe("launch wrapper (claude-via-proxy)", { concurrency: CONCURRENCY }, () => {
it("exits with error when claude command is not found", async () => {
const wrapperProc = fork(WRAPPER_PATH, ["--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: "/nonexistent/path/to/claude" }),
});
let stderr = "";
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.ok(code !== 0, `Wrapper should exit non-zero. stderr: ${stderr}`);
});
it("sets ANTHROPIC_BASE_URL and forwards to child process", async () => {
const script = 'process.stdout.write("BASE_URL="+process.env.ANTHROPIC_BASE_URL+"\\n")';
const wrapperProc = fork(WRAPPER_PATH, ["--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}` }),
});
let stdout = "";
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
const code = await waitClose(wrapperProc);
assert.ok(stdout.includes("BASE_URL=http://127.0.0.1:"), `Expected BASE_URL in output, got: ${stdout}`);
assert.equal(code, 0);
});
it("propagates claude exit code", async () => {
const wrapperProc = fork(WRAPPER_PATH, ["--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e process.exit(42)` }),
});
let stderr = "";
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.equal(code, 42, `Expected exit 42, got ${code}. stderr: ${stderr}`);
});
it("--remote-control wires forward-proxy env (BASE unset, HTTPS_PROXY + CA set)", async () => {
// The child prints the three routing-relevant vars. Forward mode must leave
// ANTHROPIC_BASE_URL unset (that keeps Remote Control enabled) and instead
// route via HTTPS_PROXY + the proxy's MITM CA. The wrapper splits
// CACHE_FIX_CLAUDE_CMD on spaces, so the script must contain none — hence
// the "|" delimiter rather than spaces in the output string.
const script =
'process.stdout.write("BASE="+(process.env.ANTHROPIC_BASE_URL||"UNSET")+' +
'"|HP="+(process.env.HTTPS_PROXY||"UNSET")+' +
'"|CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")';
// Own config dir: this asserts the no-bundle fallback, so it must not read the
// developer's real ~/.claude/ca-trust.pem. On a machine where a bundle builder
// has run, that file exists and legitimately wins — the assertion would fail
// for a host-state reason, not a code reason.
const configDir = tempDir("cfftrust-");
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, CLAUDE_CONFIG_DIR: configDir }),
});
let stdout = "";
let stderr = "";
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
assert.ok(stdout.includes("BASE=UNSET"), `ANTHROPIC_BASE_URL should be unset in forward mode, got: ${stdout}`);
assert.match(stdout, /HP=http:\/\/127\.0\.0\.1:\d+/, `HTTPS_PROXY should point at the proxy, got: ${stdout}`);
assert.match(stdout, /CA=\S*cache-fix-ca\/ca\.pem/, `NODE_EXTRA_CA_CERTS should point at the MITM CA, got: ${stdout}`);
});
it("--remote-control honors CACHE_FIX_CA_DIR (matches the proxy's CA path contract)", async () => {
// The proxy resolves its CA dir as CACHE_FIX_CA_DIR || claudeHome()/cache-fix-ca.
// The launcher MUST resolve NODE_EXTRA_CA_CERTS from the same input in the
// same order, or it points claude at a different (or absent) CA than the one
// the spawned proxy generated — a hard fail, or a silent trust mismatch when
// a stale default CA exists. This test pins the override path exactly.
const caDir = tempDir("cffcadir-");
const script =
'process.stdout.write("BASE="+(process.env.ANTHROPIC_BASE_URL||"UNSET")+' +
'"|CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")';
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, CACHE_FIX_CA_DIR: caDir }),
});
let stdout = "";
let stderr = "";
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
// The CA must be the override path exactly, not the default ~/.claude one.
assert.ok(
stdout.includes(`CA=${join(caDir, "ca.pem")}`),
`NODE_EXTRA_CA_CERTS should be the CACHE_FIX_CA_DIR override (${join(caDir, "ca.pem")}), got: ${stdout}`,
);
});
// --- ca-trust.d: coexisting with another component that also MITMs ---------
// NODE_EXTRA_CA_CERTS takes ONE file, so a plain assignment silently untrusts
// whatever else needed trusting. Measured 2026-07-30: an account-switching pin proxy also
// MITMs api.anthropic.com and also set this var; last writer won and broke
// Remote Control inbound on the work Mac. The contract: each component
// publishes ONLY its own ca-trust.d/<name>.pem, one external writer builds the
// merged ca-trust.pem (it needs ambient corp-root discovery, which is
// environment-specific and stays out of this repo), and we READ that bundle.
// These four tests pin the whole contract: publish, read, isolation, fallback.
it("--remote-control publishes its CA to ca-trust.d/ccf.pem before exec'ing claude", async () => {
// Publishing is how OTHER components learn to trust us, and it must happen
// before the client runs — a bundle builder that reads the dir on a cold
// start would otherwise miss us and produce a bundle without our CA.
const configDir = tempDir("cfftrust-");
const script = 'process.stdout.write("OK\\n")';
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, CLAUDE_CONFIG_DIR: configDir }),
});
let stdout = "";
let stderr = "";
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
// The child already ran and exited, so anything on disk now was written
// before the exec — that ordering is the point of this assertion.
const published = join(configDir, "ca-trust.d", "ccf.pem");
assert.ok(existsSync(published), `expected published CA at ${published}. stdout: ${stdout} stderr: ${stderr}`);
const ours = readFileSync(join(configDir, "cache-fix-ca", "ca.pem"), "utf8");
assert.equal(readFileSync(published, "utf8"), ours, "published pem must be our CA verbatim");
assert.match(ours, /BEGIN CERTIFICATE/, "published pem should be a PEM certificate");
});
it("--remote-control points NODE_EXTRA_CA_CERTS at the merged bundle when one exists", async () => {
// The whole reason the contract exists: with a merged bundle present we must
// hand claude THAT, not our own CA alone, or the other component's CA is
// untrusted for this session.
// The bundle must be a REALISTIC builder output — i.e. it has to contain our
// own CA, because that is what the builder concatenates from ca-trust.d. A
// fixture without it is the stale-bundle case, which is correctly rejected
// (see the test below). So: run once to let the proxy generate + publish our
// CA, then build the bundle from it the way the launcher would, then re-run.
const configDir = tempDir("cfftrust-");
const bundle = join(configDir, "ca-trust.pem");
const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")';
const runOnce = () => runWrapper(script, { CLAUDE_CONFIG_DIR: configDir });
const first = await runOnce();
assert.equal(first.code, 0, `first run should exit 0, got ${first.code}. stderr: ${first.err}`);
// Mimic the launcher: ambient-ish preamble + every published component pem.
writeFileSync(bundle, `# merged by the launcher\n${readFileSync(join(configDir, "ca-trust.d", "ccf.pem"), "utf8")}`);
const second = await runOnce();
assert.equal(second.code, 0, `Expected exit 0, got ${second.code}. stderr: ${second.err}`);
assert.ok(second.out.includes(`CA=${bundle}`), `NODE_EXTRA_CA_CERTS should be the merged bundle (${bundle}), got: ${second.out}`);
});
// ONE launcher run for the two cases below. Each used to open with its own
// identical "run once so the proxy mints and publishes our CA" pass, which is
// two extra launcher+proxy pairs in a file node:test already runs concurrently
// with the timing cases in proxy-held-port.test.mjs. Those cases lose their
// windows to exactly this kind of neighbour load — measured, CI went red on a
// different one of them each run.
let pyTrust;
const pythonTrustFixture = async () => {
if (pyTrust) return pyTrust;
const configDir = tempDir("cfftrust-");
const bundle = join(configDir, "ca-trust.pem");
const first = await runWrapper('process.stdout.write("x")', { CLAUDE_CONFIG_DIR: configDir });
assert.equal(first.code, 0, `fixture run should exit 0, got ${first.code}. stderr: ${first.err}`);
const ourPem = readFileSync(join(configDir, "ca-trust.d", "ccf.pem"), "utf8");
// Built the way the real builder builds it: the AMBIENT store first, then
// each component. A components-only fixture is a different machine — the one
// the "carries no ambient roots" case covers — and the launcher now
// correctly refuses to point python at it.
const ambient = ambientStorePath();
assert.ok(ambient, "this platform has no ambient CA file, so this case cannot establish its premise");
writeFileSync(bundle, `${readFileSync(ambient, "utf8")}\n${ourPem}`);
pyTrust = { configDir, bundle, ourPem };
return pyTrust;
};
// THE REPLACE-CLASS VARIABLES ARE NOT OURS TO WRITE — not gated, ABSENT.
//
// These two cases used to assert the opposite: that we set SSL_CERT_FILE and
// REQUESTS_CA_BUNDLE whenever a fingerprint-set subsumption proof passed, and
// kept the operator's value when it did not. The proof worked. It was still
// the wrong design, because a proof taken at launch outlives the thing it
// proved: the store can be rotated, revoked, made unreadable, or replaced by
// MDM, and the variable stays behind naming a bundle that no longer subsumes
// anything. On a corporate laptop that is total — system roots gone, no
// internet, every enterprise function dead, while our proxy keeps working so
// the tool still looks healthy.
//
// Two independent implementations of that gate each shipped a default-ALLOW
// arm (ours said ok on an UNREADABLE store; cswap-pin's passed when the
// ambient roots were a capath with no cafile), which is the shape of the
// class rather than two bugs.
//
// A python client that must trust this proxy adds the CA in code —
// ssl.create_default_context() then load_verify_locations() — which cannot
// narrow trust on any platform.
it("--remote-control never writes a replace-class trust variable", async () => {
const { configDir, bundle } = await pythonTrustFixture();
const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")'
+ '+"|SSL="+(process.env.SSL_CERT_FILE||"UNSET")'
+ '+"|REQ="+(process.env.REQUESTS_CA_BUNDLE||"UNSET")'
+ '+"|CURL="+(process.env.CURL_CA_BUNDLE||"UNSET")+"\\n")';
// Nothing inherited, so anything that appears was written by the launcher.
const clean = await runWrapper(script, { CLAUDE_CONFIG_DIR: configDir });
assert.equal(clean.code, 0, `Expected exit 0, got ${clean.code}. stderr: ${clean.err}`);
assert.ok(clean.out.includes(`CA=${bundle}`),
`the ADD-class variable is still ours to set, got: ${clean.out}`);
for (const key of ["SSL", "REQ", "CURL"]) {
assert.ok(clean.out.includes(`${key}=UNSET`),
`${key} is replace-class and must never be written; got: ${clean.out}`);
}
});
it("--remote-control leaves an operator's replace-class values exactly as it found them", async () => {
const { configDir, ourPem } = await pythonTrustFixture();
const script = 'process.stdout.write("SSL="+(process.env.SSL_CERT_FILE||"UNSET")'
+ '+"|REQ="+(process.env.REQUESTS_CA_BUNDLE||"UNSET")+"\\n")';
// A value we COULD prove we subsume — under the old design this is exactly
// the input that got overwritten. It must now survive untouched, which is
// what separates "we deleted the write" from "the proof happens to refuse".
const theirs = join(configDir, "operator-trust.pem");
writeFileSync(theirs, ourPem);
const res = await runWrapper(script, {
CLAUDE_CONFIG_DIR: configDir,
SSL_CERT_FILE: theirs,
REQUESTS_CA_BUNDLE: theirs,
});
assert.equal(res.code, 0, `Expected exit 0, got ${res.code}. stderr: ${res.err}`);
assert.ok(res.out.includes(`SSL=${theirs}`), `SSL_CERT_FILE was modified: ${res.out}`);
assert.ok(res.out.includes(`REQ=${theirs}`), `REQUESTS_CA_BUNDLE was modified: ${res.out}`);
});
it("--remote-control leaves the python vars alone when the bundle carries no ambient roots", async () => {
// "The merged bundle is a superset by construction" is FALSE. It is a
// superset of the ambient corporate store only when the builder found one.
// Measured across this fleet, ~/.claude/ca-trust.pem:
// a Linux box 127 certs, 2 components, 125 ambient
// a work Mac 168 certs, 2 components, 166 ambient
// a personal Mac 2 certs, 2 components, 0 ambient <- no corp store
// On the last one the merged bundle IS the two component CAs, so pointing
// SSL_CERT_FILE at it leaves a python client trusting our proxies and
// nothing else — the lone-CA bug again, through the door we had just
// declared safe.
//
// So the gate is a PROOF, not a shape: the bundle must subsume the ambient
// store. A bundle of components only cannot, and the vars stay untouched.
const configDir = tempDir("cffnoamb-");
const bundle = join(configDir, "ca-trust.pem");
const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")'
+ '+"|SSL="+(process.env.SSL_CERT_FILE||"UNSET")+"\\n")';
const first = await runWrapper(script, { CLAUDE_CONFIG_DIR: configDir });
assert.equal(first.code, 0, `first run should exit 0, got ${first.code}. stderr: ${first.err}`);
// A components-only bundle: exactly what that Mac has.
writeFileSync(bundle, readFileSync(join(configDir, "ca-trust.d", "ccf.pem"), "utf8"));
const res = await runWrapper(script, { CLAUDE_CONFIG_DIR: configDir });
assert.equal(res.code, 0, `Expected exit 0, got ${res.code}. stderr: ${res.err}`);
assert.ok(res.out.includes(`CA=${bundle}`),
`NODE_EXTRA_CA_CERTS should still take the bundle (node merges), got: ${res.out}`);
assert.ok(res.out.includes("SSL=UNSET"),
`SSL_CERT_FILE must stay unset when the bundle carries no ambient roots, got: ${res.out}`);
});
it("--remote-control never makes its own CA the whole python trust world", async () => {
// The standalone case, and the one that makes this dangerous rather than
// merely incomplete. With no merged bundle the launcher hands claude OUR CA
// ALONE — correct for NODE_EXTRA_CA_CERTS, which node MERGES with its
// built-in store, and catastrophic for SSL_CERT_FILE, which urllib does not
// merge but REPLACES. Naming a one-certificate file there leaves a python
// client trusting exactly our proxy and nothing else on the internet.
//
// So the two python vars are gated on handing over the MERGED bundle, not on
// having any CA at all. The bundle is the ambient store plus each component,
// hence a superset; our own CA is not a superset of anything.
//
// Found from the other side: a peer is about to write SSL_CERT_FILE too, and
// asking which of us wins surfaced that CCF's own fallback was the loser.
const configDir = tempDir("cffsolo-");
const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")'
+ '+"|SSL="+(process.env.SSL_CERT_FILE||"UNSET")+"\\n")';
// No ca-trust.pem is ever written here, so the launcher takes the fallback.
const res = await runWrapper(script, { CLAUDE_CONFIG_DIR: configDir });
assert.equal(res.code, 0, `Expected exit 0, got ${res.code}. stderr: ${res.err}`);
assert.match(res.out, /CA=\S*cache-fix-ca\/ca\.pem/,
`NODE_EXTRA_CA_CERTS should still be our own CA, got: ${res.out}`);
assert.ok(res.out.includes("SSL=UNSET"),
`SSL_CERT_FILE must NOT become our one-cert CA, got: ${res.out}`);
});
it("--remote-control leaves the ambient python trust vars alone when it has no usable CA", async () => {
// The other half of the contract. With no usable CA we hand claude nothing
// and let it fall back to the ambient store — so we must not have pointed
// python at a file we then refused to vouch for, and equally must not have
// deleted a REQUESTS_CA_BUNDLE the user configured. Whatever came in, comes
// out.
const configDir = tempDir("cfftrust-");
const ambient = join(configDir, "operator-configured.pem");
writeFileSync(ambient, "# the user's own bundle\n");
// An UNPARSEABLE ca.pem is the only reachable way to make caForClaude null;
// the sibling test at "does not hand claude a ca.pem that failed to parse"
// uses the same fixture. An absent CA dir does NOT work — the launcher mints
// into it and then correctly wires all three. Measured: that first draft of
// this test asserted UNSET and got the freshly minted ca.pem, i.e. it was
// encoding a premise that does not exist rather than the contract.
const caDir = tempDir("cffca-");
writeFileSync(join(caDir, "ca.key"), "-----BEGIN PRIVATE KEY-----\nplaceholder\n-----END PRIVATE KEY-----\n");
writeFileSync(join(caDir, "ca.pem"), "-----BEGIN CERTIFICATE-----\ntruncated\n");
const script = 'process.stdout.write("SSL="+(process.env.SSL_CERT_FILE||"UNSET")'
+ '+"|REQ="+(process.env.REQUESTS_CA_BUNDLE||"UNSET")+"\\n")';
const res = await runWrapper(script, {
CLAUDE_CONFIG_DIR: configDir,
CACHE_FIX_CA_DIR: caDir,
SSL_CERT_FILE: ambient,
REQUESTS_CA_BUNDLE: ambient,
});
assert.equal(res.code, 0, `Expected exit 0, got ${res.code}. stderr: ${res.err}`);
assert.ok(res.out.includes(`SSL=${ambient}`), `SSL_CERT_FILE must survive untouched, got: ${res.out}`);
assert.ok(res.out.includes(`REQ=${ambient}`), `REQUESTS_CA_BUNDLE must survive untouched, got: ${res.out}`);
});
it("--remote-control never writes the merged bundle and never touches a sibling component's pem", async () => {
// Single-writer invariant. Two launchers both "helpfully" rebuilding the
// merged file race one output, and a component that rewrites a sibling's pem
// can untrust it. So: we write exactly one path, ca-trust.d/ccf.pem.
const configDir = tempDir("cfftrust-");
const trustDir = join(configDir, "ca-trust.d");
mkdirSync(trustDir, { recursive: true });
const sibling = join(trustDir, "other-component.pem");
const SIBLING_BYTES = "# another component's CA — must survive untouched\n";
writeFileSync(sibling, SIBLING_BYTES);
const script = 'process.stdout.write("OK\\n")';
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, CLAUDE_CONFIG_DIR: configDir }),
});
let stderr = "";
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
assert.equal(readFileSync(sibling, "utf8"), SIBLING_BYTES, "sibling component's pem must be untouched");
assert.ok(!existsSync(join(configDir, "ca-trust.pem")), "we must NOT create the merged bundle — exactly one external writer owns it");
assert.ok(existsSync(join(trustDir, "ccf.pem")), "our own pem should still be published");
});
it("--remote-control hands claude a bundle node actually loads CAs from", async () => {
// COUNT THE CERTIFICATES, do not check the path. Every other launcher test
// here asserts WHICH file was handed over, and a mutant that accepts an
// unjudgeable merge unconditionally satisfies all of them while handing
// claude a bundle node loads ZERO certificates from — measured, and the
// session then cannot verify the very proxy it is routed through.
//
// So the child reports what the LOADER read, not what the env says. The two
// are different questions and only the second one has ever been asked here.
const configDir = tempDir("cfftrust-");
const bundle = join(configDir, "ca-trust.pem");
// `getCACertificates` does not exist before v22.15 and `engines` says >=18,
// so the count comes from a real handshake against a leaf our CA issued —
// the same question the shipped probe asks, asked the same way.
// No spaces: runWrapper splits CACHE_FIX_CLAUDE_CMD on whitespace, so a
// script with any in it reaches node truncated (measured — `const` alone,
// "Unexpected token <eof>"). Every sibling test is written this way for the
// same reason.
const script = 'process.stdout.write("N="+(require("node:tls").getCACertificates?'
+ 'require("node:tls").getCACertificates("extra").length:-1)+"\\n")';
const runOnce = () => runWrapper(script, { CLAUDE_CONFIG_DIR: configDir });
const first = await runOnce();
assert.equal(first.code, 0, `first run should exit 0, got ${first.code}. stderr: ${first.err}`);
const n0 = Number(/N=(-?\d+)/.exec(first.out)?.[1]);
if (n0 === -1) return; // pre-v22.15: the count is unavailable, not wrong
// A merge that is BROKEN, not stale: it carries our CA, so a
// "does it contain us" check passes, and it is torn AHEAD of that CA, so
// node loads nothing at all from it.
//
// TWO publishers, not one. With only ours in ca-trust.d, salvage rebuilding
// and salvage being deleted outright both end at 1 certificate — the
// fallback is our own CA, which is also the whole rebuild — so no count can
// tell them apart. Measured: deleting the salvage call left this test green
// until the peer was added.
const ours = readFileSync(join(configDir, "ca-trust.d", "ccf.pem"), "utf8");
const peer = readFileSync(join(configDir, "cache-fix-ca", "leaf.pem"), "utf8");
writeFileSync(join(configDir, "ca-trust.d", "zpeer.pem"), peer);
writeFileSync(bundle, "-----BEGIN CERTIFICATE-----\nQUFB\n" + ours + peer);
// ...and the probe must be UNABLE TO JUDGE it, because `unknown` and
// `ok:false` are different branches and only the first can see a mutant
// that accepts unjudgeable merges. With a serveable leaf this same bundle
// yields `{ok:false}` — measured, and that is why the first version of this
// test killed nothing.
//
// A DIRECTORY at leaf.pem, not a delete and not a chmod. The proxy runs
// ensureCA() on every launch and publishes by rename, so both of those are
// undone inside the very run they were meant to affect — measured: after
// `rm` the file is back, after `chmod 0` the mode reads 0600 again, and the
// launcher lands on `{ok:false}` either way. Renaming ONTO a directory
// fails with EISDIR, so this one survives, and the probe's own readFileSync
// then throws — which is exactly what `unknown` means.
const leafPem = join(configDir, "cache-fix-ca", "leaf.pem");
rmSync(leafPem, { force: true });
mkdirSync(leafPem);
assert.equal(bundleUsable(bundle, {
keyPath: join(configDir, "cache-fix-ca", "leaf.key"),
certPath: leafPem, host: "api.anthropic.com",
}).unknown, true, "premise: this fixture must be UNJUDGEABLE, not refused");
const second = await runOnce();
assert.equal(second.code, 0, `Expected exit 0, got ${second.code}. stderr: ${second.err}`);
const n = Number(/N=(-?\d+)/.exec(second.out)?.[1]);
// COUNT, do not test for non-zero. `n > 0` is satisfied by the bare
// fallback (our own CA alone, 1 certificate), so it cannot tell a rebuild
// from no rebuild — measured, it left salvage-deleted green. Both publishers
// must survive: that is what salvage is for.
assert.ok(n >= 2,
`expected both publishers to survive, node loaded ${n}. stderr: ${second.err}`);
// ...and the sentence has to name what it chose. This is the SECOND arm of
// the message ternary; the third is asserted where the census cannot save a
// refused merge. Both were unmeasured until a mutation swapped their two
// strings and left the whole suite green — the value and the sentence are
// built by separate expressions, so only an assertion per arm ties them
// together. Round 14 was this same disagreement in the FIRST arm.
//
// Asserted HERE and not on the torn-ahead test, which looks like it
// rebuilds and does not: its trust dir holds only our own CA, so salvage
// returns null and the launcher correctly says "using our own CA only".
// Measured — the assertion was written there first and failed.
assert.ok(/rebuilt from the publishers that work/.test(second.err),
`handed a rebuild but did not say so. got stderr: ${second.err}`);
});
it("--remote-control falls back to its own CA when no merged bundle exists (unchanged standalone behaviour)", async () => {
// A plain CCF user with no other MITM and no bundle builder must see exactly
// what they saw before this contract existed.
const configDir = tempDir("cfftrust-");
const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")';
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, CLAUDE_CONFIG_DIR: configDir }),
});
let stdout = "";
let stderr = "";
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
assert.ok(
stdout.includes(`CA=${join(configDir, "cache-fix-ca", "ca.pem")}`),
`with no bundle NODE_EXTRA_CA_CERTS must be our own CA, got: ${stdout}`,
);
});
it("--remote-control never leaves a truncated ccf.pem visible while publishing", async () => {
// A torn pem is not a cosmetic problem: Node's PEM reader aborts the whole
// extras load on an unterminated block. Measured on node v24 / openssl 3.5,
// with a leaf signed by the CCF CA and a bundle = good.pem + torn.pem:
// torn AFTER -> "Ignoring extra certs ... bad end line", verify still ok
// torn BEFORE -> "... ASN1 lib", verify FAILS UNABLE_TO_VERIFY_LEAF_SIGNATURE
// The builder concatenates sort(ca-trust.d/*.pem), so a torn OURS lands ahead
// of any sibling that sorts later and takes every other component CA and
// corporate root down with it. A plain
// writeFileSync(dst) is exactly what leaves that state visible to a
// concurrent builder, so the write must be rename-into-place.
//
// Distinguishing atomic from non-atomic needs a property that holds ONLY for
// rename-into-place, and "replaces a read-only file" is not it: a read-only
// target is also defeated by unlink-then-write, which is maximally
// non-atomic (a reader can observe ENOENT, then zero length, then a partial
// file). An earlier version of this test asserted exactly that and passed
// green against `unlinkSync(dst); writeFileSync(dst, ours)` — it proved
// nothing.
//
// The property that separates them is INODE STABILITY for an existing
// reader. A builder that opened ccf.pem before the publish holds a
// descriptor on the old inode. rename() swaps a new inode into the
// directory entry and leaves the old one intact and fully readable until
// that descriptor closes, so the reader still sees complete old content.
// Any in-place rewrite — O_TRUNC or unlink-and-recreate — either truncates
// the very bytes that reader is consuming or leaves it on a deleted inode
// whose content is gone. So: hold a descriptor open across the launch, then
// read it to the end.
const configDir = tempDir("cfftrust-");
const trustDir = join(configDir, "ca-trust.d");
const dst = join(trustDir, "ccf.pem");
mkdirSync(trustDir, { recursive: true });
// A stale published file, standing in for "an existing complete file a
// builder may be reading right now". Its content differs from our CA, so the
// publish path must replace it rather than take the byte-compare skip.
const STALE = "-----BEGIN CERTIFICATE-----\nc3RhbGUtcHVibGlzaGVk\n-----END CERTIFICATE-----\n";
writeFileSync(dst, STALE);
// The load-bearing observer: a descriptor held across the publish. Measured,
// and counter-intuitive enough to be worth stating — the 1 ms sampler below
// CANNOT see the O_TRUNC window for a ~1.2 KB write (100 samples over a
// truncate+write saw only the complete file, never length 0). Only this
// descriptor catches it, because O_TRUNC destroys the bytes under an existing
// reader while rename leaves that inode intact. Deleting these two assertions
// as "duplicated by the sampler" was tried and silently dropped O_TRUNC
// detection entirely.
const readerFd = openSync(dst, "r");
const inodeBefore = fstatSync(readerFd).ino;
// A builder does not hold a descriptor open across our publish — it opens
// ca-trust.d/*.pem by NAME whenever it rebuilds. So the property that
// actually protects it is that the NAME never resolves to anything but a
// complete file. Sample it as fast as the runtime allows for the whole
// launch: with rename the name is always the old or the new file; with
// O_TRUNC or unlink-then-write it is briefly zero-length or absent. In
// practice this catches a SLOW bad write, not a fast one (see the note
// above) — it is the cheap wide net, the descriptor is the precise one.
const observations = [];
const sampler = setInterval(() => {
try { observations.push(readFileSync(dst, "utf8")); }
catch (e) { observations.push(`ENOENT:${e.code}`); }
}, 1);
const script = 'process.stdout.write("OK\\n")';
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, CLAUDE_CONFIG_DIR: configDir }),
});
let stderr = "";
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
const code = await waitClose(wrapperProc);
clearInterval(sampler);
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
// THE assertion. Every sample taken by name, across the whole launch, must
// be one of the two COMPLETE states — never absent, never partial.
//
// Mutation-tested, and the result bounds what this test is worth:
// writeFileSync(dst) [O_TRUNC] -> FAILS (caught)
// unlinkSync + writeFileSync -> passes (NOT caught)
// temp + renameSync -> passes
// The unlink variant's window between unlink and create is shorter than the
// sampler's 1 ms floor, so a timing-based observer cannot see it. Hence the
// test name: no TRUNCATED file is ever visible. Not "atomic" in general —
// proving that would need an inotify watch for IN_DELETE-before-IN_CREATE,
// which is not worth a dependency for a shape nothing here writes.
const ourCa = readFileSync(join(configDir, "cache-fix-ca", "ca.pem"), "utf8");
const bad = observations.filter((o) => o !== STALE && o !== ourCa);
assert.deepEqual(
bad, [],
`every observation of ccf.pem must be a complete file (stale or ours); saw ${bad.length} bad ` +
`of ${observations.length}: ${JSON.stringify(bad.slice(0, 3))}`,
);
assert.ok(observations.length > 0, "sampler must have observed the file at least once");
// O_TRUNC destroys the bytes under this reader; rename does not. This is the
// assertion that actually catches a truncating in-place write.
const viaOldFd = readFileSync(readerFd, "utf8");
closeSync(readerFd);
assert.equal(viaOldFd, STALE,
"a reader holding the pre-publish descriptor must still see the COMPLETE old pem");
assert.notEqual(statSync(dst).ino, inodeBefore, "publish must swap a new inode into place");
const pem = readFileSync(dst, "utf8");
assert.notEqual(pem, STALE, "publish must actually replace the stale pem");
assert.equal(pem, readFileSync(join(configDir, "cache-fix-ca", "ca.pem"), "utf8"), "published pem must be our CA verbatim");
// No leftover temp: a builder globbing *.pem must not find a partial sibling,
// and nothing may be left behind for the next run to trip over.
const leftovers = readdirSync(trustDir).filter((f) => f !== "ccf.pem");
assert.deepEqual(leftovers, [], `ca-trust.d must contain only ccf.pem, found: ${leftovers.join(", ")}`);
// Balanced markers — the exact property whose absence voids the whole bundle.
const begins = (pem.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
const ends = (pem.match(/-----END CERTIFICATE-----/g) || []).length;
assert.equal(begins, ends, `published pem must have balanced BEGIN/END, got ${begins}/${ends}`);
assert.ok(begins >= 1, "published pem must contain at least one certificate");
});
it("--remote-control reaps an old orphan temp but leaves a concurrent publisher's fresh one alone", async () => {
// The reaper cannot tell an orphan from a live temp by NAME — both are
// ccf.pem.<pid>.<uuid>. A second launcher publishing at the same moment has
// written its temp and not yet renamed it; deleting that makes ITS
// renameSync throw a publish failure we caused, and leaves whichever
// launcher won first on disk rather than the current publisher's bytes.
// Age is the only signal available: the write-to-rename window is one small
// write to the same directory, so anything older than the gate is genuinely
// abandoned and anything younger may be in flight.
//
// Both fixtures exist in the same directory across one launch, so the test
// fails if the reaper is unconditional (fresh one dies) OR absent (old one
// survives) — one launch, two opposite outcomes.
const configDir = tempDir("cfftrust-");
const trustDir = join(configDir, "ca-trust.d");
mkdirSync(trustDir, { recursive: true });
const stale = join(trustDir, "ccf.pem.99999.aaaaaaaa-orphan");
const fresh = join(trustDir, "ccf.pem.99998.bbbbbbbb-inflight");
writeFileSync(stale, "# abandoned by a kill between write and rename\n");
writeFileSync(fresh, "# a concurrent launcher's temp, not yet renamed\n");
// Backdate past the gate. Real time cannot be used — the gate is a minute and
// a test may not sleep for one.
// A sibling publisher's file, aged past the gate. Named to share the prefix
// the sweep matches on, because that is the boundary under test.
const peerAged = join(trustDir, "ccf.pemcorp.pem");
writeFileSync(peerAged, "# another component's published CA\n");
const longAgo = new Date(Date.now() - 3600_000);
utimesSync(stale, longAgo, longAgo);
utimesSync(peerAged, longAgo, longAgo);
const { code, err } = await runWrapper('process.stdout.write("OK\\n")', { CLAUDE_CONFIG_DIR: configDir });
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${err}`);
assert.ok(!existsSync(stale), "a temp older than the gate is abandoned and must be reaped");
assert.ok(existsSync(fresh), "a temp younger than the gate may belong to a live publisher and must survive");
// ...and the PUBLISHED CA must survive, which the prefix is the only thing
// protecting. Measured with `ccf.pem.` shortened to `ccf.pem`: the sweep
// removed ccf.pem itself — the file every other component on the machine
// reads. Same two-literals hazard as SCRATCH_PREFIX, 50 lines earlier, and
// this assertion is what makes the prefix boundary testable rather than
// merely intended.
// ...and a SIBLING publisher's file must survive. `ccf.pem` itself cannot
// test the prefix here — the launcher republishes it in this very run, so it
// is always younger than the 60 s gate and no prefix can reach it. A peer's
// pem can be old, and it is what a widened prefix would eat next: measured
// with the sweep prefix shortened by one character, an aged `ccf.pem`-named
// file is removed, and the only reason our own survives is timing.
assert.ok(existsSync(peerAged),
"the orphan sweep deleted a sibling publisher's pem — the prefix is not specific enough");
});
it("--remote-control still reaps orphans when publishing itself fails", async () => {
// Reaping used to share the publish try-block, so renameSync throwing jumped
// straight past it. That made cleanup conditional on the one thing whose
// failure creates the mess: on a host where publishing is persistently
// broken — a root-owned ccf.pem, a read-only mount, ENOSPC — every launch
// abandoned one full-CA temp and collected none, growing without bound in
// the directory a bundle builder globs.
//
// A directory at the publish target reproduces that class of failure
// portably: rename() onto it fails EISDIR, and unlike a permission fixture
// it behaves the same when the suite runs as root (measured: chmod-based
// fixtures pass vacuously in a root container, which is how CI runs).
const configDir = tempDir("cfftrust-");
const trustDir = join(configDir, "ca-trust.d");
mkdirSync(join(trustDir, "ccf.pem"), { recursive: true });
const stale = join(trustDir, "ccf.pem.99999.aaaaaaaa-orphan");
writeFileSync(stale, "# abandoned by an earlier kill\n");
const longAgo = new Date(Date.now() - 3600_000);
utimesSync(stale, longAgo, longAgo);
const { code, err } = await runWrapper('process.stdout.write("OK\\n")', { CLAUDE_CONFIG_DIR: configDir });
// Publishing is how OTHERS trust us; this session only needs its own CA, so
// the failure must stay non-fatal and merely visible.
assert.equal(code, 0, `publish failure must not fail the session, got ${code}. stderr: ${err}`);
assert.match(err, /could not publish CA/, "a publish failure must be reported, not swallowed");
assert.ok(!existsSync(stale), "orphans must be reaped even on the launches where publishing fails");
});
it("--remote-control does not print the wiring banner that would undo its own coexistence", async () => {
// The launcher relays the spawned proxy's stderr, so the server's standalone
// `export NODE_EXTRA_CA_CERTS=<ca.pem>` advice used to appear immediately
// after the launcher had published to ca-trust.d and adopted the merged
// bundle. An operator following the line on screen pins the variable to our
// CA alone for every later process, silently untrusting every other MITM —
// the exact failure the contract exists to prevent.
const configDir = tempDir("cfftrust-");
const { code, err } = await runWrapper('process.stdout.write("OK\\n")', { CLAUDE_CONFIG_DIR: configDir });
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${err}`);
assert.doesNotMatch(err, /export NODE_EXTRA_CA_CERTS=/,
`the launcher must not tell the operator to pin the variable; stderr: ${err}`);
// The proxy is still in forward-proxy mode — the banner is suppressed, not
// the feature. Without this the assertion above would also pass if the
// launcher silently stopped starting a forward proxy at all.
assert.match(err, /forward-proxy/, `forward-proxy must still be on; stderr: ${err}`);
});
it("--remote-control blames our own CA, not the bundle, when ca.pem is the unparseable one", async () => {
// Parsing our own CA used to sit inside the bundle try-block, so a corrupt
// or zero-byte ca.pem threw from OUR parse and was reported as
// `ignoring <ca-trust.pem> (no start line)` — naming a file that may be
// perfectly healthy — and then fell back to the very ca.pem that had just
// failed to parse. The session then failed every request with
// UNABLE_TO_VERIFY_LEAF_SIGNATURE while the only diagnostic pointed at the
// wrong component.
const configDir = tempDir("cfftrust-");
const caDir = tempDir("cffca-");
// ca.key must be present alongside it: the proxy's reuse guard keys on
// existsSync(ca.pem) && existsSync(ca.key), so a corrupt ca.pem with its key
// still beside it is REUSED rather than regenerated. That is what makes this
// state reachable at all — corrupt the pem alone and the proxy quietly mints
// a fresh one before the launcher ever reads it, and the test would pass
// while exercising nothing.
writeFileSync(join(caDir, "ca.key"), "-----BEGIN PRIVATE KEY-----\nplaceholder\n-----END PRIVATE KEY-----\n");
// Truncated mid-block: exists, non-empty, and does not parse.
writeFileSync(join(caDir, "ca.pem"), "-----BEGIN CERTIFICATE-----\ntruncated\n");
// A healthy bundle, present so the message cannot be excused as "absent".
writeFileSync(join(configDir, "ca-trust.pem"),
"-----BEGIN CERTIFICATE-----\nc3RhbGUtYnV0LXdlbGwtZm9ybWVk\n-----END CERTIFICATE-----\n");
const { err } = await runWrapper('process.stdout.write("OK\\n")',
{ CLAUDE_CONFIG_DIR: configDir, CACHE_FIX_CA_DIR: caDir });
assert.match(err, /our own CA at .*ca\.pem does not parse/,
`the unparseable file must be named as ours; stderr: ${err}`);
assert.doesNotMatch(err, /ignoring .*ca-trust\.pem/,
`a healthy bundle must not be blamed for our own CA failing to parse; stderr: ${err}`);
});
it("--remote-control does not publish a ca.pem that failed to parse", async () => {
// Blaming the right file and not consuming it (the two tests above) covered
// what THIS session does with a corrupt ca.pem. It still published those
// bytes: the copy into ca-trust.d/ccf.pem happens before the X509 parse, so
// an unparseable CA was handed to every OTHER component on the machine.
//
// That is the worse half. Our own session degrades to node's built-in store
// and keeps working; the bundle builder concatenates sort(*.pem) and "ccf"
// sorts first, so a corrupt entry in the leading position aborts node's
// whole extras load for every sibling — measured on this box, a fused
// bundle loads 0 extra CAs and warns `bad base64 decode`. One broken file
// here costs every other component its CA, and its corporate roots with it.
//
// Publishing nothing is the honest state, and it is strictly better than
// publishing garbage: a builder that finds no ccf.pem simply builds a bundle
// without us, which our own guard then rejects (it does not carry our CA)
// and we fall back to our own — exactly the no-builder path that already
// works. Any PREVIOUS good ccf.pem must survive, because it is what siblings
// are currently trusting and a stale-but-valid CA beats none.
const configDir = tempDir("cfftrust-");
const caDir = tempDir("cffca-");
// Same reuse-guard reasoning as the blame test above: the key must be
// present or the proxy regenerates a healthy ca.pem and this exercises
// nothing.
writeFileSync(join(caDir, "ca.key"), "-----BEGIN PRIVATE KEY-----\nplaceholder\n-----END PRIVATE KEY-----\n");
writeFileSync(join(caDir, "ca.pem"), "-----BEGIN CERTIFICATE-----\ntruncated\n");
// A previously-published, well-formed entry. Whatever we do with the corrupt
// one, this must still be here afterwards.
const trustDir = join(configDir, "ca-trust.d");
mkdirSync(trustDir, { recursive: true });
const priorGood = "-----BEGIN CERTIFICATE-----\ncHJldmlvdXNseS1wdWJsaXNoZWQtb3Vycw==\n-----END CERTIFICATE-----\n";
writeFileSync(join(trustDir, "ccf.pem"), priorGood);
await runWrapper('process.stdout.write("OK\\n")',
{ CLAUDE_CONFIG_DIR: configDir, CACHE_FIX_CA_DIR: caDir });
const published = readFileSync(join(trustDir, "ccf.pem"), "utf8");
assert.doesNotMatch(published, /truncated/,
"the unparseable ca.pem must never reach ca-trust.d — it voids every sibling's CA");
assert.equal(published, priorGood,
"the last known-good published CA must survive a corrupt ca.pem");
});
it("--remote-control does not hand claude a ca.pem that failed to parse", async () => {
// Naming the broken file in the warning was only half the fix. caForClaude
// still defaulted to it, so the session was wired to a PEM we had just
// proven unreadable — the message was right and the behavior was unchanged.
// Unset is the honest state: we have no usable CA to add, so node falls back
// to its built-in store rather than to a file we vouch for and cannot read.
const configDir = tempDir("cfftrust-");