mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-19 05:57:37 +02:00
fix(test-harness): spawn claude without shell interpolation
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user