SPB Git

Repository Public

real-runtime.test.ts 33.3 KB 559 lines typescript

1/**2 * End-to-end integration: real server ⇄ REAL runtime (child process, own tmux socket), driven3 * through the browser API with real HTTP and WebSockets. Tests run in order and share state.4 *5 * Nothing here writes Claude configuration (CR_INSTALL_CLAUDE_HOOKS is unset; skills/MCP are only6 * listed) and no prompt is ever sent to Claude.7 */8import { execFileSync } from 'node:child_process';9import { createHash } from 'node:crypto';10import fs from 'node:fs';11import path from 'node:path';12import { afterAll, beforeAll, describe, expect, it } from 'vitest';13import type { AgentTask, FileContent, FileEntry, GitStatus, McpServerInfo, Notification, Project, SearchHit, ServerEvent, SkillInfo, SystemStatus, TerminalSession } from '@cr/shared';14import { Browser, HOOK_SCRIPT, PASSWORD, Stream, sleep, startRealStack, waitFor, type RealStack } from './real-harness.js';1516const sha = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex');17const CONV_ID = '11111111-aaaa-bbbb-cccc-222222222222';1819let stack: RealStack;20let b: Browser;21let events: Stream;22let project: Project;23let projectAbs: string;24/** The long-lived shell session that goes through detach, runtime restart, tmux loss and restart. */25let main: TerminalSession;2627const serverEvents = (): ServerEvent[] => events.messages as unknown as ServerEvent[];28const session = (id: string): Promise<TerminalSession> => b.getJson<TerminalSession>(`/sessions/${id}`);29const task = (id: string): Promise<AgentTask> => b.getJson<AgentTask>(`/tasks/${id}`);30const waitState = (id: string, states: TerminalSession['state'][], ms = 20_000): Promise<TerminalSession> =>31  waitFor(async () => {32    const s = await session(id);33    return states.includes(s.state) ? s : null;34  }, ms, `session ${id} in ${states.join('|')}`);35const waitTask = (id: string, states: AgentTask['state'][], ms = 30_000): Promise<AgentTask> =>36  waitFor(async () => {37    const t = await task(id);38    return states.includes(t.state) ? t : null;39  }, ms, `task ${id} in ${states.join('|')}`);40async function createSession(body: Record<string, unknown>): Promise<TerminalSession> {41  const res = await b.post('/sessions', { projectId: project.id, kind: 'shell', ...body });42  expect(res.status, res.text).toBe(201);43  return res.json<TerminalSession>();44}45async function attach(id: string, cols = 120, rows = 32): Promise<Stream> {46  const s = await new Stream(b.ws(`/sessions/${id}/stream?cols=${cols}&rows=${rows}`)).open();47  expect(s.upgradeStatus, 'terminal upgrade refused').toBeNull();48  await s.waitMsg((m) => m.t === 'ready', 10_000, 'terminal ready');49  return s;50}51async function createTask(body: Record<string, unknown>): Promise<AgentTask> {52  const res = await b.post('/tasks', { projectId: project.id, title: 'it', ...body });53  expect(res.status, res.text).toBe(201);54  return res.json<AgentTask>();55}56/** Runs the real `cr-hook` script inside the session, the way Claude Code would. */57const hookCommand = (payload: Record<string, unknown>, marker: string): string =>58  ` echo '${JSON.stringify(payload)}' | '${HOOK_SCRIPT}'; echo ${marker}-$((20+3))\n`;5960beforeAll(async () => {61  stack = await startRealStack();62  b = stack.browser();63}, 180_000);6465afterAll(async () => {66  await events?.close().catch(() => undefined);67  await stack?.close();68  if (stack) {69    // Leave nothing behind: no tmux server on our socket, no test database.70    expect(stack.tmux(['ls'])).toBeNull();71    expect(execFileSync('psql', ['-ltA'], { encoding: 'utf8' })).not.toContain(stack.dbName);72  }73}, 60_000);7475describe('real server ⇄ real runtime', () => {76  it('setup → enrol TOTP → logout → login (password + TOTP)', async () => {77    expect((await b.sync()).setupRequired).toBe(true);78    expect((await b.get('/projects')).status).toBe(401);79    const setup = await b.post('/auth/setup', { setupToken: stack.setupToken(), username: 'admin', password: PASSWORD });80    expect(setup.status, setup.text).toBe(200);81    await b.sync();82    expect((await b.get('/projects')).status).toBe(403); // second factor still missing83    const start = (await b.post('/auth/totp/enroll/start')).json<{ secret: string }>();84    const fin = await b.post('/auth/totp/enroll/finish', { code: stack.totp(start.secret) });85    expect(fin.status, fin.text).toBe(200);86    await b.sync();87    expect((await b.post('/auth/logout')).status).toBe(200);88    expect((await b.get('/projects')).status).toBe(401);8990    stack.clock.offsetMs += 30_000; // next TOTP step: the enrolment code cannot be replayed91    b = stack.browser();92    const login = await b.post('/auth/login', { username: 'admin', password: PASSWORD, totp: stack.totp(start.secret) });93    expect(login.status, login.text).toBe(200);94    expect(login.json()).toMatchObject({ ok: true, next: 'done' });95    const status = await b.sync();96    expect(status).toMatchObject({ authenticated: true, setupRequired: false });97    expect((await b.get('/projects')).status).toBe(200);9899    events = await new Stream(b.ws('/events')).open();100    await events.waitMsg((m) => m.type === 'hello', 5000, 'events hello');101  });102103  it('creates a project directory (mode create)', async () => {104    const res = await b.post('/projects', { name: 'Integration Demo', mode: 'create', path: 'demo' });105    expect(res.status, res.text).toBe(201);106    project = res.json<Project>();107    expect(project).toMatchObject({ name: 'Integration Demo', rootLabel: 'ws', path: 'demo', gitBranch: null, activeSessions: 0 });108    projectAbs = path.join(stack.workspace, 'demo');109    expect(fs.statSync(projectAbs).isDirectory()).toBe(true);110    expect((await b.post('/projects', { name: 'dup', mode: 'create', path: 'demo' })).status).toBe(409);111    expect((await b.post('/projects', { name: 'x', mode: 'existing', path: 'missing' })).status).toBe(400);112    expect((await b.post('/projects', { name: 'x', mode: 'create', path: '../escape' })).status).toBe(400);113  });114115  it('files: write / read / conflict (409 + details) / mkdir / rename / upload (+overwrite) / download / search / delete', async () => {116    const p = `/projects/${project.id}`;117    const created = await b.put(`${p}/file`, { path: 'notes.txt', content: 'alpha needle-one\n', create: true });118    expect(created.status, created.text).toBe(200);119    const meta = created.json<FileContent>();120    expect(meta).toMatchObject({ path: 'notes.txt', size: 17, sha256: sha('alpha needle-one\n'), encoding: 'utf8', content: 'alpha needle-one\n' });121    expect(fs.readFileSync(path.join(projectAbs, 'notes.txt'), 'utf8')).toBe('alpha needle-one\n');122    expect((await b.put(`${p}/file`, { path: 'notes.txt', content: 'x', create: true })).status).toBe(409);123124    const read = await b.getJson<FileContent>(`${p}/file?path=notes.txt`);125    expect(read).toMatchObject({ content: 'alpha needle-one\n', sha256: meta.sha256 });126127    const saved = await b.put(`${p}/file`, { path: 'notes.txt', content: 'beta needle-one\n', baseSha256: read.sha256 });128    expect(saved.status, saved.text).toBe(200);129    expect(saved.json<FileContent>()).toMatchObject({ sha256: sha('beta needle-one\n'), content: 'beta needle-one\n' });130131    // Someone else changes the file on disk: the stale editor gets a 409 with the disk state.132    fs.writeFileSync(path.join(projectAbs, 'notes.txt'), 'changed elsewhere\n');133    const conflict = await b.put(`${p}/file`, { path: 'notes.txt', content: 'mine\n', baseSha256: sha('beta needle-one\n') });134    expect(conflict.status).toBe(409);135    const err = conflict.json<{ error: { code: string; message: string; details: { sha256: string; mtimeMs: number } } }>().error;136    expect(err.code).toBe('conflict');137    expect(err.details.sha256).toBe(sha('changed elsewhere\n'));138    expect(typeof err.details.mtimeMs).toBe('number');139    expect(fs.readFileSync(path.join(projectAbs, 'notes.txt'), 'utf8')).toBe('changed elsewhere\n');140    // Overwrite on purpose with the sha the conflict reported.141    expect((await b.put(`${p}/file`, { path: 'notes.txt', content: 'final needle-one\n', baseSha256: err.details.sha256 })).status).toBe(200);142143    expect((await b.post(`${p}/fs`, { op: 'mkdir', path: 'src/deep' })).status).toBe(200);144    const renamed = await b.post(`${p}/fs`, { op: 'rename', from: 'notes.txt', to: 'src/deep/renamed.txt' });145    expect(renamed.status, renamed.text).toBe(200);146    expect(fs.existsSync(path.join(projectAbs, 'notes.txt'))).toBe(false);147    expect((await b.post(`${p}/fs`, { op: 'rename', from: 'nope', to: 'x' })).status).toBe(404);148    expect((await b.post(`${p}/fs`, { op: 'delete', path: 'src' })).status).toBe(400); // confirm:true is mandatory149150    const bytes = Buffer.concat([Buffer.from([0, 1, 2, 255]), Buffer.alloc(300_000, 7)]);151    const up = await b.req('POST', `${p}/upload?path=${encodeURIComponent('uploads/new dir/blob.bin')}`, { raw: bytes });152    expect(up.status, up.text).toBe(201);153    expect(up.json()).toMatchObject({ path: 'uploads/new dir/blob.bin', size: bytes.length });154    const again = await b.req('POST', `${p}/upload?path=${encodeURIComponent('uploads/new dir/blob.bin')}`, { raw: Buffer.from('v2') });155    expect(again.status).toBe(409);156    expect(again.json<{ error: { code: string } }>().error.code).toBe('exists');157    const over = await b.req('POST', `${p}/upload?path=${encodeURIComponent('uploads/new dir/blob.bin')}&overwrite=1`, { raw: Buffer.from('v2-bytes') });158    expect(over.status, over.text).toBe(201);159    const dl = await b.get(`${p}/download?path=${encodeURIComponent('uploads/new dir/blob.bin')}`);160    expect(dl.status).toBe(200);161    expect(dl.buf.equals(Buffer.from('v2-bytes'))).toBe(true);162    expect(dl.headers.get('content-disposition')).toContain('attachment');163    expect((await b.get(`${p}/file?path=${encodeURIComponent('uploads/new dir/blob.bin')}`)).status).toBe(200); // now text164    const big = await b.req('POST', `${p}/upload?path=big.bin`, { raw: bytes });165    expect(big.status).toBe(201);166    expect((await b.get(`${p}/download?path=big.bin`)).buf.equals(bytes)).toBe(true);167    expect((await b.get(`${p}/file?path=big.bin`)).status).toBe(415); // binary168169    const list = await b.getJson<FileEntry[]>(`${p}/files?path=`);170    expect(list.map((e) => `${e.type}:${e.name}`)).toEqual(['dir:src', 'dir:uploads', 'file:big.bin']);171172    const byName = await b.getJson<SearchHit[]>(`${p}/search?q=renamed&mode=name`);173    expect(byName.map((h) => h.path)).toContain('src/deep/renamed.txt');174    const byContent = await b.getJson<SearchHit[]>(`${p}/search?q=needle-one&mode=content`);175    expect(byContent).toEqual([expect.objectContaining({ path: 'src/deep/renamed.txt', line: 1 })]);176    expect(byContent[0]?.preview).toContain('needle-one');177    expect(await b.getJson<SearchHit[]>(`${p}/search?q=needle-one&mode=content&path=uploads`)).toEqual([]);178179    expect((await b.post(`${p}/fs`, { op: 'delete', path: 'big.bin', confirm: true })).status).toBe(200);180    expect(fs.existsSync(path.join(projectAbs, 'big.bin'))).toBe(false);181    expect((await b.get(`${p}/file?path=..%2F..%2Fetc%2Fpasswd`)).status).toBe(400);182    fs.symlinkSync('/etc', path.join(projectAbs, 'escape'));183    expect((await b.get(`${p}/file?path=escape%2Fpasswd`)).status).toBe(403);184    fs.rmSync(path.join(projectAbs, 'escape'));185  });186187  it('git status / diff on a repository initialised inside the project', async () => {188    const p = `/projects/${project.id}`;189    expect(await b.getJson<GitStatus>(`${p}/git/status`)).toMatchObject({ isRepo: false, files: [] });190    expect((await b.get(`${p}/git/diff`)).status).toBe(400);191    const git = (...args: string[]): void => {192      execFileSync('git', ['-c', 'user.name=it', '-c', 'user.email=it@example.invalid', '-c', 'commit.gpgsign=false', ...args], { cwd: projectAbs, stdio: 'pipe' });193    };194    git('init', '-q', '-b', 'main');195    git('add', 'src');196    git('commit', '-q', '-m', 'init');197    fs.appendFileSync(path.join(projectAbs, 'src/deep/renamed.txt'), 'added-line\n');198    const st = await b.getJson<GitStatus>(`${p}/git/status`);199    expect(st).toMatchObject({ isRepo: true, branch: 'main', ahead: 0, behind: 0 });200    expect(st.files).toContainEqual({ path: 'src/deep/renamed.txt', index: '.', worktree: 'M' });201    expect(st.files.some((f) => f.index === '?' && f.path.startsWith('uploads'))).toBe(true);202    const diff = await b.getJson<{ diff: string; truncated: boolean }>(`${p}/git/diff?path=${encodeURIComponent('src/deep/renamed.txt')}`);203    expect(diff.truncated).toBe(false);204    expect(diff.diff).toContain('+added-line');205    expect((await b.getJson<{ diff: string }>(`${p}/git/diff`)).diff).toContain('+added-line');206    expect((await b.getJson<{ diff: string }>(`${p}/git/diff?staged=1`)).diff).toBe('');207  });208209  it('shell session: terminal I/O, resize, detach on close, reattach to the same shell', async () => {210    main = await createSession({ title: 'main shell' });211    expect(main).toMatchObject({ kind: 'shell', state: 'DETACHED', projectId: project.id, attachedClients: 0, exitCode: null });212    expect(typeof main.pid).toBe('number');213214    const t = await attach(main.id, 120, 32);215    t.type(' echo it-$((40+2))\n');216    await t.waitOutput('it-42');217    t.type(' CRV=keepme-$((3+4)); pwd\n');218    await t.waitOutput(projectAbs);219    t.control({ t: 'ping', n: 7 });220    expect(await t.waitMsg((m) => m.t === 'pong', 5000, 'pong')).toEqual({ t: 'pong', n: 7 });221    t.control({ t: 'resize', cols: 100, rows: 40 });222    await waitFor(async () => {223      t.type(' echo size=$(stty size | tr " " x)\n');224      await sleep(400);225      return t.output.includes('size=40x100');226    }, 10_000, 'resize applied to the pty');227228    const running = await waitState(main.id, ['RUNNING']);229    expect(running.attachedClients).toBe(1);230    expect(serverEvents().some((e) => e.type === 'session.updated' && e.session.id === main.id && e.session.state === 'RUNNING')).toBe(true);231232    await t.close();233    const detached = await waitState(main.id, ['DETACHED']);234    expect(detached.attachedClients).toBe(0);235    await waitFor(() => serverEvents().some((e) => e.type === 'session.updated' && e.session.id === main.id && e.session.state === 'DETACHED'), 5000, 'DETACHED event');236237    const t2 = await attach(main.id);238    t2.type(' echo v=$CRV\n');239    await t2.waitOutput('v=keepme-7');240    await waitState(main.id, ['RUNNING']);241    await t2.close();242    expect((await b.getJson<{ text: string }>(`/sessions/${main.id}/snapshot`)).text).toContain('v=keepme-7');243  }, 90_000);244245  it('attach state: RUNNING within a second of a browser attaching; two clients; DETACHED after the last one leaves', async () => {246    const s = await createSession({ title: 'attach state' });247    expect(s).toMatchObject({ state: 'DETACHED', attachedClients: 0 });248    await sleep(1200); // let the runtime report the idle session first: the next change is the attach249250    const t0 = Date.now();251    const a = await attach(s.id);252    const running = await waitState(s.id, ['RUNNING'], 1000);253    expect(Date.now() - t0).toBeLessThan(1000);254    expect(running.attachedClients).toBe(1);255    await waitFor(() => serverEvents().some((e) => e.type === 'session.updated' && e.session.id === s.id && e.session.state === 'RUNNING' && e.session.attachedClients === 1), 2000, 'RUNNING event with 1 client');256257    const c = await attach(s.id);258    await waitFor(async () => (await session(s.id)).attachedClients === 2, 2000, 'two clients');259    await sleep(2500); // runtime events + several reconciler passes agree260    expect(await session(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 2 });261262    await a.close();263    await waitFor(async () => (await session(s.id)).attachedClients === 1, 3000, 'one client left');264    await sleep(2500);265    expect(await session(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 1 });266267    // History printed now is replayed to the next browser that attaches.268    c.type(" seq -f 'sb-%.0f' 1 300\n");269    await c.waitOutput('sb-300');270    const t1 = Date.now();271    await c.close();272    const detached = await waitState(s.id, ['DETACHED'], 5000);273    expect(Date.now() - t1).toBeLessThan(5000);274    expect(detached.attachedClients).toBe(0);275    await sleep(2500);276    expect(await session(s.id)).toMatchObject({ state: 'DETACHED', attachedClients: 0 });277278    const again = await attach(s.id);279    await waitState(s.id, ['RUNNING'], 1000);280    await again.waitOutput('sb-300');281    await waitFor(() => /sb-1\r\n/.test(again.output), 5000, 'history replay');282    const seen = [...again.output.matchAll(/sb-(\d+)(?=\x1b|\r|\n)/g)].map((m) => Number(m[1]));283    expect(new Set(seen).size).toBe(300);284    expect(seen.slice(0, 250)).toEqual(Array.from({ length: 250 }, (_, i) => i + 1)); // the replayed history, in order285    await again.close();286    await waitState(s.id, ['DETACHED'], 5000);287    expect((await b.post(`/sessions/${s.id}/terminate`)).json<TerminalSession>().state).toBe('STOPPED');288  }, 90_000);289290  it('a second session is isolated; terminate ⇒ STOPPED; unknown session ⇒ refused', async () => {291    const other = await createSession({ title: 'other', cwd: 'src' });292    expect(other.id).not.toBe(main.id);293    expect(other.pid).not.toBe(main.pid);294    const t = await attach(other.id);295    t.type(' echo other=${CRV:-unset} in=$(basename "$PWD") sid=$CR_SESSION_ID tok=${CR_RUNTIME_TOKEN:-none} db=${DATABASE_URL:-none} key=${CR_SECRET_KEY:-none}\n');296    await t.waitOutput(`other=unset in=src sid=${other.id} tok=none db=none key=none`);297    expect(execFileSync('ps', ['-axo', 'command'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })).not.toContain(stack.config.runtimeToken);298299    const stopped = await b.post(`/sessions/${other.id}/terminate`);300    expect(stopped.status, stopped.text).toBe(200);301    expect(stopped.json<TerminalSession>()).toMatchObject({ state: 'STOPPED', attachedClients: 0, pid: null });302    expect(await t.waitClose()).toBe(4410);303    expect(stack.tmux(['has-session', '-t', `=cr-${other.id}`])).toBeNull();304    expect((await session(main.id)).state).not.toBe('STOPPED');305306    // Ended or unknown sessions never become a WebSocket.307    expect((await new Stream(b.ws(`/sessions/${other.id}/stream`)).open()).upgradeStatus).toBe(410);308    expect((await new Stream(b.ws('/sessions/00000000-0000-4000-8000-000000000000/stream')).open()).upgradeStatus).toBe(404);309    expect((await b.post(`/sessions/${other.id}/reconnect`, { restart: true })).json<TerminalSession>().state).toBe('STOPPED');310  }, 60_000);311312  it('a shell that exits on its own ⇒ STOPPED / FAILED with its exit code', async () => {313    const ok = await createSession({ title: 'exits 0' });314    const bad = await createSession({ title: 'exits 3' });315    const [a, c] = [await attach(ok.id), await attach(bad.id)];316    a.type(' exit 0\n');317    c.type(' exit 3\n');318    expect(await waitState(ok.id, ['STOPPED', 'FAILED'])).toMatchObject({ state: 'STOPPED', exitCode: 0 });319    expect(await waitState(bad.id, ['STOPPED', 'FAILED'])).toMatchObject({ state: 'FAILED', exitCode: 3 });320    expect(await a.waitClose()).toBe(4410);321    expect(await c.waitClose()).toBe(4410);322    // The dead panes are dropped once the exit code is recorded.323    await waitFor(() => stack.tmux(['has-session', '-t', `=cr-${bad.id}`]) === null, 10_000, 'dead pane removed');324  }, 60_000);325326  it('tmux session killed behind the server ⇒ RECOVERABLE (process lost, metadata kept)', async () => {327    const victim = await createSession({ title: 'victim' });328    expect(stack.tmux(['kill-session', '-t', `=cr-${victim.id}`])).not.toBeNull();329    const s = await waitState(victim.id, ['RECOVERABLE', 'STOPPED', 'FAILED']);330    expect(s).toMatchObject({ state: 'RECOVERABLE', pid: null, attachedClients: 0 });331    expect((await session(main.id)).state).toBe('DETACHED');332    expect((await b.post(`/sessions/${victim.id}/terminate`)).json<TerminalSession>().state).toBe('STOPPED');333  }, 60_000);334335  it('Claude hook (real cr-hook script) ⇒ WAITING_FOR_INPUT, conversation id, generic notification', async () => {336    const t = await attach(main.id);337    await waitState(main.id, ['RUNNING']);338    t.type(hookCommand({ hook_event_name: 'Notification', session_id: CONV_ID, message: 'needs permission' }, 'hook-a'));339    await t.waitOutput('hook-a-23');340    const waiting = await waitState(main.id, ['WAITING_FOR_INPUT']);341    expect(waiting.claudeConversationId).toBe(CONV_ID);342    expect(waiting.waitingReason).toBe('Claude needs your attention'); // generic: the hook message is not exposed343    const notes = await b.getJson<Notification[]>('/notifications');344    const note = notes.find((n) => n.kind === 'session.waiting');345    expect(note).toMatchObject({ title: 'A session is waiting for input', body: '', link: `/terminal/?s=${main.id}`, readAt: null });346    expect(JSON.stringify(notes)).not.toContain('needs permission');347    await waitFor(() => serverEvents().some((e) => e.type === 'notification' && e.notification.kind === 'session.waiting'), 5000, 'notification event');348    // Activity / attach changes never clear WAITING_FOR_INPUT: only a hook does.349    await t.close();350    await waitFor(async () => (await session(main.id)).attachedClients === 0, 5000, 'no client left');351    await sleep(3000);352    expect(await session(main.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', attachedClients: 0 });353    // ...and a browser attaching again does not either.354    const t2 = await attach(main.id);355    await waitFor(async () => (await session(main.id)).attachedClients === 1, 2000, 'client counted');356    await sleep(2500);357    expect(await session(main.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', attachedClients: 1, waitingReason: 'Claude needs your attention' });358    await t2.close();359    await waitFor(async () => (await session(main.id)).attachedClients === 0, 5000, 'no client left');360    expect((await session(main.id)).state).toBe('WAITING_FOR_INPUT');361  }, 60_000);362363  it('runtime down ⇒ OFFLINE, states untouched; restart ⇒ same tmux sessions, hooks still accepted', async () => {364    const t = await attach(main.id);365    await stack.stopRuntime();366    expect(await t.waitClose()).toBe(4503);367    const down = await b.getJson<SystemStatus>('/system/status');368    expect(down.components.find((c) => c.component === 'runtime')).toMatchObject({ health: 'OFFLINE' });369    expect(down.components.find((c) => c.component === 'tmux')?.health).toBe('UNKNOWN');370    expect(down.components.find((c) => c.component === 'database')?.health).toBe('ONLINE');371    expect(down.host.cpuPct).toBeNull();372    expect(down.claude).toEqual({ version: null, authenticated: null });373374    await sleep(3500); // several reconciler passes with the runtime unreachable375    expect(await session(main.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', claudeConversationId: CONV_ID });376    expect((await b.post('/sessions', { projectId: project.id, kind: 'shell' })).status).toBe(503);377    expect((await b.post(`/sessions/${main.id}/terminate`)).status).toBe(503);378    expect((await b.post(`/sessions/${main.id}/reconnect`)).status).toBe(503);379    expect((await b.get(`/projects/${project.id}/files`)).status).toBe(503);380    expect((await session(main.id)).state).toBe('WAITING_FOR_INPUT');381    expect((await new Stream(b.ws(`/sessions/${main.id}/stream`)).open().then((s) => s.waitClose())) === 4503).toBe(true);382    expect(stack.tmux(['has-session', '-t', `=cr-${main.id}`])).not.toBeNull(); // tmux outlived the runtime383384    await stack.startRuntime();385    await waitFor(async () => (await b.getJson<SystemStatus>('/system/status')).components.find((c) => c.component === 'runtime')?.health === 'ONLINE', 15_000, 'runtime ONLINE');386    const t2 = await attach(main.id);387    t2.type(' echo v=$CRV\n');388    await t2.waitOutput('v=keepme-7'); // same shell process as before the restart389    expect((await session(main.id)).state).toBe('WAITING_FOR_INPUT');390391    // The session still holds the hook token of the previous runtime: it must keep working.392    // (Retried: the server's /events link to the runtime reconnects with a backoff.)393    let n = 0;394    await waitFor(async () => {395      const marker = `hook-b${++n}`;396      t2.type(hookCommand({ hook_event_name: 'UserPromptSubmit', session_id: CONV_ID }, marker));397      await t2.waitOutput(`${marker}-23`);398      await sleep(1500);399      return (await session(main.id)).state === 'RUNNING';400    }, 30_000, 'UserPromptSubmit hook ⇒ RUNNING after a runtime restart', 0);401    expect((await session(main.id)).waitingReason).toBeNull();402    await t2.close();403  }, 120_000);404405  it('host reboot (tmux server gone) ⇒ RECOVERABLE; reconnect {restart:true} ⇒ fresh process, same id', async () => {406    const t = await attach(main.id);407    expect(stack.tmux(['kill-server'])).not.toBeNull();408    expect(await t.waitClose()).toBe(4410);409    const lost = await waitState(main.id, ['RECOVERABLE', 'STOPPED', 'FAILED']);410    expect(lost).toMatchObject({ state: 'RECOVERABLE', pid: null, claudeConversationId: CONV_ID });411    expect((await new Stream(b.ws(`/sessions/${main.id}/stream`)).open()).upgradeStatus).toBe(410);412    expect((await b.post(`/sessions/${main.id}/reconnect`)).json<TerminalSession>().state).toBe('RECOVERABLE'); // no restart asked413414    const res = await b.post(`/sessions/${main.id}/reconnect`, { restart: true });415    expect(res.status, res.text).toBe(200);416    const back = res.json<TerminalSession>();417    expect(back).toMatchObject({ id: main.id, state: 'DETACHED', exitCode: null, endedAt: null });418    expect(typeof back.pid).toBe('number');419    const t2 = await attach(main.id);420    t2.type(' echo again-$((1+1)) v=${CRV:-gone} in=$(basename "$PWD")\n');421    await t2.waitOutput('again-2 v=gone in=demo'); // new process: memory did not survive, cwd did422    await t2.close();423  }, 90_000);424425  it('tasks: live log stream, SUCCEEDED with usage, artifacts, failure, cancel, retry rules, host allowlist', async () => {426    const t = await createTask({427      title: 'three lines',428      command: 'for i in 1 2 3; do echo line-$i; sleep 0.3; done; mkdir -p out; echo artifact-body > out/result.txt',429      artifactGlobs: ['out/*.txt'],430    });431    expect(t).toMatchObject({ host: 'local', exitCode: null, retryOf: null, idempotent: false });432    expect(['QUEUED', 'RUNNING']).toContain(t.state);433    const log = await new Stream(b.ws(`/tasks/${t.id}/log/stream`)).open();434    expect(log.upgradeStatus).toBeNull();435    await log.waitOutput('line-1');436    expect(await log.waitClose(20_000)).toBe(1000);437    expect(log.output).toMatch(/line-1\r?\nline-2\r?\nline-3/);438439    const done = await waitTask(t.id, ['SUCCEEDED', 'FAILED', 'LOST', 'TIMED_OUT', 'CANCELLED']);440    expect(done).toMatchObject({ state: 'SUCCEEDED', exitCode: 0 });441    expect(done.logBytes).toBeGreaterThan(15);442    expect(done.startedAt).not.toBeNull();443    expect(done.endedAt).not.toBeNull();444    for (const v of [done.cpuSec, done.maxRssMb]) expect(v === null || (typeof v === 'number' && Number.isFinite(v) && v >= 0)).toBe(true);445    const res = await b.get(`/tasks/${t.id}/log`);446    expect(res.status).toBe(200);447    expect(res.text).toContain('line-3');448    expect(Number(res.headers.get('x-log-size'))).toBe(res.buf.length);449    expect((await b.get(`/tasks/${t.id}/log?offset=${res.buf.length}`)).buf.length).toBe(0);450451    const withArtifacts = await waitFor(async () => {452      const cur = await task(t.id);453      return cur.artifacts.length > 0 ? cur : null;454    }, 10_000, 'artifact collected');455    expect(withArtifacts.artifacts).toEqual([expect.objectContaining({ name: 'result.txt', size: 14 })]);456    const art = await b.get(`/tasks/${t.id}/artifacts/${withArtifacts.artifacts[0]?.id}`);457    expect(art.status).toBe(200);458    expect(art.text).toBe('artifact-body\n');459    await waitFor(() => serverEvents().some((e) => e.type === 'task.updated' && e.task.id === t.id && e.task.state === 'SUCCEEDED'), 5000, 'task.updated event');460    await waitFor(async () => (await b.getJson<Notification[]>('/notifications')).some((n) => n.kind === 'task.succeeded'), 5000, 'task notification');461462    const failing = await createTask({ title: 'fails', command: 'echo to-stderr >&2; exit 3' });463    expect(await waitTask(failing.id, ['SUCCEEDED', 'FAILED', 'LOST'])).toMatchObject({ state: 'FAILED', exitCode: 3 });464    expect((await b.get(`/tasks/${failing.id}/log`)).text).toContain('to-stderr');465    const retryRefused = await b.post(`/tasks/${failing.id}/retry`);466    expect(retryRefused.status).toBe(409);467    expect(retryRefused.json<{ error: { code: string } }>().error.code).toBe('not_idempotent');468469    const long = await createTask({ title: 'sleeper', command: 'echo started; sleep 60' });470    await waitTask(long.id, ['RUNNING']);471    await waitFor(async () => (await b.get(`/tasks/${long.id}/log`)).text.includes('started'), 10_000, 'sleeper started');472    const cancel = await b.post(`/tasks/${long.id}/cancel`);473    expect(cancel.status, cancel.text).toBe(200);474    expect(['CANCELLING', 'CANCELLED']).toContain(cancel.json<AgentTask>().state);475    const cancelled = await waitTask(long.id, ['CANCELLED', 'FAILED', 'SUCCEEDED', 'LOST'], 20_000);476    expect(cancelled.state).toBe('CANCELLED');477    expect(cancelled.endedAt).not.toBeNull();478    expect((await b.post(`/tasks/${long.id}/cancel`)).status).toBe(409);479480    const timed = await createTask({ title: 'times out', command: 'sleep 30', timeoutSec: 1 });481    expect((await waitTask(timed.id, ['TIMED_OUT', 'FAILED', 'SUCCEEDED', 'LOST', 'CANCELLED'], 20_000)).state).toBe('TIMED_OUT');482483    const idem = await createTask({ title: 'idempotent', command: 'echo once', idempotent: true });484    await waitTask(idem.id, ['SUCCEEDED']);485    const retried = await b.post(`/tasks/${idem.id}/retry`);486    expect(retried.status, retried.text).toBe(201);487    const second = retried.json<AgentTask>();488    expect(second).toMatchObject({ retryOf: idem.id, idempotent: true, command: 'echo once' });489    expect(second.id).not.toBe(idem.id);490    expect((await waitTask(second.id, ['SUCCEEDED', 'FAILED', 'LOST'])).state).toBe('SUCCEEDED');491492    const remote = await b.post('/tasks', { projectId: project.id, title: 'remote', command: 'hostname', host: 'M3U96a' });493    expect(remote.status).toBe(403);494    expect(remote.json<{ error: { code: string } }>().error.code).toBe('host_not_allowed');495    expect(stack.tmux(['ls']) ?? '').not.toContain('crt-'); // no task runner left behind496  }, 180_000);497498  it('/system/status: real host metrics, component health, Claude version', async () => {499    const st = await b.getJson<SystemStatus>('/system/status');500    const health = Object.fromEntries(st.components.map((c) => [c.component, c.health]));501    expect(health).toMatchObject({ database: 'ONLINE', runtime: 'ONLINE', tmux: 'ONLINE', agentd: 'UNKNOWN' });502    expect(st.host.name.length).toBeGreaterThan(0);503    expect(st.host.load).toHaveLength(3);504    for (const v of [st.host.memUsedMb, st.host.memTotalMb, st.host.diskFreeGb, st.host.diskTotalGb, st.host.uptimeSec]) {505      expect(typeof v).toBe('number');506      expect(v as number).toBeGreaterThan(0);507    }508    expect(st.host.memUsedMb as number).toBeLessThanOrEqual(st.host.memTotalMb as number);509    expect(st.host.cpuPct === null || (st.host.cpuPct >= 0 && st.host.cpuPct <= 100)).toBe(true);510    expect(st.counts.liveSessions).toBe(1);511    expect(st.counts.wsClients).toBeGreaterThanOrEqual(1);512    expect(st.claude.version).toMatch(/^\d+\.\d+\.\d+/);513    expect([true, false, null]).toContain(st.claude.authenticated);514    expect(['ONLINE', 'DEGRADED', 'UNKNOWN']).toContain(health.claude);515  }, 60_000);516517  it('skills and MCP listings answer quickly (read-only, configuration only)', async () => {518    const t0 = Date.now();519    const skills = await b.get('/skills');520    expect(skills.status, skills.text).toBe(200);521    const mcp = await b.get('/mcp?live=0');522    expect(mcp.status, mcp.text).toBe(200);523    const byDefault = await b.get(`/mcp?projectId=${project.id}`); // default = no live health checks524    expect(byDefault.status, byDefault.text).toBe(200);525    expect(Date.now() - t0).toBeLessThan(3000);526    const list = skills.json<SkillInfo[]>();527    expect(Array.isArray(list)).toBe(true);528    for (const s of list) expect(['skill', 'command', 'agent']).toContain(s.kind);529    const servers = mcp.json<McpServerInfo[]>();530    expect(Array.isArray(servers)).toBe(true);531    for (const s of [...servers, ...byDefault.json<McpServerInfo[]>()]) expect(s).toMatchObject({ status: 'unknown', checkedAt: null });532    expect((await b.post('/mcp/-rf/test')).status).toBe(400); // option-like names never reach the CLI533    expect((await b.req('DELETE', '/mcp/-x')).status).toBe(400);534    expect((await b.post('/mcp', { name: '-x', transport: 'stdio', target: 'true' })).status).toBe(400);535  }, 30_000);536537  it('claude session: the real CLI starts under tmux and produces output (no input sent)', async () => {538    const res = await b.post('/sessions', { projectId: project.id, kind: 'claude', title: 'claude smoke' });539    expect(res.status, res.text).toBe(201);540    const s = res.json<TerminalSession>();541    expect(s).toMatchObject({ kind: 'claude', state: 'DETACHED' });542    expect((await b.post('/sessions', { projectId: project.id, kind: 'claude', resumeConversationId: '--dangerously-skip-permissions' })).status).toBe(400);543    const t = await attach(s.id);544    await waitFor(() => t.bytes > 0, 20_000, 'any output from claude');545    const stopped = await b.post(`/sessions/${s.id}/terminate`);546    expect(stopped.status, stopped.text).toBe(200);547    expect(stopped.json<TerminalSession>().state).toBe('STOPPED');548    await t.waitClose();549    expect(stack.tmux(['has-session', '-t', `=cr-${s.id}`])).toBeNull();550  }, 90_000);551552  it('cleans up: terminating the last session leaves no cr-* tmux session', async () => {553    expect((await b.post(`/sessions/${main.id}/terminate`)).json<TerminalSession>().state).toBe('STOPPED');554    const live = (await b.getJson<TerminalSession[]>('/sessions')).filter((s) => !['STOPPED', 'FAILED'].includes(s.state));555    expect(live).toEqual([]);556    expect(stack.tmux(['ls']) ?? '').not.toContain('cr-');557  }, 30_000);558});559
Files main
Menu

Theme