1- import { describe , it } from "node:test" ;
1+ import { after , describe , it } from "node:test" ;
22import assert from "node:assert/strict" ;
33import { fork } from "node:child_process" ;
44import { fileURLToPath } from "node:url" ;
55import { dirname , resolve , join } from "node:path" ;
66import { tmpdir } from "node:os" ;
7- import { closeSync , existsSync , fstatSync , mkdirSync , mkdtempSync , openSync , readFileSync , readdirSync , statSync , utimesSync , writeFileSync } from "node:fs" ;
7+ import { closeSync , existsSync , fstatSync , mkdirSync , mkdtempSync , openSync , readFileSync , readdirSync , rmSync , statSync , utimesSync , writeFileSync } from "node:fs" ;
88import http from "node:http" ;
99
1010const __dirname = dirname ( fileURLToPath ( import . meta. url ) ) ;
1111const WRAPPER_PATH = resolve ( __dirname , "../bin/claude-via-proxy.mjs" ) ;
1212const SERVER_PATH = resolve ( __dirname , "../proxy/server.mjs" ) ;
1313
14+ // Every temp dir this file makes, removed once at the end.
15+ //
16+ // Registered centrally rather than rmSync'd per test: forward mode mints an RSA
17+ // CA and leaf inside each config dir, so a leak is not an empty directory, it is
18+ // private key material. Measured before this: one `node --test` of this file
19+ // left 38 dirs behind, and a /tmp that had accumulated 1954 of them held 432
20+ // ca.key / leaf.key files. Per-test cleanup would also skip exactly the runs
21+ // that matter — a failing test throws before its own rmSync — and every future
22+ // test would have to remember. after() runs on pass and on fail.
23+ const tempDirs = [ ] ;
24+ function tempDir ( prefix ) {
25+ const d = mkdtempSync ( join ( tmpdir ( ) , prefix ) ) ;
26+ tempDirs . push ( d ) ;
27+ return d ;
28+ }
29+ after ( ( ) => {
30+ for ( const d of tempDirs ) {
31+ try { rmSync ( d , { recursive : true , force : true } ) ; } catch { /* already gone */ }
32+ }
33+ } ) ;
34+
1435describe ( "proxy server lifecycle" , ( ) => {
1536 it ( "starts and responds to health check" , async ( ) => {
1637 const proxyProc = fork ( SERVER_PATH , [ ] , {
@@ -78,7 +99,7 @@ function cleanEnv(overrides) {
7899 // bundle advertising a CA nothing signs with — precisely the failure this
79100 // feature exists to prevent. It also silently poisons the suite itself: two
80101 // cases were reading the host's real merged bundle instead of a fixture.
81- env . CLAUDE_CONFIG_DIR = mkdtempSync ( join ( tmpdir ( ) , "cffcfg-" ) ) ;
102+ env . CLAUDE_CONFIG_DIR = tempDir ( "cffcfg-" ) ;
82103 return { ...env , ...overrides } ;
83104}
84105
@@ -171,7 +192,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
171192 // developer's real ~/.claude/ca-trust.pem. On a machine where a bundle builder
172193 // has run, that file exists and legitimately wins — the assertion would fail
173194 // for a host-state reason, not a code reason.
174- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
195+ const configDir = tempDir ( "cfftrust-" ) ;
175196 const wrapperProc = fork ( WRAPPER_PATH , [ "--remote-control" , "--proxy-port" , "0" ] , {
176197 stdio : [ "ignore" , "pipe" , "pipe" , "ipc" ] ,
177198 env : cleanEnv ( { CACHE_FIX_CLAUDE_CMD : `${ NODE } -e ${ script } ` , CLAUDE_CONFIG_DIR : configDir } ) ,
@@ -199,7 +220,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
199220 // same order, or it points claude at a different (or absent) CA than the one
200221 // the spawned proxy generated — a hard fail, or a silent trust mismatch when
201222 // a stale default CA exists. This test pins the override path exactly.
202- const caDir = mkdtempSync ( join ( tmpdir ( ) , "cffcadir-" ) ) ;
223+ const caDir = tempDir ( "cffcadir-" ) ;
203224 const script =
204225 'process.stdout.write("BASE="+(process.env.ANTHROPIC_BASE_URL||"UNSET")+' +
205226 '"|CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")' ;
@@ -240,7 +261,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
240261 // Publishing is how OTHER components learn to trust us, and it must happen
241262 // before the client runs — a bundle builder that reads the dir on a cold
242263 // start would otherwise miss us and produce a bundle without our CA.
243- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
264+ const configDir = tempDir ( "cfftrust-" ) ;
244265 const script = 'process.stdout.write("OK\\n")' ;
245266 const wrapperProc = fork ( WRAPPER_PATH , [ "--remote-control" , "--proxy-port" , "0" ] , {
246267 stdio : [ "ignore" , "pipe" , "pipe" , "ipc" ] ,
@@ -276,7 +297,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
276297 // fixture without it is the stale-bundle case, which is correctly rejected
277298 // (see the test below). So: run once to let the proxy generate + publish our
278299 // CA, then build the bundle from it the way the launcher would, then re-run.
279- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
300+ const configDir = tempDir ( "cfftrust-" ) ;
280301 const bundle = join ( configDir , "ca-trust.pem" ) ;
281302 const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")' ;
282303 const runOnce = ( ) => runWrapper ( script , { CLAUDE_CONFIG_DIR : configDir } ) ;
@@ -295,7 +316,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
295316 // Single-writer invariant. Two launchers both "helpfully" rebuilding the
296317 // merged file race one output, and a component that rewrites a sibling's pem
297318 // can untrust it. So: we write exactly one path, ca-trust.d/ccf.pem.
298- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
319+ const configDir = tempDir ( "cfftrust-" ) ;
299320 const trustDir = join ( configDir , "ca-trust.d" ) ;
300321 mkdirSync ( trustDir , { recursive : true } ) ;
301322 const sibling = join ( trustDir , "other-component.pem" ) ;
@@ -324,7 +345,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
324345 it ( "--remote-control falls back to its own CA when no merged bundle exists (unchanged standalone behaviour)" , async ( ) => {
325346 // A plain CCF user with no other MITM and no bundle builder must see exactly
326347 // what they saw before this contract existed.
327- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
348+ const configDir = tempDir ( "cfftrust-" ) ;
328349 const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")' ;
329350 const wrapperProc = fork ( WRAPPER_PATH , [ "--remote-control" , "--proxy-port" , "0" ] , {
330351 stdio : [ "ignore" , "pipe" , "pipe" , "ipc" ] ,
@@ -377,7 +398,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
377398 // the very bytes that reader is consuming or leaves it on a deleted inode
378399 // whose content is gone. So: hold a descriptor open across the launch, then
379400 // read it to the end.
380- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
401+ const configDir = tempDir ( "cfftrust-" ) ;
381402 const trustDir = join ( configDir , "ca-trust.d" ) ;
382403 const dst = join ( trustDir , "ccf.pem" ) ;
383404 mkdirSync ( trustDir , { recursive : true } ) ;
@@ -480,7 +501,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
480501 // Both fixtures exist in the same directory across one launch, so the test
481502 // fails if the reaper is unconditional (fresh one dies) OR absent (old one
482503 // survives) — one launch, two opposite outcomes.
483- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
504+ const configDir = tempDir ( "cfftrust-" ) ;
484505 const trustDir = join ( configDir , "ca-trust.d" ) ;
485506 mkdirSync ( trustDir , { recursive : true } ) ;
486507 const stale = join ( trustDir , "ccf.pem.99999.aaaaaaaa-orphan" ) ;
@@ -511,7 +532,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
511532 // portably: rename() onto it fails EISDIR, and unlike a permission fixture
512533 // it behaves the same when the suite runs as root (measured: chmod-based
513534 // fixtures pass vacuously in a root container, which is how CI runs).
514- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
535+ const configDir = tempDir ( "cfftrust-" ) ;
515536 const trustDir = join ( configDir , "ca-trust.d" ) ;
516537 mkdirSync ( join ( trustDir , "ccf.pem" ) , { recursive : true } ) ;
517538 const stale = join ( trustDir , "ccf.pem.99999.aaaaaaaa-orphan" ) ;
@@ -534,7 +555,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
534555 // bundle. An operator following the line on screen pins the variable to our
535556 // CA alone for every later process, silently untrusting every other MITM —
536557 // the exact failure the contract exists to prevent.
537- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
558+ const configDir = tempDir ( "cfftrust-" ) ;
538559 const { code, err } = await runWrapper ( 'process.stdout.write("OK\\n")' , { CLAUDE_CONFIG_DIR : configDir } ) ;
539560 assert . equal ( code , 0 , `Expected exit 0, got ${ code } . stderr: ${ err } ` ) ;
540561 assert . doesNotMatch ( err , / e x p o r t N O D E _ E X T R A _ C A _ C E R T S = / ,
@@ -553,8 +574,8 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
553574 // failed to parse. The session then failed every request with
554575 // UNABLE_TO_VERIFY_LEAF_SIGNATURE while the only diagnostic pointed at the
555576 // wrong component.
556- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
557- const caDir = mkdtempSync ( join ( tmpdir ( ) , "cffca-" ) ) ;
577+ const configDir = tempDir ( "cfftrust-" ) ;
578+ const caDir = tempDir ( "cffca-" ) ;
558579 // ca.key must be present alongside it: the proxy's reuse guard keys on
559580 // existsSync(ca.pem) && existsSync(ca.key), so a corrupt ca.pem with its key
560581 // still beside it is REUSED rather than regenerated. That is what makes this
@@ -583,8 +604,8 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
583604 // proven unreadable — the message was right and the behavior was unchanged.
584605 // Unset is the honest state: we have no usable CA to add, so node falls back
585606 // to its built-in store rather than to a file we vouch for and cannot read.
586- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
587- const caDir = mkdtempSync ( join ( tmpdir ( ) , "cffca-" ) ) ;
607+ const configDir = tempDir ( "cfftrust-" ) ;
608+ const caDir = tempDir ( "cffca-" ) ;
588609 // ca.key beside it, or the proxy regenerates and the state is unreachable.
589610 writeFileSync ( join ( caDir , "ca.key" ) , "-----BEGIN PRIVATE KEY-----\nplaceholder\n-----END PRIVATE KEY-----\n" ) ;
590611 writeFileSync ( join ( caDir , "ca.pem" ) , "-----BEGIN CERTIFICATE-----\ntruncated\n" ) ;
@@ -605,7 +626,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
605626 // normal state right after a CCF upgrade, so this is not a corner case.
606627 // (a sibling component hit the same hazard from the other side and guards
607628 // it identically.)
608- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
629+ const configDir = tempDir ( "cfftrust-" ) ;
609630 const bundle = join ( configDir , "ca-trust.pem" ) ;
610631 // A plausible stale bundle: real PEM content, just not ours.
611632 writeFileSync ( bundle , "-----BEGIN CERTIFICATE-----\nc3RhbGUtYnVuZGxlLXdpdGhvdXQtb3VyLUNB\n-----END CERTIFICATE-----\n" ) ;
@@ -650,7 +671,7 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
650671 // and containment together still do not prove Node verifies with the result —
651672 // only a handshake does. They are here to keep a KNOWN-bad bundle from ever
652673 // reaching the client.
653- const configDir = mkdtempSync ( join ( tmpdir ( ) , "cfftrust-" ) ) ;
674+ const configDir = tempDir ( "cfftrust-" ) ;
654675 const bundle = join ( configDir , "ca-trust.pem" ) ;
655676 const script = 'process.stdout.write("CA="+(process.env.NODE_EXTRA_CA_CERTS||"UNSET")+"\\n")' ;
656677 const runOnce = ( ) => runWrapper ( script , { CLAUDE_CONFIG_DIR : configDir } ) ;
@@ -783,4 +804,52 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
783804 assert . equal ( occurrences , 1 , `127.0.0.1 should appear exactly once, got NP=${ np } ` ) ;
784805 assert . ok ( np . split ( "," ) . includes ( "localhost" ) , `localhost should be added, got NP=${ np } ` ) ;
785806 } ) ;
807+
808+ it ( "a plain fork of the server still gets the wiring recipe" , async ( ) => {
809+ // The suppression must key on the launcher, not on "someone fork()ed me".
810+ // Keying it on process.channel was measured suppressing the recipe for THIS
811+ // suite's own forks and for any supervisor's — the operator got no wiring
812+ // instructions plus a false claim that a launcher had wired the client.
813+ const caDir = tempDir ( "cffca-" ) ;
814+ const p = fork ( SERVER_PATH , [ ] , {
815+ stdio : [ "ignore" , "pipe" , "pipe" , "ipc" ] ,
816+ env : cleanEnv ( { CACHE_FIX_FORWARD_PROXY : "on" , CACHE_FIX_CA_DIR : caDir , CACHE_FIX_PROXY_PORT : "0" } ) ,
817+ } ) ;
818+ let err = "" ;
819+ p . stderr . on ( "data" , ( c ) => { err += c . toString ( ) ; } ) ;
820+ await new Promise ( ( res ) => setTimeout ( res , 6000 ) ) ;
821+ p . kill ( "SIGTERM" ) ;
822+ await new Promise ( ( res ) => p . on ( "exit" , res ) ) ;
823+
824+ assert . match ( err , / e x p o r t N O D E _ E X T R A _ C A _ C E R T S = / ,
825+ `a non-launcher fork must still be told how to wire; stderr: ${ err } ` ) ;
826+ assert . doesNotMatch ( err , / C l i e n t w i r e d b y t h e l a u n c h e r / ,
827+ `nothing wired this client, so it must not claim otherwise; stderr: ${ err } ` ) ;
828+ } ) ;
829+
830+ it ( "creates every temp dir through the registrar, so none outlive the run" , ( ) => {
831+ // A leak is invisible to every other assertion in this file — measured: with
832+ // the cleanup neutered the suite still reported 23 pass / 0 fail while
833+ // leaving 39 directories behind. So the thing to pin is not "the dirs are
834+ // gone" (after() has not run yet when a test executes) but "nothing bypasses
835+ // the registrar", which is the only way one can survive.
836+ //
837+ // Source-level on purpose: forward mode mints an RSA CA and leaf inside each
838+ // config dir, so a bypassed site leaks private key material, and the next
839+ // person to add a test is exactly who would reintroduce it.
840+ // Matched on the call shape taking a string literal, which the registrar
841+ // itself does not have (it takes `prefix`). Comment lines are skipped and
842+ // the pattern is assembled rather than written out, so neither this
843+ // assertion nor the prose above it can flag itself.
844+ const src = readFileSync ( new URL ( import . meta. url ) , "utf8" ) ;
845+ const call = new RegExp ( [ "mkdtempSync\\(join\\(tmpdir\\(\\)," , "\\s*\"[^\"]+\"\\s*\\)\\)" ] . join ( "" ) ) ;
846+ const raw = src . split ( "\n" )
847+ . map ( ( line , i ) => [ i + 1 , line ] )
848+ . filter ( ( [ , line ] ) => ! line . trim ( ) . startsWith ( "//" ) )
849+ . filter ( ( [ , line ] ) => call . test ( line ) )
850+ . map ( ( [ n ] ) => n ) ;
851+ assert . deepEqual ( raw , [ ] ,
852+ `every temp dir must go through tempDir(); raw mkdtempSync at line(s): ${ raw . join ( ", " ) } ` ) ;
853+ assert . ok ( tempDirs . length > 0 , "premise: this file does create temp dirs" ) ;
854+ } ) ;
786855} ) ;
0 commit comments