Files
nexu-io__open-design/apps/daemon/tests/codex-session-resume.test.ts
open-design-crew[bot] 4908baa4f4 feat(design-systems): ship structured runtime workflow (#6805)
* feat(design-systems): add structured runtime contract (#6456)

* feat(design-systems): add structured runtime contract

* chore: retrigger CI for PR 6456

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>

* feat(design-systems): resolve structured runtime intents

* fix(design-systems): preserve workspace runtime scope

* test(design-systems): cover scoped prompt runtime

* fix(design-systems): use one component authority

* fix(design-systems): preserve run workspace authority

* fix(design-systems): honor runtime rollback gate

* fix(design-systems): pin run binding authority

* fix(runs): reject client-supplied scopes

* fix(design-systems): keep user package assets scoped

* feat(design-systems): validate generated UI adherence

* test(e2e): assert durable reload authority state

* fix(design-systems): reject non-executable adherence evidence

* fix: harden design system adherence validation

* fix(design-systems): validate JSX selectors structurally

* fix(design-systems): close adherence input gaps

* chore(nix): refresh pnpm dependency hashes

* chore(ci): retrigger design system validation

* fix(daemon): bound design system artifact reads

* fix(design-systems): bridge Codex runtime tools

* docs(codex): clarify shell environment policy

---------

Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-08-13 09:02:20 +00:00

782 lines
32 KiB
TypeScript

import type { Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
import { writeMcpConfig } from '../src/mcp-config.js';
import { clearToken, setToken } from '../src/mcp-tokens.js';
import { runHadFailedDesignSystemWrapper } from '../src/runtimes/run-artifacts.js';
// End-to-end coverage for codex native (capture-style) session resume.
//
// codex mints its OWN session id and reports it on the first stream event
// `{"type":"thread.started","thread_id":...}`. The daemon must:
// 1. capture that thread id from the stream and persist it (NOT the
// daemon-minted `newSessionId`, which codex ignores),
// 2. on the next turn in the same conversation, resume with
// `codex exec resume <thread_id>` and send ONLY the new turn (no
// flattened-history resend), and
// 3. when the stored thread is gone (`no rollout found for thread id`),
// clear the stale handle, surface a retryable error, and let the next
// turn start a fresh session re-seeded with the full transcript.
//
// On origin/main codex is not `resumesSessionViaCli`, so it always runs plain
// `exec` and re-pays the whole transcript — turn-2 here would carry no
// `resume` and would include the prior reply, going red.
type StartedServer = {
url: string;
server: Server;
shutdown?: () => Promise<void> | void;
};
type RunStatus = {
id: string;
status: string;
error: string | null;
errorCode: string | null;
eventsLogPath: string;
resumable?: boolean;
};
type ExecInvocation = { argv: string[]; stdin: string; cwd: string };
type RunEvent = { event: string; data: unknown };
const THREAD = '019eef4f-0000-7000-8000-000000000abc';
const FIRST_REPLY_SENTINEL = 'FIRST_TURN_REPLY_SENTINEL_8af31';
describe('codex native session resume', () => {
const originalEnv = snapshotEnv();
let started: StartedServer | null = null;
let binDir: string | null = null;
afterEach(async () => {
await Promise.resolve(started?.shutdown?.());
if (started?.server) {
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
}
started = null;
if (binDir) await removeTempDir(binDir);
binDir = null;
restoreEnv(originalEnv);
// The MCP-directive red-spec below writes an authenticated `github` server
// into the process-wide OD_DATA_DIR (tests/setup.ts shares one root for the
// whole daemon Vitest run). Reset it after every test so that state cannot
// leak into later test files and make them suite-order dependent.
// `writeMcpConfig` unconditionally overwrites to an empty server list and
// `clearToken` is a documented no-op when the entry is absent, so neither
// needs to tolerate "nothing was written" — let a real teardown failure
// surface instead of silently degrading the isolation guarantee.
const dataDir = process.env.OD_DATA_DIR;
if (dataDir) {
await writeMcpConfig(dataDir, { servers: [] });
await clearToken(dataDir, 'github');
}
});
it('captures the thread id on turn 1 and resumes it (without resending history) on turn 2', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-resume-bin-'));
const { bin, logPath } = await writeCapturingCodex(binDir, 'codex-capture');
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: { codex: { CODEX_BIN: bin, CODEX_HOME: binDir } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
const turn1 = await sendRunAndWait(started.url, conversationId, 'first user request');
expect(turn1.status).toBe('succeeded');
const turn2 = await sendRunAndWait(started.url, conversationId, 'second user request please');
expect(turn2.status).toBe('succeeded');
// Filter to this conversation's chat-turn `exec` calls (cwd points at its
// project dir). codex is also used as the daemon's background memory-llm and
// for `login status` / `debug models` probes — those run from the repo root
// and must not be counted as chat turns.
const runs = await readChatTurnExecs(logPath, conversationId);
expect(runs).toHaveLength(2);
const [create, resume] = runs as [ExecInvocation, ExecInvocation];
// Turn 1 is a fresh `exec` — no resume, no leaked id.
expect(create.argv).toContain('exec');
expect(create.argv).not.toContain('resume');
expect(create.argv).not.toContain(THREAD);
// Turn 2 resumes the captured thread id as the trailing positional arg.
expect(resume.argv.slice(0, 2)).toEqual(['exec', 'resume']);
expect(resume.argv[resume.argv.length - 1]).toBe(THREAD);
// Resume must pass sandbox via `-c`, never the `--sandbox` flag (rejected
// by `exec resume`).
expect(resume.argv).not.toContain('--sandbox');
// The turn-2 prompt must NOT re-send turn-1's assistant reply (history is
// carried by the resumed session), but must carry the new user message.
expect(resume.stdin).not.toContain(FIRST_REPLY_SENTINEL);
expect(resume.stdin).toContain('second user request please');
});
it('transparently auto-reseeds within the same turn on `no rollout found`', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-fallback-bin-'));
const { bin, logPath } = await writeMissingRolloutCodex(binDir, 'codex-fallback');
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: { codex: { CODEX_BIN: bin, CODEX_HOME: binDir } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
// Turn 1 creates + persists the thread.
const turn1 = await sendRunAndWait(started.url, conversationId, 'first request');
expect(turn1.status).toBe('succeeded');
// Turn 2 resumes, but the rollout is gone (`no rollout found`). The daemon
// must NOT surface an error: it clears the dead thread and TRANSPARENTLY
// re-runs the same turn as a fresh `exec` (full transcript), within this one
// run. The user sees a succeeded turn — no "resend" prompt, no turn 3.
const turn2 = await sendRunAndWait(started.url, conversationId, 'second request');
expect(turn2.status).toBe('succeeded');
const events = await readRunEvents(turn2.eventsLogPath);
expect(events.filter((e) => e.event === 'error')).toEqual([]);
expect(hasDiagnostic(events, {
type: 'agent_resume_auto_reseed',
reason: 'resume_failed',
stale_session_cleared: true,
})).toBe(true);
// Two execs happened inside turn 1+2: the create, then turn 2's dead
// `exec resume`, then the in-turn fresh `exec` reseed.
const runs = await readChatTurnExecs(logPath, conversationId);
expect(runs).toHaveLength(3);
const [create, deadResume, fresh] = runs as [
ExecInvocation,
ExecInvocation,
ExecInvocation,
];
expect(create.argv).not.toContain('resume');
expect(deadResume.argv.slice(0, 2)).toEqual(['exec', 'resume']);
expect(fresh.argv).not.toContain('resume');
});
it('starts fresh on turn 2 when turn 1 succeeds without a captured thread id', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-nohandle-bin-'));
const { bin, logPath } = await writeNoHandleCodex(binDir, 'codex-nohandle');
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: { codex: { CODEX_BIN: bin, CODEX_HOME: binDir } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
expect((await sendRunAndWait(started.url, conversationId, 'first request')).status)
.toBe('succeeded');
expect((await sendRunAndWait(started.url, conversationId, 'second request')).status)
.toBe('succeeded');
const runs = await readChatTurnExecs(logPath, conversationId);
expect(runs).toHaveLength(2);
const [turn1, turn2] = runs as [ExecInvocation, ExecInvocation];
expect(turn1.argv).not.toContain('resume');
expect(turn2.argv[0]).toBe('exec');
expect(turn2.argv).not.toContain('resume');
});
it.runIf(process.platform !== 'win32')(
'executes the active DS resolver through the Codex shell environment policy',
async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-ds-shell-bin-'));
const { bin, logPath } = await writeExecutingDesignSystemWrapperCodex(
binDir,
'codex-ds-shell-success',
);
// These values reach the Codex process but must not cross its shell-tool
// boundary. The run-scoped OD wrapper variables below are supplied by
// the daemon and are the only non-baseline values the wrapper needs.
process.env.SHOULD_NOT_LEAK = 'unrelated-secret';
process.env.OD_API_TOKEN = 'unrelated-api-token';
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: { codex: { CODEX_BIN: bin, CODEX_HOME: binDir } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url, {
designSystemId: 'hud',
});
const run = await sendRunAndWait(
started.url,
conversationId,
'Resolve the account settings component with the active design system.',
'codex',
'hud',
);
const [invocation] = await readChatTurnExecs(logPath, conversationId);
expect(invocation?.argv).toContain('allow_login_shell=false');
const includeOnly = invocation?.argv.find((arg) =>
arg.startsWith('shell_environment_policy.include_only='),
);
expect(includeOnly).toContain('"OD_NODE_BIN"');
expect(includeOnly).toContain('"OD_BIN"');
expect(includeOnly).toContain('"OD_TOOL_TOKEN"');
expect(includeOnly).not.toContain('OD_API_TOKEN');
const events = await readRunEvents(run.eventsLogPath);
expect(runHadFailedDesignSystemWrapper(events)).toBe(false);
expect(JSON.stringify(events)).toContain('account.settings');
expect(JSON.stringify(events)).toContain('unrelatedCredentialPresent\\":false');
expect(run.status).toBe('succeeded');
},
);
it('fails a zero-artifact structured DS turn when the Codex wrapper shell command failed', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-ds-wrapper-bin-'));
const { bin, logPath } = await writeFailedDesignSystemWrapperCodex(
binDir,
'codex-ds-wrapper-failure',
);
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: { codex: { CODEX_BIN: bin, CODEX_HOME: binDir } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url, {
designSystemId: 'hud',
});
const run = await sendRunAndWait(
started.url,
conversationId,
'Build an account settings page with the active design system.',
'codex',
'hud',
);
const [invocation] = await readChatTurnExecs(logPath, conversationId);
expect(invocation?.stdin).toContain('tools design-systems resolve');
const events = await readRunEvents(run.eventsLogPath);
expect(runHadFailedDesignSystemWrapper(events)).toBe(true);
expect(run.status).toBe('failed');
});
it('reseeds (no resume) when another agent ran in the conversation between codex turns', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-intervene-bin-'));
const { bin, logPath } = await writeCapturingCodex(binDir, 'codex-intervene');
const claudeBin = await writeMinimalClaude(binDir, 'claude-intervene');
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: {
codex: { CODEX_BIN: bin, CODEX_HOME: binDir },
claude: { CLAUDE_BIN: claudeBin },
},
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
// Turn 1: codex creates + persists its session.
expect((await sendRunAndWait(started.url, conversationId, 'codex first', 'codex')).status)
.toBe('succeeded');
// Turn 2: a DIFFERENT agent runs, adding a completed assistant turn that the
// codex session never saw.
expect((await sendRunAndWait(started.url, conversationId, 'now claude', 'claude')).status)
.toBe('succeeded');
// Turn 3: codex returns. Its stored session is behind, so the daemon must
// start a fresh `exec` (and reseed the full transcript) — NOT resume the
// stale session and silently drop the intervening claude turn.
expect((await sendRunAndWait(started.url, conversationId, 'codex again', 'codex')).status)
.toBe('succeeded');
const runs = await readChatTurnExecs(logPath, conversationId);
expect(runs).toHaveLength(2); // only the two codex turns hit the codex bin
const [create, afterIntervening] = runs as [ExecInvocation, ExecInvocation];
expect(create.argv).not.toContain('resume');
// The headline guard (issue raised by @nettee): after a different agent
// completed a turn the codex session never saw, codex must NOT resume that
// stale session — it starts a fresh `exec` so the run is reseeded with the
// full transcript instead of silently dropping the intervening turn.
expect(afterIntervening.argv).not.toContain('resume');
expect(afterIntervening.argv[0]).toBe('exec');
});
// Guards the fix that moved the connected-external-MCP directive out of the
// cached `daemonSystemPrompt` and into the per-turn instruction slice. The
// directive reflects live OAuth Bearer validity, so keeping it in the cached
// prefix churned the whole prompt-cache prefix (history included) whenever a
// token expired mid-conversation. Now it must ride in the per-turn slice, i.e.
// be re-sent on EVERY turn — including a clean resume, which never re-sends the
// cached stable block. On origin/main the directive lived in the stable block,
// so a clean resume dropped it and the turn-2 assertion below goes red.
it('re-sends the connected-MCP directive in the per-turn slice on resume turns', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-mcp-bin-'));
const { bin, logPath } = await writeCapturingCodex(binDir, 'codex-mcp');
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'codex',
agentCliEnv: { codex: { CODEX_BIN: bin, CODEX_HOME: binDir } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
// A connected external MCP server = enabled config + a live (non-expired)
// OAuth Bearer. That is exactly what makes the daemon render the
// "already authenticated" directive.
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for the MCP directive test');
await writeMcpConfig(dataDir, {
servers: [
{
id: 'github',
label: 'GitHub',
transport: 'http',
url: 'https://mcp.test.invalid/github',
enabled: true,
},
],
});
await setToken(dataDir, 'github', {
accessToken: 'live-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3_600_000,
savedAt: Date.now(),
});
const conversationId = await createConversation(started.url);
const turn1 = await sendRunAndWait(started.url, conversationId, 'first user request');
expect(turn1.status).toBe('succeeded');
const turn2 = await sendRunAndWait(
started.url,
conversationId,
'second user request please',
);
expect(turn2.status).toBe('succeeded');
const runs = await readChatTurnExecs(logPath, conversationId);
expect(runs).toHaveLength(2);
const [create, resume] = runs as [ExecInvocation, ExecInvocation];
const MCP_MARKER = 'External MCP servers — already authenticated';
// A marker that only appears inside the cached stable block (daemonSystemPrompt).
const STABLE_BLOCK_MARKER = '# Identity and workflow charter (background)';
// Turn 1 (fresh seed) carries the directive.
expect(create.stdin).toContain(MCP_MARKER);
expect(create.stdin).toContain('`github`');
// Turn 2 is a clean resume: the cached stable block is NOT re-sent...
expect(resume.argv.slice(0, 2)).toEqual(['exec', 'resume']);
expect(resume.stdin).not.toContain(STABLE_BLOCK_MARKER);
// ...but the MCP directive IS, because it now lives in the per-turn slice.
expect(resume.stdin).toContain(MCP_MARKER);
expect(resume.stdin).toContain('`github`');
});
});
// Minimal fake Claude CLI (claude-stream-json): emits an init frame + one
// assistant text reply and exits 0. Used only to inject an intervening
// completed turn from a different agent.
async function writeMinimalClaude(dir: string, name: string): Promise<string> {
const bin = path.join(dir, name);
await writeFile(
bin,
`#!/usr/bin/env node
const argv = process.argv.slice(2);
if (argv.includes('--version')) { console.log('claude-code 1.0.0-intervene'); process.exit(0); }
if (argv.includes('--help')) { console.log('Usage: claude -p'); process.exit(0); }
console.log(JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-intervene' }));
console.log(JSON.stringify({ type: 'assistant', message: { id: 'm1', content: [{ type: 'text', text: 'CLAUDE_INTERVENING_REPLY' }], stop_reason: 'end_turn' } }));
setTimeout(() => process.exit(0), 10);
`,
'utf8',
);
await chmod(bin, 0o755);
return bin;
}
// Fake codex CLI: mints a FIXED thread id on a create turn and echoes it on a
// resume turn. Logs `{argv, stdin}` per invocation so the test can assert the
// resume shape and the skipped transcript.
async function writeCapturingCodex(
dir: string,
name: string,
): Promise<{ bin: string; logPath: string }> {
const bin = path.join(dir, name);
const logPath = path.join(dir, `${name}-log.jsonl`);
await writeFile(
bin,
fakeCodexSource({
logPath,
body: `
const isResume = argv.includes('resume');
console.log(JSON.stringify({ type: 'thread.started', thread_id: THREAD }));
console.log(JSON.stringify({ type: 'turn.started' }));
const text = isResume ? 'Resumed reply.' : ${JSON.stringify(FIRST_REPLY_SENTINEL)};
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'item-1', type: 'agent_message', text } }));
console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 10, cached_input_tokens: 4, output_tokens: 3 } }));
setTimeout(() => process.exit(0), 10);`,
}),
'utf8',
);
await chmod(bin, 0o755);
return { bin, logPath };
}
// Fake codex CLI: succeeds on a create turn (persisting the thread), but a
// resume turn fails with codex's real "no rollout found" error and exits 1.
async function writeMissingRolloutCodex(
dir: string,
name: string,
): Promise<{ bin: string; logPath: string }> {
const bin = path.join(dir, name);
const logPath = path.join(dir, `${name}-log.jsonl`);
await writeFile(
bin,
fakeCodexSource({
logPath,
body: `
if (argv.includes('resume')) {
process.stderr.write('Error: thread/resume: thread/resume failed: no rollout found for thread id ' + THREAD + '\\n');
setTimeout(() => process.exit(1), 10);
return;
}
console.log(JSON.stringify({ type: 'thread.started', thread_id: THREAD }));
console.log(JSON.stringify({ type: 'turn.started' }));
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'item-1', type: 'agent_message', text: 'Created reply.' } }));
console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 8, cached_input_tokens: 0, output_tokens: 2 } }));
setTimeout(() => process.exit(0), 10);`,
}),
'utf8',
);
await chmod(bin, 0o755);
return { bin, logPath };
}
// Fake codex CLI: succeeds but never reports `thread.started.thread_id`.
// Without a durable handle, the daemon must leave the next turn on fresh `exec`.
async function writeNoHandleCodex(
dir: string,
name: string,
): Promise<{ bin: string; logPath: string }> {
const bin = path.join(dir, name);
const logPath = path.join(dir, `${name}-log.jsonl`);
await writeFile(
bin,
fakeCodexSource({
logPath,
body: `
console.log(JSON.stringify({ type: 'thread.started' }));
console.log(JSON.stringify({ type: 'turn.started' }));
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'item-1', type: 'agent_message', text: 'Reply without thread id.' } }));
console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 8, cached_input_tokens: 0, output_tokens: 2 } }));
setTimeout(() => process.exit(0), 10);`,
}),
'utf8',
);
await chmod(bin, 0o755);
return { bin, logPath };
}
async function writeFailedDesignSystemWrapperCodex(
dir: string,
name: string,
): Promise<{ bin: string; logPath: string }> {
const bin = path.join(dir, name);
const logPath = path.join(dir, `${name}-log.jsonl`);
await writeFile(
bin,
fakeCodexSource({
logPath,
body: `
const command = '\"$OD_NODE_BIN\" \"$OD_BIN\" tools design-systems resolve --intent account.settings';
console.log(JSON.stringify({ type: 'thread.started', thread_id: THREAD }));
console.log(JSON.stringify({ type: 'turn.started' }));
console.log(JSON.stringify({ type: 'item.started', item: { id: 'resolve-1', type: 'command_execution', command } }));
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'resolve-1', type: 'command_execution', command, aggregated_output: 'zsh:1: permission denied', exit_code: 126, status: 'failed' } }));
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'item-1', type: 'agent_message', text: 'I could not access the design-system resolver, so I did not create files.' } }));
console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 8, cached_input_tokens: 0, output_tokens: 10 } }));
setTimeout(() => process.exit(0), 10);`,
}),
'utf8',
);
await chmod(bin, 0o755);
return { bin, logPath };
}
async function writeExecutingDesignSystemWrapperCodex(
dir: string,
name: string,
): Promise<{ bin: string; logPath: string }> {
const bin = path.join(dir, name);
const logPath = path.join(dir, `${name}-log.jsonl`);
await writeFile(
bin,
fakeCodexSource({
logPath,
body: `
const includeOnly = argv.find((arg) => arg.startsWith('shell_environment_policy.include_only='));
const includedKeys = new Set([...(includeOnly || '').matchAll(/"([A-Z][A-Z0-9_]*)"/g)].map((match) => match[1]));
const toolEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => includedKeys.has(key)));
const unrelatedCredentialPresent = Boolean(toolEnv.OPENAI_API_KEY || toolEnv.OD_API_TOKEN || toolEnv.SHOULD_NOT_LEAK);
const command = '"$OD_NODE_BIN" "$OD_BIN" tools design-systems resolve --intent account.settings';
const result = require('node:child_process').spawnSync('/bin/sh', ['-c', command], {
env: toolEnv,
encoding: 'utf8',
});
const output = JSON.stringify({
stdout: result.stdout,
stderr: result.stderr,
unrelatedCredentialPresent,
});
const failed = result.status !== 0 || unrelatedCredentialPresent;
console.log(JSON.stringify({ type: 'thread.started', thread_id: THREAD }));
console.log(JSON.stringify({ type: 'turn.started' }));
console.log(JSON.stringify({ type: 'item.started', item: { id: 'resolve-1', type: 'command_execution', command } }));
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'resolve-1', type: 'command_execution', command, aggregated_output: output, exit_code: failed ? (result.status || 1) : 0, status: failed ? 'failed' : 'completed' } }));
console.log(JSON.stringify({ type: 'item.completed', item: { id: 'item-1', type: 'agent_message', text: output } }));
console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 8, cached_input_tokens: 0, output_tokens: 10 } }));
setTimeout(() => process.exit(0), 10);`,
}),
'utf8',
);
await chmod(bin, 0o755);
return { bin, logPath };
}
function fakeCodexSource(opts: { logPath: string; body: string }): string {
return `#!/usr/bin/env node
const fs = require('node:fs');
const logPath = ${JSON.stringify(opts.logPath)};
const THREAD = ${JSON.stringify(THREAD)};
const argv = process.argv.slice(2);
if (argv.includes('--version')) { console.log('codex-cli 0.133.0'); process.exit(0); }
if (argv.includes('--help')) { console.log('Usage: codex exec [--sandbox MODE]'); process.exit(0); }
let stdin = '';
let done = false;
function finish() {
if (done) return; done = true;
try { fs.appendFileSync(logPath, JSON.stringify({ argv, stdin, cwd: process.cwd() }) + '\\n'); } catch {}
run();
}
function run() {
// Faithful to the real CLI: codex exec resume rejects the create-only
// -C / --add-dir flags. If the daemon ever appends them on a resume turn,
// die exactly like the real binary so the e2e catches the regression.
if (argv.includes('resume') && (argv.includes('-C') || argv.includes('--add-dir'))) {
process.stderr.write("error: unexpected argument '-C' found\\n");
setTimeout(() => process.exit(2), 10);
return;
}${opts.body}
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', (d) => { stdin += d; });
process.stdin.on('end', finish);
process.stdin.on('error', finish);
setTimeout(finish, 1500);
`;
}
function snapshotEnv(): Record<string, string | undefined> {
return {
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
LANGFUSE_BASE_URL: process.env.LANGFUSE_BASE_URL,
OPEN_DESIGN_TELEMETRY_RELAY_URL: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL,
POSTHOG_KEY: process.env.POSTHOG_KEY,
POSTHOG_HOST: process.env.POSTHOG_HOST,
OD_API_TOKEN: process.env.OD_API_TOKEN,
SHOULD_NOT_LEAK: process.env.SHOULD_NOT_LEAK,
};
}
function restoreEnv(env: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
function clearTelemetryEnv(): void {
delete process.env.POSTHOG_KEY;
delete process.env.POSTHOG_HOST;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_BASE_URL;
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
}
async function putConfig(url: string, patch: Record<string, unknown>): Promise<void> {
const response = await fetch(`${url}/api/app-config`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
});
expect(response.status).toBe(200);
}
async function createConversation(
url: string,
options: { designSystemId?: string } = {},
): Promise<string> {
const projectId = `codex_resume_${randomUUID()}`;
const projectResponse = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Codex resume smoke',
metadata: { kind: 'prototype' },
...(options.designSystemId
? { designSystemId: options.designSystemId }
: {}),
skipDiscoveryBrief: true,
}),
});
expect(projectResponse.status).toBe(200);
const projectBody = (await projectResponse.json()) as {
conversationId: string;
id: string;
};
return `${projectId}::${projectBody.conversationId}`;
}
async function sendRunAndWait(
url: string,
encoded: string,
message: string,
agentId = 'codex',
designSystemId?: string,
): Promise<RunStatus> {
const [projectId, conversationId] = encoded.split('::');
const assistantMessageId = `assistant_codex_${randomUUID()}`;
const runResponse = await fetch(`${url}/api/runs`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-od-analytics-device-id': 'codex-resume-test',
'x-od-analytics-session-id': 'codex-resume-session',
'x-od-analytics-client-type': 'web',
},
body: JSON.stringify({
projectId,
conversationId,
assistantMessageId,
clientRequestId: `client_codex_${randomUUID()}`,
agentId,
...(designSystemId ? { designSystemId } : {}),
message,
currentPrompt: message,
}),
});
expect(runResponse.status).toBe(202);
const body = (await runResponse.json()) as { runId: string };
return await waitForRun(url, body.runId);
}
async function waitForRun(url: string, runId: string): Promise<RunStatus> {
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
const response = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`);
expect(response.status).toBe(200);
const run = (await response.json()) as RunStatus;
if (run.status === 'failed' || run.status === 'succeeded' || run.status === 'canceled') {
return run;
}
await delay(100);
}
throw new Error(`run ${runId} did not finish`);
}
// Chat-turn `exec` invocations for this conversation, in call order. Identified
// by the chat turn's cwd (the project working dir, which contains the project
// id) — this drops the background memory-llm exec (repo-root cwd) and the
// login/models probes. The cwd is set via the spawn, not an argv flag, and on a
// resume turn there is no `-C` to key off, so cwd is the reliable discriminator.
async function readChatTurnExecs(
logPath: string,
encoded: string,
): Promise<ExecInvocation[]> {
const projectId = encoded.split('::')[0] ?? encoded;
let raw = '';
try {
raw = await readFile(logPath, 'utf8');
} catch {
return [];
}
return raw
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as ExecInvocation)
.filter(
(rec) =>
rec.argv[0] === 'exec' &&
typeof rec.cwd === 'string' &&
rec.cwd.includes(projectId),
);
}
async function readRunEvents(eventsLogPath: string): Promise<RunEvent[]> {
let raw = '';
try {
raw = await readFile(eventsLogPath, 'utf8');
} catch {
return [];
}
return raw
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as RunEvent);
}
function hasDiagnostic(events: RunEvent[], expected: Record<string, unknown>): boolean {
return events.some((event) => {
if (event.event !== 'diagnostic' || !event.data || typeof event.data !== 'object') {
return false;
}
return Object.entries(expected).every(
([key, value]) => (event.data as Record<string, unknown>)[key] === value,
);
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function removeTempDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}