From aa3bd6f0425396c076383967a03b71f672c41a32 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:56:07 -0700 Subject: [PATCH] fix(test-harness): spawn claude without shell interpolation --- test/helpers/session-runner.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/test/helpers/session-runner.ts b/test/helpers/session-runner.ts index 01eb0243d..f433332f5 100644 --- a/test/helpers/session-runner.ts +++ b/test/helpers/session-runner.ts @@ -173,13 +173,9 @@ export async function runSkillTest(options: { // restores operator MCP along with the operator env. if (isHermeticEnabled()) args.push('--strict-mcp-config'); - // Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions - // where afterAll cleanup deletes the dir before cat reads the file (especially - // with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable. - const promptFile = path.join(os.tmpdir(), `.prompt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fs.writeFileSync(promptFile, prompt); - - const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], { + // Spawn claude directly with array-form args (no shell interpolation). + // Prompt is piped via stdin using a Blob to avoid temp files and shell escaping. + const proc = Bun.spawn(['claude', ...args], { cwd: workingDirectory, // Hermetic by default (see test/helpers/hermetic-env.ts): operator // session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack) @@ -189,6 +185,7 @@ export async function runSkillTest(options: { // suite exercising the INTERACTIVE prose-fallback path opts out by passing // `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last. env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }), + stdin: new Blob([prompt]), stdout: 'pipe', stderr: 'pipe', }); @@ -201,11 +198,12 @@ export async function runSkillTest(options: { const timeoutId = setTimeout(() => { timedOut = true; proc.kill(); - // proc.kill() only signals the `sh -c` wrapper. The claude child it - // spawned can survive as an orphan that inherited our stdout/stderr - // pipes, so without cancel() the read loop below blocks until the - // orphan finally exits (observed: a 600s timeout stretching past 1400s - // and tripping bun's per-test timeout instead of returning a result). + // proc.kill() signals claude itself (direct spawn, no shell wrapper), + // but tool subprocesses claude spawned can survive as orphans that + // inherited our stdout/stderr pipes, so without cancel() the read loop + // below blocks until the orphan finally exits (observed: a 600s timeout + // stretching past 1400s and tripping bun's per-test timeout instead of + // returning a result). reader.cancel().catch(() => { /* stream already closed */ }); }, timeout); @@ -314,8 +312,6 @@ export async function runSkillTest(options: { const exitCode = await proc.exited; clearTimeout(timeoutId); - try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ } - if (timedOut) { exitReason = 'timeout'; } else if (exitCode === 0) {