SPB Git

Repository Public

codex.test.ts 42.1 KB 718 lines typescript

1/**2 * Codex profile (CR_AGENT=codex): argv rules, parsers, config.toml notify install, notify relay,3 * profile-aware routes, and a real-tmux launch of the real `codex` binary (no input sent).4 * CODEX_HOME always points at a temp dir: the real ~/.codex is never read or written.5 */6import fs from 'node:fs';7import os from 'node:os';8import path from 'node:path';9import { execFileSync, spawn } from 'node:child_process';10import { fileURLToPath } from 'node:url';11import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';12import type { ClaudeConversation, McpServerInfo, RtSessionInfo, SkillInfo } from '@cr/shared';13import type { Runtime } from './main.js';14import { agentHooksRequested, loadConfig } from './config.js';15import { buildCodexArgs, findForbiddenCodexArg, findInvalidCodexArgs } from './codex-args.js';16import { listCodexSessions, parseCodexLoginStatus, parseCodexVersion, parseRolloutMeta, scanCodexSkills, summarizeRollout } from './codex.js';17import { buildCodexAddArgv, buildCodexGetArgv, buildCodexListArgv, buildCodexRemoveArgv, parseCodexMcpList, validateCodexAdd } from './codex-mcp.js';18import { installCodexNotify, parseTomlStringArray, uninstallCodexNotify } from './codex-notify.js';19import { parseHookRequest } from './hooks.js';20import { createClaudePlugin, type ClaudeInfo } from './claude.js';21import { createSkillsPlugin } from './skills.js';22import { createMcpPlugin } from './mcp.js';23import { isolatedClaudeEnv } from './fsx-claude-env.js';24import { buildApp, call, errCode, makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';25import { collectEvents, makeTestEnv, request, sessionSpec, startRuntime, waitUntil } from './core-testkit.js';26import { XtermClient } from './core-xterm-testkit.js';27import type { AddSpec } from './fsx-mcp-config.js';2829const NOTIFY_SCRIPT = fileURLToPath(new URL('../bin/cr-codex-notify', import.meta.url));30const UUID_A = '01a1899f-6f8e-7631-826e-24fb75e51d5a';31const UUID_B = '01a18d61-b3e6-7231-aea5-98f7e593c292';32const UUID_C = '01a1c65c-2374-7d83-a684-379f4cb817e2';3334function findCodex(): string | null {35  try {36    const p = execFileSync(process.env.SHELL ?? '/bin/zsh', ['-lc', 'command -v codex'], { encoding: 'utf8' }).trim().split('\n').pop() ?? '';37    return p.startsWith('/') ? p : null;38  } catch {39    return null;40  }41}4243describe('profile configuration', () => {44  const base = { CR_RUNTIME_TOKEN: 'x'.repeat(40), HOME: '/Users/u' };45  it('defaults to the claude profile exactly as before', () => {46    const c = loadConfig({ ...base });47    expect(c.agent).toBe('claude');48    expect(c.claudeBin).toBe('claude');49    expect(c.tmuxSocketName).toBe('claude-remote');50    expect(c.dataDir).toBe(path.join(os.homedir(), 'claude-remote-data'));51    expect(loadConfig({ ...base, CR_CLAUDE_BIN: '/x/claude' }).claudeBin).toBe('/x/claude');52    expect(loadConfig({ ...base, CR_AGENT: 'claude', CR_AGENT_BIN: '/y/claude', CR_CLAUDE_BIN: '/x/claude' }).claudeBin).toBe('/y/claude');53  });54  it('codex profile: own binary, tmux socket and data dir; CR_CLAUDE_BIN is ignored', () => {55    const c = loadConfig({ ...base, CR_AGENT: 'codex', CR_CLAUDE_BIN: '/x/claude' });56    expect(c.agent).toBe('codex');57    expect(c.claudeBin).toBe('codex');58    expect(c.tmuxSocketName).toBe('codex-remote');59    expect(c.dataDir).toBe(path.join(os.homedir(), 'codex-remote-data'));60    const d = loadConfig({ ...base, CR_AGENT: 'codex', CR_AGENT_BIN: '/opt/homebrew/bin/codex', CR_TMUX_SOCKET: 'custom' });61    expect(d.claudeBin).toBe('/opt/homebrew/bin/codex');62    expect(d.tmuxSocketName).toBe('custom');63  });64  it('rejects an unknown CR_AGENT', () => {65    expect(() => loadConfig({ ...base, CR_AGENT: 'gemini' })).toThrow(/CR_AGENT/);66  });67  it('hook installation flag: CR_INSTALL_AGENT_HOOKS, with CR_INSTALL_CLAUDE_HOOKS as a claude-only alias', () => {68    expect(agentHooksRequested('claude', {})).toBe(false);69    expect(agentHooksRequested('claude', { CR_INSTALL_CLAUDE_HOOKS: '1' })).toBe(true);70    expect(agentHooksRequested('claude', { CR_INSTALL_AGENT_HOOKS: '1' })).toBe(true);71    expect(agentHooksRequested('codex', { CR_INSTALL_AGENT_HOOKS: '1' })).toBe(true);72    expect(agentHooksRequested('codex', { CR_INSTALL_CLAUDE_HOOKS: '1' })).toBe(false);73    expect(agentHooksRequested('codex', { CR_INSTALL_AGENT_HOOKS: 'true' })).toBe(false);74  });75});7677describe('codex argv safety (unit)', () => {78  it('rejects every approval / sandbox bypass flag of codex-cli 0.166', () => {79    for (const args of [80      ['--dangerously-bypass-approvals-and-sandbox'],81      ['--yolo'],82      ['resume', '--last', '--yolo'],83      ['--dangerously-bypass-hook-trust'],84      ['--approve-for-me'],85      ['--not-so-yolo'],86      ['--full-auto'],87      ['--dangerously-bypass-approvals-and-sandbox=true'],88      ['--sandbox', 'danger-full-access'],89      ['--sandbox=danger-full-access'],90      ['-s', 'danger-full-access'],91      ['-sdanger-full-access'],92      ['--ask-for-approval', 'never'],93      ['-a', 'never'],94      ['-anever'],95      ['--some-future-bypass-flag'],96    ]) {97      expect(findForbiddenCodexArg(args), args.join(' ')).not.toBeNull();98    }99  });100  it('rejects every configuration override form', () => {101    for (const args of [102      ['-c', 'approval_policy="never"'],103      ['-capproval_policy="never"'],104      ['--config', 'sandbox_mode="danger-full-access"'],105      ['--config=sandbox_mode="danger-full-access"'],106      ['--enable', 'x'],107      ['--disable', 'hooks'],108      ['-p', 'loose'],109      ['--profile', 'loose'],110      ['--profile=loose'],111      ['--remote', 'ws://evil'],112    ]) {113      expect(findForbiddenCodexArg(args), args.join(' ')).not.toBeNull();114    }115  });116  it('accepts harmless options and stops at "--"', () => {117    expect(findForbiddenCodexArg([])).toBeNull();118    expect(findForbiddenCodexArg(['resume', '--last'])).toBeNull();119    expect(findForbiddenCodexArg(['resume', UUID_A])).toBeNull();120    expect(findForbiddenCodexArg(['--sandbox', 'read-only'])).toBeNull();121    expect(findForbiddenCodexArg(['-a', 'on-request'])).toBeNull();122    expect(findForbiddenCodexArg(['--', '--yolo'])).toBeNull(); // a positional, refused by the shape rule below123  });124  it('only allows the three shapes the server produces (a free positional would be a PROMPT)', () => {125    expect(findInvalidCodexArgs([])).toBeNull();126    expect(findInvalidCodexArgs(['resume', '--last'])).toBeNull();127    expect(findInvalidCodexArgs(['resume', UUID_A])).toBeNull();128    for (const args of [129      ['write me a poem'],130      ['resume'],131      ['resume', 'my-session-name'],132      ['resume', '--all'],133      ['resume', UUID_A, 'and then do this'],134      ['resume', '--last', UUID_A],135      ['--sandbox', 'read-only'],136      ['--', '--yolo'],137      ['exec', 'rm -rf /'],138      ['fork', UUID_A],139    ]) {140      expect(findInvalidCodexArgs(args), args.join(' ')).not.toBeNull();141    }142  });143  it('builds resume arguments', () => {144    expect(buildCodexArgs({})).toEqual([]);145    expect(buildCodexArgs({ continueLast: true })).toEqual(['resume', '--last']);146    expect(buildCodexArgs({ resumeConversationId: UUID_A, continueLast: true })).toEqual(['resume', UUID_A]);147  });148});149150describe('codex CLI output parsers', () => {151  it('login status: real outputs of 0.166 (printed on stderr)', () => {152    expect(parseCodexLoginStatus('\nLogged in using ChatGPT\n')).toEqual({ authenticated: true, method: 'chatgpt' });153    expect(parseCodexLoginStatus('\nNot logged in\n')).toEqual({ authenticated: false, method: null });154    const api = parseCodexLoginStatus('Logged in using an API key - sk-proj-***ABCD');155    expect(api).toEqual({ authenticated: true, method: 'api_key' });156    expect(JSON.stringify(api)).not.toContain('sk-');157    expect(parseCodexLoginStatus('')).toEqual({ authenticated: null, method: null });158    expect(parseCodexLoginStatus('error: unexpected argument')).toEqual({ authenticated: null, method: null });159  });160  it('version', () => {161    expect(parseCodexVersion('codex-cli 0.166.0\n')).toBe('0.166.0');162    expect(parseCodexVersion('codex-cli 0.163.0-alpha.10.2')).toBe('0.163.0-alpha.10.2');163    expect(parseCodexVersion('nope')).toBeNull();164  });165  it('mcp list --json: real 0.166 shape, env VALUES never copied', () => {166    const stdout = JSON.stringify([167      {168        name: 't3',169        enabled: true,170        disabled_reason: null,171        transport: { type: 'stdio', command: 'npx', args: ['-y', 'some-server', '--api-key=sk-test1234567890'], env: { FOO: 'bar', API_KEY: 'sk-test123' }, env_vars: ['HOME_TOKEN'], cwd: null },172        startup_timeout_sec: null,173        tool_timeout_sec: null,174        auth_status: 'unsupported',175      },176      {177        name: 't2',178        enabled: true,179        disabled_reason: null,180        transport: { type: 'streamable_http', url: 'https://user:pw@example.com/mcp?key=SECRETQ', bearer_token_env_var: 'MY_TOK', http_headers: { Authorization: 'Bearer zzz' }, env_http_headers: { 'X-Key': 'KEY_ENV' }, http_headers_helper: null },181        auth_status: 'not_logged_in',182      },183      { name: 'off', enabled: false, disabled_reason: 'user', transport: { type: 'stdio', command: 'echo', args: [], env: null, env_vars: [], cwd: null }, auth_status: 'unsupported' },184      { name: 'broken' },185      'garbage',186    ]);187    const list = parseCodexMcpList(stdout, '2026-11-10T00:00:00.000Z') as McpServerInfo[];188    expect(list.map((s) => s.name)).toEqual(['off', 't2', 't3']);189    const t3 = list.find((s) => s.name === 't3') as McpServerInfo;190    expect(t3).toMatchObject({ scope: 'user', transport: 'stdio', status: 'unknown', lastError: null, checkedAt: '2026-11-10T00:00:00.000Z' });191    expect(t3.target).toBe('npx -y some-server --api-key=••••');192    expect(t3.envKeys).toEqual(['API_KEY', 'FOO', 'HOME_TOKEN']);193    const t2 = list.find((s) => s.name === 't2') as McpServerInfo;194    expect(t2).toMatchObject({ transport: 'http', target: 'https://example.com/mcp', status: 'needs_auth', envKeys: ['KEY_ENV', 'MY_TOK'] });195    expect(list.find((s) => s.name === 'off')).toMatchObject({ status: 'unknown', lastError: 'Disabled' });196    const all = JSON.stringify(list);197    for (const secret of ['sk-test', 'bar', 'SECRETQ', 'user:pw', 'Bearer zzz']) expect(all).not.toContain(secret);198    expect(parseCodexMcpList('[]')).toEqual([]);199    expect(parseCodexMcpList('No MCP servers configured yet.')).toBeNull();200    expect(parseCodexMcpList('{"a":1}')).toBeNull();201  });202  it('mcp argv builders follow `codex mcp add|remove|get|list --help`', () => {203    const stdio: AddSpec = { name: 'srv', transport: 'stdio', target: 'npx', args: ['-y', 'pkg', '--flag'], env: { A: '1', B: 'x=y' }, headers: {}, scope: 'user' };204    expect(buildCodexAddArgv(stdio)).toEqual(['mcp', 'add', 'srv', '--env', 'A=1', '--env', 'B=x=y', '--', 'npx', '-y', 'pkg', '--flag']);205    const http: AddSpec = { name: 'web', transport: 'http', target: 'https://example.com/mcp', args: [], env: {}, headers: {}, scope: 'user' };206    expect(buildCodexAddArgv(http)).toEqual(['mcp', 'add', 'web', '--url', 'https://example.com/mcp']);207    expect(buildCodexRemoveArgv('srv')).toEqual(['mcp', 'remove', 'srv']);208    expect(buildCodexGetArgv('srv')).toEqual(['mcp', 'get', 'srv', '--json']);209    expect(buildCodexListArgv()).toEqual(['mcp', 'list', '--json']);210    for (const argv of [buildCodexAddArgv(stdio), buildCodexAddArgv(http)]) {211      expect(argv.slice(0, argv.indexOf('--') < 0 ? argv.length : argv.indexOf('--')).some((a) => a === '-c' || a === '--config')).toBe(false);212    }213    expect(validateCodexAdd(stdio)).toBeNull();214    expect(validateCodexAdd(http)).toBeNull();215    expect(validateCodexAdd({ ...http, transport: 'sse' })).toMatch(/SSE/);216    expect(validateCodexAdd({ ...http, headers: { Authorization: 'Bearer x' } })).toMatch(/headers/);217    expect(validateCodexAdd({ ...stdio, scope: 'project' })).toMatch(/scope/);218    expect(validateCodexAdd({ ...stdio, name: '-c' })).not.toBeNull();219    expect(validateCodexAdd({ ...stdio, target: '--yolo' })).not.toBeNull();220  });221});222223describe('codex sessions (rollout files)', () => {224  let tmp: string;225  let sessions: string;226  const big = 'x'.repeat(22_000);227  const meta = (id: string, cwd: string, extra: Record<string, unknown> = {}): string =>228    JSON.stringify({ timestamp: 't', ordinal: 0, type: 'session_meta', payload: { session_id: id, id, timestamp: 't', cwd, originator: 'codex-tui', cli_version: '0.166.0', source: 'vscode', thread_source: 'user', base_instructions: { text: big }, ...extra } });229  const msg = (role: string, text: string): string =>230    JSON.stringify({ timestamp: 't', ordinal: 1, type: 'response_item', payload: { type: 'message', role, content: [{ type: role === 'assistant' ? 'output_text' : 'input_text', text }] } });231  const other = JSON.stringify({ timestamp: 't', type: 'event_msg', payload: { type: 'task_started' } });232  function rollout(day: string, time: string, id: string, lines: string[], mtime: Date): string {233    const dir = path.join(sessions, ...day.split('-'));234    fs.mkdirSync(dir, { recursive: true });235    const f = path.join(dir, `rollout-${day}T${time}-${id}.jsonl`);236    fs.writeFileSync(f, lines.join('\n') + '\n');237    fs.utimesSync(f, mtime, mtime);238    return f;239  }240  beforeAll(() => {241    tmp = makeTmp('cr-codex-sess-');242    sessions = path.join(tmp, 'sessions');243    const env = msg('user', '<environment_context>\n  <cwd>/w/proj</cwd>\n</environment_context>');244    rollout('2026-10-29', '17-44-49', UUID_A, [meta(UUID_A, '/w/proj'), other, msg('developer', 'You are Codex'), env, msg('user', '  ouvre   safari\nstp '), msg('assistant', 'ok'), msg('user', 'merci')], new Date('2026-10-29T22:00:00Z'));245    rollout('2026-10-30', '11-15-52', UUID_B, [meta(UUID_B, '/w/other'), env, msg('user', 'elsewhere')], new Date('2026-10-30T12:00:00Z'));246    rollout('2026-11-10', '11-48-09', UUID_C, [meta(UUID_C, '/w/proj')], new Date('2026-11-10T12:00:00Z'));247    // Not offered: sub-agent thread, exec run, id mismatch, non-rollout file, garbage.248    rollout('2026-11-09', '10-00-00', '11111111-1111-7111-8111-111111111111', [meta('11111111-1111-7111-8111-111111111111', '/w/proj', { thread_source: 'subagent' }), msg('user', 'sub')], new Date('2026-11-09T10:00:00Z'));249    rollout('2026-11-09', '11-00-00', '22222222-2222-7222-8222-222222222222', [meta('22222222-2222-7222-8222-222222222222', '/w/proj', { source: 'exec' }), msg('user', 'exec')], new Date('2026-11-09T11:00:00Z'));250    rollout('2026-11-09', '12-00-00', '33333333-3333-7333-8333-333333333333', [meta('44444444-4444-7444-8444-444444444444', '/w/proj'), msg('user', 'mismatch')], new Date('2026-11-09T12:00:00Z'));251    fs.writeFileSync(path.join(sessions, '2026', '11', '09', 'notes.jsonl'), 'x\n');252    rollout('2026-11-09', '13-00-00', '55555555-5555-7555-8555-555555555555', ['not json at all'], new Date('2026-11-09T13:00:00Z'));253  });254  afterAll(() => rmTmp(tmp));255256  it('reads the meta record, also when the first line is cut by the bounded read', () => {257    expect(parseRolloutMeta(meta(UUID_A, '/w/proj') + '\n')).toEqual({ id: UUID_A, cwd: '/w/proj', interactive: true });258    const cut = meta(UUID_A, '/w/pr"oj é').slice(0, 600);259    expect(parseRolloutMeta(cut)).toEqual({ id: UUID_A, cwd: '/w/pr"oj é', interactive: true });260    expect(parseRolloutMeta('{"type":"event_msg","payload":{}}\n')).toBeNull();261    expect(parseRolloutMeta('garbage')).toBeNull();262    expect(parseRolloutMeta(meta('not-a-uuid', '/w'))).toBeNull();263  });264  it('summarises with the first real prompt and counts user/assistant messages only', () => {265    const text = [meta(UUID_A, '/w'), msg('user', '<environment_context>x</environment_context>'), msg('developer', 'sys'), msg('user', 'hello'), msg('assistant', 'hi'), 'broken{'].join('\n');266    expect(summarizeRollout(text, true)).toEqual({ summary: 'hello', messageCount: 2 });267    expect(summarizeRollout(`${msg('user', 'a'.repeat(400))}\n${msg('user', 'cut').slice(0, 30)}`, false).summary).toHaveLength(160);268  });269  it('lists only the sessions recorded for the requested cwd, newest first', async () => {270    const list = await listCodexSessions(sessions, ['/w/proj']);271    expect(list.map((c) => c.id)).toEqual([UUID_C, UUID_A]);272    expect(list[1]).toEqual({ id: UUID_A, summary: 'ouvre safari stp', updatedAt: '2026-10-29T22:00:00.000Z', messageCount: 3 });273    expect(list[0]).toMatchObject({ summary: '', messageCount: 0 });274    expect((await listCodexSessions(sessions, ['/w/other'])).map((c) => c.id)).toEqual([UUID_B]);275    expect(await listCodexSessions(sessions, ['/nowhere'])).toEqual([]);276    expect(await listCodexSessions(path.join(tmp, 'missing'), ['/w/proj'])).toEqual([]);277  });278  it('bounds the number of files examined', async () => {279    // Newest first: the single file examined is the 2026-11-10 one.280    expect((await listCodexSessions(sessions, ['/w/proj'], { maxScanned: 1 })).map((c) => c.id)).toEqual([UUID_C]);281  });282  it('matches the real rollout format on this machine (read-only, skipped when absent)', async (ctx) => {283    const real = path.join(os.homedir(), '.codex', 'sessions');284    if (!fs.existsSync(real)) return ctx.skip();285    const list = await listCodexSessions(real, [os.homedir()]);286    for (const c of list) expect(c.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);287  });288});289290describe('codex config.toml notify install / uninstall', () => {291  let dir: string;292  let file: string;293  const OURS = `notify = [${JSON.stringify(NOTIFY_SCRIPT)}]`;294  beforeEach(() => {295    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-codex-notify-'));296    file = path.join(dir, 'config.toml');297  });298  afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));299  const read = (): string => fs.readFileSync(file, 'utf8');300301  it('creates the file when missing, idempotently, and uninstalls back to empty', async () => {302    expect(await installCodexNotify(file, NOTIFY_SCRIPT)).toEqual({ status: 'installed', changed: true });303    expect(read()).toBe(`${OURS}\n`);304    expect(fs.statSync(file).mode & 0o777).toBe(0o600);305    expect(await installCodexNotify(file, NOTIFY_SCRIPT)).toEqual({ status: 'installed', changed: false });306    expect(await uninstallCodexNotify(file)).toEqual({ status: 'absent', changed: true });307    expect(read()).toBe('');308    expect(await uninstallCodexNotify(file)).toEqual({ status: 'absent', changed: false });309    expect(await uninstallCodexNotify(path.join(dir, 'nope.toml'))).toEqual({ status: 'absent', changed: false });310  });311312  it('preserves unrelated keys, tables, comments and the file mode; install then uninstall is byte-identical', async () => {313    const original = [314      '# my codex config',315      'model = "gpt-6.1"',316      'approval_policy = "on-request"   # keep',317      'project_doc_fallback_filenames = [',318      '  "CLAUDE.md",  # notify = ["not a key"]',319      ']',320      'instructions = """',321      'notify = ["inside a string"]',322      '[not-a-table]',323      '"""',324      '',325      '[features]',326      'js_repl = false',327      '',328      '[mcp_servers.node_repl]',329      'command = "/Applications/x"',330      'notify = "a key of this table, not the top-level one"',331      '',332      '[mcp_servers.node_repl.env]',333      'TOKEN = "abc"',334      '',335    ].join('\n');336    fs.writeFileSync(file, original, { mode: 0o640 });337    expect(await installCodexNotify(file, NOTIFY_SCRIPT)).toEqual({ status: 'installed', changed: true });338    const lines = read().split('\n');339    // Added at the end of the top-level region, before the first table.340    expect(lines.indexOf(OURS)).toBe(10);341    expect(lines[11]).toBe('');342    expect(lines[12]).toBe('[features]');343    expect(read().replace(`${OURS}\n`, '')).toBe(original);344    expect(fs.statSync(file).mode & 0o777).toBe(0o640);345    // The real CLI still accepts the file and sees our entry at the top level (when codex is installed).346    expect(await installCodexNotify(file, NOTIFY_SCRIPT)).toEqual({ status: 'installed', changed: false });347    expect(await uninstallCodexNotify(file)).toEqual({ status: 'absent', changed: true });348    expect(read()).toBe(original);349  });350351  it('a file that starts with a table gets the key in front of it', async () => {352    const original = '[features]\njs_repl = false\n';353    fs.writeFileSync(file, original);354    await installCodexNotify(file, NOTIFY_SCRIPT);355    expect(read()).toBe(`${OURS}\n\n${original}`);356    await uninstallCodexNotify(file);357    expect(read()).toBe(original);358  });359360  it('replaces our own entry when the deploy directory moved (single-line or multi-line), never duplicating it', async () => {361    fs.writeFileSync(file, 'model = "x"\nnotify = [\n  "/old/deploy/bin/cr-codex-notify",   # ours\n]\n\n[features]\na = 1\n');362    expect(await installCodexNotify(file, NOTIFY_SCRIPT)).toEqual({ status: 'installed', changed: true });363    expect(read()).toBe(`model = "x"\n${OURS}\n\n[features]\na = 1\n`);364    expect(read().match(/^notify/gm)).toHaveLength(1);365  });366367  it('REFUSES to touch a different notify program (the real-world case: Codex Computer Use)', async () => {368    const original = 'notify = ["/Users/u/.codex/computer-use/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient", "turn-ended"]\n\n[features]\njs_repl = false\n';369    fs.writeFileSync(file, original);370    const before = fs.statSync(file).mtimeMs;371    expect(await installCodexNotify(file, NOTIFY_SCRIPT)).toEqual({ status: 'conflict', changed: false });372    expect(await uninstallCodexNotify(file)).toEqual({ status: 'conflict', changed: false });373    expect(read()).toBe(original);374    expect(fs.statSync(file).mtimeMs).toBe(before);375    // Other shapes that are not ours either.376    for (const other of ['notify = "just-a-string"\n', "notify = ['/x/other', 'cr-codex-notify']\n", 'notify = []\n', '"notify" = ["/bin/true"]\n']) {377      fs.writeFileSync(file, other);378      expect((await installCodexNotify(file, NOTIFY_SCRIPT)).status, other).toBe('conflict');379      expect(read()).toBe(other);380    }381  });382383  it('refuses a file it cannot follow and bad script paths', async () => {384    const broken = 'instructions = """\nnever closed\n[features]\n';385    fs.writeFileSync(file, broken);386    await expect(installCodexNotify(file, NOTIFY_SCRIPT)).rejects.toThrow(/untouched/);387    expect(read()).toBe(broken);388    await expect(installCodexNotify(file, 'relative/cr-codex-notify')).rejects.toThrow();389    await expect(installCodexNotify(file, '/abs/other-name')).rejects.toThrow();390  });391392  it('keeps CRLF line endings', async () => {393    fs.writeFileSync(file, 'model = "x"\r\n\r\n[features]\r\na = 1\r\n');394    await installCodexNotify(file, NOTIFY_SCRIPT);395    expect(read()).toBe(`model = "x"\r\n${OURS}\r\n\r\n[features]\r\na = 1\r\n`);396  });397398  it('parses TOML string arrays strictly', () => {399    expect(parseTomlStringArray(' ["a b", \'c\'] # x')).toEqual(['a b', 'c']);400    expect(parseTomlStringArray('[\n "a", # c\n]\n')).toEqual(['a']);401    expect(parseTomlStringArray('["a\\"b"]')).toEqual(['a"b']);402    expect(parseTomlStringArray('[]')).toEqual([]);403    for (const bad of ['"a"', '[1]', '["a" "b"]', '["a"', '["a"] x', '[["a"]]', '["""a"""]']) expect(parseTomlStringArray(bad), bad).toBeNull();404  });405406  it('the real codex CLI reads a config written by installCodexNotify (skipped when codex is absent)', async (ctx) => {407    const codex = findCodex();408    if (!codex) return ctx.skip();409    fs.writeFileSync(file, '[mcp_servers.t1]\ncommand = "echo"\nargs = ["hi"]\n');410    await installCodexNotify(file, NOTIFY_SCRIPT);411    // --strict-config would be an interactive-only flag; `mcp list --json` parses the same file.412    const out = execFileSync(codex, ['mcp', 'list', '--json'], { env: { ...process.env, CODEX_HOME: dir }, encoding: 'utf8' });413    expect((parseCodexMcpList(out) as McpServerInfo[]).map((s) => s.name)).toEqual(['t1']);414    expect(read()).toContain(OURS);415  });416});417418describe('codex notify payload → hook event', () => {419  const sid = '0b0e0f3a-1111-4222-8333-444455556666';420  it('agent-turn-complete becomes Stop; the prompt and the assistant message are dropped', () => {421    const body = { type: 'agent-turn-complete', 'thread-id': UUID_A, 'turn-id': 't', cwd: '/w', 'input-messages': ['secret prompt'], 'last-assistant-message': 'secret answer' };422    const hook = parseHookRequest(body, sid);423    expect(hook).toEqual({ id: sid, event: 'Stop', conversationId: UUID_A, message: null });424    expect(JSON.stringify(hook)).not.toContain('secret');425  });426  it('other notify types are reported under a name the server ignores', () => {427    expect(parseHookRequest({ type: 'something-else' }, sid)).toEqual({ id: sid, event: 'CodexNotify', conversationId: null, message: null });428  });429  it('needs the session header and leaves Claude hook bodies alone', () => {430    expect(() => parseHookRequest({ type: 'agent-turn-complete' }, undefined)).toThrow();431    expect(parseHookRequest({ hook_event_name: 'Stop', session_id: 'conv-12345678', type: 'x' }, sid)).toMatchObject({ event: 'Stop', conversationId: 'conv-12345678' });432  });433});434435describe('codex profile routes (isolated CODEX_HOME)', () => {436  let tmp: string;437  let ws: string;438  let proj: string;439  let home: string;440  let codexHome: string;441  let fake: string;442443  beforeAll(() => {444    tmp = makeTmp('cr-codex-routes-');445    ws = path.join(tmp, 'ws');446    proj = path.join(ws, 'proj');447    home = path.join(tmp, 'home');448    codexHome = path.join(home, '.codex');449    fs.mkdirSync(proj, { recursive: true });450    fs.mkdirSync(codexHome, { recursive: true });451    // A fake `codex` implementing the observed behaviours of the subcommands we call.452    fake = path.join(tmp, 'codex');453    fs.writeFileSync(454      fake,455      `#!/bin/sh456state="$CODEX_HOME/fake-mcp.json"457[ -f "$state" ] || printf '[]' > "$state"458printf '%s\\n' "$*" >> "$CODEX_HOME/calls.log"459case "$1 $2" in460  "--version "*) echo "codex-cli 0.166.0" ;;461  "login status") if [ -f "$CODEX_HOME/auth.json" ]; then echo "Logged in using an API key - sk-***MASK" >&2; else echo "Not logged in" >&2; exit 1; fi ;;462  "mcp list") cat "$state" ;;463  "mcp add") printf '[{"name":"%s","enabled":true,"transport":{"type":"stdio","command":"echo","args":[],"env":{"K":"topsecretvalue"},"env_vars":[],"cwd":null},"auth_status":"unsupported"}]' "$3" > "$state"; echo "Added global MCP server '$3'." ;;464  "mcp remove") if grep -q "\\"name\\":\\"$3\\"" "$state"; then printf '[]' > "$state"; echo "Removed global MCP server '$3'."; else echo "No MCP server named '$3' found."; fi ;;465  "mcp get") if grep -q "\\"name\\":\\"$3\\"" "$state"; then printf '{"name":"%s","enabled":true,"transport":{"type":"stdio","command":"echo","args":[],"env":{"K":"topsecretvalue"},"env_vars":[],"cwd":null}}' "$3"; else echo "Error: No MCP server named '$3' found." >&2; exit 1; fi ;;466  *) exit 2 ;;467esac468`,469      { mode: 0o755 },470    );471  });472  afterAll(() => rmTmp(tmp));473474  const setup = async (): Promise<ReturnType<typeof buildApp>> => {475    const env = { ...isolatedClaudeEnv(home), claudeBin: fake };476    const config = makeConfig([ws], { agent: 'codex', claudeBin: fake });477    return buildApp(config, createClaudePlugin({ env }), createSkillsPlugin({ env }), createMcpPlugin({ env, listTtlMs: 0 }));478  };479480  it('/claude/info reports the Codex version and authentication without the key', async () => {481    const app = await setup();482    const out = await call<ClaudeInfo>(app, 'GET', '/claude/info');483    expect(out.body).toEqual({ path: fake, version: '0.166.0', authenticated: false, method: null, subscription: null, configDir: codexHome, agent: 'codex' });484    await app.close();485    fs.writeFileSync(path.join(codexHome, 'auth.json'), '{}');486    const app2 = await setup();487    const out2 = await call<ClaudeInfo>(app2, 'GET', '/claude/info');488    expect(out2.body).toMatchObject({ authenticated: true, method: 'api_key' });489    expect(JSON.stringify(out2.body)).not.toMatch(/sk-|MASK/);490    await app2.close();491  });492493  it('/claude/conversations lists Codex sessions of the project directory', async () => {494    const real = fs.realpathSync(proj);495    const dir = path.join(codexHome, 'sessions', '2026', '11', '10');496    fs.mkdirSync(dir, { recursive: true });497    fs.writeFileSync(498      path.join(dir, `rollout-2026-11-10T10-00-00-${UUID_A}.jsonl`),499      [500        JSON.stringify({ type: 'session_meta', payload: { id: UUID_A, cwd: real, thread_source: 'user', source: 'vscode' } }),501        JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fix the bug' }] } }),502      ].join('\n') + '\n',503    );504    fs.writeFileSync(path.join(dir, `rollout-2026-11-10T11-00-00-${UUID_B}.jsonl`), JSON.stringify({ type: 'session_meta', payload: { id: UUID_B, cwd: '/somewhere/else' } }) + '\n');505    const app = await setup();506    const out = await call<ClaudeConversation[]>(app, 'POST', '/claude/conversations', { cwd: proj });507    expect(out.status).toBe(200);508    expect(out.body.map((c) => [c.id, c.summary, c.messageCount])).toEqual([[UUID_A, 'fix the bug', 1]]);509    expect((await call(app, 'POST', '/claude/conversations', { cwd: '/etc' })).status).toBeGreaterThanOrEqual(400);510    await app.close();511  });512513  it('/skills lists what exists for Codex and nothing invented', async () => {514    const app = await setup();515    expect((await call<SkillInfo[]>(app, 'GET', '/skills')).body).toEqual([]);516    const mk = (p: string, text: string): void => {517      fs.mkdirSync(path.dirname(p), { recursive: true });518      fs.writeFileSync(p, text);519    };520    mk(path.join(codexHome, 'skills', 'pdf', 'SKILL.md'), '---\nname: pdf\ndescription: Work with PDF files\n---\nbody');521    mk(path.join(codexHome, 'skills', '.system', 'imagegen', 'SKILL.md'), '---\ndescription: Generate images\n---\n');522    mk(path.join(home, '.agents', 'skills', 'shared', 'SKILL.md'), '---\ndescription: Shared skill\n---\n');523    mk(path.join(codexHome, '.disabled', 'skills', 'old', 'SKILL.md'), '---\ndescription: Parked\n---\n');524    mk(path.join(codexHome, 'AGENTS.md'), '# Global\n\nAlways answer in French.\n');525    mk(path.join(codexHome, 'prompts', 'x.md'), 'not supported by this CLI version'); // must NOT be listed526    mk(path.join(codexHome, 'commands', 'c.md'), 'claude-only concept'); // must NOT be listed527    mk(path.join(proj, 'AGENTS.md'), 'Project rules.\n');528    mk(path.join(proj, '.agents', 'skills', 'deploy', 'SKILL.md'), '---\ndescription: Deploy it\n---\n');529    mk(path.join(proj, '.codex', 'skills', 'lint', 'SKILL.md'), '---\ndescription: Lint it\n---\n');530    fs.symlinkSync('/etc/passwd', path.join(proj, 'AGENTS.override.md'));531    const user = (await call<SkillInfo[]>(app, 'GET', '/skills')).body;532    expect(user.map((s) => [s.kind, s.source, s.name, s.enabled, s.location])).toEqual([533      ['skill', 'user', 'pdf', true, '~/.codex/skills/pdf'],534      ['skill', 'user', 'shared', true, '~/.agents/skills/shared'],535      ['skill', 'plugin', 'imagegen', true, '~/.codex/skills/.system/imagegen'],536      ['skill', 'user', 'old', false, '~/.codex/.disabled/skills/old'],537      ['agent', 'user', 'AGENTS.md', true, '~/.codex/AGENTS.md'],538    ]);539    expect(user[0]?.description).toBe('Work with PDF files');540    expect(user[4]?.description).toBe('Always answer in French.');541    const withProject = (await call<SkillInfo[]>(app, 'GET', `/skills?root=${encodeURIComponent(proj)}`)).body.filter((s) => s.source === 'project');542    expect(withProject.map((s) => [s.kind, s.name])).toEqual([543      ['skill', 'deploy'],544      ['skill', 'lint'],545      ['agent', 'AGENTS.md'],546    ]);547    // Operations: skills only, in the Codex directories.548    expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'pdf' })).status).toBe(200);549    expect(fs.existsSync(path.join(codexHome, '.disabled', 'skills', 'pdf', 'SKILL.md'))).toBe(true);550    expect((await call(app, 'POST', '/skills/op', { op: 'enable', kind: 'skill', name: 'pdf' })).status).toBe(200);551    expect(fs.existsSync(path.join(codexHome, 'skills', 'pdf', 'SKILL.md'))).toBe(true);552    const refused = await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'agent', name: 'AGENTS', confirm: true });553    expect([refused.status, errCode(refused)]).toEqual([400, 'unsupported']);554    expect(fs.existsSync(path.join(codexHome, 'AGENTS.md'))).toBe(true);555    expect(fs.existsSync(path.join(home, '.claude'))).toBe(false);556    await app.close();557  });558559  it('/mcp goes through `codex mcp …` with argv arrays and never returns env values', async () => {560    const app = await setup();561    expect((await call<McpServerInfo[]>(app, 'GET', '/mcp')).body).toEqual([]);562    const add = await call(app, 'POST', '/mcp/add', { name: 'srv', transport: 'stdio', target: 'echo', args: ['a b'], env: { K: 'topsecretvalue' }, headers: {}, scope: 'user' });563    expect(add.status).toBe(200);564    const calls = fs.readFileSync(path.join(codexHome, 'calls.log'), 'utf8');565    expect(calls).toContain('mcp add srv --env K=topsecretvalue -- echo a b');566    expect(calls).not.toMatch(/(^|\s)(-c|--config|--yolo)(\s|$)/m);567    const list = await call<McpServerInfo[]>(app, 'GET', '/mcp');568    expect(list.body).toMatchObject([{ name: 'srv', scope: 'user', transport: 'stdio', target: 'echo', envKeys: ['K'], status: 'unknown' }]);569    expect(list.body[0]?.checkedAt).not.toBeNull();570    expect(JSON.stringify(list.body)).not.toContain('topsecretvalue');571    // `codex mcp add` would silently replace: the runtime refuses a duplicate name.572    const dup = await call(app, 'POST', '/mcp/add', { name: 'srv', transport: 'stdio', target: 'echo', args: [], env: {}, headers: {}, scope: 'user' });573    expect([dup.status, errCode(dup)]).toEqual([409, 'exists']);574    for (const bad of [575      { name: 's2', transport: 'sse', target: 'https://example.com/sse', args: [], env: {}, headers: {}, scope: 'user' },576      { name: 's2', transport: 'http', target: 'https://example.com/mcp', args: [], env: {}, headers: { A: 'b' }, scope: 'user' },577      { name: 's2', transport: 'stdio', target: 'echo', args: [], env: {}, headers: {}, scope: 'project', root: proj },578    ]) {579      const r = await call(app, 'POST', '/mcp/add', bad);580      expect([r.status, errCode(r)], JSON.stringify(bad)).toEqual([400, 'unsupported']);581    }582    const test = await call<{ status: string }>(app, 'POST', '/mcp/test', { name: 'srv' });583    expect([test.status, test.body.status]).toEqual([200, 'unknown']);584    expect(JSON.stringify(test.body)).not.toContain('topsecretvalue');585    expect((await call(app, 'POST', '/mcp/test', { name: 'nope' })).status).toBe(404);586    expect((await call(app, 'POST', '/mcp/remove', { name: 'nope' })).status).toBe(404);587    expect((await call(app, 'POST', '/mcp/remove', { name: 'srv' })).status).toBe(200);588    expect((await call<McpServerInfo[]>(app, 'GET', '/mcp')).body).toEqual([]);589    await app.close();590  });591592  it('the real codex CLI: info, mcp add/list/get/remove round trip in a temp CODEX_HOME (skipped when codex is absent)', async (ctx) => {593    const codex = findCodex();594    if (!codex) return ctx.skip();595    const home2 = path.join(tmp, 'home-real');596    fs.mkdirSync(path.join(home2, '.codex'), { recursive: true });597    const env = { ...isolatedClaudeEnv(home2), claudeBin: codex };598    const app = await buildApp(makeConfig([ws], { agent: 'codex', claudeBin: codex }), createClaudePlugin({ env }), createMcpPlugin({ env, listTtlMs: 0 }));599    const info = (await call<ClaudeInfo>(app, 'GET', '/claude/info')).body;600    expect(info.version).toMatch(/^\d+\.\d+\.\d+/);601    expect(info.authenticated).toBe(false); // empty CODEX_HOME: "Not logged in"602    expect(info.configDir).toBe(path.join(home2, '.codex'));603    expect((await call(app, 'POST', '/mcp/add', { name: 'cr_test', transport: 'stdio', target: 'echo', args: ['hello'], env: { CR_FAKE_KEY: 'sk-thisisnotreal1234567890' }, headers: {}, scope: 'user' })).status).toBe(200);604    expect((await call(app, 'POST', '/mcp/add', { name: 'cr_http', transport: 'http', target: 'https://example.invalid/mcp', args: [], env: {}, headers: {}, scope: 'user' })).status).toBe(200);605    const list = (await call<McpServerInfo[]>(app, 'GET', '/mcp')).body;606    expect(list.map((s) => [s.name, s.transport, s.target, s.envKeys])).toEqual([607      ['cr_http', 'http', 'https://example.invalid/mcp', []],608      ['cr_test', 'stdio', 'echo hello', ['CR_FAKE_KEY']],609    ]);610    expect(JSON.stringify(list)).not.toContain('thisisnotreal');611    expect((await call(app, 'POST', '/mcp/add', { name: 'cr_test', transport: 'stdio', target: 'echo', args: [], env: {}, headers: {}, scope: 'user' })).status).toBe(409);612    expect((await call(app, 'POST', '/mcp/test', { name: 'cr_test' })).status).toBe(200);613    expect((await call(app, 'POST', '/mcp/test', { name: 'missing' })).status).toBe(404);614    expect((await call(app, 'POST', '/mcp/remove', { name: 'missing' })).status).toBe(404);615    expect((await call(app, 'POST', '/mcp/remove', { name: 'cr_test' })).status).toBe(200);616    expect((await call<McpServerInfo[]>(app, 'GET', '/mcp')).body.map((s) => s.name)).toEqual(['cr_http']);617    await app.close();618  }, 60_000);619});620621describe('codex sessions (real tmux)', () => {622  let codexHome: string;623  beforeAll(() => {624    codexHome = fs.realpathSync(fs.mkdtempSync('/tmp/crt-codexhome-'));625    process.env.CR_SESSION_POLL_MS = '300';626  });627  afterAll(() => {628    delete process.env.CR_SESSION_POLL_MS;629    fs.rmSync(codexHome, { recursive: true, force: true });630  });631632  it('refuses bypass flags, config overrides and free prompts with 400 (nothing is spawned)', async () => {633    const env = makeTestEnv({ agent: 'codex', claudeBin: '/usr/bin/false' });634    const rt: Runtime = await startRuntime(env.config);635    try {636      const post = (args: string[]): ReturnType<typeof request> =>637        request(env.config.socketPath, 'POST', '/sessions', { token: env.config.token, body: sessionSpec(env, { kind: 'claude', args }) });638      for (const args of [['--dangerously-bypass-approvals-and-sandbox'], ['--yolo'], ['-c', 'approval_policy="never"'], ['--config=x=1'], ['resume', '--last', '--yolo']]) {639        const r = await post(args);640        expect([r.status, r.json<{ error: { code: string } }>().error.code], args.join(' ')).toEqual([400, 'forbidden_flag']);641      }642      for (const args of [['do something expensive'], ['resume', 'not-a-uuid'], ['resume', '--', '--yolo'], ['--resume', UUID_A]]) {643        const r = await post(args);644        expect([r.status, r.json<{ error: { code: string } }>().error.code], args.join(' ')).toEqual([400, 'invalid_resume']);645      }646      expect((await request(env.config.socketPath, 'GET', '/sessions', { token: env.config.token })).json<unknown[]>()).toEqual([]);647      const health = (await request(env.config.socketPath, 'GET', '/health', { token: env.config.token })).json<{ agent: string; agentHooks: { status: string } }>();648      expect(health.agent).toBe('codex');649      expect(health.agentHooks.status).toBe('not_requested');650    } finally {651      await rt.stop();652      env.cleanup();653    }654  });655656  it('codex smoke: the real CLI starts under tmux with a temp CODEX_HOME and renders (no input sent)', async (ctx) => {657    const codex = findCodex();658    if (!codex) return ctx.skip();659    const env = makeTestEnv({ agent: 'codex', shell: process.env.SHELL ?? '/bin/zsh', claudeBin: codex });660    const rt = await startRuntime(env.config);661    try {662      const res = await request(env.config.socketPath, 'POST', '/sessions', { token: env.config.token, body: sessionSpec(env, { kind: 'claude', env: { CODEX_HOME: codexHome } }) });663      expect(res.status).toBe(200);664      const id = res.json<RtSessionInfo>().id;665      const t = await XtermClient.open(env, id, 120, 40);666      await waitUntil(() => t.bytes > 0, 15_000, 'any output bytes');667      const snapshot = async (): Promise<string> =>668        (await request(env.config.socketPath, 'GET', `/sessions/${id}/snapshot`, { token: env.config.token })).json<{ text: string }>().text;669      await waitUntil(async () => (await snapshot()).replace(/\s+/g, '').length > 20, 30_000, 'codex to render something');670      const s = (await request(env.config.socketPath, 'GET', `/sessions/${id}`, { token: env.config.token })).json<RtSessionInfo>();671      expect(s.alive).toBe(true); // still running: it did not crash at startup672      await t.close();673      expect((await request(env.config.socketPath, 'DELETE', `/sessions/${id}?force=1`, { token: env.config.token })).status).toBe(200);674      // The session used the temp home, never the real one.675      expect(fs.readdirSync(codexHome).length).toBeGreaterThan(0);676    } finally {677      await rt.stop();678      env.cleanup();679    }680  }, 90_000);681682  it('cr-codex-notify inside a session reaches /hooks/claude as Stop; outside a session it is a no-op', async () => {683    const env = makeTestEnv({ agent: 'codex' });684    const rt = await startRuntime(env.config);685    const ev = await collectEvents(env.config.socketPath, env.config.token);686    try {687      const res = await request(env.config.socketPath, 'POST', '/sessions', { token: env.config.token, body: sessionSpec(env) });688      const id = res.json<RtSessionInfo>().id;689      const t = await XtermClient.open(env, id, 120, 30);690      const payload = JSON.stringify({ type: 'agent-turn-complete', 'thread-id': UUID_B, 'turn-id': 'x', cwd: env.workspace, 'input-messages': ["it's a prompt"], 'last-assistant-message': 'done' });691      // Same calling convention as Codex: configured args first, the JSON as LAST argument.692      t.type(`${NOTIFY_SCRIPT} turn-ended '${payload.replace(/'/g, `'\\''`)}'; echo rc=$?\n`);693      const hook = await waitUntil(() => ev.events.find((e) => e.type === 'claude.hook'), 10_000, 'claude.hook event');694      expect(hook).toEqual({ type: 'claude.hook', id, event: 'Stop', conversationId: UUID_B, message: null });695      await t.close();696      await request(env.config.socketPath, 'DELETE', `/sessions/${id}?force=1`, { token: env.config.token });697      // Outside a Codex Remote session: exits 0, prints nothing, calls nothing.698      const before = ev.events.length;699      const out = await new Promise<{ code: number | null; text: string }>((resolve) => {700        const child = spawn(NOTIFY_SCRIPT, [payload], { env: { PATH: process.env.PATH ?? '/usr/bin:/bin' }, stdio: ['ignore', 'pipe', 'pipe'] });701        let text = '';702        child.stdout.on('data', (b: Buffer) => (text += b.toString()));703        child.stderr.on('data', (b: Buffer) => (text += b.toString()));704        child.on('close', (code) => resolve({ code, text }));705      });706      expect(out).toEqual({ code: 0, text: '' });707      // With the session variables but no payload argument: still a silent no-op.708      execFileSync(NOTIFY_SCRIPT, [], { env: { PATH: process.env.PATH ?? '/usr/bin:/bin', CR_SESSION_ID: id, CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: 'x' } });709      expect(ev.events.filter((e) => e.type === 'claude.hook').length).toBe(1);710      expect(ev.events.length).toBeGreaterThanOrEqual(before);711    } finally {712      ev.close();713      await rt.stop();714      env.cleanup();715    }716  }, 30_000);717});718
Files main
Menu

Theme