Repository Public
1import fs from 'node:fs';2import os from 'node:os';3import path from 'node:path';4import { spawn } from 'node:child_process';5import { fileURLToPath } from 'node:url';6import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';7import type { RtSessionInfo } from '@cr/shared';8import { bearerToken, classifyToken, safeEqual } from './auth.js';9import { Backpressure } from './core-backpressure.js';10import { EventBus } from './events.js';11import { buildRuntime, type Runtime } from './main.js';12import { aggregateTree, cpuPctBetween, parseMeminfo, parsePs, parseVmStat } from './metrics.js';13import { cleanUserEnv, sanitizedBaseEnv, shQuote } from './core-util.js';14import { collectEvents, makeTestEnv, openWs, request, sessionSpec, startRuntime, TermClient, waitUntil, type TestEnv } from './core-testkit.js';1516describe('auth primitives', () => {17 it('compares tokens safely and parses bearer headers', () => {18 expect(safeEqual('abc', 'abc')).toBe(true);19 expect(safeEqual('abc', 'abd')).toBe(false);20 expect(safeEqual('abc', 'abcd')).toBe(false);21 expect(bearerToken('Bearer tok')).toBe('tok');22 expect(bearerToken('bearer tok')).toBe('tok');23 expect(bearerToken('Basic tok')).toBeNull();24 expect(bearerToken(undefined)).toBeNull();25 expect(bearerToken('Bearer a b')).toBeNull();26 expect(classifyToken('r', 'r', 'h')).toBe('runtime');27 expect(classifyToken('h', 'r', 'h')).toBe('hook');28 expect(classifyToken('x', 'r', 'h')).toBeNull();29 expect(classifyToken(null, 'r', 'h')).toBeNull();30 });31});3233describe('environment hygiene + quoting', () => {34 it('strips runtime secrets and reserved names', () => {35 const env = sanitizedBaseEnv({ PATH: '/bin', CR_RUNTIME_TOKEN: 't', DATABASE_URL: 'd', CR_SECRET_KEY: 'k', CR_AGENTD_TOKEN: 'a', TMUX: 'x', HOME: '/h' });36 expect(env).toEqual({ PATH: '/bin', HOME: '/h' });37 expect(cleanUserEnv({ A: '1', CR_HOOK_TOKEN: 'spoof', DATABASE_URL: 'x' })).toEqual({ A: '1' });38 expect(() => cleanUserEnv({ 'A=B': '1' })).toThrow();39 expect(() => cleanUserEnv({ A: 'x\0y' })).toThrow();40 expect(shQuote("a'b")).toBe(`'a'\\''b'`);41 });42});4344describe('Backpressure', () => {45 it('pauses above the high-water mark, resumes when drained, and gives up after the saturation window', () => {46 vi.useFakeTimers();47 try {48 let buffered = 0;49 const calls: string[] = [];50 const make = () =>51 new Backpressure({52 highWaterBytes: 100,53 buffered: () => buffered,54 pause: () => calls.push('pause'),55 resume: () => calls.push('resume'),56 onSaturated: () => calls.push('saturated'),57 });58 const bp = make();59 buffered = 100;60 bp.check();61 expect(calls).toEqual([]); // not above the mark62 buffered = 101;63 bp.check();64 bp.check();65 expect(calls).toEqual(['pause']);66 buffered = 60; // still above half: stays paused67 vi.advanceTimersByTime(1000);68 expect(calls).toEqual(['pause']);69 buffered = 50;70 vi.advanceTimersByTime(60);71 expect(calls).toEqual(['pause', 'resume']);72 expect(bp.paused).toBe(false);7374 buffered = 500; // saturated for 30 s ⇒ onSaturated exactly once (caller closes 4429)75 bp.check();76 vi.advanceTimersByTime(29_900);77 expect(calls).toEqual(['pause', 'resume', 'pause']);78 vi.advanceTimersByTime(200);79 expect(calls).toEqual(['pause', 'resume', 'pause', 'saturated']);80 vi.advanceTimersByTime(60_000);81 bp.check();82 expect(calls).toHaveLength(4);83 } finally {84 vi.useRealTimers();85 }86 });87});8889describe('EventBus', () => {90 it('validates, fans out, isolates failing listeners and unsubscribes', () => {91 const bus = new EventBus();92 const seen: unknown[] = [];93 bus.subscribe(() => {94 throw new Error('boom');95 });96 const off = bus.subscribe((e) => seen.push(e));97 bus.emit({ type: 'session.exit', id: 'a', exitCode: 1 });98 off();99 bus.emit({ type: 'session.exit', id: 'b', exitCode: null });100 expect(seen).toEqual([{ type: 'session.exit', id: 'a', exitCode: 1 }]);101 // Not a valid RtEvent ⇒ refused at the source.102 expect(() => bus.emit({ type: 'nope' } as never)).toThrow();103 });104});105106describe('metrics parsers', () => {107 it('computes cpu%, memory and process-tree aggregates from real formats', () => {108 expect(cpuPctBetween({ atMs: 0, idle: 100, total: 200 }, { atMs: 1, idle: 150, total: 400 })).toBe(75);109 expect(cpuPctBetween({ atMs: 0, idle: 1, total: 2 }, { atMs: 1, idle: 1, total: 2 })).toBeNull();110 const vm = 'Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 1000.\nPages active: 200.\nPages wired down: 50.\nPages occupied by compressor: 25.\n';111 expect(parseVmStat(vm)).toBe(275 * 16384);112 expect(parseVmStat('garbage')).toBeNull();113 expect(parseMeminfo('MemTotal: 1000 kB\nMemFree: 1 kB\nMemAvailable: 400 kB\n')).toBe(600 * 1024);114 expect(parseMeminfo('nothing')).toBeNull();115 const rows = parsePs(' 10 1 1,5 2048\n 11 10 2.5 1024\n 12 11 0.0 1024\n 99 1 9.0 4096\n junk\n');116 expect(rows).toHaveLength(4);117 expect(aggregateTree(rows, 10)).toEqual({ cpuPct: 4, rssMb: 4 });118 expect(aggregateTree(rows, 12345)).toBeNull();119 });120});121122describe('runtime process (unix socket, auth, errors, health, metrics)', () => {123 let env: TestEnv;124 let rt: Runtime;125 let sock: string;126 let token: string;127 beforeAll(async () => {128 env = makeTestEnv();129 rt = await startRuntime(env.config);130 sock = env.config.socketPath;131 token = env.config.token;132 });133 afterAll(async () => {134 await rt.stop();135 env.cleanup();136 });137138 it('listens only on a 0600 unix socket inside a 0700 directory', () => {139 const st = fs.statSync(sock);140 expect(st.isSocket()).toBe(true);141 expect(st.mode & 0o777).toBe(0o600);142 expect(fs.statSync(path.dirname(sock)).mode & 0o777).toBe(0o700);143 const address = rt.app.server.address();144 expect(address).toBe(sock); // a string path, not a TCP {address, port}145 });146147 it('requires the bearer token on every HTTP route and WebSocket upgrade', async () => {148 for (const url of ['/health', '/sessions', '/tasks', '/host/metrics', '/nope']) {149 expect((await request(sock, 'GET', url, { token: null })).status, url).toBe(401);150 const wrong = await request(sock, 'GET', url, { token: `${token.slice(0, -1)}x` });151 expect(wrong.status, url).toBe(401);152 expect(wrong.json<{ error: { code: string } }>().error.code).toBe('unauthorized');153 }154 expect((await request(sock, 'GET', '/health', { headers: { authorization: `Basic ${token}` } })).status).toBe(401);155 for (const url of ['/events', '/sessions/00000000-0000-4000-8000-000000000000/attach', '/tasks/00000000-0000-4000-8000-000000000000/log/follow']) {156 await expect(openWs(sock, url, null), url).rejects.toThrow('HTTP 401');157 await expect(openWs(sock, url, 'wrong-token'), url).rejects.toThrow('HTTP 401');158 await expect(openWs(sock, url, rt.core.hookToken), url).rejects.toThrow('HTTP 401');159 }160 const ok = await openWs(sock, '/events', token);161 ok.close();162 });163164 it('maps errors to {error:{code,message}}', async () => {165 const notFound = await request(sock, 'GET', '/nope', { token });166 expect(notFound.status).toBe(404);167 expect(notFound.json()).toEqual({ error: { code: 'not_found', message: 'no such route' } });168 const zod = await request(sock, 'POST', '/sessions', { token, body: { id: 'x' } });169 expect(zod.status).toBe(400);170 expect(zod.json<{ error: { code: string; message: string } }>().error.code).toBe('bad_request');171 const rtErr = await request(sock, 'GET', '/sessions/00000000-0000-4000-8000-000000000000', { token });172 expect(rtErr.status).toBe(404);173 expect(rtErr.json()).toEqual({ error: { code: 'not_found', message: 'no such session' } });174 const badJson = await request(sock, 'POST', '/sessions', { token, rawBody: '{ not json', headers: { 'content-type': 'application/json' } });175 expect(badJson.status).toBe(400);176 expect(badJson.json<{ error: { code: string } }>().error.code).toBe('bad_request');177 });178179 it('GET /health reports real component state', async () => {180 const h = (await request(sock, 'GET', '/health', { token })).json<{ ok: boolean; version: string; tmux: boolean; claude: { path: string; version: string | null }; uptimeSec: number }>();181 expect(h.ok).toBe(true);182 expect(h.version).toMatch(/^\d+\.\d+\.\d+/);183 expect(h.tmux).toBe(true);184 expect(typeof h.claude.path).toBe('string');185 expect(h.claude.version === null || h.claude.version.length > 0).toBe(true);186 expect(h.uptimeSec).toBeGreaterThanOrEqual(0);187 });188189 it('GET /host/metrics returns measured values, including per-session process trees', async () => {190 const id = (await request(sock, 'POST', '/sessions', { token, body: sessionSpec(env) })).json<RtSessionInfo>().id;191 await new Promise((r) => setTimeout(r, 400));192 const before = Date.now();193 const m = (await request(sock, 'GET', '/host/metrics', { token })).json<{194 collectedAtMs: number; hostname: string; cpuPct: number | null; load: number[]; memUsedMb: number | null; memTotalMb: number;195 diskFreeGb: number | null; diskTotalGb: number | null; uptimeSec: number; sessions: { id: string; cpuPct: number | null; rssMb: number | null }[];196 }>();197 expect(m.collectedAtMs).toBeGreaterThanOrEqual(before);198 expect(m.hostname).toBe(os.hostname());199 expect(m.cpuPct).not.toBeNull();200 expect(m.cpuPct!).toBeGreaterThanOrEqual(0);201 expect(m.cpuPct!).toBeLessThanOrEqual(100);202 expect(m.load).toHaveLength(3);203 expect(m.memTotalMb).toBe(Math.round(os.totalmem() / 1048576));204 expect(m.memUsedMb!).toBeGreaterThan(0);205 expect(m.memUsedMb!).toBeLessThanOrEqual(m.memTotalMb);206 expect(m.diskTotalGb!).toBeGreaterThan(0);207 expect(m.diskFreeGb!).toBeLessThanOrEqual(m.diskTotalGb!);208 expect(Math.abs(m.uptimeSec - os.uptime())).toBeLessThan(5);209 const s = m.sessions.find((x) => x.id === id);210 expect(s).toBeDefined();211 expect(s!.rssMb!).toBeGreaterThan(0); // a live shell has a resident set212 expect(s!.cpuPct!).toBeGreaterThanOrEqual(0);213 await request(sock, 'DELETE', `/sessions/${id}?force=1`, { token });214 });215216 it('graceful stop detaches clients (1001) and leaves the tmux session running', async () => {217 const env2 = makeTestEnv();218 const first = await startRuntime(env2.config);219 const s2 = env2.config.socketPath;220 const t2 = env2.config.token;221 try {222 const id = (await request(s2, 'POST', '/sessions', { token: t2, body: sessionSpec(env2) })).json<RtSessionInfo>().id;223 const client = await TermClient.open(s2, id, t2);224 await client.waitMsg('ready');225 client.type('echo up-$((1+1))\n');226 await client.waitOutput('up-2');227 const oldHookToken = first.core.hookToken;228 await first.stop();229 expect(await client.waitClose()).toBe(1001);230 expect(fs.existsSync(s2)).toBe(false);231 const row = await first.core.tmux.getSession(`cr-${id}`);232 expect(row).toMatchObject({ paneDead: false });233 await waitUntil(async () => (await first.core.tmux.getSession(`cr-${id}`))?.attached === 0, 5000, 'attach client gone');234235 const second = await startRuntime(env2.config);236 try {237 expect((await request(s2, 'GET', `/sessions/${id}`, { token: t2 })).json<RtSessionInfo>()).toMatchObject({ id, alive: true });238 expect(second.core.hookToken).toBe(oldHookToken); // kept: the surviving session still holds it239 } finally {240 await second.stop();241 }242 } finally {243 env2.cleanup();244 }245 });246247 it('refuses to start a second runtime on a live socket', async () => {248 const second = await buildRuntime({ ...env.config, tmuxSocketName: env.config.tmuxSocketName }, { plugins: 'core' });249 await expect(second.start()).rejects.toThrow(/already listening/);250 await second.stop();251 expect((await request(sock, 'GET', '/health', { token })).status).toBe(200); // the first one is untouched252 });253});254255describe('tmux survives the death of the runtime process', () => {256 it('SIGKILL of a real runtime process leaves the session running; a new runtime sees it, state intact', async () => {257 const env = makeTestEnv();258 const runtimeDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));259 const child = spawn(process.execPath, ['--import', 'tsx', path.join(runtimeDir, 'src', 'main.ts')], {260 cwd: runtimeDir,261 stdio: 'ignore',262 env: {263 ...process.env,264 CR_RUNTIME_PLUGINS: 'core',265 CR_DATA_DIR: env.config.dataDir,266 CR_RUNTIME_SOCKET: env.config.socketPath,267 CR_RUNTIME_TOKEN: env.config.token,268 CR_WORKSPACE_ROOTS: env.workspace,269 CR_TMUX_SOCKET: env.config.tmuxSocketName,270 CR_SHELL: '/bin/sh',271 CR_LOG_LEVEL: 'silent',272 },273 });274 let rt: Runtime | null = null;275 try {276 await waitUntil(() => fs.existsSync(env.config.socketPath), 20_000, 'child runtime socket');277 const sock = env.config.socketPath;278 const token = env.config.token;279 const id = (await request(sock, 'POST', '/sessions', { token, body: sessionSpec(env) })).json<RtSessionInfo>().id;280 const a = await TermClient.open(sock, id, token);281 a.type('KEPT=alive-$((20+3))\n');282 a.type('echo set-$KEPT\n');283 await a.waitOutput('set-alive-23');284285 child.kill('SIGKILL'); // no graceful shutdown at all286 await new Promise<void>((resolve) => child.once('exit', () => resolve()));287 await a.waitClose();288 expect(fs.existsSync(sock)).toBe(true); // stale socket left behind by the kill289290 rt = await startRuntime(env.config); // replaces the stale socket291 const s = (await request(sock, 'GET', `/sessions/${id}`, { token })).json<RtSessionInfo>();292 expect(s).toMatchObject({ id, alive: true, attached: 0 });293 const b = await TermClient.open(sock, id, token);294 b.type('echo again-$KEPT\n');295 await b.waitOutput('again-alive-23'); // same shell process as before the kill296 const events = await collectEvents(sock, token);297 b.type('exit 5\n');298 expect(await b.waitMsg('exit')).toEqual({ t: 'exit', code: 5 });299 await waitUntil(() => events.events.some((e) => e.type === 'session.exit' && e.id === id && e.exitCode === 5), 8000, 'session.exit after restart');300 events.close();301 } finally {302 child.kill('SIGKILL');303 await rt?.stop();304 env.cleanup();305 }306 });307});308