SPB Git

Repository Public

real-runtime-codex.test.ts 13.8 KB 244 lines typescript

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
Files main
Menu

Theme