@@ -5,14 +5,35 @@ import { createServer } from "node:net";
55import { tmpdir } from "node:os" ;
66import { join , resolve } from "node:path" ;
77import { bundledBunPath } from "../src/lib/bun-runtime" ;
8- import { killProxy } from "../src/lib/process-control " ;
8+ import { redactSecretString } from "../src/lib/redact " ;
99
1010const BIN_CCX = join ( import . meta. dir , ".." , "bin" , "ccx.mjs" ) ;
1111const nodeAvailable = spawnSync ( "node" , [ "--version" ] , {
1212 stdio : "ignore" ,
1313 windowsHide : true ,
1414} ) . status === 0 ;
1515const runnable = process . platform === "win32" && nodeAvailable ;
16+ const STARTUP_STDERR_LIMIT = 16_384 ;
17+
18+ function startupStderrDiagnostic ( stderr : string , env : NodeJS . ProcessEnv ) : string {
19+ let sanitized = stderr ;
20+ for ( const [ name , value ] of Object . entries ( env ) ) {
21+ if ( value && / t o k e n | s e c r e t | p a s s w o r d | c r e d e n t i a l | a p i _ ? k e y / i. test ( name ) ) {
22+ sanitized = sanitized . replaceAll ( value , "[REDACTED]" ) ;
23+ }
24+ }
25+ return redactSecretString ( sanitized ) . replace ( / \x1b \[ [ 0 - 9 ; ] * m / g, "" ) . trim ( ) || "(empty)" ;
26+ }
27+
28+ test ( "launcher startup diagnostics redact labelled and inherited credentials" , ( ) => {
29+ const result = startupStderrDiagnostic (
30+ "startup failed: fixture-private-value\nAuthorization: Bearer fixture-bearer-value\n" ,
31+ { CUSTOM_SECRET : "fixture-private-value" } ,
32+ ) ;
33+ expect ( result ) . toContain ( "startup failed: [REDACTED]" ) ;
34+ expect ( result ) . not . toContain ( "fixture-private-value" ) ;
35+ expect ( result ) . not . toContain ( "fixture-bearer-value" ) ;
36+ } ) ;
1637
1738type Health = {
1839 status : string ;
@@ -155,6 +176,22 @@ function inspectWindowsProcessIdentity(pid: number): WindowsProcessIdentity | nu
155176 throw lastError ;
156177}
157178
179+ function stopOwnedWindowsProcessTree ( identity : WindowsProcessIdentity ) : void {
180+ const current = inspectWindowsProcessIdentity ( identity . pid ) ;
181+ if ( ! current ) return ;
182+ if ( ! sameProcess ( current , identity ) ) throw new Error ( "owned test process identity changed before cleanup" ) ;
183+ // This is a test-owned Node launcher (or its proven Bun child), not the
184+ // current home's proxy. Production killProxy correctly refuses that target.
185+ const result = spawnSync ( "taskkill.exe" , [ "/PID" , String ( identity . pid ) , "/T" , "/F" ] , {
186+ stdio : "ignore" ,
187+ timeout : 10_000 ,
188+ windowsHide : true ,
189+ } ) ;
190+ if ( result . status !== 0 && sameProcess ( inspectWindowsProcessIdentity ( identity . pid ) , identity ) ) {
191+ throw new Error ( `owned test process tree did not stop (taskkill exit=${ result . status } )` ) ;
192+ }
193+ }
194+
158195function removeTree ( path : string ) : void {
159196 // Windows can retain the copied executable's image handle briefly after
160197 // taskkill returns. Retry only transient fixture-cleanup errors, with a cap.
@@ -187,17 +224,52 @@ async function effectiveRuntime(override: string): Promise<string> {
187224 let primaryError : unknown ;
188225 try {
189226 port = await freePort ( ) ;
227+ const launcherEnv = isolatedLauncherEnv ( root , override ) ;
228+ // Explicit start persists routing intent, so its isolated home needs an
229+ // installed config. Use static models to keep discovery out of this test.
230+ writeFileSync ( join ( launcherEnv . CODEXCOMMANDER_HOME ! , "config.json" ) , JSON . stringify ( {
231+ port,
232+ hostname : "127.0.0.1" ,
233+ multiAgentGuidanceEnabled : true ,
234+ defaultProvider : "mock" ,
235+ providers : {
236+ mock : {
237+ adapter : "openai-chat" ,
238+ baseUrl : "http://127.0.0.1:9/v1" ,
239+ allowPrivateNetwork : true ,
240+ liveModels : false ,
241+ models : [ "test-model" ] ,
242+ defaultModel : "test-model" ,
243+ } ,
244+ } ,
245+ } ) , { mode : 0o600 } ) ;
246+ let startupStderr = "" ;
190247 launcher = spawn ( "node" , [ BIN_CCX , "start" , "--port" , String ( port ) ] , {
191- stdio : "ignore" ,
248+ stdio : [ "ignore" , "ignore" , "pipe" ] ,
192249 windowsHide : true ,
193- env : isolatedLauncherEnv ( root , override ) ,
250+ env : launcherEnv ,
251+ } ) ;
252+ launcher . stderr ?. setEncoding ( "utf8" ) ;
253+ launcher . stderr ?. on ( "data" , ( chunk : string ) => {
254+ // Drain the pipe continuously, retaining only a bounded startup prefix.
255+ startupStderr += chunk . slice ( 0 , Math . max ( 0 , STARTUP_STDERR_LIMIT - startupStderr . length ) ) ;
194256 } ) ;
195257 if ( ! launcher . pid ) throw new Error ( "Node launcher has no process id" ) ;
196258 launcherPid = launcher . pid ;
197259 ownedLauncher = captureWindowsProcessIdentity ( launcherPid ) ;
198260
199261 const health = await waitForHealth ( port , 25_000 , launcher ) ;
200- if ( ! health ) throw new Error ( "proxy did not become healthy" ) ;
262+ if ( ! health ) {
263+ // Drop the last line if the capture filled, so truncation cannot expose a
264+ // partial credential whose label/value boundary fell beyond the limit.
265+ const stderr = startupStderr . length === STARTUP_STDERR_LIMIT
266+ ? startupStderr . slice ( 0 , Math . max ( 0 , startupStderr . lastIndexOf ( "\n" ) ) )
267+ : startupStderr ;
268+ throw new Error (
269+ `proxy did not become healthy (exit=${ launcher . exitCode } , signal=${ launcher . signalCode } ); `
270+ + `startup stderr: ${ startupStderrDiagnostic ( stderr , launcherEnv ) } ` ,
271+ ) ;
272+ }
201273 const identity = windowsProcessIdentity ( health . pid ) ;
202274 if ( ! identity || identity . parentPid !== launcher . pid ) {
203275 throw new Error ( "health PID is not the spawned Node launcher's direct Bun child" ) ;
@@ -218,16 +290,20 @@ async function effectiveRuntime(override: string): Promise<string> {
218290 if ( ownedLauncher ) {
219291 try {
220292 if ( sameProcess ( inspectWindowsProcessIdentity ( ownedLauncher . pid ) , ownedLauncher ) ) {
221- killProxy ( ownedLauncher . pid ) ;
293+ stopOwnedWindowsProcessTree ( ownedLauncher ) ;
222294 launcherTreeStopped = true ;
223295 }
224296 } catch ( error ) {
225297 cleanupErrors . push ( `launcher cleanup failed: ${ String ( error ) } ` ) ;
226298 }
227299 }
228- if ( ! launcherTreeStopped && launcher && launcherPid && launcher . exitCode === null && launcher . signalCode === null ) {
300+ if ( ! ownedLauncher && ! launcherTreeStopped && launcher && launcherPid && launcher . exitCode === null && launcher . signalCode === null ) {
229301 try {
230- killProxy ( launcherPid ) ;
302+ const identity = captureWindowsProcessIdentity ( launcherPid ) ;
303+ if ( launcher . exitCode !== null || launcher . signalCode !== null ) {
304+ throw new Error ( "test launcher exited before fallback cleanup identity was captured" ) ;
305+ }
306+ stopOwnedWindowsProcessTree ( identity ) ;
231307 launcherTreeStopped = true ;
232308 } catch ( error ) {
233309 cleanupErrors . push ( `launcher tree fallback failed: ${ String ( error ) } ` ) ;
@@ -239,7 +315,7 @@ async function effectiveRuntime(override: string): Promise<string> {
239315 if ( ownedProxy ) {
240316 try {
241317 if ( sameProcess ( inspectWindowsProcessIdentity ( ownedProxy . pid ) , ownedProxy ) ) {
242- killProxy ( ownedProxy . pid ) ;
318+ stopOwnedWindowsProcessTree ( ownedProxy ) ;
243319 }
244320 } catch ( error ) {
245321 cleanupErrors . push ( `proxy cleanup failed: ${ String ( error ) } ` ) ;
0 commit comments