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.
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) {