Repository Public
1/**2 * Integration harness: the REAL runtime (`node apps/runtime/dist/main.js`, child process, its own3 * tmux socket) + the real server (`buildServer`, ephemeral port, throwaway database), driven with4 * real HTTP (`fetch`) and `ws` exactly like a browser would.5 *6 * Safety: `CR_INSTALL_CLAUDE_HOOKS` is never set (the developer's `~/.claude/settings.json` is not7 * touched), every directory is a temp dir, and `close()` kills only this harness' tmux server.8 */9import { execFileSync, spawn, type ChildProcess } from 'node:child_process';10import fs from 'node:fs';11import net from 'node:net';12import path from 'node:path';13import { randomBytes } from 'node:crypto';14import type { AddressInfo } from 'node:net';15import { fileURLToPath } from 'node:url';16import WebSocket from 'ws';17import { buildServer, type Server } from '../app.js';18import { loadConfig, type ServerConfig } from '../config.js';19import { migrate } from '../migrate.js';20import { base32Decode, totpAt } from '../security/totp.js';21import { createTestDb, dropTestDb } from '../testkit/harness.js';2223export const ORIGIN = 'http://localhost:8620';24export const PASSWORD = 'correct horse battery staple';25export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');26export const RUNTIME_DIR = path.join(REPO_ROOT, 'apps', 'runtime');27export const HOOK_SCRIPT = path.join(RUNTIME_DIR, 'bin', 'cr-hook');28export const CODEX_NOTIFY_SCRIPT = path.join(RUNTIME_DIR, 'bin', 'cr-codex-notify');2930export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));3132/** Polls `fn` until it returns a truthy value; throws with `label` after `ms`. */33export async function waitFor<T>(fn: () => T | Promise<T>, ms: number, label: string, everyMs = 100): Promise<NonNullable<T>> {34 const end = Date.now() + ms;35 let last: unknown;36 for (;;) {37 try {38 const v = await fn();39 if (v) return v as NonNullable<T>;40 last = v;41 } catch (err) {42 last = err;43 }44 if (Date.now() > end) throw new Error(`timeout (${ms} ms) waiting for: ${label}${last instanceof Error ? ` — last error: ${last.message}` : ''}`);45 await sleep(everyMs);46 }47}4849function newestMtime(dir: string): number {50 let newest = 0;51 for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {52 const p = path.join(dir, ent.name);53 newest = Math.max(newest, ent.isDirectory() ? newestMtime(p) : fs.statSync(p).mtimeMs);54 }55 return newest;56}5758/** `pnpm --filter @cr/runtime build` when `dist/` is missing or older than the sources. */59export function ensureRuntimeBuilt(): string {60 const main = path.join(RUNTIME_DIR, 'dist', 'main.js');61 const srcTime = Math.max(newestMtime(path.join(RUNTIME_DIR, 'src')), newestMtime(path.join(REPO_ROOT, 'packages', 'shared', 'dist')));62 if (!fs.existsSync(main) || fs.statSync(main).mtimeMs < srcTime) {63 execFileSync('pnpm', ['--filter', '@cr/runtime', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });64 }65 return main;66}6768function socketAccepts(socketPath: string): Promise<boolean> {69 return new Promise((resolve) => {70 const c = net.connect(socketPath);71 c.once('connect', () => {72 c.destroy();73 resolve(true);74 });75 c.once('error', () => resolve(false));76 });77}7879export interface HttpResult {80 status: number;81 headers: Headers;82 buf: Buffer;83 text: string;84 json<T = unknown>(): T;85}8687/** Cookie-jar + CSRF aware HTTP client over real sockets. */88export class Browser {89 cookies: Record<string, string> = {};90 csrf: string | null = null;91 constructor(readonly addr: string) {}9293 get cookieHeader(): string {94 return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');95 }96 async req(method: string, url: string, o: { json?: unknown; raw?: Buffer; headers?: Record<string, string> } = {}): Promise<HttpResult> {97 const headers: Record<string, string> = { ...o.headers };98 if (this.cookieHeader) headers.cookie = this.cookieHeader;99 let body: string | Buffer | undefined;100 if (method !== 'GET') {101 headers.origin = ORIGIN;102 if (this.csrf) headers['x-csrf-token'] = this.csrf;103 if (o.raw !== undefined) {104 body = o.raw;105 headers['content-type'] ??= 'application/octet-stream';106 } else if (method !== 'DELETE' || o.json !== undefined) {107 body = JSON.stringify(o.json ?? {});108 headers['content-type'] = 'application/json';109 }110 }111 const res = await fetch(`http://${this.addr}/api/v1${url}`, { method, headers, body, redirect: 'manual' });112 for (const line of res.headers.getSetCookie()) {113 const [pair = '', ...attrs] = line.split(';');114 const eq = pair.indexOf('=');115 const name = pair.slice(0, eq).trim();116 const value = pair.slice(eq + 1).trim();117 const expired = attrs.some((a) => /^\s*max-age=0\s*$/i.test(a)) || value === '';118 if (expired) delete this.cookies[name];119 else this.cookies[name] = value;120 }121 const buf = Buffer.from(await res.arrayBuffer());122 const text = buf.toString('utf8');123 return { status: res.status, headers: res.headers, buf, text, json: <T>() => JSON.parse(text) as T };124 }125 get = (url: string): Promise<HttpResult> => this.req('GET', url);126 post = (url: string, json?: unknown): Promise<HttpResult> => this.req('POST', url, { json });127 put = (url: string, json?: unknown): Promise<HttpResult> => this.req('PUT', url, { json });128 /** GET that must answer 200 with JSON. */129 async getJson<T>(url: string): Promise<T> {130 const r = await this.get(url);131 if (r.status !== 200) throw new Error(`GET ${url} → ${r.status} ${r.text.slice(0, 300)}`);132 return r.json<T>();133 }134 async sync(): Promise<Record<string, unknown>> {135 const s = (await this.get('/auth/status')).json<Record<string, unknown>>();136 this.csrf = (s.csrfToken as string | null) ?? null;137 return s;138 }139 ws(pathAndQuery: string): WebSocket {140 return new WebSocket(`ws://${this.addr}/api/v1${pathAndQuery}`, { headers: { cookie: this.cookieHeader, origin: ORIGIN }, perMessageDeflate: false });141 }142}143144/** A browser WebSocket: binary frames accumulate in `output`, text frames are parsed JSON. */145export class Stream {146 output = '';147 bytes = 0;148 readonly messages: Record<string, unknown>[] = [];149 closeCode: number | null = null;150 upgradeStatus: number | null = null;151 private readonly opened: Promise<void>;152153 constructor(readonly ws: WebSocket) {154 ws.on('message', (data: WebSocket.RawData, isBinary: boolean) => {155 const buf = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);156 if (isBinary) {157 this.bytes += buf.length;158 this.output += buf.toString('utf8');159 } else {160 try {161 this.messages.push(JSON.parse(buf.toString('utf8')) as Record<string, unknown>);162 } catch {163 /* ignored */164 }165 }166 });167 ws.on('close', (code) => {168 this.closeCode = code;169 });170 this.opened = new Promise((resolve) => {171 ws.once('open', () => resolve());172 ws.once('unexpected-response', (_req, res) => {173 this.upgradeStatus = res.statusCode ?? 0;174 res.resume();175 this.closeCode ??= -1;176 resolve();177 });178 ws.once('error', () => {179 this.closeCode ??= -1;180 resolve();181 });182 });183 }184 /** Resolves once the socket is open (or was refused: see `upgradeStatus` / `closeCode`). */185 async open(): Promise<this> {186 await this.opened;187 return this;188 }189 type(text: string): void {190 this.ws.send(Buffer.from(text, 'utf8'), { binary: true });191 }192 control(msg: unknown): void {193 this.ws.send(JSON.stringify(msg));194 }195 waitOutput(needle: string | RegExp, ms = 15_000): Promise<boolean> {196 return waitFor(() => (typeof needle === 'string' ? this.output.includes(needle) : needle.test(this.output)), ms, `terminal output ${String(needle)} (got ${JSON.stringify(this.output.slice(-400))})`, 50);197 }198 waitMsg(pred: (m: Record<string, unknown>) => boolean, ms = 10_000, label = 'message'): Promise<Record<string, unknown>> {199 return waitFor(() => this.messages.find(pred), ms, label, 50);200 }201 waitClose(ms = 15_000): Promise<number> {202 return waitFor(() => this.closeCode, ms, 'websocket close', 50);203 }204 async close(): Promise<void> {205 if (this.closeCode !== null) return;206 this.ws.close(1000);207 await this.waitClose(5000).catch(() => this.ws.terminate());208 }209}210211export interface RealStack {212 server: Server;213 config: ServerConfig;214 addr: string;215 dataDir: string;216 workspace: string;217 tmuxSocket: string;218 runtimeSocket: string;219 dbName: string;220 clock: { offsetMs: number };221 /** Captured runtime stdout/stderr (JSON logs), for diagnostics. */222 runtimeLog: string[];223 runtimeRunning(): boolean;224 startRuntime(): Promise<void>;225 stopRuntime(signal?: NodeJS.Signals): Promise<void>;226 /** `tmux -L <this harness' socket> <args>`; returns stdout, or null when tmux failed. */227 tmux(args: string[]): string | null;228 setupToken(): string;229 totp(secretB32: string): string;230 browser(): Browser;231 close(): Promise<void>;232}233234/**235 * `env` is given to BOTH processes (agent profile: CR_AGENT, CR_AGENT_BIN, CODEX_HOME,236 * CR_INSTALL_AGENT_HOOKS…). Hook installation is only ever requested by a test that also points237 * the agent's configuration directory at a temp dir.238 */239export async function startRealStack(o: { execHosts?: string; env?: Record<string, string> } = {}): Promise<RealStack> {240 const runtimeMain = ensureRuntimeBuilt();241 // /tmp (short): a unix socket path is limited to ~104 bytes. Real paths: /tmp is a symlink on macOS.242 const base = fs.realpathSync(fs.mkdtempSync('/tmp/cr-it-'));243 const dataDir = path.join(base, 'data');244 const workspace = path.join(base, 'ws');245 fs.mkdirSync(dataDir, { mode: 0o700 });246 fs.mkdirSync(workspace);247 const tmuxSocket = `cr-it-${randomBytes(5).toString('hex')}`;248 const runtimeToken = randomBytes(32).toString('hex');249 const runtimeSocket = path.join(dataDir, 'run', 'runtime.sock');250 const runtimeLog: string[] = [];251252 const runtimeEnv: NodeJS.ProcessEnv = { ...process.env };253 // Never install hooks into the developer's ~/.claude, never leak an outer Claude Code / tmux context.254 for (const k of ['CR_INSTALL_CLAUDE_HOOKS', 'CR_INSTALL_AGENT_HOOKS', 'CR_AGENT', 'CR_AGENT_BIN', 'CR_CLAUDE_BIN', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'TMUX', 'TMUX_PANE', 'NODE_OPTIONS', 'VITEST', 'VITEST_POOL_ID', 'VITEST_WORKER_ID']) delete runtimeEnv[k];255 Object.assign(runtimeEnv, {256 NODE_ENV: 'test',257 CR_DATA_DIR: dataDir,258 CR_WORKSPACE_ROOTS: `ws=${workspace}`,259 CR_RUNTIME_SOCKET: runtimeSocket,260 CR_RUNTIME_TOKEN: runtimeToken,261 CR_TMUX_SOCKET: tmuxSocket,262 CR_EXEC_HOSTS: o.execHosts ?? '',263 CR_LOG_LEVEL: 'warn',264 CR_SESSION_POLL_MS: '500',265 CR_TASK_KILL_GRACE_MS: '2000',266 ...o.env,267 });268269 let child: ChildProcess | null = null;270 const startRuntime = async (): Promise<void> => {271 if (child) throw new Error('runtime already running');272 const c = spawn(process.execPath, [runtimeMain], { cwd: RUNTIME_DIR, env: runtimeEnv, stdio: ['ignore', 'pipe', 'pipe'] });273 child = c;274 const keep = (d: Buffer): void => {275 runtimeLog.push(d.toString());276 if (runtimeLog.length > 500) runtimeLog.shift();277 };278 c.stdout?.on('data', keep);279 c.stderr?.on('data', keep);280 c.once('exit', () => {281 if (child === c) child = null;282 });283 await waitFor(async () => {284 if (child !== c) throw new Error(`runtime exited during startup: ${runtimeLog.join('').slice(-800)}`);285 return socketAccepts(runtimeSocket);286 }, 30_000, 'runtime socket');287 };288 const stopRuntime = async (signal: NodeJS.Signals = 'SIGTERM'): Promise<void> => {289 const c = child;290 if (!c) return;291 const gone = new Promise<void>((resolve) => c.once('exit', () => resolve()));292 c.kill(signal);293 const timer = setTimeout(() => c.kill('SIGKILL'), 12_000);294 await gone;295 clearTimeout(timer);296 child = null;297 };298 const tmux = (args: string[]): string | null => {299 try {300 return execFileSync('tmux', ['-L', tmuxSocket, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });301 } catch {302 return null;303 }304 };305306 let dbName: string | null = null;307 let server: Server | null = null;308 const cleanup = async (): Promise<void> => {309 await server?.close().catch(() => undefined);310 await stopRuntime().catch(() => undefined);311 tmux(['kill-server']);312 // tmux leaves the (now dead) socket file behind.313 fs.rmSync(path.join(process.env.TMUX_TMPDIR ?? '/tmp', `tmux-${process.getuid?.() ?? 0}`, tmuxSocket), { force: true });314 if (dbName) dropTestDb(dbName);315 fs.rmSync(base, { recursive: true, force: true });316 };317318 try {319 await startRuntime();320 const db = createTestDb();321 dbName = db.dbName;322 await migrate(db.databaseUrl);323 const config = loadConfig({324 NODE_ENV: 'test',325 CR_PUBLIC_ORIGIN: ORIGIN,326 CR_DATA_DIR: dataDir,327 CR_WORKSPACE_ROOTS: `ws=${workspace}`,328 CR_RUNTIME_SOCKET: runtimeSocket,329 CR_RUNTIME_TOKEN: runtimeToken,330 DATABASE_URL: db.databaseUrl,331 CR_SECRET_KEY: randomBytes(32).toString('hex'),332 CR_EXEC_HOSTS: o.execHosts ?? '',333 CR_LOG_LEVEL: 'silent',334 CR_WEB_DIR: path.join(dataDir, 'no-web'),335 ...o.env,336 });337 config.passwordCost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };338 config.rateLimit = { api: 100_000, preAuth: 100_000, status: 100_000, health: 100_000 };339 config.reconcileIntervalMs = 1000;340 const clock = { offsetMs: 0 };341 server = await buildServer(config, { now: () => Date.now() + clock.offsetMs, clusterSource: null, skillsRegistry: null });342 await server.app.listen({ port: 0, host: '127.0.0.1' });343 server.startBackground();344 const addr = `127.0.0.1:${(server.app.server.address() as AddressInfo).port}`;345 return {346 server, config, addr, dataDir, workspace, tmuxSocket, runtimeSocket, dbName, clock, runtimeLog,347 runtimeRunning: () => child !== null,348 startRuntime, stopRuntime, tmux,349 setupToken: () => fs.readFileSync(path.join(dataDir, 'setup-token'), 'utf8').trim(),350 totp: (secret) => totpAt(base32Decode(secret), Date.now() + clock.offsetMs),351 browser: () => new Browser(addr),352 close: cleanup,353 };354 } catch (err) {355 await cleanup();356 throw err;357 }358}359