feat: add a restricted and supervised Claude Code runner

Preserve configured authentication and models while enforcing tool access, strict completion JSON, bounded output and process cleanup. Cover argv, failure handling, session metadata and Windows process containment.
This commit is contained in:
Garry Tan committed 2026-09-08 05:31:49 +00:00
1 parent 0530392821
commit 3498f9e35b
7 files changed
+690

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bun
// Claude-only outside-review runner. Prompts arrive on stdin, never argv.
import { claudeCodeMain } from '../lib/claude-code';
process.exit(await claudeCodeMain(process.argv.slice(2)));
+79
View File
@@ -0,0 +1,79 @@
/** Windows lifetime containment for the dedicated gstack-claude-code process. */
export class WindowsReviewSupervisionError extends Error {
constructor(message: string) {
super(`Claude Code Windows process supervision could not initialize: ${message}. No reviewer was started. Update Bun or check the host's process policy, then retry.`);
this.name = 'WindowsReviewSupervisionError';
}
}
// This handle is intentionally never closed in JavaScript: closing it would
// terminate this runner too. The OS closes it when the dedicated CLI exits,
// after its JSON has flushed, and kills every remaining descendant with it.
// Keep the native library alive for the same lifetime.
let lifetime: { handle: number | bigint; library: unknown } | undefined;
/**
* Join an unnamed, non-inheritable job BEFORE spawning the provider. Children
* inherit membership, not the handle. Unlike taskkill /T, job membership still
* contains a descendant after its immediate parent has exited.
*
* Call only from claudeCodeMain in its dedicated CLI process, never from the
* reusable runClaudeCode API or a host/test process that owns other work.
* https://learn.microsoft.com/windows/win32/procthread/job-objects
*/
export async function initializeWindowsReviewJob(): Promise<void> {
if (process.platform !== 'win32' || lifetime) return;
let library: Awaited<ReturnType<typeof openKernel>> | undefined;
let job: number | bigint = 0n;
let currentProcess: number | bigint = 0n;
try {
library = await openKernel();
const api = library.symbols;
// NULL security attributes make this handle non-inheritable; NULL name
// makes the job private to this invocation.
job = api.CreateJobObjectW(null, null);
if (!job) throw new Error(`CreateJobObjectW failed (${api.GetLastError()})`);
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION on Windows' 64-bit ABI:
// basic limits (64), IO_COUNTERS (48), then four SIZE_T fields (32).
// LimitFlags is the DWORD at byte 16 in the basic-limit structure.
const limits = Buffer.alloc(144);
limits.writeUInt32LE(0x00002000, 16); // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
const { ptr } = await import('bun:ffi');
if (!api.SetInformationJobObject(job, 9, ptr(limits), limits.byteLength)) {
throw new Error(`SetInformationJobObject failed (${api.GetLastError()})`);
}
// AssignProcessToJobObject requires PROCESS_SET_QUOTA | PROCESS_TERMINATE.
currentProcess = api.OpenProcess(0x0101, 0, process.pid);
if (!currentProcess) throw new Error(`OpenProcess failed (${api.GetLastError()})`);
if (!api.AssignProcessToJobObject(job, currentProcess)) {
throw new Error(`AssignProcessToJobObject failed (${api.GetLastError()})`);
}
lifetime = { handle: job, library };
api.CloseHandle(currentProcess);
} catch (error) {
// These failures occur before assignment. Never close a job that already
// contains this process: preserve its handle until the CLI reports failure.
if (library && !lifetime) {
if (currentProcess) library.symbols.CloseHandle(currentProcess);
if (job) library.symbols.CloseHandle(job);
library.close();
}
throw new WindowsReviewSupervisionError(error instanceof Error ? error.message : String(error));
}
}
async function openKernel() {
const { dlopen, FFIType } = await import('bun:ffi');
// HANDLE is an integer token, not an address. Bun explicitly requires an
// integer FFI type here; ptr is reserved for the actual buffer parameters.
return dlopen('kernel32.dll', {
CreateJobObjectW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.u64 },
SetInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
AssignProcessToJobObject: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 },
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
GetLastError: { args: [], returns: FFIType.u32 },
});
}
+247
View File
@@ -0,0 +1,247 @@
/** Restricted, supervised Claude Code invocation for outside reviews. */
import { spawn, type ChildProcess } from 'node:child_process';
import { resolveClaudeCommand, type ClaudeCommand } from './claude-bin';
import { initializeWindowsReviewJob, WindowsReviewSupervisionError } from './claude-code-windows-job';
export const CLAUDE_CODE_OUTPUT_LIMIT = 32 * 1024 * 1024;
const DRAIN_TIMEOUT_MS = 500;
export interface ClaudeCodeOptions {
cwd: string;
access: 'none' | 'read-only';
timeoutMs: number;
prompt: string;
resume?: string;
env?: NodeJS.ProcessEnv;
}
export interface ClaudeCodeResult {
status: 'completed' | 'unavailable' | 'error';
provider: 'claude-code';
result: string;
error?: { code: string; message: string };
session_id?: string;
usage?: Record<string, unknown>;
modelUsage?: Record<string, unknown>;
model?: string;
exit_code?: number | null;
stderr?: string;
}
function failure(code: string, message: string, unavailable = false): ClaudeCodeResult {
return { status: unavailable ? 'unavailable' : 'error', provider: 'claude-code', result: '', error: { code, message } };
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Keep argument construction separate so wrappers never interpolate a prompt. */
export function claudeCodeArgs(options: Pick<ClaudeCodeOptions, 'access' | 'resume'>, command: ClaudeCommand): string[] {
const args = [
...command.argsPrefix, '-p', '--output-format', 'json',
'--disable-slash-commands',
'--tools', options.access === 'none' ? '' : 'Read,Grep,Glob',
'--disallowedTools', 'mcp__*',
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
'--settings', '{"disableAllHooks":true}',
'--permission-mode', 'default',
];
if (options.access === 'read-only') args.push('--allowedTools', 'Read,Grep,Glob');
if (options.resume) args.push('--resume', options.resume);
return args;
}
/** Kill only the process tree owned by this invocation, never other sessions. */
function killTree(child: ChildProcess): void {
if (typeof child.pid !== 'number') return;
if (process.platform === 'win32') {
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
const timer = setTimeout(() => { killer.kill(); child.kill(); }, DRAIN_TIMEOUT_MS);
timer.unref();
killer.once('error', () => { clearTimeout(timer); child.kill(); });
killer.once('close', () => { clearTimeout(timer); child.kill(); });
killer.unref();
return;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
try { child.kill('SIGKILL'); } catch { /* Already reaped. */ }
}
}
}
/** Parse only a completed invocation; a valid JSON error must never pass a gate. */
export function parseClaudeCodeResult(stdout: string, stderr: string, exitCode: number | null): ClaudeCodeResult {
const diagnostic = stderr.trim().slice(0, 16 * 1024);
let raw: unknown;
try { raw = JSON.parse(stdout); } catch { /* Classified below, after CLI failure. */ }
const obj = isObject(raw) ? raw : undefined;
const response = typeof obj?.result === 'string' ? obj.result : typeof obj?.response === 'string' ? obj.response : '';
const actualFailure = exitCode !== 0 || !obj || Boolean(obj.is_error) || !response.trim();
let result: ClaudeCodeResult;
if (actualFailure && /\b(?:authentication(?:[_ ](?:failed|error))?|unauthorized|not authenticated|invalid (?:x-)?api[- ]key|login required|please (?:run .*login|log in)|not logged in)\b/i.test(`${diagnostic}\n${response}\n${stdout.slice(0, 4096)}`)) {
result = failure('authentication', 'Claude Code authentication failed. Run claude interactively in this execution context to authenticate.', true);
} else if (exitCode !== 0) {
result = failure('exit', `Claude Code exited with ${exitCode === null ? 'a signal' : `code ${exitCode}`}.${response ? ` ${response.slice(0, 4096)}` : ''}`);
} else if (raw === undefined) {
result = failure('invalid-json', 'Claude Code returned invalid JSON. Check the CLI installation and diagnostic output.');
} else if (!obj) {
result = failure('invalid-response', 'Claude Code returned JSON that is not an object.');
} else if (obj.is_error || (typeof obj.subtype === 'string' && obj.subtype.startsWith('error'))) {
result = failure('provider-error', `Claude Code reported an error.${response ? ` ${response.slice(0, 4096)}` : ''}`);
} else if (!response.trim()) {
result = failure('empty-response', 'Claude Code returned no response text.');
} else {
result = { status: 'completed', provider: 'claude-code', result: response };
}
if (obj) {
if (typeof obj.session_id === 'string' && obj.session_id) result.session_id = obj.session_id;
if (isObject(obj.usage)) result.usage = obj.usage;
if (isObject(obj.modelUsage)) result.modelUsage = obj.modelUsage;
// Do not choose one model from modelUsage: fallback/multi-model sessions
// must retain all their attribution, and absent identity stays unknown.
if (typeof obj.model === 'string' && obj.model) result.model = obj.model;
}
result.exit_code = exitCode;
if (diagnostic) result.stderr = diagnostic;
return result;
}
/** Prompt stdin, direct argv, bounded output and process-group supervision. */
export async function runClaudeCode(options: ClaudeCodeOptions): Promise<ClaudeCodeResult> {
if (!options.cwd || !['none', 'read-only'].includes(options.access) ||
!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2_147_483_647 ||
!options.prompt.trim() || (options.resume !== undefined && !options.resume.trim())) {
return failure('arguments', 'Provide --cwd, --access none|read-only, a positive --timeout-ms, and a nonempty prompt on stdin.');
}
const env = options.env ?? process.env;
const command = resolveClaudeCommand(env);
if (!command) return failure('not-found', 'Claude Code CLI not found. Install Claude Code or set GSTACK_CLAUDE_BIN, then retry.', true);
let child: ChildProcess;
try {
child = spawn(command.command, claudeCodeArgs(options, command), {
cwd: options.cwd, env, stdio: ['pipe', 'pipe', 'pipe'],
detached: process.platform !== 'win32', windowsHide: true,
});
} catch (error) {
return failure('spawn', `Claude Code could not start: ${(error as Error).message}`, true);
}
return await new Promise<ClaudeCodeResult>((resolve) => {
let settled = false;
let stopped: ClaudeCodeResult | undefined;
let exitCode: number | null = null;
let bytes = 0;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let drainTimer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeoutTimer);
clearTimeout(drainTimer);
process.off('SIGINT', onInterrupt);
process.off('SIGTERM', onTerminate);
process.off('exit', onParentExit);
killTree(child);
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
child.unref();
const err = Buffer.concat(stderr).toString('utf8');
if (stopped) {
stopped.exit_code = exitCode;
if (err.trim()) stopped.stderr = err.trim().slice(0, 16 * 1024);
resolve(stopped);
} else {
resolve(parseClaudeCodeResult(Buffer.concat(stdout).toString('utf8'), err, exitCode));
}
};
const boundDrain = () => {
drainTimer ??= setTimeout(() => {
stopped ??= failure('output-drain', 'Claude Code output pipes did not close after execution. Outside coverage is unavailable.', true);
finish();
}, DRAIN_TIMEOUT_MS);
};
const stop = (result: ClaudeCodeResult) => {
stopped ??= result;
killTree(child);
boundDrain();
};
const collect = (chunks: Buffer[], chunk: Buffer | string) => {
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const remaining = CLAUDE_CODE_OUTPUT_LIMIT - bytes;
bytes += data.byteLength;
if (remaining > 0) chunks.push(data.subarray(0, remaining));
if (bytes > CLAUDE_CODE_OUTPUT_LIMIT) stop(failure('output-limit', 'Claude Code output exceeded the 32 MiB limit.'));
};
const onInterrupt = () => stop(failure('interrupted', 'Claude Code outside review was interrupted (SIGINT).', true));
const onTerminate = () => stop(failure('interrupted', 'Claude Code outside review was interrupted (SIGTERM).', true));
const onParentExit = () => killTree(child);
const timeoutTimer = setTimeout(() => stop(failure('timeout', `Claude Code timed out after ${options.timeoutMs}ms.`, true)), options.timeoutMs);
process.on('SIGINT', onInterrupt);
process.on('SIGTERM', onTerminate);
process.on('exit', onParentExit);
child.stdout?.on('data', (chunk) => collect(stdout, chunk));
child.stderr?.on('data', (chunk) => collect(stderr, chunk));
child.once('error', (error) => {
stopped = failure('spawn', `Claude Code could not start: ${error.message}`, true);
finish();
});
child.once('exit', (code) => {
exitCode = code;
// Allow already-written output to drain naturally. A descendant holding
// the pipes past this bound is unavailable coverage, even if killing it
// would make an otherwise valid response look like a clean completion.
boundDrain();
});
child.once('close', (code) => { exitCode = code; finish(); });
child.stdin?.on('error', (error: NodeJS.ErrnoException) => {
// A rejected/auth-failed CLI may exit before reading its whole prompt;
// retain that actual provider error instead of replacing it with EPIPE.
if (error.code !== 'EPIPE' && error.code !== 'ERR_STREAM_DESTROYED') {
stop(failure('stdin', `Claude Code could not read its prompt: ${error.message}`, true));
}
});
child.stdin?.end(options.prompt);
});
}
export async function claudeCodeMain(argv: string[]): Promise<number> {
let result: ClaudeCodeResult;
try {
const values = new Map<string, string>();
for (let i = 0; i < argv.length; i += 2) {
if (!['--cwd', '--access', '--timeout-ms', '--resume'].includes(argv[i]) || values.has(argv[i]) || argv[i + 1] === undefined) {
throw new Error('Usage: gstack-claude-code --cwd <repo> --access none|read-only --timeout-ms <n> [--resume <session-id>] (prompt on stdin)');
}
values.set(argv[i], argv[i + 1]);
}
const cwd = values.get('--cwd') ?? '';
const access = values.get('--access') as ClaudeCodeOptions['access'];
const timeoutMs = Number(values.get('--timeout-ms'));
if (!cwd || !['none', 'read-only'].includes(access) || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
throw new Error('Provide --cwd, --access none|read-only, and a positive --timeout-ms.');
}
// This entry runs in a dedicated process. Its Windows job owns the CLI and
// descendants even after the immediate provider process exits; the final
// process.exit happens only after the result below has flushed to stdout.
await initializeWindowsReviewJob();
result = await runClaudeCode({ cwd, access, timeoutMs, resume: values.get('--resume'), prompt: await Bun.stdin.text() });
} catch (error) {
result = error instanceof WindowsReviewSupervisionError
? failure('supervision', error.message, true)
: failure('arguments', (error as Error).message);
}
// The CLI shim exits immediately after this promise. Wait for backpressure:
// otherwise a valid multi-megabyte review is truncated in a pipe at exit.
await new Promise<void>((resolve, reject) => {
process.stdout.write(`${JSON.stringify(result)}\n`, (error) => error ? reject(error) : resolve());
});
return result.status === 'completed' ? 0 : 1;
}
+10
View File
@@ -259,6 +259,16 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
// pattern hit is a false positive — the point of these files is Windows
// coverage, so auto-excluding them defeats the regression tests they carry.
const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
{
file: 'test/claude-code-windows-job.test.ts',
reason: 'invokes Bun directly; verifies Windows job containment at the standalone CLI boundary',
},
{
file: 'test/claude-code-runner.test.ts',
// The bin/ path is launched through process.execPath (Bun), never as a
// shebang executable. Keep taskkill tree supervision in the Windows lane.
reason: 'invokes the runner via Bun argv; fake CLI and timeout descendant assertions cover native Windows taskkill',
},
{
file: 'test/setup-windows-rerun-refresh.test.ts',
// Trips the "spawns bin/ shebang script" pattern via path.join(..., 'bin',
+222
View File
@@ -0,0 +1,222 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, 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 CAPTURE = path.join(DIR, 'capture.json');
const PID = path.join(DIR, 'descendant.pid');
writeFileSync(FAKE, `
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const prompt = await Bun.stdin.text();
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 === 'timeout' || mode === 'descendant' || mode === 'escaped') {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:['ignore','inherit','inherit'], detached:mode === 'escaped'});
writeFileSync(process.env.PID_FILE!, String(child.pid));
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, 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 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 readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] !== 'Z';
return true;
} catch { return false; }
}
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 {
if (running(pid)) process.kill(pid, 'SIGKILL');
}
}
describe('Claude Code restricted execution', () => {
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('');
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[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 () => {
const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name));
const start = Date.now();
const result = await run('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);
await expectDescendantDead();
});
test('a child exiting with inherited pipes is unavailable within the drain deadline', async () => {
const start = Date.now();
const result = await run('descendant');
expect(result.status).toBe('unavailable');
expect(result.error?.code).toBe('output-drain');
expect(Date.now() - start).toBeLessThan(2000);
if (process.platform !== 'win32') {
await expectDescendantDead();
} else {
// taskkill /T cannot discover an orphan after its parent has exited.
// Bound-return/unavailable behavior remains covered here; the timeout
// case above verifies descendant termination while the parent is alive.
const pid = Number(readFileSync(PID, 'utf8'));
if (running(pid)) process.kill(pid, 'SIGKILL');
}
});
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 {
const pid = Number(readFileSync(PID, 'utf8'));
if (running(pid)) process.kill(pid, 'SIGKILL');
}
});
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);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const DIR = mkdtempSync(path.join(tmpdir(), 'claude-windows-job-'));
const FAKE = path.join(DIR, 'fake claude.ts');
const PID_FILE = path.join(DIR, 'descendant.pid');
const CLI = path.join(ROOT, 'bin/gstack-claude-code');
const FAILED_JOB = path.join(DIR, 'failed job.ts');
// Exercise the initialization failure at the actual CLI boundary on every OS,
// without adding a production bypass flag for this safety requirement.
writeFileSync(FAILED_JOB, `
Object.defineProperty(process, 'platform', { value: 'win32' });
// OpenProcess(0) is invalid on Windows. Other OSes fail earlier opening the
// Windows DLL; both must fail closed before spawning the configured reviewer.
Object.defineProperty(process, 'pid', { value: 0 });
`);
writeFileSync(FAKE, `
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
await Bun.stdin.text();
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: ['ignore', 'inherit', 'inherit'],
});
writeFileSync(process.env.PID_FILE!, String(child.pid));
if (process.env.FAKE_MODE === 'timeout') await new Promise(() => {});
await new Promise(resolve => process.stdout.write(JSON.stringify({ result: 'NO_FINDINGS' }), resolve));
process.exit(0);
`);
afterAll(() => rmSync(DIR, { recursive: true, force: true }));
function environment(mode: string): NodeJS.ProcessEnv {
return {
...process.env,
GSTACK_CLAUDE_BIN: process.execPath,
GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]),
FAKE_MODE: mode,
PID_FILE,
};
}
function alive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
async function expectDead(pid: number) {
for (let i = 0; i < 40 && alive(pid); i++) await Bun.sleep(25);
expect(alive(pid)).toBe(false);
}
function cleanupDescendant() {
try {
const pid = Number(readFileSync(PID_FILE, 'utf8'));
if (alive(pid)) process.kill(pid, 'SIGKILL');
} catch { /* No descendant was created. */ }
}
// These exercise the actual standalone CLI boundary. A job must never be
// attached to the test runner process, which owns unrelated concurrent work.
describe('Windows Claude CLI job containment', () => {
test('job initialization failure stops before reviewer dispatch and emits a named error', () => {
rmSync(PID_FILE, { force: true });
const result = spawnSync(process.execPath, ['--preload', FAILED_JOB, CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '2000'], {
env: environment('descendant'), input: 'review', encoding: 'utf8', timeout: 10_000,
});
expect(result.status).toBe(1);
const parsed = JSON.parse(result.stdout);
expect(parsed.provider).toBe('claude-code');
expect(parsed.status).toBe('unavailable');
expect(parsed.error.code).toBe('supervision');
expect(parsed.error.message).toContain('Claude Code Windows process supervision could not initialize');
expect(() => readFileSync(PID_FILE)).toThrow();
});
for (const [mode, expected] of [['descendant', 'output-drain'], ['timeout', 'timeout']]) {
test.skipIf(process.platform !== 'win32')(`${mode} kills owned descendants and preserves a sibling process`, async () => {
const sibling = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
const started = Date.now();
try {
const result = spawnSync(process.execPath, [CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '2000'], {
env: environment(mode), input: 'review', encoding: 'utf8', timeout: 10_000,
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
const parsed = JSON.parse(result.stdout);
expect(parsed.status).toBe('unavailable');
expect(parsed.error.code).toBe(expected);
expect(Date.now() - started).toBeLessThan(6000);
await expectDead(Number(readFileSync(PID_FILE, 'utf8')));
expect(alive(sibling.pid!)).toBe(true);
} finally {
cleanupDescendant();
sibling.kill('SIGKILL');
}
});
}
test.skipIf(process.platform !== 'win32')('abrupt runner exit closes the job and reaps its descendants', async () => {
rmSync(PID_FILE, { force: true });
const runner = spawn(process.execPath, [CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '10000'], {
env: environment('timeout'), stdio: ['pipe', 'ignore', 'ignore'],
});
runner.stdin!.end('review');
try {
let descendant = 0;
for (let i = 0; i < 200 && !descendant; i++) {
try { descendant = Number(readFileSync(PID_FILE, 'utf8')); } catch { await Bun.sleep(25); }
}
expect(descendant).toBeGreaterThan(0);
runner.kill('SIGKILL');
await expectDead(descendant);
} finally {
runner.kill('SIGKILL');
cleanupDescendant();
}
});
});
+4
View File
@@ -112,6 +112,10 @@ describe('test-free-shards: Windows curation', () => {
const files = collectFreeTestFiles(ROOT);
const result = curateWindowsSafe(files, ROOT);
expect(result.safe.length + result.excluded.length).toBe(files.length);
// Its bin/ reference is passed to Bun argv, so it must exercise native
// Windows taskkill supervision instead of disappearing behind curation.
expect(result.safe).toContain('test/claude-code-runner.test.ts');
expect(result.safe).toContain('test/claude-code-windows-job.test.ts');
// Sanity: at least one excluded entry, since we know test/ship-version-sync.test.ts uses /bin/bash
expect(result.excluded.length).toBeGreaterThan(0);
// Every excluded entry has a non-empty reason