SPB Git

Repository Public

agent-profile.test.ts 16.6 KB 281 lines typescript

1/**2 * Agent profiles (CR_AGENT): branding, health, CLI arguments, conversation ids, per-profile PWA3 * assets, and the "terminal input ends WAITING_FOR_INPUT" rule that exists ONLY for profiles4 * without a prompt-submit hook (Codex). Two harnesses: one per profile.5 */6import fs from 'node:fs';7import path from 'node:path';8import { afterAll, beforeAll, describe, expect, it } from 'vitest';9import type WebSocket from 'ws';10import type { AuthStatus, SystemStatus, TerminalSession } from '@cr/shared';11import { loadConfig } from '../config.js';12import { brandAsset } from '../static.js';13import { buildClaudeArgs } from './sessions/index.js';14import { Client, createHarness, openWs, setupAdmin, until, upgradeStatus, type Harness } from '../testkit/harness.js';1516const UUID = '01a1899f-6f8e-7631-826e-24fb75e51d5a';17const closed = (ws: WebSocket): Promise<number> => new Promise((resolve) => ws.once('close', (code) => resolve(code)));1819function webFixture(c: { webDir: string; dataDir: string }): void {20  c.webDir = path.join(c.dataDir, 'web');21  const w = (rel: string, text: string): void => {22    fs.mkdirSync(path.dirname(path.join(c.webDir, rel)), { recursive: true });23    fs.writeFileSync(path.join(c.webDir, rel), text);24  };25  w('index.html', '<html>app</html>');26  w('404.html', '<html>missing</html>');27  w('manifest.webmanifest', '{"name":"Claude Remote"}');28  w('icons/icon-192.png', 'CLAUDE-192');29  w('icons/only-default.png', 'DEFAULT-ONLY');30  w('brand/codex/manifest.webmanifest', '{"name":"Codex Remote"}');31  w('brand/codex/icons/icon-192.png', 'CODEX-192');32}3334interface Ctx {35  h: Harness;36  admin: Client;37  secret: string;38  addr: string;39}40async function boot(env: Record<string, string>): Promise<Ctx> {41  const h = await createHarness({ env, tweak: webFixture });42  const { client: admin, secret } = await setupAdmin(h);43  return { h, admin, secret, addr: await h.listen() };44}45const get = async (c: Ctx, id: string): Promise<TerminalSession> => (await c.admin.get(`/sessions/${id}`)).json<TerminalSession>();46const hook = (c: Ctx, id: string, event: string, conversationId: string | null = null): Promise<void> =>47  c.h.server.ctx.sessions.onRuntimeEvent({ type: 'claude.hook', id, event, conversationId, message: null });48async function attach(c: Ctx, id: string): Promise<WebSocket> {49  const ws = openWs(c.addr, `/sessions/${id}/stream?cols=100&rows=30`, { cookie: c.admin.cookieHeader });50  const got: unknown[] = [];51  ws.on('message', (d) => got.push(d));52  expect(await upgradeStatus(ws)).toBe(101);53  await until(() => got.length > 0); // `ready`54  return ws;55}56const sources = async (c: Ctx, id: string): Promise<string[]> =>57  (await c.h.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]))58    .map((r) => `${r.detail.to}:${r.detail.source ?? ''}`);5960describe('configuration', () => {61  it('CR_AGENT selects the profile; default is claude; unknown values are refused', () => {62    expect(loadConfig({ NODE_ENV: 'test' }).agent).toMatchObject({ id: 'claude', productName: 'Claude Remote', displayName: 'Claude Code' });63    expect(loadConfig({ NODE_ENV: 'test', CR_AGENT: '' }).agent.id).toBe('claude');64    expect(loadConfig({ NODE_ENV: 'test', CR_AGENT: 'codex' }).agent).toMatchObject({ id: 'codex', productName: 'Codex Remote', displayName: 'Codex' });65    expect(() => loadConfig({ NODE_ENV: 'test', CR_AGENT: 'other' })).toThrow(/CR_AGENT/);66    // Cookie names do not depend on the profile (the two instances live on different hosts).67    expect(loadConfig({ NODE_ENV: 'test', CR_AGENT: 'codex' }).cookieName).toBe(loadConfig({ NODE_ENV: 'test' }).cookieName);68  });69  it('builds CLI arguments per profile from validated fields only', () => {70    expect(buildClaudeArgs('claude', {})).toEqual([]);71    expect(buildClaudeArgs('claude', { continueLast: true })).toEqual(['--continue']);72    expect(buildClaudeArgs('claude', { resumeConversationId: 'abcdef12-3456-7890' })).toEqual(['--resume', 'abcdef12-3456-7890']);73    expect(buildClaudeArgs('claude', {}, 'codex')).toEqual([]);74    expect(buildClaudeArgs('claude', { continueLast: true }, 'codex')).toEqual(['resume', '--last']);75    expect(buildClaudeArgs('claude', { resumeConversationId: UUID }, 'codex')).toEqual(['resume', UUID]);76    // Codex session ids are UUIDs: a session name, an option or a prompt can never be passed.77    for (const bad of ['abcdef12-3456-7890', '--last', '--yolo', 'my session', `${UUID} do this`, '-c', '']) {78      if (bad === '') continue;79      expect(() => buildClaudeArgs('claude', { resumeConversationId: bad }, 'codex'), bad).toThrow(/invalid conversation id/);80    }81    expect(() => buildClaudeArgs('claude', { resumeConversationId: UUID, continueLast: true }, 'codex')).toThrow();82    expect(() => buildClaudeArgs('shell', { continueLast: true }, 'codex')).toThrow();83    expect(buildClaudeArgs('shell', {}, 'codex')).toEqual([]);84  });85  it('brandAsset only maps the manifest and single-segment icon names that exist in the export', () => {86    const dir = fs.realpathSync(fs.mkdtempSync('/tmp/cr-brand-'));87    try {88      webFixture({ webDir: '', dataDir: dir });89      const web = path.join(dir, 'web');90      expect(brandAsset(web, 'codex', '/manifest.webmanifest')).toBe('brand/codex/manifest.webmanifest');91      expect(brandAsset(web, 'codex', '/icons/icon-192.png')).toBe('brand/codex/icons/icon-192.png');92      expect(brandAsset(web, 'codex', '/icons/only-default.png')).toBeNull();93      expect(brandAsset(web, 'codex', '/icons/../manifest.webmanifest')).toBeNull();94      expect(brandAsset(web, 'codex', '/icons/a/b.png')).toBeNull();95      expect(brandAsset(web, 'codex', '/index.html')).toBeNull();96      expect(brandAsset(web, 'claude', '/manifest.webmanifest')).toBeNull();97      fs.symlinkSync('/etc/passwd', path.join(web, 'brand', 'codex', 'icons', 'evil.png'));98      expect(brandAsset(web, 'codex', '/icons/evil.png')).toBeNull();99    } finally {100      fs.rmSync(dir, { recursive: true, force: true });101    }102  });103});104105describe('claude profile (default: no CR_AGENT)', () => {106  let c: Ctx;107  beforeAll(async () => {108    c = await boot({});109  });110  afterAll(async () => {111    await c.h.close();112  });113114  it('health, branding, system status, OpenAPI title, TOTP issuer and passkey RP name say Claude', async () => {115    // Health and the system status block are byte-compatible with the pre-profile server.116    expect((await c.admin.get('/health')).json()).toEqual({ ok: true, version: c.h.config.version });117    const anon = new Client(c.h);118    expect((await anon.get('/auth/status')).json<AuthStatus>().branding).toEqual({ agent: 'claude', productName: 'Claude Remote', agentName: 'Claude Code' });119    expect((await c.admin.get('/system/status')).json<SystemStatus>().claude).toEqual({ version: '9.9.9', authenticated: true });120    expect((await c.admin.get('/openapi.json')).json<{ info: { title: string } }>().info.title).toBe('Claude Remote API');121    const totp = (await c.admin.post('/auth/totp/enroll/start')).json<{ otpauthUrl: string }>();122    expect(decodeURIComponent(totp.otpauthUrl)).toContain('Claude Remote');123    const reg = (await c.admin.post('/auth/passkey/register/options')).json<{ rp: { name: string } }>();124    expect(reg.rp.name).toBe('Claude Remote');125  });126127  it('serves the default manifest and icons', async () => {128    const app = c.h.server.app;129    expect((await app.inject({ url: '/manifest.webmanifest' })).body).toBe('{"name":"Claude Remote"}');130    expect((await app.inject({ url: '/icons/icon-192.png' })).body).toBe('CLAUDE-192');131    expect((await app.inject({ url: '/brand/codex/manifest.webmanifest' })).statusCode).toBe(200); // plain static file132  });133134  it('sessions: Claude title and arguments', async () => {135    const s = (await c.admin.post('/sessions', { kind: 'claude', resumeConversationId: 'abcdef12-3456-7890' })).json<TerminalSession>();136    expect(s.title).toBe('Claude');137    expect(c.h.rt.specs.get(s.id)).toMatchObject({ kind: 'claude', args: ['--resume', 'abcdef12-3456-7890'] });138  });139140  it('terminal input does NOT end WAITING_FOR_INPUT: only the UserPromptSubmit hook does', async () => {141    const s = (await c.admin.post('/sessions', { kind: 'claude' })).json<TerminalSession>();142    const ws = await attach(c, s.id);143    await until(async () => (await get(c, s.id)).state === 'RUNNING', 2000);144    await hook(c, s.id, 'Stop');145    expect(await get(c, s.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', waitingReason: 'Claude finished and is waiting for your next message' });146    let echoed = false;147    ws.on('message', (_d, isBinary) => (echoed ||= isBinary));148    ws.send(Buffer.from('typing a prompt\r'), { binary: true });149    await until(() => echoed, 2000); // the input reached the (fake, echoing) runtime and came back150    await new Promise((r) => setTimeout(r, 300));151    expect((await get(c, s.id)).state).toBe('WAITING_FOR_INPUT');152    expect(c.h.server.ctx.sessions.inputEndsWaiting).toBe(false);153    await c.h.server.ctx.sessions.inputReceived(s.id); // even called directly it is a no-op154    expect((await get(c, s.id)).state).toBe('WAITING_FOR_INPUT');155    await hook(c, s.id, 'UserPromptSubmit');156    expect((await get(c, s.id)).state).toBe('RUNNING');157    expect((await sources(c, s.id)).filter((x) => x.endsWith(':input'))).toEqual([]);158    ws.close();159    await closed(ws);160  });161});162163describe('codex profile (CR_AGENT=codex)', () => {164  let c: Ctx;165  beforeAll(async () => {166    c = await boot({ CR_AGENT: 'codex' });167  });168  afterAll(async () => {169    await c.h.close();170  });171172  it('health, branding, system status, OpenAPI title, TOTP issuer and passkey RP name say Codex', async () => {173    expect((await c.admin.get('/health')).json()).toEqual({ ok: true, version: c.h.config.version, agent: 'codex' });174    const anon = new Client(c.h);175    const status = (await anon.get('/auth/status')).json<AuthStatus>();176    expect(status.branding).toEqual({ agent: 'codex', productName: 'Codex Remote', agentName: 'Codex' });177    expect(status).toMatchObject({ authenticated: false, user: null, csrfToken: null }); // branding is all an anonymous caller learns178    expect((await c.admin.get('/system/status')).json<SystemStatus>().claude).toMatchObject({ agent: 'codex', agentName: 'Codex' });179    expect((await c.admin.get('/openapi.json')).json<{ info: { title: string } }>().info.title).toBe('Codex Remote API');180    const totp = (await c.admin.post('/auth/totp/enroll/start')).json<{ otpauthUrl: string }>();181    expect(decodeURIComponent(totp.otpauthUrl)).toContain('Codex Remote');182    expect(decodeURIComponent(totp.otpauthUrl)).not.toContain('Claude');183    const reg = (await c.admin.post('/auth/passkey/register/options')).json<{ rp: { name: string } }>();184    expect(reg.rp.name).toBe('Codex Remote');185  });186187  it('serves the Codex manifest and icons at the same URLs, falling back to the default file', async () => {188    const app = c.h.server.app;189    const manifest = await app.inject({ url: '/manifest.webmanifest' });190    expect([manifest.statusCode, manifest.body]).toEqual([200, '{"name":"Codex Remote"}']);191    expect(manifest.headers['content-security-policy']).toBeDefined();192    expect((await app.inject({ url: '/icons/icon-192.png' })).body).toBe('CODEX-192');193    expect((await app.inject({ url: '/icons/only-default.png' })).body).toBe('DEFAULT-ONLY');194    expect((await app.inject({ url: '/icons/missing.png' })).statusCode).toBe(404);195    expect((await app.inject({ url: '/icons/..%2F..%2Findex.html' })).statusCode).toBeGreaterThanOrEqual(400);196    expect((await app.inject({ url: '/' })).body).toBe('<html>app</html>');197  });198199  it('sessions: kind stays `claude` on the wire and in the database; Codex title and arguments', async () => {200    const plain = (await c.admin.post('/sessions', { kind: 'claude' })).json<TerminalSession>();201    expect(plain).toMatchObject({ kind: 'claude', title: 'Codex', state: 'DETACHED' });202    expect(c.h.rt.specs.get(plain.id)).toMatchObject({ kind: 'claude', args: [] });203    const row = await c.h.server.ctx.db.one<{ kind: string }>('SELECT kind FROM terminal_sessions WHERE id = $1', [plain.id]);204    expect(row?.kind).toBe('claude'); // no migration: the CHECK (kind IN ('claude','shell')) still holds205    const resumed = (await c.admin.post('/sessions', { kind: 'claude', resumeConversationId: UUID })).json<TerminalSession>();206    expect(c.h.rt.specs.get(resumed.id)?.args).toEqual(['resume', UUID]);207    const cont = (await c.admin.post('/sessions', { kind: 'claude', continueLast: true })).json<TerminalSession>();208    expect(c.h.rt.specs.get(cont.id)?.args).toEqual(['resume', '--last']);209    for (const bad of ['abcdef12-3456-7890', '--yolo', '--last']) {210      expect((await c.admin.post('/sessions', { kind: 'claude', resumeConversationId: bad })).statusCode, bad).toBe(400);211    }212    const sneaky = (await c.admin.post('/sessions', { kind: 'claude', args: ['--yolo'], env: { CODEX_HOME: '/x' } })).json<TerminalSession>();213    expect(c.h.rt.specs.get(sneaky.id)).toMatchObject({ args: [], env: {} });214    expect((await c.admin.post('/sessions', { kind: 'shell' })).json<TerminalSession>().title).toBe('Shell');215  });216217  it('hooks: only UUID conversation ids are stored; restart of a lost session uses `resume`', async () => {218    const s = (await c.admin.post('/sessions', { kind: 'claude' })).json<TerminalSession>();219    await hook(c, s.id, 'Stop', 'conv-12345678'); // valid for Claude, not a Codex session id220    expect(await get(c, s.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', claudeConversationId: null, waitingReason: 'Codex finished and is waiting for your next message' });221    await hook(c, s.id, 'Stop', UUID);222    expect((await get(c, s.id)).claudeConversationId).toBe(UUID);223    // Host reboot: the runtime no longer knows the session.224    c.h.rt.sessions.delete(s.id);225    await c.h.server.reconcile();226    expect((await get(c, s.id)).state).toBe('RECOVERABLE');227    const restarted = await c.admin.post(`/sessions/${s.id}/reconnect`, { restart: true });228    expect(restarted.statusCode).toBe(200);229    expect(c.h.rt.specs.get(s.id)?.args).toEqual(['resume', UUID]);230  });231232  it('terminal input from a browser returns WAITING_FOR_INPUT → RUNNING, once per waiting period', async () => {233    const s = (await c.admin.post('/sessions', { kind: 'claude' })).json<TerminalSession>();234    const ws = await attach(c, s.id);235    await until(async () => (await get(c, s.id)).state === 'RUNNING', 2000);236    expect(c.h.server.ctx.sessions.inputEndsWaiting).toBe(true);237238    // Typing while RUNNING changes nothing.239    ws.send(Buffer.from('a'), { binary: true });240    await new Promise((r) => setTimeout(r, 150));241    expect(await sources(c, s.id)).toEqual(['CREATED:', 'STARTING:create', 'DETACHED:start', 'RUNNING:attach']);242243    // Turn finished (cr-codex-notify → Stop).244    await hook(c, s.id, 'Stop', UUID);245    expect((await get(c, s.id)).state).toBe('WAITING_FOR_INPUT');246    // Control frames (resize / ping) are not input.247    ws.send(JSON.stringify({ t: 'resize', cols: 90, rows: 20 }));248    ws.send(JSON.stringify({ t: 'ping', n: 1 }));249    await new Promise((r) => setTimeout(r, 200));250    expect((await get(c, s.id)).state).toBe('WAITING_FOR_INPUT');251252    // First keystrokes: exactly one transition, however many frames follow.253    for (const ch of ['h', 'e', 'l', 'l', 'o', '\r']) ws.send(Buffer.from(ch), { binary: true });254    await until(async () => (await get(c, s.id)).state === 'RUNNING', 2000);255    expect(await get(c, s.id)).toMatchObject({ state: 'RUNNING', waitingReason: null });256    await new Promise((r) => setTimeout(r, 200));257    expect((await sources(c, s.id)).filter((x) => x === 'RUNNING:input')).toHaveLength(1);258259    // Next waiting period: again exactly one.260    await hook(c, s.id, 'Stop', UUID);261    expect((await get(c, s.id)).state).toBe('WAITING_FOR_INPUT');262    ws.send(Buffer.from('again\r'), { binary: true });263    ws.send(Buffer.from('more'), { binary: true });264    await until(async () => (await get(c, s.id)).state === 'RUNNING', 2000);265    await new Promise((r) => setTimeout(r, 200));266    expect((await sources(c, s.id)).filter((x) => x === 'RUNNING:input')).toHaveLength(2);267    ws.close();268    await closed(ws);269  });270271  it('after a server restart the reconciler re-learns which sessions are waiting', async () => {272    const s = (await c.admin.post('/sessions', { kind: 'claude' })).json<TerminalSession>();273    await c.h.server.ctx.db.q("UPDATE terminal_sessions SET state = 'WAITING_FOR_INPUT' WHERE id = $1", [s.id]); // state written by a previous process274    await c.h.server.ctx.sessions.inputReceived(s.id);275    expect((await get(c, s.id)).state).toBe('WAITING_FOR_INPUT'); // not known yet: no query per keystroke276    await c.h.server.reconcile();277    await c.h.server.ctx.sessions.inputReceived(s.id);278    expect((await get(c, s.id)).state).toBe('RUNNING');279  });280});281
Files main
Menu

Theme