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