Repository Public
1import { randomBytes } from 'node:crypto';2import { afterAll, beforeAll, describe, expect, it } from 'vitest';3import type WebSocket from 'ws';4import type { TerminalSession } from '@cr/shared';5import { Client, PASSWORD, createHarness, login, openWs, setupAdmin, until, upgradeStatus, type Harness } from '../testkit/harness.js';67let h: Harness;8let admin: Client;9let secret: string;10let addr: string;1112const create = async (): Promise<TerminalSession> => (await admin.post('/sessions', { kind: 'shell' })).json<TerminalSession>();13const closed = (ws: WebSocket): Promise<number> => new Promise((resolve) => ws.once('close', (code) => resolve(code)));14/** Collects frames; `next()` resolves with the following frame in arrival order. */15function frames(ws: WebSocket): { next(): Promise<{ data: Buffer; isBinary: boolean }>; all: { data: Buffer; isBinary: boolean }[] } {16 const all: { data: Buffer; isBinary: boolean }[] = [];17 let read = 0;18 ws.on('message', (data, isBinary) => all.push({ data: data as Buffer, isBinary }));19 return {20 all,21 async next() {22 await until(() => all.length > read);23 return all[read++] as { data: Buffer; isBinary: boolean };24 },25 };26}27async function attach(id: string, client = admin): Promise<{ ws: WebSocket; f: ReturnType<typeof frames> }> {28 const ws = openWs(addr, `/sessions/${id}/stream?cols=100&rows=30`, { cookie: client.cookieHeader });29 const f = frames(ws);30 expect(await upgradeStatus(ws)).toBe(101);31 const ready = await f.next();32 expect(ready.isBinary).toBe(false);33 expect(JSON.parse(ready.data.toString())).toEqual({ t: 'ready', sessionId: id, cols: 100, rows: 30 });34 return { ws, f };35}3637beforeAll(async () => {38 h = await createHarness({ tweak: (c) => (c.wsAuthRecheckMs = 150) });39 ({ client: admin, secret } = await setupAdmin(h));40 addr = await h.listen();41});42afterAll(async () => {43 await h.close();44});4546describe('upgrade authorisation', () => {47 it('refuses without a cookie (401), with a wrong or missing Origin (403), before the second factor (403)', async () => {48 const s = await create();49 const path = `/sessions/${s.id}/stream`;50 expect(await upgradeStatus(openWs(addr, path))).toBe(401);51 expect(await upgradeStatus(openWs(addr, path, { cookie: 'cr_sid=' + 'B'.repeat(43) }))).toBe(401);52 expect(await upgradeStatus(openWs(addr, path, { cookie: admin.cookieHeader, origin: 'https://evil.example' }))).toBe(403);53 expect(await upgradeStatus(openWs(addr, path, { cookie: admin.cookieHeader, origin: null }))).toBe(403);54 const half = new Client(h);55 await half.post('/auth/login', { username: 'admin', password: PASSWORD });56 expect(await upgradeStatus(openWs(addr, path, { cookie: half.cookieHeader }))).toBe(403);57 for (const p of ['/events', `/tasks/${s.id}/log/stream`]) {58 expect(await upgradeStatus(openWs(addr, p))).toBe(401);59 expect(await upgradeStatus(openWs(addr, p, { cookie: admin.cookieHeader, origin: 'https://evil.example' }))).toBe(403);60 }61 expect(h.rt.attachSockets.get(s.id)?.size ?? 0).toBe(0); // nothing ever reached the runtime62 });63 it('refuses unknown, foreign and ended sessions', async () => {64 expect(await upgradeStatus(openWs(addr, '/sessions/00000000-0000-4000-8000-000000000000/stream', { cookie: admin.cookieHeader }))).toBe(404);65 expect(await upgradeStatus(openWs(addr, '/sessions/not-a-uuid/stream', { cookie: admin.cookieHeader }))).toBe(400);66 const db = h.server.ctx.db;67 const other = await db.one<{ id: string }>("INSERT INTO users (username, password_hash) VALUES ('other', 'x') RETURNING id");68 const foreign = await db.one<{ id: string }>(69 "INSERT INTO terminal_sessions (owner_id, kind, title, state, cwd_abs) VALUES ($1, 'shell', 'theirs', 'RUNNING', $2) RETURNING id", [other?.id as string, h.workspace],70 );71 expect(await upgradeStatus(openWs(addr, `/sessions/${foreign?.id}/stream`, { cookie: admin.cookieHeader }))).toBe(404);72 expect((await admin.get(`/sessions/${foreign?.id}`)).statusCode).toBe(404);73 expect((await admin.post(`/sessions/${foreign?.id}/terminate`)).statusCode).toBe(404);74 await db.q('DELETE FROM users WHERE id = $1', [other?.id as string]);75 const ended = await create();76 await admin.post(`/sessions/${ended.id}/terminate`);77 expect(await upgradeStatus(openWs(addr, `/sessions/${ended.id}/stream`, { cookie: admin.cookieHeader }))).toBe(410);78 });79});8081describe('terminal stream', () => {82 it('echoes binary through the runtime byte-for-byte, including a 1 MiB frame', async () => {83 const s = await create();84 const { ws, f } = await attach(s.id);85 ws.send(Buffer.from([0, 255, 27, 91, 65, 10, 13]), { binary: true });86 const small = await f.next();87 expect(small.isBinary).toBe(true);88 expect([...small.data]).toEqual([0, 255, 27, 91, 65, 10, 13]);89 const big = randomBytes(1024 * 1024);90 ws.send(big, { binary: true });91 const echoed = await f.next();92 expect(echoed.isBinary).toBe(true);93 expect(echoed.data.length).toBe(big.length);94 expect(echoed.data.equals(big)).toBe(true);95 ws.close();96 await closed(ws);97 });98 it('passes resize/ping control frames, keeps text frames text, drops invalid ones', async () => {99 const s = await create();100 const { ws, f } = await attach(s.id);101 ws.send(JSON.stringify({ t: 'resize', cols: 80, rows: 24 }));102 ws.send('not json');103 ws.send(JSON.stringify({ t: 'resize', cols: 99999, rows: 24 }));104 ws.send(JSON.stringify({ t: 'exec', cmd: 'id' }));105 ws.send(JSON.stringify({ t: 'ping', n: 7 }));106 const pong = await f.next();107 expect(pong.isBinary).toBe(false);108 expect(JSON.parse(pong.data.toString())).toEqual({ t: 'pong', n: 7 });109 expect(h.rt.controls.filter((c) => c.sessionId === s.id).map((c) => c.msg)).toEqual([{ t: 'resize', cols: 80, rows: 24 }, { t: 'ping', n: 7 }]);110 ws.close();111 await closed(ws);112 await until(() => (h.rt.attachSockets.get(s.id)?.size ?? 0) === 0); // closing the browser side detaches upstream113 });114 it('frames above 1 MiB are rejected', async () => {115 const s = await create();116 const { ws, f } = await attach(s.id);117 const done = closed(ws);118 ws.send(Buffer.alloc(1024 * 1024 + 1), { binary: true });119 // 1009 (message too big); 1006 is possible when the peer is cut while still writing.120 expect([1009, 1006]).toContain(await done);121 expect(f.all.length).toBe(1); // only `ready`: the oversized frame was never relayed and echoed122 await until(() => (h.rt.attachSockets.get(s.id)?.size ?? 0) === 0);123 });124 it('two sessions are isolated', async () => {125 const a = await create();126 const b = await create();127 const A = await attach(a.id);128 const B = await attach(b.id);129 A.ws.send(Buffer.from('only-a'), { binary: true });130 B.ws.send(Buffer.from('only-b'), { binary: true });131 expect((await A.f.next()).data.toString()).toBe('only-a');132 expect((await B.f.next()).data.toString()).toBe('only-b');133 await new Promise((r) => setTimeout(r, 100));134 expect(A.f.all.length).toBe(2);135 expect(B.f.all.length).toBe(2);136 const status = (await admin.get('/system/status')).json<{ counts: { wsClients: number; liveSessions: number } }>();137 expect(status.counts.wsClients).toBe(2);138 A.ws.close();139 B.ws.close();140 await Promise.all([closed(A.ws), closed(B.ws)]);141 await until(async () => (await admin.get('/system/status')).json<{ counts: { wsClients: number } }>().counts.wsClients === 0);142 });143 it('session state follows the proxied streams: RUNNING at once, DETACHED after the last one, WAITING_FOR_INPUT untouched', async () => {144 // No runtime events and no reconciler in this harness: only the server's own knowledge145 // of its streams (+ the runtime query after a detach) can move the state.146 const get = async (id: string): Promise<TerminalSession> => (await admin.get(`/sessions/${id}`)).json<TerminalSession>();147 const hook = (id: string, event: string): Promise<void> => h.server.ctx.sessions.onRuntimeEvent({ type: 'claude.hook', id, event, conversationId: null, message: null });148 const s = await create();149 expect(s).toMatchObject({ state: 'DETACHED', attachedClients: 0 });150151 const t0 = Date.now();152 const A = await attach(s.id);153 await until(async () => (await get(s.id)).state === 'RUNNING', 1000);154 expect(Date.now() - t0).toBeLessThan(1000);155 expect((await get(s.id)).attachedClients).toBe(1);156 const B = await attach(s.id);157 await until(async () => (await get(s.id)).attachedClients === 2, 1000);158159 A.ws.close();160 await closed(A.ws);161 await until(async () => (await get(s.id)).attachedClients === 1, 1000);162 await new Promise((r) => setTimeout(r, 600)); // past the post-detach runtime check163 expect(await get(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 1 });164165 B.ws.close();166 await closed(B.ws);167 await until(async () => (await get(s.id)).state === 'DETACHED', 3000);168 expect((await get(s.id)).attachedClients).toBe(0);169 const transitions = await h.server.ctx.db.q<{ detail: { from: string; to: string; source: string } }>(170 "SELECT detail FROM terminal_events WHERE session_id = $1 AND kind = 'state' ORDER BY id", [s.id]);171 expect(transitions.map((r) => `${r.detail.to}:${r.detail.source ?? ''}`).slice(-2)).toEqual(['RUNNING:attach', 'DETACHED:reconcile']);172173 // WAITING_FOR_INPUT (hook) survives a detach and a re-attach; only the hook clears it.174 const C = await attach(s.id);175 await until(async () => (await get(s.id)).state === 'RUNNING', 1000);176 await hook(s.id, 'Notification');177 expect((await get(s.id)).state).toBe('WAITING_FOR_INPUT');178 C.ws.close();179 await closed(C.ws);180 await until(async () => (await get(s.id)).attachedClients === 0, 1000);181 await new Promise((r) => setTimeout(r, 2000)); // both post-detach checks have run182 expect(await get(s.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', attachedClients: 0 });183 const D = await attach(s.id);184 await until(async () => (await get(s.id)).attachedClients === 1, 1000);185 expect((await get(s.id)).state).toBe('WAITING_FOR_INPUT');186 await hook(s.id, 'UserPromptSubmit');187 expect(await get(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 1, waitingReason: null });188 D.ws.close();189 await closed(D.ws);190 await until(async () => (await get(s.id)).state === 'DETACHED', 3000);191 });192 it('the runtime ending the session closes the browser socket with its code', async () => {193 const s = await create();194 const { ws } = await attach(s.id);195 const done = closed(ws);196 await admin.post(`/sessions/${s.id}/terminate`);197 expect(await done).toBe(4410);198 });199 it('runtime unreachable ⇒ 4503', async () => {200 const s = await create();201 await h.rt.stop();202 const ws = openWs(addr, `/sessions/${s.id}/stream`, { cookie: admin.cookieHeader });203 expect(await upgradeStatus(ws)).toBe(101);204 expect(await closed(ws)).toBe(4503);205 await h.rt.start();206 });207});208209describe('auth session lifetime on open sockets', () => {210 it('logout closes the terminal socket with 4401 and detaches upstream', async () => {211 const c = await login(h, secret);212 const s = await create();213 const { ws } = await attach(s.id, c);214 const done = closed(ws);215 await c.post('/auth/logout');216 expect(await done).toBe(4401);217 await until(() => (h.rt.attachSockets.get(s.id)?.size ?? 0) === 0);218 });219 it('a session revoked or expired behind our back is caught by the periodic re-check', async () => {220 const c = await login(h, secret);221 const s = await create();222 const { ws } = await attach(s.id, c);223 const done = closed(ws);224 const sid = (await c.get('/auth/devices')).json<{ id: string; current: boolean }[]>().find((d) => d.current)?.id as string;225 await h.server.ctx.db.q("UPDATE auth_sessions SET expires_at = now() - interval '1 second' WHERE id = $1", [sid]);226 expect(await done).toBe(4401);227 });228});229230describe('events stream', () => {231 it('sends hello, then ServerEvents, then auth.expired before closing', async () => {232 const c = await login(h, secret);233 const ws = openWs(addr, '/events', { cookie: c.cookieHeader });234 const f = frames(ws);235 expect(await upgradeStatus(ws)).toBe(101);236 const hello = JSON.parse((await f.next()).data.toString());237 expect(hello.type).toBe('hello');238 expect(Number.isNaN(Date.parse(hello.serverTime))).toBe(false);239 const s = await create();240 await until(() => f.all.some((m) => m.data.toString().includes(s.id)));241 const ev = JSON.parse((f.all.find((m) => m.data.toString().includes(s.id)) as { data: Buffer }).data.toString());242 expect(ev).toMatchObject({ type: 'session.updated', session: { id: s.id } });243 expect(JSON.stringify(ev)).not.toContain(h.workspace);244 const done = closed(ws);245 await c.post('/auth/logout');246 expect(await done).toBe(4401);247 expect(JSON.parse((f.all.at(-1) as { data: Buffer }).data.toString())).toEqual({ type: 'auth.expired' });248 });249});250251describe('task log stream', () => {252 it('streams the runtime log bytes and ignores client frames', async () => {253 const t = (await admin.post('/tasks', { title: 't', command: 'echo hi' })).json<{ id: string }>();254 h.rt.taskLogs.set(t.id, Buffer.from('line 1\nline 2\n'));255 const ws = openWs(addr, `/tasks/${t.id}/log/stream?offset=0`, { cookie: admin.cookieHeader });256 const f = frames(ws);257 const done = closed(ws);258 expect(await upgradeStatus(ws)).toBe(101);259 ws.send(Buffer.from('ignored'), { binary: true });260 const first = await f.next();261 expect(first.isBinary).toBe(true);262 expect(first.data.toString()).toBe('line 1\nline 2\n');263 expect(await done).toBe(1000);264 expect(await upgradeStatus(openWs(addr, '/tasks/00000000-0000-4000-8000-000000000000/log/stream', { cookie: admin.cookieHeader }))).toBe(404);265 });266});267