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