Repository Public
1/**2 * End-to-end integration of the CODEX profile: real server ⇄ REAL runtime started with3 * `CR_AGENT=codex`, through the browser API with real HTTP and WebSockets.4 *5 * `CODEX_HOME` points at a temp dir for the runtime, its tmux server and every session, so the6 * notify installation (`CR_INSTALL_AGENT_HOOKS=1`) and the real `codex` binary only ever touch7 * that directory: the developer's `~/.codex` is checksummed before and after. No prompt is sent8 * to Codex (nothing is typed into its TUI).9 */10import { execFileSync } from 'node:child_process';11import { createHash } from 'node:crypto';12import fs from 'node:fs';13import os from 'node:os';14import path from 'node:path';15import { afterAll, beforeAll, describe, expect, it } from 'vitest';16import type { AuthStatus, ClaudeConversation, McpServerInfo, Notification, SkillInfo, SystemStatus, TerminalSession } from '@cr/shared';17import { Browser, CODEX_NOTIFY_SCRIPT, PASSWORD, Stream, sleep, startRealStack, waitFor, type RealStack } from './real-harness.js';1819const THREAD_ID = '01a1899f-6f8e-7631-826e-24fb75e51d5a';20const REAL_CONFIG = path.join(os.homedir(), '.codex', 'config.toml');21const digest = (file: string): string | null => (fs.existsSync(file) ? createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null);2223function findCodex(): string | null {24 try {25 const p = execFileSync(process.env.SHELL ?? '/bin/zsh', ['-lc', 'command -v codex'], { encoding: 'utf8' }).trim().split('\n').pop() ?? '';26 return p.startsWith('/') ? p : null;27 } catch {28 return null;29 }30}3132async function signIn(stack: RealStack): Promise<Browser> {33 const b = stack.browser();34 await b.sync();35 const setup = await b.post('/auth/setup', { setupToken: stack.setupToken(), username: 'admin', password: PASSWORD });36 expect(setup.status, setup.text).toBe(200);37 await b.sync();38 const start = (await b.post('/auth/totp/enroll/start')).json<{ secret: string; otpauthUrl: string }>();39 expect(decodeURIComponent(start.otpauthUrl)).toContain('Codex Remote');40 const fin = await b.post('/auth/totp/enroll/finish', { code: stack.totp(start.secret) });41 expect(fin.status, fin.text).toBe(200);42 await b.sync();43 return b;44}4546describe('real server ⇄ real runtime (CR_AGENT=codex)', () => {47 let stack: RealStack;48 let b: Browser;49 let codexHome: string;50 let realBefore: string | null;51 const codexBin = findCodex();5253 const session = (id: string): Promise<TerminalSession> => b.getJson<TerminalSession>(`/sessions/${id}`);54 const waitState = (id: string, states: TerminalSession['state'][], ms = 20_000): Promise<TerminalSession> =>55 waitFor(async () => {56 const s = await session(id);57 return states.includes(s.state) ? s : null;58 }, ms, `session ${id} in ${states.join('|')}`);59 async function attach(id: string): Promise<Stream> {60 const s = await new Stream(b.ws(`/sessions/${id}/stream?cols=120&rows=32`)).open();61 expect(s.upgradeStatus, 'terminal upgrade refused').toBeNull();62 await s.waitMsg((m) => m.t === 'ready', 10_000, 'terminal ready');63 return s;64 }65 const transitions = async (id: string): Promise<string[]> =>66 (await stack.server.ctx.db.q<{ detail: { to: string; source?: string } }>("SELECT detail FROM terminal_events WHERE session_id = $1 AND kind = 'state' ORDER BY id", [id]))67 .map((r) => `${r.detail.to}:${r.detail.source ?? ''}`);6869 beforeAll(async () => {70 realBefore = digest(REAL_CONFIG);71 codexHome = fs.realpathSync(fs.mkdtempSync('/tmp/cr-it-codexhome-'));72 // An existing user configuration that must survive the notify installation untouched.73 fs.writeFileSync(path.join(codexHome, 'config.toml'), '# test config\n\n[features]\njs_repl = false\n', { mode: 0o600 });74 stack = await startRealStack({75 env: { CR_AGENT: 'codex', CODEX_HOME: codexHome, CR_INSTALL_AGENT_HOOKS: '1', ...(codexBin ? { CR_AGENT_BIN: codexBin } : {}) },76 });77 b = await signIn(stack);78 }, 180_000);7980 afterAll(async () => {81 await stack?.close();82 if (stack) {83 expect(stack.tmux(['ls'])).toBeNull();84 expect(execFileSync('psql', ['-ltA'], { encoding: 'utf8' })).not.toContain(stack.dbName);85 }86 fs.rmSync(codexHome, { recursive: true, force: true });87 expect(digest(REAL_CONFIG)).toBe(realBefore); // the developer's ~/.codex/config.toml was never written88 }, 60_000);8990 it('reports the codex profile: health, branding, system status; notify installed in the temp CODEX_HOME only', async () => {91 expect((await b.getJson<{ agent: string }>('/health')).agent).toBe('codex');92 expect((await b.getJson<AuthStatus>('/auth/status')).branding).toEqual({ agent: 'codex', productName: 'Codex Remote', agentName: 'Codex' });93 const toml = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');94 expect(toml).toBe(`# test config\nnotify = [${JSON.stringify(CODEX_NOTIFY_SCRIPT)}]\n\n[features]\njs_repl = false\n`);95 const status = await b.getJson<SystemStatus>('/system/status');96 expect(status.claude).toMatchObject({ agent: 'codex', agentName: 'Codex' });97 expect(status.components.find((c) => c.component === 'agent-hooks')).toBeUndefined(); // installed: nothing to report98 if (codexBin) {99 expect(status.claude.version).toMatch(/^\d+\.\d+\.\d+/);100 expect(status.claude.authenticated).toBe(false); // empty CODEX_HOME ⇒ "Not logged in"101 expect(status.components.find((c) => c.component === 'claude')).toMatchObject({ health: 'DEGRADED', detail: 'not authenticated' });102 }103 }, 60_000);104105 it('shell session: create, attach, echo', async () => {106 const res = await b.post('/sessions', { kind: 'shell', title: 'codex-instance shell' });107 expect(res.status, res.text).toBe(201);108 const s = res.json<TerminalSession>();109 expect(s).toMatchObject({ kind: 'shell', state: 'DETACHED' });110 expect(stack.tmux(['has-session', '-t', `=cr-${s.id}`])).not.toBeNull();111 const t = await attach(s.id);112 t.type(' echo cx-$((40+2)); echo home=$CODEX_HOME\n');113 await t.waitOutput('cx-42');114 await t.waitOutput(`home=${codexHome}`); // sessions inherit the instance's CODEX_HOME115 await waitState(s.id, ['RUNNING']);116 await t.close();117 await waitState(s.id, ['DETACHED']);118 expect((await b.post(`/sessions/${s.id}/terminate`)).json<TerminalSession>().state).toBe('STOPPED');119 }, 60_000);120121 it('notify script → WAITING_FOR_INPUT → terminal input → RUNNING (no prompt-submit hook in Codex)', async () => {122 const s = (await b.post('/sessions', { kind: 'shell', title: 'notify' })).json<TerminalSession>();123 const t = await attach(s.id);124 await waitState(s.id, ['RUNNING']);125 // What Codex does at the end of a turn: run the configured program with the JSON as last argument.126 const payload = JSON.stringify({ type: 'agent-turn-complete', 'thread-id': THREAD_ID, 'turn-id': 't1', cwd: stack.workspace, 'input-messages': ['PROMPT-TEXT'], 'last-assistant-message': 'ANSWER-TEXT' });127 t.type(` '${CODEX_NOTIFY_SCRIPT}' '${payload}'; echo notify-$((20+3))\n`);128 await t.waitOutput('notify-23');129 const waiting = await waitState(s.id, ['WAITING_FOR_INPUT']);130 expect(waiting).toMatchObject({ claudeConversationId: THREAD_ID, waitingReason: 'Codex finished and is waiting for your next message' });131 const notes = await b.getJson<Notification[]>('/notifications');132 expect(notes.find((n) => n.kind === 'session.waiting')).toMatchObject({ title: 'A session is waiting for input', link: `/terminal/?s=${s.id}` });133 expect(JSON.stringify([waiting, notes])).not.toMatch(/PROMPT-TEXT|ANSWER-TEXT/);134 // Nothing but input ends the wait: output activity and control frames do not.135 t.control({ t: 'ping', n: 1 });136 await sleep(1500);137 expect((await session(s.id)).state).toBe('WAITING_FOR_INPUT');138 // The user types the next message.139 t.type(' echo next-$((1+1))\n');140 await t.waitOutput('next-2');141 const running = await waitState(s.id, ['RUNNING'], 5000);142 expect(running.waitingReason).toBeNull();143 t.type(' echo more\n');144 await sleep(500);145 expect((await transitions(s.id)).filter((x) => x === 'RUNNING:input')).toHaveLength(1);146 // A second turn ends: waiting again, and input ends it again.147 t.type(` '${CODEX_NOTIFY_SCRIPT}' '${payload}'\n`);148 await waitState(s.id, ['WAITING_FOR_INPUT']);149 t.type(' true\n');150 await waitState(s.id, ['RUNNING'], 5000);151 expect((await transitions(s.id)).filter((x) => x === 'RUNNING:input')).toHaveLength(2);152 await t.close();153 expect((await b.post(`/sessions/${s.id}/terminate`)).json<TerminalSession>().state).toBe('STOPPED');154 }, 90_000);155156 it('bypass attempts are refused before anything is spawned', async () => {157 for (const bad of ['--dangerously-bypass-approvals-and-sandbox', '--yolo', 'abcdef12-3456-7890', '--last']) {158 expect((await b.post('/sessions', { kind: 'claude', resumeConversationId: bad })).status, bad).toBe(400);159 }160 // Straight to the runtime (what a compromised server could try): still refused.161 const direct = async (args: string[]): Promise<number> => {162 const id = crypto.randomUUID();163 try {164 await stack.server.ctx.rt.json('POST', '/sessions', { id, kind: 'claude', cwd: stack.workspace, args, env: {}, cols: 80, rows: 24 });165 return 200;166 } catch (err) {167 return (err as { status?: number }).status ?? -1;168 }169 };170 expect(await direct(['--yolo'])).toBe(400);171 expect(await direct(['-c', 'approval_policy="never"'])).toBe(400);172 expect(await direct(['--dangerously-bypass-approvals-and-sandbox'])).toBe(400);173 expect(await direct(['a free prompt would start a paid turn'])).toBe(400);174 expect((stack.tmux(['ls']) ?? '').split('\n').filter((l) => l.startsWith('cr-'))).toEqual([]);175 }, 30_000);176177 it('codex session: the real CLI starts under tmux in the temp CODEX_HOME and produces output (no input sent)', async (ctx) => {178 if (!codexBin) return ctx.skip();179 const res = await b.post('/sessions', { kind: 'claude', title: 'codex smoke' });180 expect(res.status, res.text).toBe(201);181 const s = res.json<TerminalSession>();182 expect(s).toMatchObject({ kind: 'claude', state: 'DETACHED' });183 const t = await attach(s.id);184 await waitFor(() => t.bytes > 0, 20_000, 'any output from codex');185 await waitFor(async () => (await b.getJson<{ text: string }>(`/sessions/${s.id}/snapshot`)).text.replace(/\s+/g, '').length > 20, 30_000, 'codex to render its UI');186 expect((await session(s.id)).state).toBe('RUNNING'); // attached and alive: it did not exit at startup187 const stopped = await b.post(`/sessions/${s.id}/terminate`);188 expect(stopped.status, stopped.text).toBe(200);189 expect(stopped.json<TerminalSession>().state).toBe('STOPPED');190 await t.waitClose();191 expect(stack.tmux(['has-session', '-t', `=cr-${s.id}`])).toBeNull();192 }, 120_000);193194 it('conversations, skills and MCP listings go through the Codex paths', async (ctx) => {195 const project = (await b.post('/projects', { name: 'Codex Demo', mode: 'create', path: 'cdemo' })).json<{ id: string }>();196 const abs = path.join(stack.workspace, 'cdemo');197 const day = path.join(codexHome, 'sessions', '2026', '11', '10');198 fs.mkdirSync(day, { recursive: true });199 fs.writeFileSync(200 path.join(day, `rollout-2026-11-10T09-00-00-${THREAD_ID}.jsonl`),201 [202 JSON.stringify({ type: 'session_meta', payload: { id: THREAD_ID, cwd: abs, thread_source: 'user', source: 'vscode', originator: 'codex-tui' } }),203 JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'refactor the parser' }] } }),204 ].join('\n') + '\n',205 );206 const convs = await b.getJson<ClaudeConversation[]>(`/projects/${project.id}/conversations`);207 expect(convs.map((c) => [c.id, c.summary])).toEqual([[THREAD_ID, 'refactor the parser']]);208 fs.mkdirSync(path.join(codexHome, 'skills', 'demo'), { recursive: true });209 fs.writeFileSync(path.join(codexHome, 'skills', 'demo', 'SKILL.md'), '---\ndescription: Demo skill\n---\n');210 fs.writeFileSync(path.join(abs, 'AGENTS.md'), 'Project instructions.\n');211 const skills = await b.getJson<SkillInfo[]>(`/skills?projectId=${project.id}`);212 expect(skills.some((k) => k.kind === 'skill' && k.name === 'demo' && k.source === 'user')).toBe(true);213 expect(skills.every((k) => k.kind !== 'command')).toBe(true);214 if (!codexBin) return ctx.skip();215 const mcp = await b.getJson<McpServerInfo[]>('/mcp');216 expect(mcp).toEqual([]); // `codex mcp list --json` on the temp config217 }, 60_000);218});219220describe('codex notify conflict (real runtime)', () => {221 it('an existing different notify is left untouched and reported as a degraded component', async () => {222 const realBefore = digest(REAL_CONFIG);223 const codexHome = fs.realpathSync(fs.mkdtempSync('/tmp/cr-it-codexhome-'));224 const original = 'notify = ["/Applications/Other.app/Contents/MacOS/notifier", "turn-ended"]\n\n[features]\njs_repl = false\n';225 fs.writeFileSync(path.join(codexHome, 'config.toml'), original);226 const stack = await startRealStack({ env: { CR_AGENT: 'codex', CODEX_HOME: codexHome, CR_INSTALL_AGENT_HOOKS: '1' } });227 try {228 const b = await signIn(stack);229 expect(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')).toBe(original);230 const status = await b.getJson<SystemStatus>('/system/status');231 const hooks = status.components.find((c) => c.component === 'agent-hooks');232 expect(hooks).toMatchObject({ health: 'DEGRADED' });233 expect(hooks?.detail).toMatch(/another notify program/);234 const health = await stack.server.ctx.rt.json<{ agent: string; agentHooks: { requested: boolean; status: string } }>('GET', '/health');235 expect(health).toMatchObject({ agent: 'codex', agentHooks: { requested: true, status: 'conflict' } });236 } finally {237 await stack.close();238 fs.rmSync(codexHome, { recursive: true, force: true });239 }240 expect(stack.tmux(['ls'])).toBeNull();241 expect(digest(REAL_CONFIG)).toBe(realBefore);242 }, 180_000);243});244