Repository Public
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