Files
gstack/test/helpers/codex-session-runner.ts
Garry Tanandcapy-ai[bot] 9cf105ccff v1.91.149.0 feat(evals): Codex host for gate cases, sol weekly and Fable/Astra monthly census arms (GSTA-49 TM2) (#3292)
* feat(evals): Opus 5.5 is the paid gate's model under test; judge stays Fable (GSTA-49 TM1)

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

* v1.91.140.0 chore: stamp TM1 (Opus 5.5 model under test) and CHANGELOG

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

* feat(evals): Codex host adapter, real Codex cost, two-arm weekly and monthly census (GSTA-49 TM2)

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

* chore: TM2 CHANGELOG under [Unreleased]; re-pin codex-host-runner in the harness manifest after the main merge

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

* ci: retrigger PR workflows (base changed to main after open; no pull_request run fired)

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

* v1.91.149.0 chore: stamp TM2 (Codex host, census arms) and CHANGELOG

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-19 18:26:20 +00:00

527 lines
22 KiB
TypeScript

/**
* Codex CLI subprocess runner for skill E2E testing.
*
* Spawns `codex exec` as a completely independent process, parses its JSONL
* output, and returns structured results. Follows the same pattern as
* session-runner.ts but adapted for the Codex CLI.
*
* Key differences from Claude session-runner:
* - Uses `codex exec` instead of `claude -p`
* - Output is JSONL with different event types (item.completed, turn.completed, thread.started)
* - Uses `--json` flag instead of `--output-format stream-json`
* - Needs temp HOME with skill installed at ~/.codex/skills/{skillName}/SKILL.md
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process';
import { StringDecoder } from 'node:string_decoder';
import { hermeticChildEnv } from './hermetic-env';
import { extractSkillSections } from './skill-fixture';
import { killProcessGroup } from '../../scripts/test-strict-output';
import { CODEX_FRONTIER_MODEL } from '../../scripts/resolvers/constants';
import { estimateCostUsd, hasPricing } from '../../lib/pricing';
// --- Interfaces ---
export interface CodexResult {
output: string; // Full agent message text
reasoning: string[]; // [codex thinking] blocks
toolCalls: string[]; // [codex ran] commands
tokens: number; // Total tokens used
exitCode: number; // Process exit code
durationMs: number; // Wall clock time
sessionId: string | null; // Thread ID for session continuity
rawLines: string[]; // Raw JSONL lines for debugging
stderr: string; // Stderr output (skill loading errors, auth failures)
usage?: CodexTokenUsage | null; // Summed turn.completed usage; null when no turn completed
model?: string; // The --model this session ran
}
/**
* Token classes of Codex `turn.completed` usage. `cached_input` (cache reads)
* and `cache_write_input` are subsets of `input`; `reasoning_output` is a
* subset of `output`.
*/
export interface CodexTokenUsage {
input: number;
cached_input: number;
cache_write_input: number;
output: number;
reasoning_output: number;
}
export const NO_CODEX_USAGE: CodexTokenUsage = Object.freeze({ input: 0, cached_input: 0, cache_write_input: 0, output: 0, reasoning_output: 0 });
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
/** Add one `turn.completed` usage object to a running total. */
export function addCodexTurnUsage(total: CodexTokenUsage, usage: Record<string, unknown> | undefined): CodexTokenUsage {
return {
input: total.input + count(usage?.input_tokens),
cached_input: total.cached_input + count(usage?.cached_input_tokens),
cache_write_input: total.cache_write_input + count(usage?.cache_write_input_tokens),
output: total.output + count(usage?.output_tokens),
reasoning_output: total.reasoning_output + count(usage?.reasoning_output_tokens),
};
}
/**
* A session's cost at lib/pricing.ts rates: cache reads at the cached-input
* rate, all other input (cache writes included) at the input rate, output at
* the output rate. Null when the model has no price row.
*/
export function codexUsageCostUsd(usage: CodexTokenUsage, model: string | undefined): number | null {
if (!hasPricing(model)) return null;
const cached = Math.min(usage.cached_input, usage.input);
return estimateCostUsd({ input: usage.input - cached, cached, output: usage.output }, model);
}
/** Existing pipe-drain allowance, separate from the model's work budget. */
export const CODEX_DRAIN_GRACE_MS = 5_000;
export class CodexHarnessError extends Error {
constructor(message: string, readonly result?: CodexResult) {
super(message);
this.name = 'CodexHarnessError';
}
}
// --- JSONL parser (ported from Python in codex/SKILL.md.tmpl) ---
export interface ParsedCodexJSONL {
output: string;
reasoning: string[];
toolCalls: string[];
tokens: number;
sessionId: string | null;
usage: CodexTokenUsage | null;
}
/**
* Parse an array of JSONL lines from `codex exec --json` into structured data.
* Pure function — no I/O, no side effects.
*
* Handles these Codex event types:
* - thread.started → extract thread_id (session ID)
* - item.completed → extract reasoning, agent_message, command_execution
* - turn.completed → extract token usage
*/
export function parseCodexJSONL(lines: string[]): ParsedCodexJSONL {
const outputParts: string[] = [];
const reasoning: string[] = [];
const toolCalls: string[] = [];
let tokens = 0;
let sessionId: string | null = null;
let usage: CodexTokenUsage | null = null;
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
const t = obj.type || '';
if (t === 'thread.started') {
const tid = obj.thread_id || '';
if (tid) sessionId = tid;
} else if (t === 'item.completed' && obj.item) {
const item = obj.item;
const itype = item.type || '';
const text = item.text || '';
if (itype === 'reasoning' && text) {
reasoning.push(text);
} else if (itype === 'agent_message' && text) {
outputParts.push(text);
} else if (itype === 'command_execution') {
const cmd = item.command || '';
if (cmd) toolCalls.push(cmd);
}
} else if (t === 'turn.completed') {
const turn = obj.usage || {};
tokens += (turn.input_tokens || 0) + (turn.output_tokens || 0);
usage = addCodexTurnUsage(usage ?? NO_CODEX_USAGE, turn);
}
} catch { /* skip malformed lines */ }
}
return {
output: outputParts.join('\n'),
reasoning,
toolCalls,
tokens,
sessionId,
usage,
};
}
// --- Skill installation helper ---
/**
* Install a SKILL.md into a temp HOME directory for Codex to discover.
* Creates ~/.codex/skills/{skillName}/SKILL.md in the temp HOME and copies
* agents/openai.yaml when present so Codex sees the same metadata as a real install.
*
* When `sections` is provided, the installed SKILL.md is an EXTRACTION
* (frontmatter + the named `## <section>` blocks via
* test/helpers/skill-fixture.ts) instead of the full 1000-1900-line file —
* CLAUDE.md: "E2E test fixtures: extract, don't copy". Omit `sections` only
* when the test's purpose is to validate the real generated artifact itself
* (e.g., codex-discover-skill asserts the full SKILL.md loads without
* "invalid" / "Skipped loading" stderr from Codex).
*
* Returns the temp HOME path. Caller is responsible for cleanup.
*/
export function installSkillToTempHome(
skillDir: string,
skillName: string,
tempHome?: string,
sections?: string[],
runtimeRoot?: string,
): string {
const home = tempHome || fs.mkdtempSync(path.join(os.tmpdir(), 'codex-e2e-'));
const destDir = path.join(home, '.codex', 'skills', skillName);
fs.mkdirSync(destDir, { recursive: true });
const srcSkill = path.join(skillDir, 'SKILL.md');
if (sections && sections.length > 0) {
// extractSkillSections throws loudly on a missing file or renamed
// section — a fixture is never silently written empty.
fs.writeFileSync(path.join(destDir, 'SKILL.md'), extractSkillSections(skillDir, sections));
} else {
// A missing/unreadable full fixture must fail just like an extraction.
// Preserve copyFileSync's filesystem diagnostic; an agent can mention a
// nonexistent skill in its response and otherwise pass discovery checks.
fs.copyFileSync(srcSkill, path.join(destDir, 'SKILL.md'));
}
if (runtimeRoot) {
// The temp HOME has no installed gstack runtime; point runtime helpers at the one under test.
const installed = path.join(destDir, 'SKILL.md');
fs.writeFileSync(installed, fs.readFileSync(installed, 'utf8').replaceAll('~/.codex/skills/gstack', runtimeRoot));
}
const srcOpenAIYaml = path.join(skillDir, 'agents', 'openai.yaml');
if (fs.existsSync(srcOpenAIYaml)) {
const destAgentsDir = path.join(destDir, 'agents');
fs.mkdirSync(destAgentsDir, { recursive: true });
fs.copyFileSync(srcOpenAIYaml, path.join(destAgentsDir, 'openai.yaml'));
}
return home;
}
// --- Spawn core ---
/**
* Spawn `codex <args>` in its own process group and collect its JSONL until
* exit, the deadline or an abort, with the existing drain grace. Shared by
* runCodexSkill and the Codex host (codex-host-runner.ts). `onLine` sees each
* JSONL line as it arrives with the elapsed milliseconds since `startTime`.
*/
export async function execCodex(args: string[], opts: {
cwd: string;
env: Record<string, string>;
startTime: number;
deadline: number;
signal?: AbortSignal;
onLine?: (line: string, elapsedMs: number) => void;
/** Called with the group-owning pid right after spawn; a throw kills the group. */
onSpawn?: (pid: number) => void;
}): Promise<CodexResult> {
const { cwd, env, startTime, deadline, signal, onLine } = opts;
const proc = spawn('codex', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
env,
});
if (opts.onSpawn) {
try {
if (proc.pid === undefined) throw new Error('Codex did not start');
opts.onSpawn(proc.pid);
} catch (error) {
killProcessGroup(proc, 'SIGKILL');
proc.stdout!.destroy();
proc.stderr!.destroy();
throw error;
}
}
const collectedLines: string[] = [];
// work --exit--> drain (<=5s) --> result
// \--timeout/abort--> kill group + close pipes --> timeout result
// Exit status comes from 'exit'; a forced drain after exit 0 is an error.
let stdoutBuffer = '';
let stderr = '';
let exitCode: number | undefined;
let timedOut = false;
let drainExpired = false;
let streamError: { stream: 'stdout' | 'stderr'; error: Error } | undefined;
let spawnError: Error | undefined;
let stdoutDone = false;
let stderrDone = false;
let stdoutEnded = false;
let stderrEnded = false;
let finalized = false;
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
let workTimer: ReturnType<typeof setTimeout> | undefined;
let drainTimer: ReturnType<typeof setTimeout> | undefined;
let finish!: () => void;
const finished = new Promise<void>((resolve) => { finish = resolve; });
const maybeFinish = () => {
if (exitCode !== undefined && stdoutDone && stderrDone) finish();
};
const closePipes = () => {
// Destroy both streams: canceling stdout alone cannot release an
// inherited stderr pipe held by a descendant in another process group.
proc.stdout!.destroy();
proc.stderr!.destroy();
};
const expireDrain = () => {
drainExpired = !stdoutDone || !stderrDone;
killProcessGroup(proc, 'SIGKILL');
closePipes();
// Also bound the rare case where a failed group kill produces no exit.
finish();
};
const stopRun = () => {
// A real auth/crash exit that preceded a pipe stall stays a real exit.
if (exitCode === undefined) timedOut = true;
else drainExpired ||= !stdoutDone || !stderrDone;
killProcessGroup(proc, 'SIGKILL');
closePipes();
clearTimeout(drainTimer);
drainTimer = setTimeout(expireDrain, CODEX_DRAIN_GRACE_MS);
maybeFinish();
};
const onExit = (code: number | null, exitSignal: NodeJS.Signals | null) => {
exitCode = code ?? (exitSignal ? 128 + (os.constants.signals[exitSignal] ?? 0) : 1);
clearTimeout(workTimer);
clearTimeout(drainTimer);
// 'exit' means the child is gone; 'close' also waits for descendant
// pipes. Start the drain allowance at actual exit, never at close.
drainTimer = setTimeout(expireDrain, CODEX_DRAIN_GRACE_MS);
maybeFinish();
};
const onSpawnError = (error: Error) => {
if (finalized) return;
spawnError = error;
exitCode = 1;
closePipes();
finish();
};
// Closure releases lifecycle waits, but only 'end' proves all bytes were
// drained. A destroyed pipe can emit 'close' without either EOF or error.
const onStdoutDone = () => { if (!finalized) { stdoutDone = true; maybeFinish(); } };
const onStderrDone = () => { if (!finalized) { stderrDone = true; maybeFinish(); } };
const onStdoutEnd = () => { if (!finalized) { stdoutBuffer += stdoutDecoder.end(); stdoutEnded = true; onStdoutDone(); } };
const onStderrEnd = () => { if (!finalized) { stderr += stderrDecoder.end(); stderrEnded = true; onStderrDone(); } };
const onStreamError = (stream: 'stdout' | 'stderr', error: Error) => {
if (!finalized) streamError ??= { stream, error };
};
const onStdout = (chunk: Buffer) => {
if (finalized) return;
stdoutBuffer += stdoutDecoder.write(chunk);
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
collectedLines.push(line);
onLine?.(line, Date.now() - startTime);
try {
const event = JSON.parse(line);
if (event.type === 'item.completed' && event.item) {
const item = event.item;
const elapsed = Math.round((Date.now() - startTime) / 1000);
if (item.type === 'command_execution' && item.command) {
process.stderr.write(` [codex ${elapsed}s] ran: ${item.command.slice(0, 100)}\n`);
} else if (item.type === 'agent_message' && item.text) {
process.stderr.write(` [codex ${elapsed}s] message: ${item.text.slice(0, 100)}\n`);
}
}
} catch { /* malformed JSONL is ignored by parseCodexJSONL too */ }
}
};
const onStderr = (chunk: Buffer) => { if (!finalized) stderr += stderrDecoder.write(chunk); };
proc.on('exit', onExit);
proc.on('error', onSpawnError);
proc.stdout!.on('data', onStdout);
proc.stderr!.on('data', onStderr);
proc.stdout!.on('end', onStdoutEnd).on('close', onStdoutDone).on('error', error => onStreamError('stdout', error));
proc.stderr!.on('end', onStderrEnd).on('close', onStderrDone).on('error', error => onStreamError('stderr', error));
signal?.addEventListener('abort', stopRun, { once: true });
workTimer = setTimeout(stopRun, Math.max(0, deadline - Date.now()));
if (signal?.aborted || Date.now() >= deadline) stopRun();
try {
await finished;
if (stdoutBuffer.trim()) collectedLines.push(stdoutBuffer);
const parsed = parseCodexJSONL(collectedLines);
const result: CodexResult = {
...parsed,
exitCode: timedOut ? 124 : exitCode ?? 1,
durationMs: Date.now() - startTime,
rawLines: collectedLines,
stderr,
};
if (stderr.trim()) process.stderr.write(` [codex stderr] ${stderr.trim().slice(0, 200)}\n`);
if (spawnError) throw new CodexHarnessError(`Could not start Codex: ${spawnError.message}`, result);
const incompleteStreams = [!stdoutEnded && 'stdout', !stderrEnded && 'stderr'].filter(Boolean).join(' and ');
if (result.exitCode === 0 && (drainExpired || streamError || incompleteStreams)) {
throw new CodexHarnessError(
drainExpired ? `Codex output drain exceeded ${CODEX_DRAIN_GRACE_MS}ms after exit 0`
: streamError ? `Codex ${streamError.stream} stream failed: ${streamError.error.message}`
: `Codex ${incompleteStreams} closed before EOF after exit 0`,
result,
);
}
return result;
} finally {
finalized = true;
clearTimeout(workTimer);
clearTimeout(drainTimer);
signal?.removeEventListener('abort', stopRun);
// Child exit and closed pipes do not prove its descendants exited:
// background tools may redirect both streams. Reap the owned group on
// every result, including ordinary success and genuine process failure.
killProcessGroup(proc, 'SIGKILL');
closePipes();
proc.removeListener('exit', onExit);
// Retain the harmless error listener through stream teardown so a late
// OS spawn error cannot turn into an unhandled event after cleanup.
proc.stdout!.removeListener('data', onStdout);
proc.stderr!.removeListener('data', onStderr);
}
}
// --- Main runner ---
/**
* Run a Codex skill via `codex exec` and return structured results.
*
* Spawns codex in a temp HOME with the skill installed, parses JSONL output,
* and returns a CodexResult. Skips gracefully if codex binary is not found.
*/
export async function runCodexSkill(opts: {
skillDir: string; // Path to skill directory containing SKILL.md
prompt: string; // What to ask Codex to do with the skill
timeoutMs?: number; // Default 300000 (5 min)
cwd?: string; // Working directory
skillName?: string; // Skill name for installation (default: dirname)
sandbox?: string; // Sandbox mode (default: 'read-only')
sections?: string[]; // Install only these `## <section>` blocks (extract, don't copy)
model?: string; // Exact Codex model ID (passed with --model)
configOverrides?: string[]; // TOML key=value overrides (passed with -c)
ignoreUserConfig?: boolean; // Add --ignore-user-config; auth still comes from CODEX_HOME
signal?: AbortSignal; // Abort the process group when an enclosing eval expires
runtimeRoot?: string; // gstack runtime that ~/.codex/skills/gstack helper paths resolve to
}): Promise<CodexResult> {
const {
skillDir,
prompt,
timeoutMs = 300_000,
cwd,
skillName,
sandbox = 'read-only',
sections,
model,
configOverrides = [],
ignoreUserConfig = false,
signal,
runtimeRoot,
} = opts;
const startTime = Date.now();
const deadline = startTime + timeoutMs;
const name = skillName || path.basename(skillDir) || 'gstack';
const emptyResult = (exitCode: number, output = ''): CodexResult => ({
output, reasoning: [], toolCalls: [], tokens: 0, exitCode,
durationMs: Date.now() - startTime, sessionId: null, rawLines: [], stderr: '', usage: null,
});
if (signal?.aborted || Date.now() >= deadline) return emptyResult(124);
// Preflight and setup are part of the budget, not extra time before it.
// Bun's implicit child environment retains the launch-time PATH. Use the
// current environment so preflight and the actual spawn see the same shims.
const whichResult = Bun.spawnSync(['which', 'codex'], {
env: process.env,
timeout: Math.max(1, Math.min(30_000, deadline - Date.now())),
});
if (signal?.aborted || Date.now() >= deadline) return emptyResult(124);
if (whichResult.exitCode !== 0) {
if (whichResult.signalCode) throw new CodexHarnessError('Codex binary lookup did not complete');
return emptyResult(-1, 'SKIP: codex binary not found');
}
// Set up temp HOME with skill installed
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-e2e-'));
const realHome = os.homedir();
try {
installSkillToTempHome(skillDir, name, tempHome, sections, runtimeRoot);
// Copy authentication only. Copying the whole operator ~/.codex tree leaks
// plugins, MCP servers, rules, memories, and skills into a supposedly
// hermetic E2E; required private MCPs can then fail before the model starts.
const realCodexConfig = process.env.CODEX_HOME || path.join(realHome, '.codex');
const tempCodexDir = path.join(tempHome, '.codex');
if (fs.existsSync(realCodexConfig)) {
for (const entry of ['auth.json']) {
const src = path.join(realCodexConfig, entry);
const dst = path.join(tempCodexDir, entry);
if (fs.existsSync(src) && !fs.existsSync(dst)) {
fs.cpSync(src, dst, { recursive: true });
}
}
}
if (signal?.aborted || Date.now() >= deadline) return emptyResult(124);
// Build codex exec command.
// --skip-git-repo-check: newer codex CLIs refuse exec in an untrusted
// non-git directory ("Not inside a trusted directory and
// --skip-git-repo-check was not specified") — our temp skill dirs are
// exactly that. Empirically verified against codex on this machine.
const args = ['exec', '--json', '-s', sandbox, '--skip-git-repo-check'];
if (ignoreUserConfig) args.push('--ignore-user-config');
const sessionModel = model ?? process.env.GSTACK_CODEX_MODEL ?? CODEX_FRONTIER_MODEL;
args.push('--model', sessionModel);
for (const override of configOverrides) args.push('-c', override);
args.push(prompt);
// Spawn codex with temp HOME so it discovers our installed skill.
// Hermetic scrub (test/helpers/hermetic-env.ts) with codex's auth surface
// re-admitted: codex auths from $HOME/.codex (copied into tempHome above)
// plus OPENAI_API_KEY/CODEX_* when present. HOME override merges last.
// node:child_process spawn with `detached` (own process group) — mirrors
// session-runner.ts. Bun.spawn's bare proc.kill() signalled only codex
// itself; command subprocesses codex spawned survived as orphans holding
// our pipes open (the same blocked-drain hang the claude runner fixed —
// this copy never inherited that fix until now).
// Codex refuses to create its PATH aliases (codex-linux-sandbox among
// them) when CODEX_HOME sits under its temp dir, so the child gets a temp
// dir beside CODEX_HOME instead of the shard TMPDIR that holds tempHome.
const tempTmpDir = path.join(tempHome, 'tmp');
fs.mkdirSync(tempTmpDir, { recursive: true });
const result = await execCodex(args, {
cwd: cwd || skillDir, startTime, deadline, signal,
env: hermeticChildEnv(
{ HOME: tempHome, CODEX_HOME: tempCodexDir, TMPDIR: tempTmpDir },
{ extraAllow: ['OPENAI_API_KEY', 'CODEX_*'] },
),
}).catch((error: unknown) => {
if (error instanceof CodexHarnessError && error.result) error.result.model = sessionModel;
throw error;
});
return { ...result, model: sessionModel };
} finally {
// Clean up temp HOME
try { fs.rmSync(tempHome, { recursive: true, force: true }); } catch { /* non-fatal */ }
}
}