SPB Git

Repository Public

real-harness.ts 14.1 KB 359 lines typescript

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
Files main
Menu

Theme