Files
gstack/test/claude-code-runner.test.ts
T
Garry Tanandcapy-ai[bot] b3b08d0108 v1.91.349.0 fix(flaky): 2026-10-30 flaky pass — paid detector gaps, QA deadline receipt ack, Chromium WSAENOBUFS backport, free-test races (GSTA-178) (#3646)
* fix(flaky): 2026-10-30 flaky pass — two paid detector gaps, a guard-PTY dispatch delta, five free races, the QA deadline receipt ack and a Chromium WSAENOBUFS backport (GSTA-178)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(changelog): move the GSTA-178 entry to changelog.d/capy-gsta178-flaky.md (fragments, #3654)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(playwright-patch): read the 1.62.1 backport patch by literal path so pregate maps the patch to its test

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (no-ack worker test, passive-pitch negation, shared descendant cleanup)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(changelog): note the review fixes in the GSTA-178 fragment

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(project-structure): patches/ also carries the TcpPortRandomizationWin backport

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 04:53:38 +00:00

394 lines
20 KiB
TypeScript

import { afterAll, describe, expect, spyOn, test } from 'bun:test';
import { existsSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { runClaudeCode } from '../lib/claude-code';
const ROOT = path.resolve(import.meta.dir, '..');
const DIR = mkdtempSync(path.join(tmpdir(), 'claude-code-runner-'));
const FAKE = path.join(DIR, 'fake claude.ts');
const DESCENDANT = path.join(DIR, 'pipe holder.ts');
const CAPTURE = path.join(DIR, 'capture.json');
const PID = path.join(DIR, 'descendant.pid');
const ACTOR_STAGES = path.join(DIR, 'actor-stages.jsonl');
const DESCENDANT_STAGES = path.join(DIR, 'descendant-stages.jsonl');
const DIR_IDENTITY = lstatSync(DIR, { bigint: true });
const RESOLVED_DIR = realpathSync(DIR);
// Publish readiness only after the grandchild has initialized and flushed both
// inherited pipes; a PID returned by spawn alone does not establish that state.
// The pid lands by rename, so a poller that sees the file sees the whole pid
// (Free Tests 39694715116: an existence poll read the empty file as pid 0).
writeFileSync(DESCENDANT, `
import { appendFileSync, renameSync, writeFileSync } from 'node:fs';
const record = stage => {
if (process.env.FAKE_MODE === 'timeout') {
try { appendFileSync(${JSON.stringify(DESCENDANT_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid: process.pid }) + '\\n'); } catch {}
}
};
record('descendant_start');
await Bun.sleep(Number(process.env.DESCENDANT_DELAY_MS || 0));
setInterval(() => {}, 1000);
await new Promise(resolve => process.stdout.write(' ', resolve));
record('stdout_flushed');
await new Promise(resolve => process.stderr.write(' ', resolve));
record('stderr_flushed');
writeFileSync(process.env.PID_FILE! + '.tmp', String(process.pid));
renameSync(process.env.PID_FILE! + '.tmp', process.env.PID_FILE!);
record('pid_published');
`);
writeFileSync(FAKE, `
import { spawn } from 'node:child_process';
import { appendFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
const record = (stage, pid = process.pid) => {
if (process.env.FAKE_MODE === 'timeout') {
try { appendFileSync(${JSON.stringify(ACTOR_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid }) + '\\n'); } catch {}
}
};
record('actor_start');
const prompt = await Bun.stdin.text();
record('stdin_read');
writeFileSync(process.env.CAPTURE!, JSON.stringify({args:process.argv.slice(2),prompt,cwd:process.cwd(),model:process.env.ANTHROPIC_MODEL,auth:process.env.ANTHROPIC_API_KEY}));
const mode = process.env.FAKE_MODE;
if (mode === 'startup-timeout') {
setInterval(() => {}, 1000);
await new Promise(() => {});
}
if (mode === 'timeout' || mode === 'descendant' || mode === 'escaped') {
rmSync(process.env.PID_FILE!, { force: true });
// libuv on Windows kills non-detached children when this fake exits. The
// drain fixture must survive that exit so its inherited pipes remain open.
const child = spawn(process.execPath, [process.env.DESCENDANT!], {stdio:['ignore','inherit','inherit'],
detached:mode === 'escaped' || (process.platform === 'win32' && mode === 'descendant')});
record('descendant_spawn_requested');
child.once('spawn', () => record('descendant_spawned', child.pid));
child.once('exit', () => record('descendant_exited', child.pid));
const readyBy = Date.now() + 2000;
while (!existsSync(process.env.PID_FILE!)) {
if (child.exitCode !== null || Date.now() >= readyBy) {
record('readiness_failed');
throw new Error('Descendant did not initialize its inherited pipes');
}
await Bun.sleep(5);
}
record('readiness_observed');
if (mode === 'timeout') await new Promise(() => {});
}
if (mode === 'auth') { process.stderr.write('Not logged in. Please run claude /login.'); process.exit(1); }
if (mode === 'overflow') {
const block = Buffer.alloc(1024 * 1024, 120);
for(let i=0;i<18;i++) { process.stdout.write(block); process.stderr.write(block); }
await new Promise(() => {});
}
if (mode === 'malformed') { process.stdout.write('broken JSON'); process.exit(0); }
if (mode === 'array') { process.stdout.write('[]'); process.exit(0); }
if (mode === 'null') { process.stdout.write('null'); process.exit(0); }
const result = {
result: mode === 'empty' ? ' ' : mode === 'large-success' ? 'x'.repeat(2 * 1024 * 1024) : '[P1] Seeded defect in changed.ts',
is_error:mode === 'error',
session_id:'session-123',
usage:{input_tokens:12,output_tokens:34,cache_read_input_tokens:5},
modelUsage:{'claude-first':{inputTokens:4},'claude-fallback':{inputTokens:8}},
};
await new Promise(resolve => process.stdout.write(JSON.stringify(result),resolve));
process.exit(mode === 'nonzero' ? 7 : 0);
`);
afterAll(() => rmSync(DIR, { recursive: true, force: true }));
function env(mode = 'success'): NodeJS.ProcessEnv {
return { ...process.env, GSTACK_CLAUDE_BIN: process.execPath, GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]),
FAKE_MODE: mode, CAPTURE, PID_FILE: PID, DESCENDANT, ANTHROPIC_MODEL: 'configured-model', ANTHROPIC_API_KEY: 'fake-test-credential' };
}
function run(mode = 'success', extra: Partial<Parameters<typeof runClaudeCode>[0]> = {}) {
return runClaudeCode({cwd:DIR, access:'none', timeoutMs:5000, prompt:'review this', env:env(mode), ...extra});
}
function capture() { return JSON.parse(readFileSync(CAPTURE, 'utf8')); }
function timeoutStageReceipt(file: string): object {
try {
const directory = lstatSync(DIR, { bigint: true });
if (directory.isSymbolicLink() || directory.dev !== DIR_IDENTITY.dev || directory.ino !== DIR_IDENTITY.ino || realpathSync(DIR) !== RESOLVED_DIR || ![ACTOR_STAGES, DESCENDANT_STAGES].includes(file)) return { available: false, reason: 'identity_mismatch' };
const state = lstatSync(file);
if (!state.isFile() || state.isSymbolicLink() || state.size > 4096) return { available: false, reason: 'invalid_receipt' };
const lines = readFileSync(file, 'utf8').trim().split('\n');
if (lines.length > 16) return { available: false, reason: 'invalid_receipt' };
const stages = [];
for (const line of lines) {
let event;
try { event = JSON.parse(line); } catch { return { available: false, reason: 'incomplete_receipt', stages }; }
if (!event || !['actor_start', 'stdin_read', 'descendant_spawn_requested', 'descendant_spawned', 'descendant_exited', 'readiness_failed', 'readiness_observed', 'descendant_start', 'stdout_flushed', 'stderr_flushed', 'pid_published'].includes(event.stage) || !Number.isSafeInteger(event.atUnixMs) || event.atUnixMs <= 0 || typeof event.processNs !== 'string' || !/^\d{1,24}$/.test(event.processNs) || !Number.isSafeInteger(event.pid) || event.pid <= 0) return { available: false, reason: 'invalid_receipt', stages };
stages.push({ stage: event.stage, atUnixMs: event.atUnixMs, processNs: event.processNs, pid: event.pid });
}
return { available: true, stages };
} catch (error) {
return { available: false, reason: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'not_published' : 'inspection_failed' };
}
}
function running(pid: number): boolean {
try {
process.kill(pid, 0);
// Linux can leave a killed orphan briefly as a zombie before init reaps it.
if (process.platform === 'linux') return !['Z', 'X'].includes(readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2]);
return true;
} catch { return false; }
}
/** The descendant's published pid: a positive integer in the pid file, or undefined while unpublished. */
function publishedPid(): number | undefined {
let text: string;
try { text = readFileSync(PID, 'utf8'); } catch { return undefined; }
return /^[1-9]\d*$/.test(text) ? Number(text) : undefined;
}
async function expectDescendantDead() {
const pid = Number(readFileSync(PID, 'utf8'));
try {
for (let i = 0; i < 20 && running(pid); i++) await Bun.sleep(25);
expect(running(pid)).toBe(false);
} finally {
cleanupDescendant();
}
}
function cleanupDescendant() {
let pid: number;
try { pid = Number(readFileSync(PID, 'utf8')); } catch { return; }
if (Number.isSafeInteger(pid) && pid > 0 && running(pid)) {
try { process.kill(pid, 'SIGKILL'); } catch { /* Exited after the liveness check. */ }
}
}
describe('Claude Code restricted execution', () => {
test('timeout stage diagnostics retain only bounded safe fixture events', () => {
const event = { stage: 'actor_start', atUnixMs: 123, processNs: '123', pid: process.pid };
try {
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'not_published' });
writeFileSync(ACTOR_STAGES, JSON.stringify({ ...event, secret: 'sensitive-sentinel' }) + '\n');
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: true, stages: [event] });
writeFileSync(ACTOR_STAGES, JSON.stringify(event) + '\n{"stage":');
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'incomplete_receipt', stages: [event] });
writeFileSync(ACTOR_STAGES, JSON.stringify({ ...event, stage: 'sensitive-sentinel' }));
expect(JSON.stringify(timeoutStageReceipt(ACTOR_STAGES))).not.toContain('sensitive-sentinel');
writeFileSync(ACTOR_STAGES, 'x'.repeat(4097));
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'invalid_receipt' });
expect(timeoutStageReceipt(CAPTURE)).toEqual({ available: false, reason: 'identity_mismatch' });
} finally { rmSync(ACTOR_STAGES, { force: true }); }
});
test('explicit model override stays one literal argument across access modes and resume', async () => {
const model = 'custom-model "quoted" $(touch /never)';
for (const access of ['none', 'read-only'] as const) {
const result = await run('success', { access, resume: 'session-123', env: { ...env(), GSTACK_CLAUDE_MODEL: model } });
expect(result.status).toBe('completed');
const args = capture().args;
expect(args.filter((arg: string) => arg === '--model')).toHaveLength(1);
expect(args[args.indexOf('--model') + 1]).toBe(model);
expect(args[args.indexOf('--resume') + 1]).toBe('session-123');
expect(capture().auth).toBe('fake-test-credential');
}
await run('success', { env: { ...env(), GSTACK_CLAUDE_MODEL: undefined } });
expect(capture().args).not.toContain('--model');
expect(capture().model).toBe('configured-model');
});
test('stdin stays literal; explicit tools, MCP and hook restrictions preserve configured auth/model', async () => {
const prompt = 'quotes "\' `touch /never` $(touch /never)\nEOF\n--dangerously-skip-permissions';
const result = await run('success', {prompt});
expect(result.status).toBe('completed');
expect(capture().prompt).toBe(prompt);
expect(capture().cwd).toBe(DIR);
expect(capture().model).toBe('configured-model');
expect(capture().auth).toBe('fake-test-credential');
const args = capture().args;
expect(args[args.indexOf('--tools') + 1]).toBe('');
const capability = args[args.indexOf('--append-system-prompt') + 1];
expect(capability).toContain('No tools are available');
expect(capability).toContain('Do not attempt or simulate tool calls');
expect(capability).toContain('If essential context is missing, identify it explicitly');
expect(capability).not.toContain(prompt);
expect(args).toContain('--disable-slash-commands');
expect(args).toContain('--strict-mcp-config');
expect(JSON.parse(args[args.indexOf('--mcp-config') + 1])).toEqual({mcpServers:{}});
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual({disableAllHooks:true});
expect(args[args.indexOf('--disallowedTools') + 1]).toBe('mcp__*');
expect(args).not.toContain('--model');
expect(args).not.toContain('--resume');
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args).not.toContain(prompt);
expect(result.modelUsage).toEqual({'claude-first':{inputTokens:4},'claude-fallback':{inputTokens:8}});
expect(result.usage).toEqual({input_tokens:12,output_tokens:34,cache_read_input_tokens:5});
expect(result.model).toBeUndefined();
expect(result.session_id).toBe('session-123');
});
test('consult exposes only file reading tools and preserves a literal resume argument', async () => {
const resume = 'session " with spaces $(touch /never)';
expect((await run('success', {access:'read-only', resume})).status).toBe('completed');
const args = capture().args;
expect(args[args.indexOf('--tools') + 1]).toBe('Read,Grep,Glob');
expect(args[args.indexOf('--allowedTools') + 1]).toBe('Read,Grep,Glob');
expect(args).not.toContain('--append-system-prompt');
expect(args[args.indexOf('--resume') + 1]).toBe(resume);
expect(args).not.toContain('--no-session-persistence');
});
test('PATH command overrides and argument prefixes resolve in the invocation environment', async () => {
const cliEnv = env();
cliEnv.GSTACK_CLAUDE_BIN = path.basename(process.execPath);
cliEnv.PATH = `${path.dirname(process.execPath)}${path.delimiter}${cliEnv.PATH}`;
expect((await run('success', {env:cliEnv})).status).toBe('completed');
expect(capture().args[0]).toBe('-p');
});
test('WSL-style launcher prefixes remain ordered literal argv entries', async () => {
const cliEnv = env();
cliEnv.GSTACK_CLAUDE_BIN_ARGS = JSON.stringify([FAKE,'claude','--configured-option','a value with spaces']);
expect((await run('success',{env:cliEnv})).status).toBe('completed');
expect(capture().args.slice(0,4)).toEqual(['claude','--configured-option','a value with spaces','-p']);
});
test('missing CLI and broken absolute overrides have distinct actionable failures', async () => {
const missing = await run('success', {env:{PATH:DIR}});
expect(missing.status).toBe('unavailable');
expect(missing.error?.code).toBe('not-found');
expect(missing.error?.message).toContain('GSTACK_CLAUDE_BIN');
const broken = await run('success', {env:{GSTACK_CLAUDE_BIN:path.join(DIR,'missing-cli')}});
expect(broken.status).toBe('unavailable');
expect(broken.error?.code).toBe('spawn');
});
for (const [mode, code] of [
['auth','authentication'], ['nonzero','exit'], ['error','provider-error'],
['malformed','invalid-json'], ['array','invalid-response'], ['null','invalid-response'], ['empty','empty-response'],
]) {
test(`${mode} cannot become a successful review`, async () => {
const result = await run(mode);
expect(result.status).not.toBe('completed');
expect(result.result).toBe('');
expect(result.error?.code).toBe(code);
if (mode === 'auth') expect(result.error?.message).toContain('interactively');
});
}
test('the combined stdout/stderr limit terminates a noisy CLI', async () => {
const result = await run('overflow');
expect(result.status).toBe('error');
expect(result.error?.code).toBe('output-limit');
expect(result.stderr!.length).toBeLessThanOrEqual(16 * 1024);
});
test('timeout kills its descendants and clears process signal listeners', async () => {
rmSync(PID, { force: true });
rmSync(ACTOR_STAGES, { force: true });
rmSync(DESCENDANT_STAGES, { force: true });
const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name));
const startedAtUnixMs = Date.now();
const startedProcessNs = process.hrtime.bigint().toString();
let returned: { atUnixMs: number; processNs: string; timedOut: boolean } | undefined;
const schedule = globalThis.setTimeout;
let fireTimeout: (() => void) | undefined;
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback, delay, ...args) => {
if (delay !== 500) return schedule(callback, delay, ...args);
fireTimeout = () => callback(...args);
return schedule(() => {}, 0);
});
let invocation: ReturnType<typeof run>;
try {
invocation = run('timeout', {timeoutMs:500, env:{...env('timeout'), DESCENDANT_DELAY_MS:'750'}});
} finally { timer.mockRestore(); }
try {
expect(fireTimeout).toBeDefined();
const readyBy = Date.now() + 2000;
while (publishedPid() === undefined && Date.now() < readyBy) await Bun.sleep(5);
const descendantPid = publishedPid();
expect(descendantPid, 'descendant never published its pid within the readiness window').toBeDefined();
expect(running(descendantPid!)).toBe(true);
const start = Date.now();
const expire = fireTimeout!;
fireTimeout = undefined;
expire();
const result = await invocation;
returned = { atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), timedOut: result.error?.code === 'timeout' };
expect(result.status).toBe('unavailable');
expect(result.error?.code).toBe('timeout');
expect(Date.now() - start).toBeLessThan(2000);
expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before);
await expectDescendantDead();
} finally {
console.error(JSON.stringify({ claudeTimeoutStages: { startedAtUnixMs, startedProcessNs, timeoutMs: 500, readinessMs: 2000, returned, pidPublished: existsSync(PID), actor: timeoutStageReceipt(ACTOR_STAGES), descendant: timeoutStageReceipt(DESCENDANT_STAGES) } }));
fireTimeout?.();
await invocation;
cleanupDescendant();
}
});
test('the real deadline bounds startup before descendant readiness', async () => {
rmSync(PID, { force: true });
const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name));
const start = Date.now();
const result = await run('startup-timeout', {timeoutMs:500});
expect(result.status).toBe('unavailable');
expect(result.error?.code).toBe('timeout');
expect(Date.now() - start).toBeLessThan(2000);
expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before);
expect(() => readFileSync(PID)).toThrow();
});
test('a child exiting with inherited pipes is unavailable within the drain deadline', async () => {
rmSync(PID, { force: true });
const start = Date.now();
try {
const result = await run('descendant');
expect(result.status).toBe('unavailable');
expect(result.error?.code).toBe('output-drain');
expect(Date.now() - start).toBeLessThan(2000);
// taskkill /T cannot discover an orphan after its parent has exited.
// Windows termination is covered by the timeout case while the parent
// is alive; cleanup below still runs if a drain assertion fails.
if (process.platform !== 'win32') await expectDescendantDead();
} finally { cleanupDescendant(); }
});
test.skipIf(process.platform === 'win32')('an escaped pipe holder cannot wedge draining or count as coverage', async () => {
const start = Date.now();
try {
const result = await run('escaped');
expect(result.status).toBe('unavailable');
expect(result.error?.code).toBe('output-drain');
expect(Date.now() - start).toBeLessThan(2000);
} finally {
cleanupDescendant();
}
});
test('CLI exits nonzero for malformed responses and argument failures', () => {
const cli = path.join(ROOT, 'bin/gstack-claude-code');
for (const mode of ['success','malformed','error','auth']) {
const result = spawnSync(process.execPath, [cli,'--cwd',DIR,'--access','none','--timeout-ms','5000'], {
env:env(mode), input:'a prompt', encoding:'utf8', timeout:10000,
});
expect(result.status).toBe(mode === 'success' ? 0 : 1);
expect(JSON.parse(result.stdout).status === 'completed').toBe(mode === 'success');
}
const invalid = spawnSync(process.execPath, [cli,'--access','write'], {encoding:'utf8', timeout:5000});
expect(invalid.status).toBe(1);
expect(JSON.parse(invalid.stdout).error.code).toBe('arguments');
});
test('CLI flushes a large successful JSON response before exiting', () => {
const result = spawnSync(process.execPath, [path.join(ROOT,'bin/gstack-claude-code'),'--cwd',DIR,'--access','none','--timeout-ms','5000'], {
env:env('large-success'), input:'prompt', encoding:'utf8', timeout:10000, maxBuffer:8 * 1024 * 1024,
});
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.status).toBe('completed');
expect(parsed.result.length).toBe(2 * 1024 * 1024);
});
});