Files
explore/test/node/ci-agent-session-codex.test.mjs
b10fba99e6 Initial commit
Co-authored-by: Brent Vatne <[email protected]>
Co-authored-by: Szymon Dziedzic <[email protected]>
2026-10-14 14:10:07 -07:00

414 lines
23 KiB
JavaScript

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { access, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { PassThrough, Writable } from "node:stream";
import test from "node:test";
import { tsImport } from "tsx/esm/api";
const { runCodexSession } = await tsImport("../../src/capture/agent/session-codex.ts", import.meta.url);
const { CHAT_CONFIG } = await tsImport("../../src/capture/agent/codex-config.ts", import.meta.url);
const { CiError, serializeCiError } = await tsImport("../../src/shared/ci-errors.ts", import.meta.url);
const CONFIG = { provider: "openai", model: "explicit-test-model" };
const RUNTIME_CONFIG = { ...CHAT_CONFIG, forced_login_method: "api", model_provider: "openai" };
const KEY = "private-test-key-never-in-model-context";
const ENV = { PATH: process.env.PATH, OPENAI_API_KEY: KEY, CODEX_HOME: "/untrusted/codex", HOME: "/private/home",
OPENAI_BASE_URL: "https://untrusted.test", CODEX_API_KEY: "private-other-key", ANTHROPIC_API_KEY: "private-other-provider",
NODE_OPTIONS: "--require injected.js", GITHUB_TOKEN: "private-github-token", HTTPS_PROXY: "https://secret.test" };
const TOOL = { name: "observe", description: "Inspect the current application.",
inputSchema: { type: "object", properties: {}, additionalProperties: false } };
const IMAGE = { type: "image", mimeType: "image/png", data: "aW1hZ2U=" };
const toolParams = (extra = {}) => ({ threadId: "thread-1", turnId: "turn-1", callId: "call-1", namespace: null, tool: "observe", arguments: {}, ...extra });
function harness(behavior = {}) {
const messages = [];
const kills = [];
let captured;
let child;
const send = message => child.stdout.write(`${JSON.stringify(message)}\n`);
const notify = (method, params) => send({ method, params });
const finish = (extra = {}) => notify("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null, ...extra } });
const tool = (id = "server-1", params = {}) => send({ id, method: "item/tool/call", params: toolParams(params) });
const respond = message => {
messages.push(message);
if (behavior.receive?.(message, { send, notify, finish, tool }) === true) return;
if (message.method === "initialize") send({ id: message.id, result: {} });
else if (message.method === "config/read") send({ id: message.id, result: { config: behavior.config ?? RUNTIME_CONFIG } });
else if (message.method === "account/login/start") send({ id: message.id, result: { type: "apiKey" } });
else if (message.method === "account/read") send({ id: message.id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: true } });
else if (message.method === "thread/start") send({ id: message.id, result: { thread: { id: "thread-1" } } });
else if (message.method === "turn/start") {
send({ id: message.id, result: { turn: { id: "turn-1" } } });
if (behavior.turn) behavior.turn({ send, notify, finish, tool });
else finish();
} else if (message.id !== undefined && !message.method) behavior.result?.(message, { send, notify, finish, tool });
};
const spawn = (command, args, options) => {
captured = { command, args, options };
child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
let buffer = "";
child.stdin = new Writable({ write(chunk, _encoding, callback) {
buffer += chunk.toString();
let newline;
while ((newline = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
const parsed = JSON.parse(line);
queueMicrotask(() => respond(parsed));
}
callback();
} });
child.kill = signal => {
kills.push(signal);
if (!behavior.ignoreTerm || signal === "SIGKILL") setTimeout(() => {
child.stdout.end(); child.stderr.end(); child.emit("close", 0, signal);
}, behavior.closeDelay ?? 0);
return true;
};
behavior.spawned?.(child, captured);
return child;
};
const run = (request = {}) => runCodexSession(CONFIG, {
instructions: "Explore only the supplied application tools.", prompt: "Observe the current screen.", tools: [TOOL],
callTool: async () => ({ content: [{ type: "text", text: "Observed." }] }),
deadlineMs: Date.now() + 5_000, ...request,
}, { ...ENV, ...behavior.env }, { spawn, terminateGraceMs: 15, ...(behavior.subscription ? { subscription: true } : {}) });
return { run, messages, kills, get captured() { return captured; }, get child() { return child; } };
}
async function cleaned(fixture) {
assert.ok(fixture.kills.length, "The subprocess must be stopped before resolving the session");
await assert.rejects(access(dirname(fixture.captured.options.cwd)), { code: "ENOENT" });
}
test("Codex CI uses a private API-key session, selected model, native image tools, and cumulative usage", async () => {
let calls = 0;
let rootMode;
let workspaceContents;
const fixture = harness({
turn({ tool }) { tool(); tool("same-call-retry"); },
result(message, { notify, finish }) {
if (message.id !== "same-call-retry") return;
notify("thread/tokenUsage/updated", { threadId: "thread-1", turnId: "turn-1", tokenUsage: { total: { inputTokens: 12, outputTokens: 4 } } });
notify("thread/tokenUsage/updated", { threadId: "thread-1", turnId: "turn-1", tokenUsage: { total: { inputTokens: 27, outputTokens: 9 } } });
finish();
},
});
const result = await fixture.run({ images: [IMAGE], callTool: async (name, input, signal) => {
calls++;
assert.equal(name, "observe"); assert.deepEqual(input, {}); assert.ok(signal instanceof AbortSignal);
rootMode = (await stat(dirname(fixture.captured.options.cwd))).mode & 0o777;
workspaceContents = await readdir(fixture.captured.options.cwd);
return { content: [{ type: "text", text: "Screenshot metadata." }, IMAGE] };
} });
assert.deepEqual(result, { inputTokens: 27, outputTokens: 9 });
assert.equal(calls, 1, "Repeated call IDs must not repeat stateful tool actions");
assert.equal(rootMode, 0o700);
assert.deepEqual(workspaceContents, []);
const { args, options } = fixture.captured;
assert.ok(args.includes("--strict-config"));
assert.ok(args.includes('forced_login_method="api"'));
assert.ok(args.includes('model_provider="openai"'));
assert.ok(args.includes('shell_environment_policy={inherit="none"}'));
assert.equal(JSON.stringify(args).includes(KEY), false);
assert.equal(options.env.CODEX_HOME.startsWith(dirname(options.cwd)), true);
assert.deepEqual(Object.keys(options.env).sort(), ["CODEX_HOME", "NO_COLOR", "PATH"]);
const login = fixture.messages.find(message => message.method === "account/login/start");
assert.deepEqual(login.params, { type: "apiKey", apiKey: KEY });
const thread = fixture.messages.find(message => message.method === "thread/start").params;
assert.equal(thread.model, CONFIG.model); assert.equal(thread.ephemeral, true);
assert.equal(thread.allowProviderModelFallback, false);
assert.deepEqual(thread.environments, []); assert.deepEqual(thread.runtimeWorkspaceRoots, []);
assert.deepEqual(thread.selectedCapabilityRoots, []);
assert.equal(thread.approvalPolicy, "never"); assert.equal(thread.sandbox, "read-only");
assert.deepEqual(thread.dynamicTools, [{ type: "function", ...TOOL }]);
const turn = fixture.messages.find(message => message.method === "turn/start").params;
assert.equal(turn.model, CONFIG.model); assert.deepEqual(turn.environments, []);
assert.deepEqual(turn.input[1], { type: "image", url: "data:image/png;base64,aW1hZ2U=" });
const toolResult = fixture.messages.find(message => message.id === "server-1").result;
assert.deepEqual(toolResult, { success: true, contentItems: [
{ type: "inputText", text: "Screenshot metadata." }, { type: "inputImage", imageUrl: "data:image/png;base64,aW1hZ2U=" },
] });
assert.equal(JSON.stringify({ thread, turn, toolResult }).includes(KEY), false);
await cleaned(fixture);
});
test("Codex accepts notifications before the turn/start response and validates their turn identity", async () => {
const fixture = harness({ receive(message, { send, notify, finish }) {
if (message.method !== "turn/start") return false;
notify("item/started", { threadId: "thread-1", turnId: "turn-1", item: { type: "reasoning" } });
finish();
send({ id: message.id, result: { turn: { id: "turn-1" } } });
return true;
} });
assert.deepEqual(await fixture.run(), { inputTokens: 0, outputTokens: 0 });
await cleaned(fixture);
});
test("Codex handles the pinned config/read projection while retaining explicit disabled-tool overrides", async () => {
const fixture = harness({ config: { ...RUNTIME_CONFIG, tools: { web_search: null } } });
await fixture.run();
assert.ok(fixture.captured.args.includes("tools={experimental_request_user_input={enabled=false},update_plan={enabled=false}}"));
await cleaned(fixture);
});
test("Codex refuses any login or account state that falls back to a subscription", async () => {
for (const method of ["account/login/start", "account/read"]) {
const fixture = harness({ receive(message, { send }) {
if (message.method !== method) return false;
send({ id: message.id, result: method === "account/read" ? { account: { type: "chatgpt" } } : { type: "chatgpt" } });
return true;
} });
await assert.rejects(fixture.run(), { code: "AGENT_RUNTIME_UNSAFE" });
assert.equal(fixture.messages.some(message => message.method === "thread/start"), false);
await cleaned(fixture);
}
});
test("Codex refuses unsafe configuration, approval requests, undeclared tools, and native tools", async () => {
const scenarios = [
{ config: { ...RUNTIME_CONFIG, forced_login_method: "chatgpt" }, code: "AGENT_RUNTIME_UNSAFE" },
{ config: { ...RUNTIME_CONFIG, features: { ...RUNTIME_CONFIG.features, shell_tool: true } }, code: "AGENT_RUNTIME_UNSAFE" },
{ config: { ...RUNTIME_CONFIG, mcp_servers: { injected: {} } }, code: "AGENT_RUNTIME_UNSAFE" },
{ turn: ({ send }) => send({ id: "approval", method: "item/commandExecution/requestApproval", params: {} }), code: "AGENT_TOOL_DENIED" },
{ turn: ({ tool }) => tool("unknown", { tool: "shell" }), code: "AGENT_TOOL_DENIED" },
{ turn: ({ tool }) => tool("foreign-thread", { threadId: "foreign" }), code: "AGENT_TOOL_DENIED" },
{ turn: ({ notify }) => notify("item/started", { threadId: "thread-1", turnId: "turn-1", item: { type: "commandExecution" } }), code: "AGENT_TOOL_DENIED" },
];
for (const scenario of scenarios) {
const fixture = harness(scenario);
await assert.rejects(fixture.run({ callTool: () => assert.fail("Unauthorized calls must not reach the host") }), { code: scenario.code });
await cleaned(fixture);
}
});
test("Codex completion cannot hide protocol violations or authorize subsequent host actions", async () => {
for (const turn of [
({ finish, notify }) => { finish(); notify("item/started", { threadId: "thread-1", turnId: "turn-1", item: { type: "commandExecution" } }); },
({ finish, tool }) => { finish(); tool(); },
]) {
const fixture = harness({ turn });
await assert.rejects(fixture.run({ callTool: () => assert.fail("No action may begin after completion") }), { code: "AGENT_TOOL_DENIED" });
await cleaned(fixture);
}
const premature = harness({ turn({ tool, finish }) { tool(); finish(); } });
await assert.rejects(premature.run({ callTool: () => assert.fail("A failed protocol cannot start pending actions") }), { code: "AGENT_OUTPUT_INVALID" });
await cleaned(premature);
});
test("Codex rejects conflicting tool identities and malformed usage without executing another action", async () => {
const duplicate = harness({ turn({ tool }) { tool(); tool("conflict", { arguments: { changed: true } }); } });
let calls = 0;
await assert.rejects(duplicate.run({ callTool: async () => { calls++; return { content: [] }; } }), { code: "AGENT_OUTPUT_INVALID" });
assert.ok(calls <= 1);
await cleaned(duplicate);
const usage = harness({ turn({ notify }) {
notify("thread/tokenUsage/updated", { threadId: "thread-1", turnId: "turn-1", tokenUsage: { total: { inputTokens: -1, outputTokens: 0 } } });
} });
await assert.rejects(usage.run(), { code: "AGENT_OUTPUT_INVALID" });
await cleaned(usage);
});
test("Codex preserves fatal host tool failures and returns recoverable tool errors to the model", async () => {
const fatal = new CiError({ code: "CAPTURE_FAILED", stage: "capture", message: "Capture failed.", nextAction: "Check simulator." });
const fixture = harness({ turn: ({ tool }) => tool() });
await assert.rejects(fixture.run({ callTool: async () => { throw fatal; } }), error => error === fatal);
await cleaned(fixture);
const recoverable = harness({ turn: ({ tool }) => tool(), result(message, { finish }) {
assert.deepEqual(message.result, { success: false, contentItems: [{ type: "inputText", text: "Observe again." }] }); finish();
} });
await recoverable.run({ callTool: async () => ({ isError: true, content: [{ type: "text", text: "Observe again." }] }) });
await cleaned(recoverable);
});
test("Codex classifies structured provider failures without exposing raw messages", async () => {
for (const [info, code] of [["unauthorized", "AGENT_API_KEY_INVALID"], ["usageLimitExceeded", "AGENT_QUOTA_EXHAUSTED"],
["rateLimitExceeded", "AGENT_RATE_LIMITED"], [{ httpConnectionFailed: { httpStatusCode: 404 } }, "AGENT_MODEL_NOT_FOUND"],
[{ responseTooManyFailedAttempts: { httpStatusCode: 503 } }, "AGENT_SERVICE_UNAVAILABLE"]]) {
const fixture = harness({ turn: ({ finish }) => finish({ status: "failed", error: { codexErrorInfo: info, message: KEY } }) });
await assert.rejects(fixture.run(), error => {
assert.equal(error.code, code);
assert.equal(JSON.stringify(serializeCiError(error)).includes(KEY), false);
return true;
});
await cleaned(fixture);
}
});
test("Codex cancellation closes stalled startup and waits for escalation before removing credentials", async () => {
const controller = new AbortController();
const fixture = harness({ ignoreTerm: true, receive: message => message.method === "initialize" });
const pending = fixture.run({ signal: controller.signal });
const cancelled = new CiError({ code: "AGENT_CANCELLED", stage: "agent", message: "Cancelled.", nextAction: "Retry." });
const timer = setTimeout(() => controller.abort(cancelled), 30);
try { await assert.rejects(pending, error => error === cancelled); } finally { clearTimeout(timer); }
assert.equal(fixture.kills[0], "SIGTERM");
assert.equal(fixture.kills.at(-1), "SIGKILL");
await cleaned(fixture);
});
test("Codex deadline bounds a stalled model/tool loop and cleans the private home", async () => {
const fixture = harness({ turn: ({ tool }) => tool() });
await assert.rejects(fixture.run({ deadlineMs: Date.now() + 80, callTool: () => new Promise(() => {}) }), { code: "AGENT_BUDGET_EXCEEDED" });
await cleaned(fixture);
const expired = harness();
await assert.rejects(expired.run({ deadlineMs: Date.now() - 1 }), { code: "AGENT_BUDGET_EXCEEDED" });
assert.equal(expired.captured, undefined);
});
test("Codex transport failures abort an active host callback before process teardown completes", async () => {
const fixture = harness({ turn: ({ tool }) => tool(), ignoreTerm: true });
let toolSignal;
await assert.rejects(fixture.run({ callTool: (_name, _input, signal) => {
toolSignal = signal;
fixture.child.stdout.write(`${JSON.stringify({ id: "approval", method: "item/commandExecution/requestApproval", params: {} })}\n`);
assert.equal(signal.aborted, true);
assert.deepEqual(fixture.kills, []);
return new Promise(() => {});
} }), { code: "AGENT_TOOL_DENIED" });
assert.equal(toolSignal.aborted, true);
await cleaned(fixture);
});
test("Codex rejects malformed JSON and handles early child exit without waiting for its deadline", async () => {
const malformed = harness({ receive(message) {
if (message.method !== "initialize") return false;
malformed.child.stdout.write("{private-invalid-json\n"); return true;
} });
await assert.rejects(malformed.run(), { code: "AGENT_OUTPUT_INVALID" });
await cleaned(malformed);
const exited = harness({ receive(message) {
if (message.method !== "initialize") return false;
exited.child.emit("close", 1, null); return true;
} });
await assert.rejects(exited.run(), { code: "AGENT_RUNTIME_UNAVAILABLE" });
await assert.rejects(access(dirname(exited.captured.options.cwd)), { code: "ENOENT" });
});
test("Codex cleanup kills same-group descendants after their parent exits", { skip: process.platform === "win32" }, async () => {
let descendant;
let parent;
const descendantScript = "process.on('SIGTERM',()=>{});process.stdout.write(String(process.pid));setInterval(()=>{},1000);";
const parentScript = `
const child = require('child_process').spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], {stdio:['ignore','pipe','ignore']});
const send = value => process.stdout.write(JSON.stringify(value)+'\\n');
child.stdout.once('data', data => {
send({method:'fixture/descendant',params:{pid:Number(String(data))}});
require('readline').createInterface({input:process.stdin}).on('line', line => {
const message = JSON.parse(line);
if (message.method === 'initialize') send({id:message.id,result:{}});
else if (message.method === 'config/read') send({id:message.id,result:{config:${JSON.stringify(RUNTIME_CONFIG)}}});
else if (message.method === 'account/login/start') send({id:message.id,result:{type:'apiKey'}});
else if (message.method === 'account/read') send({id:message.id,result:{account:{type:'apiKey'}}});
else if (message.method === 'thread/start') send({id:message.id,result:{thread:{id:'thread-1'}}});
else if (message.method === 'turn/start') {
send({id:message.id,result:{turn:{id:'turn-1'}}});
send({method:'turn/completed',params:{threadId:'thread-1',turn:{id:'turn-1',status:'completed'}}});
}
});
});
setInterval(()=>{},1000);
`;
try {
await runCodexSession(CONFIG, { instructions: "Use no tools.", prompt: "Reply OK.", tools: [],
callTool: () => assert.fail("This local process fixture must not call a tool"), deadlineMs: Date.now() + 5_000 }, ENV,
{ terminateGraceMs: 20, spawn: (_command, _args, options) => {
parent = spawn(process.execPath, ["-e", parentScript], options);
let buffer = "";
parent.stdout.on("data", data => {
buffer += data.toString();
let newline;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
const message = JSON.parse(line);
if (message.method === "fixture/descendant") descendant = message.params.pid;
}
});
return parent;
} });
assert.ok(Number.isSafeInteger(descendant));
assert.notEqual(parent.exitCode ?? parent.signalCode, null);
let stopped = false;
for (let attempt = 0; attempt < 50; attempt++) {
try { process.kill(descendant, 0); }
catch (error) { if (error.code === "ESRCH") { stopped = true; break; } throw error; }
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.equal(stopped, true, "A descendant ignoring SIGTERM must not survive its parent");
} finally { if (descendant) try { process.kill(descendant, "SIGKILL"); } catch {} }
});
const subscription = (behavior = {}) => harness({ subscription: true, config: { ...RUNTIME_CONFIG, forced_login_method: "chatgpt" },
receive(message, { send }) {
if (message.method !== "account/read") return false;
send({ id: message.id, result: { account: { type: "chatgpt" } } }); return true;
}, ...behavior });
async function userCodexHome(t) {
const home = await mkdtemp(join(tmpdir(), "explore-test-codex-home-"));
t.after(() => rm(home, { recursive: true, force: true }));
await writeFile(join(home, "auth.json"), '{"tokens":"original"}', { mode: 0o600 });
await writeFile(join(home, "config.toml"), '[mcp_servers.external]\ncommand = "external"\n');
return home;
}
test("local Codex uses the subscription CLI without initiating API-key login", async () => {
const fixture = subscription();
const receipts = [];
await fixture.run({ onUsage: receipt => receipts.push(receipt) });
assert.equal(fixture.captured.command, "codex");
assert.equal(dirname(fixture.captured.options.env.CODEX_HOME), dirname(fixture.captured.options.cwd));
assert.equal(fixture.captured.options.env.OPENAI_API_KEY, undefined);
assert.equal(fixture.messages.some(m => m.method === "account/login/start"), false);
assert.equal(receipts[0].source, "codex-cli");
assert.equal(receipts[0].accounting, "unknown");
await cleaned(fixture);
});
test("local Codex runs in a private home holding only the user's login, never their MCP servers or plugins", async t => {
const home = await userCodexHome(t);
let runtime;
const fixture = subscription({ env: { CODEX_HOME: home }, spawned(_child, captured) {
runtime = readdirSync(captured.options.env.CODEX_HOME);
} });
await fixture.run();
assert.notEqual(fixture.captured.options.env.CODEX_HOME, home);
assert.deepEqual(runtime, ["auth.json"]);
assert.equal(await readFile(join(home, "auth.json"), "utf8"), '{"tokens":"original"}');
await cleaned(fixture);
});
test("local Codex writes a refreshed login back to the user's home, owner-only", async t => {
const home = await userCodexHome(t);
const fixture = subscription({ env: { CODEX_HOME: home }, spawned(_child, captured) {
writeFileSync(join(captured.options.env.CODEX_HOME, "auth.json"), '{"tokens":"refreshed"}');
} });
await fixture.run();
assert.equal(await readFile(join(home, "auth.json"), "utf8"), '{"tokens":"refreshed"}');
assert.equal((await stat(join(home, "auth.json"))).mode & 0o777, 0o600);
assert.deepEqual((await readdir(home)).sort(), ["auth.json", "config.toml"]);
await cleaned(fixture);
});
test("local Codex keeps a login the user changed while the session ran", async t => {
const home = await userCodexHome(t);
const fixture = subscription({ env: { CODEX_HOME: home }, spawned(_child, captured) {
writeFileSync(join(captured.options.env.CODEX_HOME, "auth.json"), '{"tokens":"refreshed"}');
writeFileSync(join(home, "auth.json"), '{"tokens":"relogin"}');
} });
await fixture.run();
assert.equal(await readFile(join(home, "auth.json"), "utf8"), '{"tokens":"relogin"}');
await cleaned(fixture);
});
test("usage persistence failure cannot skip Codex subprocess cleanup", async () => {
const fixture = harness();
await assert.rejects(fixture.run({ onUsage() { throw new Error("receipt write failed"); } }), /receipt write failed/);
await cleaned(fixture);
});