fix(test-harness): spawn claude without shell interpolation

This commit is contained in:
t
2026-07-14 12:56:07 -07:00
parent 5cb8761d36
commit aa3bd6f042
+10 -14
View File
@@ -173,13 +173,9 @@ export async function runSkillTest(options: {
// restores operator MCP along with the operator env. // restores operator MCP along with the operator env.
if (isHermeticEnabled()) args.push('--strict-mcp-config'); if (isHermeticEnabled()) args.push('--strict-mcp-config');
// Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions // Spawn claude directly with array-form args (no shell interpolation).
// where afterAll cleanup deletes the dir before cat reads the file (especially // Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
// with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable. const proc = Bun.spawn(['claude', ...args], {
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(' ')}`], {
cwd: workingDirectory, cwd: workingDirectory,
// Hermetic by default (see test/helpers/hermetic-env.ts): operator // Hermetic by default (see test/helpers/hermetic-env.ts): operator
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack) // 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 // suite exercising the INTERACTIVE prose-fallback path opts out by passing
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last. // `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }), env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
stdin: new Blob([prompt]),
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}); });
@@ -201,11 +198,12 @@ export async function runSkillTest(options: {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
timedOut = true; timedOut = true;
proc.kill(); proc.kill();
// proc.kill() only signals the `sh -c` wrapper. The claude child it // proc.kill() signals claude itself (direct spawn, no shell wrapper),
// spawned can survive as an orphan that inherited our stdout/stderr // but tool subprocesses claude spawned can survive as orphans that
// pipes, so without cancel() the read loop below blocks until the // inherited our stdout/stderr pipes, so without cancel() the read loop
// orphan finally exits (observed: a 600s timeout stretching past 1400s // below blocks until the orphan finally exits (observed: a 600s timeout
// and tripping bun's per-test timeout instead of returning a result). // stretching past 1400s and tripping bun's per-test timeout instead of
// returning a result).
reader.cancel().catch(() => { /* stream already closed */ }); reader.cancel().catch(() => { /* stream already closed */ });
}, timeout); }, timeout);
@@ -314,8 +312,6 @@ export async function runSkillTest(options: {
const exitCode = await proc.exited; const exitCode = await proc.exited;
clearTimeout(timeoutId); clearTimeout(timeoutId);
try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ }
if (timedOut) { if (timedOut) {
exitReason = 'timeout'; exitReason = 'timeout';
} else if (exitCode === 0) { } else if (exitCode === 0) {