SPB Git

Repository Public

mock-server.mjs 50.1 KB 918 lines javascript

1#!/usr/bin/env node2/**3 * DEV-ONLY mock of the Claude Remote browser API (docs/CONTRACT.md, "Browser API").4 * Never imported by the app, never part of the static export. It exists so the web client5 * can be developed and tested without apps/server + apps/runtime.6 *7 *  - auth: setup token, password + TOTP (real RFC 6238), cookie session, CSRF, lockout8 *  - sessions: a REAL local shell through node-pty (falls back to an echo loop)9 *  - files: a temp directory seeded with a small git repository10 *  - tasks: real child processes with a live log11 *  - cluster / models / skills / mcp: FIXTURES, labelled as such in every name12 *13 * Env: MOCK_PORT (8621) · MOCK_SEED=0 to start without an administrator ·14 *      MOCK_SETUP_TOKEN · MOCK_LOCK_SEC (lockout duration, default 30) ·15 *      MOCK_AGENT=codex to answer as a "Codex Remote" instance (branding, titles, conversations)16 */17import http from 'node:http';18import os from 'node:os';19import fs from 'node:fs';20import path from 'node:path';21import crypto from 'node:crypto';22import { spawn, execFileSync } from 'node:child_process';23import { createRequire } from 'node:module';24import { fileURLToPath } from 'node:url';2526const here = path.dirname(fileURLToPath(import.meta.url));27// Which product this mock impersonates (mirrors AGENT_PROFILES / brandingOf() in @cr/shared).28const AGENT = process.env.MOCK_AGENT === 'codex' ? 'codex' : 'claude';29const CODEX = AGENT === 'codex';30const CFG_DIR = CODEX ? '.codex' : '.claude';31const BRANDING = CODEX ? { agent: 'codex', productName: 'Codex Remote', agentName: 'Codex' } : { agent: 'claude', productName: 'Claude Remote', agentName: 'Claude Code' };32const require = createRequire(import.meta.url);33const rtModules = path.resolve(here, '../../runtime/node_modules');34const { WebSocketServer } = require(path.join(rtModules, 'ws'));35let pty = null;36try {37  // node-pty's prebuilt `spawn-helper` sometimes loses its exec bit on install. We must not38  // touch files outside apps/web, so in that case load a private copy from the temp dir.39  let dir = fs.realpathSync(path.join(rtModules, 'node-pty'));40  const helpers = [`prebuilds/${process.platform}-${process.arch}/spawn-helper`, 'build/Release/spawn-helper'].map((h) => path.join(dir, h)).filter((h) => fs.existsSync(h));41  if (helpers.some((h) => !(fs.statSync(h).mode & 0o111))) {42    const copy = path.join(os.tmpdir(), `cr-mock-node-pty-${require(path.join(dir, 'package.json')).version}`);43    if (!fs.existsSync(copy)) fs.cpSync(dir, copy, { recursive: true });44    for (const h of helpers) fs.chmodSync(path.join(copy, path.relative(dir, h)), 0o755);45    dir = copy;46  }47  pty = require(dir);48} catch (e) {49  console.warn('[mock] node-pty unavailable, terminal falls back to echo:', e.message);50}51const { UserSettings } = await import('../node_modules/@cr/shared/dist/index.js');5253const PORT = Number(process.env.MOCK_PORT ?? 8621);54const SETUP_TOKEN = process.env.MOCK_SETUP_TOKEN ?? 'mock-setup-token-0000';55const LOCK_SEC = Number(process.env.MOCK_LOCK_SEC ?? 30);56export const SEED = { username: 'admin', password: 'correct-horse-battery', totpSecret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' };5758// ---------------------------------------------------------------- helpers59const uuid = () => crypto.randomUUID();60const now = () => new Date().toISOString();61const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';62function b32decode(s) {63  let bits = '';64  for (const c of s.replace(/=+$/, '').toUpperCase()) bits += B32.indexOf(c).toString(2).padStart(5, '0');65  const out = [];66  for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2));67  return Buffer.from(out);68}69function b32encode(buf) {70  let bits = '';71  for (const b of buf) bits += b.toString(2).padStart(8, '0');72  let out = '';73  for (let i = 0; i < bits.length; i += 5) out += B32[parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)];74  return out;75}76export function totp(secret, t = Date.now()) {77  const counter = Buffer.alloc(8);78  counter.writeBigUInt64BE(BigInt(Math.floor(t / 30000)));79  const h = crypto.createHmac('sha1', b32decode(secret)).update(counter).digest();80  const o = h[h.length - 1] & 0xf;81  return String((h.readUInt32BE(o) & 0x7fffffff) % 1e6).padStart(6, '0');82}83const totpOk = (secret, code) => [-1, 0, 1].some((d) => totp(secret, Date.now() + d * 30000) === code);8485class HttpError extends Error {86  constructor(status, code, message, details) {87    super(message);88    Object.assign(this, { status, code, details });89  }90}91const bad = (status, code, message, details) => new HttpError(status, code, message, details);9293// ---------------------------------------------------------------- state94let S;95const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-mock-'));96const wsEvents = new Set();97const broadcast = (ev) => {98  const s = JSON.stringify(ev);99  for (const ws of wsEvents) if (ws.readyState === 1) ws.send(s);100};101function audit(action, target = null, ok = true, detail = {}) {102  S.audit.unshift({ id: String(++S.auditSeq), at: now(), actor: S.admin?.username ?? 'anonymous', action, target, ip: '127.0.0.1', ok, detail });103}104function notify(kind, title, body, link = null) {105  const n = { id: uuid(), kind, title, body, link, readAt: null, createdAt: now() };106  S.notifications.unshift(n);107  broadcast({ type: 'notification', notification: n });108}109110function seedWorkspace() {111  const ws = path.join(tmpRoot, 'workspace-' + Date.now());112  const demo = path.join(ws, 'demo');113  fs.mkdirSync(path.join(demo, 'src'), { recursive: true });114  fs.mkdirSync(path.join(demo, 'docs'), { recursive: true });115  fs.writeFileSync(path.join(demo, 'README.md'), '# Demo project\n\nSeeded by the **dev mock server**. Nothing here is production data.\n\n- [x] markdown preview\n- [ ] gfm task list\n\n| column | value |\n|---|---|\n| a | 1 |\n');116  fs.writeFileSync(path.join(demo, 'src/index.ts'), "export function greet(name: string): string {\n  return `hello ${name}`;\n}\n");117  fs.writeFileSync(path.join(demo, 'src/util.ts'), 'export const add = (a: number, b: number) => a + b;\n');118  fs.writeFileSync(path.join(demo, 'data.json'), '{"name":"demo","items":[1,2,3],"nested":{"ok":true}}\n');119  fs.writeFileSync(path.join(demo, 'docs/notes.txt'), 'notes line 1\nneedle-in-haystack\nnotes line 3\n');120  // 1x1 PNG, a binary blob, and a text file above the 2 MiB editor limit121  fs.writeFileSync(path.join(demo, 'pixel.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'));122  fs.writeFileSync(path.join(demo, 'blob.bin'), Buffer.from([0, 1, 2, 3, 0, 255, 254, 0]));123  fs.writeFileSync(path.join(demo, 'big.log'), 'x'.repeat(80) + '\n'.repeat(1) + 'y'.repeat(2 * 1024 * 1024 + 10));124  try {125    const g = (...a) => execFileSync('git', a, { cwd: demo, stdio: 'ignore', env: { ...process.env, GIT_AUTHOR_NAME: 'mock', GIT_AUTHOR_EMAIL: 'mock@example.invalid', GIT_COMMITTER_NAME: 'mock', GIT_COMMITTER_EMAIL: 'mock@example.invalid' } });126    g('init', '-q', '-b', 'main');127    g('add', '-A');128    g('commit', '-q', '-m', 'seed');129    fs.appendFileSync(path.join(demo, 'src/util.ts'), 'export const sub = (a: number, b: number) => a - b;\n');130    fs.writeFileSync(path.join(demo, 'untracked.txt'), 'new file\n');131  } catch {132    /* git missing: project is simply not a repo */133  }134  return ws;135}136137let GEN = 0; // bumped on reset: exits caused by the reset itself must not raise events138function reset(seed = true) {139  GEN++;140  if (S) {141    for (const s of S.sessions.values()) s.proc?.kill?.();142    for (const t of S.tasks.values()) t.child?.kill?.('SIGKILL');143  }144  const wsRoot = seedWorkspace();145  S = {146    admin: seed ? { id: uuid(), username: SEED.username, password: SEED.password, totpSecret: SEED.totpSecret, pendingTotp: null, passkeys: [] } : null,147    auth: new Map(), // sid -> {id, csrf, level:'none'|'password'|'full', createdAt, lastUsedAt, ua}148    fails: 0,149    lockedUntil: 0,150    wsRoot,151    projects: new Map(),152    sessions: new Map(),153    tasks: new Map(),154    notifications: [],155    audit: [],156    auditSeq: 0,157    settings: UserSettings.parse({}),158    skills: [159      { name: 'fixture-review', description: '[fixture] Example skill served by the dev mock', source: 'user', kind: 'skill', version: '0.0.1', location: `~/${CFG_DIR}/skills/fixture-review`, enabled: true, updateAvailable: null },160      { name: 'fixture-deploy', description: '[fixture] Example slash command', source: 'project', kind: 'command', version: null, location: `${CFG_DIR}/commands/fixture-deploy.md`, enabled: true, updateAvailable: null },161      { name: 'fixture-auditor', description: '[fixture] Example sub-agent', source: 'user', kind: 'agent', version: null, location: `~/${CFG_DIR}/agents/fixture-auditor.md`, enabled: false, updateAvailable: null },162    ],163    mcp: [164      { name: 'fixture-stdio', scope: 'user', transport: 'stdio', target: 'npx fixture-mcp-server', envKeys: ['FIXTURE_API_KEY'], status: 'connected', lastError: null, checkedAt: now() },165      { name: 'fixture-http', scope: 'user', transport: 'http', target: 'https://mcp.example.invalid/…', envKeys: [], status: 'failed', lastError: '[fixture] connection refused', checkedAt: now() },166    ],167    models: process.env.MOCK_NO_MODELS ? [] : [{ id: 'fixture-model', name: '[fixture] example-7b-4bit', host: 'FIXTURE-studio-1', engine: 'mlx', endpoint: 'FIXTURE-studio-1:8001', health: 'UNKNOWN', checkedAt: null, latencyMs: null, tokensPerSec: null }],168  };169  const p = { id: uuid(), name: 'demo', description: 'Seeded by the dev mock server', rootLabel: 'workspace', path: 'demo', gitRemote: null, tags: ['mock'], pinned: true, settings: {}, createdAt: now(), lastActivityAt: null };170  S.projects.set(p.id, p);171}172173// ---------------------------------------------------------------- projects / files174const projDir = (p) => path.join(S.wsRoot, p.path);175function inside(p, rel) {176  if (typeof rel !== 'string' || rel.includes('\0') || path.isAbsolute(rel) || rel.split('/').includes('..')) throw bad(400, 'bad_path', 'Invalid path');177  return path.join(projDir(p), rel);178}179function git(p, ...args) {180  return execFileSync('git', args, { cwd: projDir(p), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });181}182function gitStatus(p) {183  try {184    const out = git(p, 'status', '--porcelain=v1', '-b', '--untracked-files=all');185    const lines = out.split('\n').filter(Boolean);186    const head = lines.shift() ?? '';187    const branch = head.replace(/^## /, '').split('...')[0].replace(/^No commits yet on /, '') || null;188    const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0);189    const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0);190    return { isRepo: true, branch, ahead, behind, files: lines.map((l) => ({ index: l[0], worktree: l[1], path: l.slice(3) })) };191  } catch {192    return { isRepo: false, branch: null, ahead: 0, behind: 0, files: [] };193  }194}195function projectDto(p) {196  const st = gitStatus(p);197  return { ...p, gitBranch: st.branch, gitDirty: st.isRepo ? st.files.length > 0 : null, activeSessions: [...S.sessions.values()].filter((s) => s.dto.projectId === p.id && LIVE.includes(s.dto.state)).length };198}199const getProject = (id) => S.projects.get(id) ?? (() => { throw bad(404, 'not_found', 'Project not found'); })();200function isBinary(buf) {201  return buf.subarray(0, 8192).includes(0);202}203function entry(p, rel) {204  const abs = inside(p, rel);205  const st = fs.lstatSync(abs);206  const type = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : st.isSymbolicLink() ? 'symlink' : 'other';207  return { name: path.basename(rel), path: rel, type, size: st.size, mtimeMs: st.mtimeMs };208}209function walk(p, rel, out, limit) {210  for (const name of fs.readdirSync(inside(p, rel))) {211    if (name === '.git' || out.length >= limit) continue;212    const r = rel ? `${rel}/${name}` : name;213    const e = entry(p, r);214    out.push(e);215    if (e.type === 'dir') walk(p, r, out, limit);216  }217  return out;218}219const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex');220221// ---------------------------------------------------------------- sessions222const LIVE = ['STARTING', 'RUNNING', 'DETACHED', 'WAITING_FOR_INPUT', 'STOPPING'];223function pushSession(s) {224  broadcast({ type: 'session.updated', session: s.dto });225}226function startProc(s, cols, rows) {227  const proj = s.dto.projectId ? S.projects.get(s.dto.projectId) : null;228  const cwd = proj ? path.join(projDir(proj), s.dto.cwd) : S.wsRoot;229  s.replay = [];230  s.replayBytes = 0;231  const onData = (d) => {232    const buf = Buffer.isBuffer(d) ? d : Buffer.from(d, 'utf8');233    s.replay.push(buf);234    s.replayBytes += buf.length;235    while (s.replayBytes > 256 * 1024 && s.replay.length > 1) s.replayBytes -= s.replay.shift().length;236    for (const ws of s.clients) if (ws.readyState === 1) ws.send(buf, { binary: true });237    const t = Date.now();238    if (t - (s.lastPush ?? 0) > 5000) {239      s.lastPush = t;240      s.dto.lastActivityAt = now();241      pushSession(s);242    }243  };244  const gen = GEN;245  const onExit = (code) => {246    if (s.proc !== proc || gen !== GEN) return;247    s.proc = null;248    Object.assign(s.dto, { state: 'STOPPED', exitCode: code ?? null, endedAt: now(), pid: null, attachedClients: 0 });249    for (const ws of s.clients) {250      ws.send(JSON.stringify({ t: 'exit', code: code ?? null }));251      ws.close(4410, 'session ended');252    }253    s.clients.clear();254    pushSession(s);255    notify('session.exited', 'Session ended', 'A terminal session has ended.', `/terminal/?s=${s.dto.id}`);256  };257  let proc;258  if (pty) {259    const p = pty.spawn(process.env.MOCK_SHELL ?? '/bin/bash', ['--noprofile', '--norc', '-i'], {260      name: 'xterm-256color', cols, rows, cwd,261      env: { ...process.env, PS1: 'mock$ ', TERM: 'xterm-256color', BASH_SILENCE_DEPRECATION_WARNING: '1', CR_SESSION_ID: s.dto.id, HISTFILE: '/dev/null' },262    });263    p.onData(onData);264    p.onExit(({ exitCode }) => onExit(exitCode));265    proc = { write: (d) => p.write(d), resize: (c, r) => p.resize(c, r), kill: () => p.kill(), pid: p.pid };266  } else {267    proc = { write: (d) => onData(Buffer.from(String(d).replace(/\r/g, '\r\n'))), resize() {}, kill: () => onExit(0), pid: null };268    setTimeout(() => onData(Buffer.from('[mock] node-pty unavailable: echo terminal\r\n')), 10);269  }270  s.proc = proc;271  if (s.dto.kind === 'claude') setTimeout(() => onData(Buffer.from(`\r\n[dev mock] This is a local shell, NOT ${BRANDING.agentName}. The mock server never starts ${CODEX ? 'Codex' : 'Claude'}.\r\n`)), 50);272  Object.assign(s.dto, { state: 'RUNNING', pid: proc.pid, exitCode: null, endedAt: null, lastActivityAt: now() });273}274function createSession(body) {275  const proj = body.projectId ? getProject(body.projectId) : null;276  if ([...S.sessions.values()].filter((s) => LIVE.includes(s.dto.state)).length >= 12) throw bad(429, 'too_many_sessions', 'Session limit reached');277  const id = uuid();278  const dto = {279    id, projectId: proj?.id ?? null, projectName: proj?.name ?? null, kind: body.kind ?? 'claude',280    title: body.title || `${CODEX && (body.kind ?? 'claude') === 'claude' ? 'Codex' : (body.kind ?? 'claude')} · ${proj?.name ?? 'workspace'}`, state: 'STARTING', cwd: body.cwd ?? '', pid: null,281    claudeConversationId: body.resumeConversationId ?? null, pinned: false, archived: false, attachedClients: 0, exitCode: null,282    createdAt: now(), lastActivityAt: null, endedAt: null, waitingReason: null,283  };284  const s = { dto, clients: new Set(), proc: null, replay: [], replayBytes: 0 };285  S.sessions.set(id, s);286  startProc(s, body.cols ?? 120, body.rows ?? 32);287  if (proj) proj.lastActivityAt = now();288  pushSession(s);289  return dto;290}291const getSession = (id) => S.sessions.get(id) ?? (() => { throw bad(404, 'not_found', 'Session not found'); })();292293// ---------------------------------------------------------------- tasks294function taskDto(t) {295  const { child, log, followers, ...dto } = t;296  return { ...dto, logBytes: t.logBytes };297}298function pushTask(t) {299  broadcast({ type: 'task.updated', task: taskDto(t) });300}301function startTask(body, retryOf = null) {302  const proj = body.projectId ? getProject(body.projectId) : null;303  if (body.host && body.host !== 'local') throw bad(400, 'host_not_allowed', 'The dev mock only runs local tasks');304  const t = {305    id: uuid(), projectId: proj?.id ?? null, title: body.title, command: body.command, host: 'local', cwd: body.cwd ?? '', state: 'RUNNING',306    exitCode: null, timeoutSec: body.timeoutSec ?? null, retryOf, idempotent: !!body.idempotent, createdAt: now(), startedAt: now(), endedAt: null,307    logBytes: 0, cpuSec: null, maxRssMb: null, artifacts: [], artifactGlobs: body.artifactGlobs ?? [],308    log: [], followers: new Set(),309  };310  const cwd = proj ? path.join(projDir(proj), t.cwd) : S.wsRoot;311  const child = spawn('/bin/bash', ['-c', t.command], { cwd, env: { ...process.env, FORCE_COLOR: '1' } });312  t.child = child;313  const onData = (d) => {314    t.log.push(d);315    t.logBytes += d.length;316    for (const ws of t.followers) if (ws.readyState === 1) ws.send(d, { binary: true });317  };318  child.stdout.on('data', onData);319  child.stderr.on('data', onData);320  let timer = null;321  if (t.timeoutSec) timer = setTimeout(() => { t.timedOut = true; child.kill('SIGTERM'); }, t.timeoutSec * 1000);322  const gen = GEN;323  child.on('close', (code) => {324    clearTimeout(timer);325    if (gen !== GEN) return;326    t.child = null;327    t.exitCode = code;328    t.endedAt = now();329    t.state = t.timedOut ? 'TIMED_OUT' : t.state === 'CANCELLING' ? 'CANCELLED' : code === 0 ? 'SUCCEEDED' : 'FAILED';330    if (t.state === 'SUCCEEDED') {331      for (const g of t.artifactGlobs) {332        const re = new RegExp('^' + g.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*') + '$');333        for (const name of fs.readdirSync(cwd)) if (re.test(name) && fs.statSync(path.join(cwd, name)).isFile()) t.artifacts.push({ id: uuid(), name, size: fs.statSync(path.join(cwd, name)).size, createdAt: now(), abs: path.join(cwd, name) });334      }335    }336    for (const ws of t.followers) ws.close(1000, 'task ended');337    pushTask(t);338    notify(t.state === 'SUCCEEDED' ? 'task.succeeded' : 'task.failed', t.state === 'SUCCEEDED' ? 'Task succeeded' : 'Task did not succeed', 'A background task has finished.', `/tasks/?t=${t.id}`);339  });340  S.tasks.set(t.id, t);341  pushTask(t);342  return t;343}344const getTask = (id) => S.tasks.get(id) ?? (() => { throw bad(404, 'not_found', 'Task not found'); })();345346// ---------------------------------------------------------------- cluster fixture347function clusterFixture() {348  if (process.env.MOCK_NO_CLUSTER) return { source: 'none', collectedAt: null, health: 'UNKNOWN', nodes: [], edges: [], totals: { nodes: 0, online: 0, cores: 0, memoryGb: 0 } };349  const at = now();350  const old = new Date(Date.now() - 3600_000).toISOString();351  const n = (id, o) => ({ id, model: null, chip: null, kind: 'mac', role: 'fixture', cpuCores: null, gpuCores: null, memoryMb: null, os: 'macOS 27.0', health: 'ONLINE', metricsAt: at, stale: false, cpuPct: null, memUsedMb: null, diskFreeGb: null, diskTotalGb: null, load1: null, apps: [], execAllowed: false, isHost: false, ...o });352  const nodes = [353    n('FIXTURE-gateway', { kind: 'server', model: 'Fixture server', chip: 'x86-64', role: 'fixture gateway', cpuCores: 16, memoryMb: 65536, os: 'Ubuntu 24.04', cpuPct: 3.1, memUsedMb: 9000, diskFreeGb: 700, diskTotalGb: 960, load1: 0.2 }),354    n('FIXTURE-studio-1', { model: 'Mac Studio', chip: 'M5 Max', cpuCores: 18, gpuCores: 40, memoryMb: 36864, cpuPct: 12.4, memUsedMb: 14200, diskFreeGb: 410.5, diskTotalGb: 994, load1: 1.92, apps: ['claude-remote'], isHost: true, execAllowed: true }),355    n('FIXTURE-studio-2', { model: 'Mac Studio', chip: 'M3 Ultra', cpuCores: 32, gpuCores: 80, memoryMb: 98304, cpuPct: 71.0, memUsedMb: 80100, diskFreeGb: 122, diskTotalGb: 1858, load1: 22.4, apps: ['fixture-app-a', 'fixture-app-b'], execAllowed: true, health: 'DEGRADED' }),356    // stale: the API still says ONLINE on purpose — the UI must show UNKNOWN357    n('FIXTURE-mini-1', { model: 'Mac mini', chip: 'M4', cpuCores: 10, gpuCores: 10, memoryMb: 16384, cpuPct: 5, memUsedMb: 7000, diskFreeGb: 180, diskTotalGb: 245, load1: 0.9, metricsAt: old, stale: true }),358    n('FIXTURE-mini-2', { model: 'Mac mini', chip: 'M6', cpuCores: 12, gpuCores: 12, memoryMb: 24576, health: 'OFFLINE', metricsAt: null }),359    n('FIXTURE-laptop-1', { model: 'MacBook Pro', chip: 'M4 Pro', cpuCores: 14, gpuCores: 20, memoryMb: 36864, health: 'UNKNOWN', metricsAt: old, stale: true }),360    n('FIXTURE-phone-1', { kind: 'mobile', model: 'iPhone', chip: null, os: 'iOS 27', health: 'UNKNOWN', metricsAt: null }),361  ];362  const edges = nodes.filter((x) => x.kind === 'mac').map((x) => ({ from: 'FIXTURE-gateway', to: x.id, kind: 'tunnel' }));363  edges.push({ from: 'FIXTURE-studio-1', to: 'FIXTURE-studio-2', kind: 'lan' }, { from: 'FIXTURE-studio-1', to: 'FIXTURE-mini-1', kind: 'lan' }, { from: 'FIXTURE-studio-1', to: 'FIXTURE-phone-1', kind: 'service' });364  const online = nodes.filter((x) => x.health === 'ONLINE' && !x.stale).length;365  return { source: 'agentd', collectedAt: at, health: 'DEGRADED', nodes, edges, totals: { nodes: nodes.length, online, cores: nodes.reduce((a, x) => a + (x.cpuCores ?? 0), 0), memoryGb: Math.round(nodes.reduce((a, x) => a + (x.memoryMb ?? 0), 0) / 1024) } };366}367368function systemStatus() {369  const mem = os.totalmem();370  let disk = { free: null, total: null };371  try {372    const st = fs.statfsSync(os.homedir());373    disk = { free: (st.bavail * st.bsize) / 1e9, total: (st.blocks * st.bsize) / 1e9 };374  } catch {}375  const live = [...S.sessions.values()].filter((s) => LIVE.includes(s.dto.state));376  return {377    collectedAt: now(), version: '0.1.0-mock',378    components: [379      { component: 'server (dev mock)', health: 'ONLINE', detail: 'apps/web/dev/mock-server.mjs', checkedAt: now() },380      { component: 'runtime', health: pty ? 'ONLINE' : 'DEGRADED', detail: pty ? 'node-pty shell (no tmux in the mock)' : 'echo fallback', checkedAt: now() },381      { component: 'database', health: 'UNKNOWN', detail: 'not present in the mock', checkedAt: now() },382      { component: 'cluster telemetry', health: 'UNKNOWN', detail: 'fixture data', checkedAt: now() },383    ],384    host: { name: os.hostname(), cpuPct: null, load: os.loadavg(), memUsedMb: Math.round((mem - os.freemem()) / 1048576), memTotalMb: Math.round(mem / 1048576), diskFreeGb: disk.free, diskTotalGb: disk.total, uptimeSec: os.uptime() },385    counts: { liveSessions: live.length, wsClients: wsEvents.size + live.reduce((a, s) => a + s.clients.size, 0), runningTasks: [...S.tasks.values()].filter((t) => t.state === 'RUNNING').length, queuedTasks: 0 },386    claude: CODEX ? { version: 'codex-cli 0.166.0 (fixture)', authenticated: false, agent: 'codex', agentName: 'Codex' } : { version: null, authenticated: null, agent: 'claude', agentName: 'Claude Code' },387  };388}389390// ---------------------------------------------------------------- auth391function cookies(req) {392  return Object.fromEntries((req.headers.cookie ?? '').split(';').map((c) => c.trim().split('=')).filter((a) => a[0]));393}394const authOf = (req) => S.auth.get(cookies(req).cr_sid) ?? null;395function newAuth(req, res, level) {396  const sid = crypto.randomBytes(24).toString('hex');397  const a = { id: uuid(), sid, csrf: crypto.randomBytes(16).toString('hex'), level, createdAt: now(), lastUsedAt: now(), ua: req.headers['user-agent'] ?? null };398  S.auth.set(sid, a);399  res.setHeader('Set-Cookie', `cr_sid=${sid}; HttpOnly; SameSite=Strict; Path=/`);400  return a;401}402function hasSecondFactor() {403  return !!S.admin && (!!S.admin.totpSecret || S.admin.passkeys.length > 0);404}405function authStatus(req) {406  const a = authOf(req);407  return {408    setupRequired: !S.admin,409    authenticated: a?.level === 'full',410    user: a && a.level !== 'none' && S.admin ? { id: S.admin.id, username: S.admin.username } : null,411    methods: { password: !!S.admin, passkeys: S.admin?.passkeys.length ?? 0, totp: !!S.admin?.totpSecret },412    csrfToken: a?.csrf ?? null,413    sessionExpiresAt: a?.level === 'full' ? new Date(Date.now() + 12 * 3600_000).toISOString() : null,414    branding: BRANDING,415  };416}417function lockCheck() {418  const left = Math.ceil((S.lockedUntil - Date.now()) / 1000);419  if (left > 0) throw bad(429, 'locked', 'Too many attempts. Try again later.', { retryAfterSec: left });420}421function fail() {422  if (++S.fails >= 5) {423    S.fails = 0;424    S.lockedUntil = Date.now() + LOCK_SEC * 1000;425  }426  audit('auth.login', null, false);427  lockCheck();428  throw bad(401, 'invalid_credentials', 'Invalid credentials');429}430431// ---------------------------------------------------------------- router432const routes = [];433const R = (method, pattern, handler, opts = {}) => {434  const keys = [];435  const re = new RegExp('^' + pattern.replace(/:(\w+)/g, (_, k) => (keys.push(k), '([^/]+)')) + '/?$');436  routes.push({ method, re, keys, handler, ...opts });437};438const PUB = { public: true };439const PART = { partial: true }; // password verified, second factor pending (enrolment endpoints)440441R('GET', '/health', () => ({ ok: true, version: '0.1.0-mock', agent: AGENT }), PUB);442R('GET', '/auth/status', ({ req }) => authStatus(req), PUB);443R('POST', '/auth/setup', ({ req, res, body }) => {444  if (S.admin) throw bad(409, 'already_configured', 'Setup has already been completed');445  if (body.setupToken !== SETUP_TOKEN) throw bad(403, 'bad_setup_token', 'Invalid setup token');446  if (!/^[a-z0-9_.-]{3,32}$/i.test(body.username ?? '') || (body.password ?? '').length < 12) throw bad(400, 'validation', 'Invalid username or password');447  S.admin = { id: uuid(), username: body.username, password: body.password, totpSecret: null, pendingTotp: null, passkeys: [] };448  newAuth(req, res, 'password');449  audit('auth.setup');450  return { ok: true, next: 'enroll_second_factor' };451}, PUB);452R('POST', '/auth/login', ({ req, res, body }) => {453  lockCheck();454  if (!S.admin || body.username !== S.admin.username || body.password !== S.admin.password) fail();455  if (!hasSecondFactor()) return newAuth(req, res, 'password'), { ok: true, next: 'enroll_second_factor' };456  if (body.totp) {457    if (!S.admin.totpSecret || !totpOk(S.admin.totpSecret, body.totp)) fail();458    S.fails = 0;459    newAuth(req, res, 'full');460    audit('auth.login');461    return { ok: true, next: 'done' };462  }463  newAuth(req, res, 'password');464  return { ok: true, next: S.admin.totpSecret ? 'totp' : 'passkey' };465}, PUB);466R('POST', '/auth/totp', ({ req, body }) => {467  lockCheck();468  const a = authOf(req);469  if (!a || a.level === 'none') throw bad(401, 'unauthenticated', 'Sign in first');470  if (!S.admin.totpSecret || !totpOk(S.admin.totpSecret, body.code ?? '')) fail();471  S.fails = 0;472  a.level = 'full';473  audit('auth.login');474  return { ok: true, next: 'done' };475}, PUB);476R('POST', '/auth/logout', ({ req, res }) => {477  S.auth.delete(cookies(req).cr_sid);478  res.setHeader('Set-Cookie', 'cr_sid=; Max-Age=0; Path=/');479  return { ok: true };480}, PUB);481R('POST', '/auth/password', ({ body }) => {482  if (body.currentPassword !== S.admin.password) throw bad(403, 'invalid_credentials', 'Current password is incorrect');483  if ((body.newPassword ?? '').length < 12) throw bad(400, 'validation', 'Password must be at least 12 characters');484  S.admin.password = body.newPassword;485  audit('auth.password');486  return { ok: true };487});488R('POST', '/auth/totp/enroll/start', () => {489  S.admin.pendingTotp = b32encode(crypto.randomBytes(20));490  return { secret: S.admin.pendingTotp, otpauthUrl: `otpauth://totp/${encodeURIComponent(BRANDING.productName)}:${S.admin.username}?secret=${S.admin.pendingTotp}&issuer=${encodeURIComponent(BRANDING.productName)}` };491}, PART);492R('POST', '/auth/totp/enroll/finish', ({ req, body }) => {493  if (!S.admin.pendingTotp || !totpOk(S.admin.pendingTotp, body.code ?? '')) throw bad(400, 'invalid_code', 'The code does not match');494  S.admin.totpSecret = S.admin.pendingTotp;495  S.admin.pendingTotp = null;496  authOf(req).level = 'full';497  audit('auth.totp.enroll');498  return { ok: true };499}, PART);500R('DELETE', '/auth/totp', () => {501  if (!S.admin.passkeys.length) throw bad(409, 'last_factor', 'Add a passkey before removing TOTP');502  S.admin.totpSecret = null;503  return { ok: true };504});505const pkOptions = (extra) => ({ challenge: crypto.randomBytes(32).toString('base64url'), rp: { name: `${BRANDING.productName} (mock)`, id: 'localhost' }, rpId: 'localhost', timeout: 60000, userVerification: 'preferred', ...extra });506R('POST', '/auth/passkey/register/options', () => pkOptions({ user: { id: Buffer.from(S.admin.id).toString('base64url'), name: S.admin.username, displayName: S.admin.username }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], attestation: 'none', authenticatorSelection: { residentKey: 'preferred' }, excludeCredentials: [] }), PART);507R('POST', '/auth/passkey/register/verify', ({ req, body }) => {508  // The mock does not verify attestation; the real server does.509  S.admin.passkeys.push({ id: uuid(), credId: (body.credential ?? body).id ?? null, label: body.label || 'Passkey', createdAt: now(), lastUsedAt: null });510  authOf(req).level = 'full';511  return { ok: true };512}, PART);513R('POST', '/auth/passkey/login/options', () => pkOptions({ allowCredentials: [] }), PUB);514R('POST', '/auth/passkey/login/verify', ({ req, res, body }) => {515  const pk = S.admin?.passkeys.find((k) => k.credId && k.credId === (body.credential ?? body).id);516  if (!pk) fail();517  pk.lastUsedAt = now();518  newAuth(req, res, 'full');519  return { ok: true, next: 'done' };520}, PUB);521R('GET', '/auth/devices', ({ req }) => [522  ...S.admin.passkeys.map((k) => ({ id: k.id, kind: 'passkey', label: k.label, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt, current: false, userAgent: null, ip: null })),523  ...[...S.auth.values()].filter((a) => a.level === 'full').map((a) => ({ id: a.id, kind: 'session', label: 'Browser session', createdAt: a.createdAt, lastUsedAt: a.lastUsedAt, current: a === authOf(req), userAgent: a.ua, ip: '127.0.0.1' })),524]);525R('DELETE', '/auth/devices/:id', ({ params }) => {526  S.admin.passkeys = S.admin.passkeys.filter((k) => k.id !== params.id);527  for (const [sid, a] of S.auth) if (a.id === params.id) S.auth.delete(sid);528  audit('auth.device.revoke', params.id);529  return { ok: true };530});531532R('GET', '/workspace/roots', () => [{ label: 'workspace', path: '(mock temp dir)' }]);533R('GET', '/projects', () => [...S.projects.values()].map(projectDto));534R('POST', '/projects', ({ body }) => {535  if (!body.name) throw bad(400, 'validation', 'Name is required');536  const rel = body.path || body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');537  const abs = path.join(S.wsRoot, rel);538  if (rel.includes('..')) throw bad(400, 'bad_path', 'Invalid path');539  if (body.mode === 'existing' && !fs.existsSync(abs)) throw bad(404, 'not_found', 'Directory does not exist');540  if (body.mode === 'create' && fs.existsSync(abs)) throw bad(409, 'exists', 'Directory already exists');541  if (body.mode === 'clone') throw bad(501, 'not_supported', 'The dev mock does not clone repositories');542  fs.mkdirSync(abs, { recursive: true });543  const p = { id: uuid(), name: body.name, description: body.description ?? '', rootLabel: 'workspace', path: rel, gitRemote: null, tags: body.tags ?? [], pinned: false, settings: {}, createdAt: now(), lastActivityAt: null };544  S.projects.set(p.id, p);545  audit('project.create', p.name);546  return projectDto(p);547});548R('GET', '/projects/:id', ({ params }) => projectDto(getProject(params.id)));549R('PATCH', '/projects/:id', ({ params, body }) => {550  const p = getProject(params.id);551  for (const k of ['name', 'description', 'tags', 'pinned', 'settings']) if (body[k] !== undefined) p[k] = body[k];552  return projectDto(p);553});554R('DELETE', '/projects/:id', ({ params }) => {555  getProject(params.id);556  S.projects.delete(params.id);557  audit('project.unregister', params.id);558  return { ok: true };559});560R('GET', '/projects/:id/conversations', ({ params }) => {561  getProject(params.id);562  if (CODEX) return [ // Codex session ids are UUIDs (v7)563    { id: '019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6071', summary: '[fixture] Refactor the parser and add tests', updatedAt: new Date(Date.now() - 3600_000).toISOString(), messageCount: 42 },564    { id: '019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6072', summary: '[fixture] Investigate flaky integration test', updatedAt: new Date(Date.now() - 86400_000 * 3).toISOString(), messageCount: 7 },565  ];566  return [567    { id: 'fixture-conv-0001', summary: '[fixture] Refactor the parser and add tests', updatedAt: new Date(Date.now() - 3600_000).toISOString(), messageCount: 42 },568    { id: 'fixture-conv-0002', summary: '[fixture] Investigate flaky integration test', updatedAt: new Date(Date.now() - 86400_000 * 3).toISOString(), messageCount: 7 },569  ];570});571R('GET', '/projects/:id/files', ({ params, query }) => {572  const p = getProject(params.id);573  const rel = query.get('path') ?? '';574  const abs = inside(p, rel);575  if (!fs.existsSync(abs)) throw bad(404, 'not_found', 'Directory not found');576  return fs.readdirSync(abs).filter((n) => n !== '.git').map((n) => entry(p, rel ? `${rel}/${n}` : n))577    .sort((a, b) => (a.type === 'dir') === (b.type === 'dir') ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1);578});579R('GET', '/projects/:id/file', ({ params, query }) => {580  const p = getProject(params.id);581  const rel = query.get('path') ?? '';582  const abs = inside(p, rel);583  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw bad(404, 'not_found', 'File not found');584  const st = fs.statSync(abs);585  if (st.size > 2 * 1024 * 1024) throw bad(413, 'too_large', 'File is larger than 2 MiB', { size: st.size, mtimeMs: st.mtimeMs });586  const buf = fs.readFileSync(abs);587  if (isBinary(buf)) throw bad(415, 'binary', 'Binary file', { size: st.size, mtimeMs: st.mtimeMs });588  return { path: rel, size: st.size, mtimeMs: st.mtimeMs, sha256: sha(buf), encoding: 'utf8', content: buf.toString('utf8') };589});590R('PUT', '/projects/:id/file', ({ params, body }) => {591  const p = getProject(params.id);592  const abs = inside(p, body.path);593  const exists = fs.existsSync(abs);594  if (body.create && exists) throw bad(409, 'exists', 'File already exists');595  if (exists && body.baseSha256 && sha(fs.readFileSync(abs)) !== body.baseSha256) throw bad(409, 'conflict', 'The file changed on disk since it was opened');596  fs.mkdirSync(path.dirname(abs), { recursive: true });597  fs.writeFileSync(abs, body.content ?? '');598  const st = fs.statSync(abs);599  return { path: body.path, size: st.size, mtimeMs: st.mtimeMs, sha256: sha(Buffer.from(body.content ?? '')), encoding: 'utf8', content: '' };600});601R('POST', '/projects/:id/fs', ({ params, body }) => {602  const p = getProject(params.id);603  if (body.op === 'mkdir') fs.mkdirSync(inside(p, body.path), { recursive: true });604  else if (body.op === 'rename') {605    if (fs.existsSync(inside(p, body.to))) throw bad(409, 'exists', 'Destination already exists');606    fs.mkdirSync(path.dirname(inside(p, body.to)), { recursive: true });607    fs.renameSync(inside(p, body.from), inside(p, body.to));608  } else if (body.op === 'delete') {609    if (body.confirm !== true || !body.path) throw bad(400, 'validation', 'Refused');610    fs.rmSync(inside(p, body.path), { recursive: true });611  } else throw bad(400, 'validation', 'Unknown operation');612  audit(`fs.${body.op}`, body.path ?? body.from);613  return { ok: true };614});615R('POST', '/projects/:id/upload', ({ params, query, raw }) => {616  const p = getProject(params.id);617  const abs = inside(p, query.get('path') ?? '');618  if (fs.existsSync(abs) && !['1', 'true'].includes(query.get('overwrite') ?? '')) throw bad(409, 'exists', 'destination already exists');619  fs.mkdirSync(path.dirname(abs), { recursive: true });620  fs.writeFileSync(abs, raw);621  return entry(p, query.get('path'));622}, { rawBody: true });623R('GET', '/projects/:id/download', ({ params, query, res }) => {624  const p = getProject(params.id);625  const abs = inside(p, query.get('path') ?? '');626  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw bad(400, 'not_a_file', 'Not a file');627  const ext = path.extname(abs).toLowerCase();628  const type = 'application/octet-stream'; // like the real server: always attachment + nosniff629  void ext;630  res.writeHead(200, { 'Content-Type': type, 'Content-Length': fs.statSync(abs).size, 'Content-Disposition': `attachment; filename="${path.basename(abs)}"`, 'X-Content-Type-Options': 'nosniff' });631  fs.createReadStream(abs).pipe(res);632  return undefined;633});634R('GET', '/projects/:id/search', ({ params, query }) => {635  const p = getProject(params.id);636  const q = (query.get('q') ?? '').toLowerCase();637  if (!q) return [];638  const all = walk(p, '', [], 5000);639  if (query.get('mode') !== 'content') return all.filter((e) => e.name.toLowerCase().includes(q)).slice(0, 100).map((e) => ({ path: e.path, line: null, preview: null }));640  const hits = [];641  for (const e of all) {642    if (e.type !== 'file' || e.size > 1e6 || hits.length >= 100) continue;643    const buf = fs.readFileSync(inside(p, e.path));644    if (isBinary(buf)) continue;645    buf.toString('utf8').split('\n').forEach((l, i) => { if (hits.length < 100 && l.toLowerCase().includes(q)) hits.push({ path: e.path, line: i + 1, preview: l.slice(0, 200) }); });646  }647  return hits;648});649R('GET', '/projects/:id/git/status', ({ params }) => gitStatus(getProject(params.id)));650R('GET', '/projects/:id/git/diff', ({ params, query }) => {651  const p = getProject(params.id);652  const rel = query.get('path') ?? '';653  inside(p, rel);654  let diff = '';655  try {656    diff = git(p, 'diff', 'HEAD', '--', rel);657    if (!diff && fs.existsSync(inside(p, rel))) {658      try { git(p, 'diff', '--no-index', '--', '/dev/null', rel); } catch (e) { diff = e.stdout?.toString() ?? ''; }659    }660  } catch {}661  return { path: rel, diff };662});663664R('GET', '/sessions', ({ query }) => {665  const archived = ['1', 'true'].includes(query.get('archived') ?? '');666  return [...S.sessions.values()].map((s) => s.dto).filter((d) => d.archived === archived);667});668R('POST', '/sessions', ({ body }) => { const d = createSession(body); audit('session.create', d.id); return d; });669R('GET', '/sessions/:id', ({ params }) => getSession(params.id).dto);670R('PATCH', '/sessions/:id', ({ params, body }) => {671  const s = getSession(params.id);672  for (const k of ['title', 'pinned', 'archived']) if (body[k] !== undefined) s.dto[k] = body[k];673  pushSession(s);674  return s.dto;675});676R('POST', '/sessions/:id/reconnect', ({ params, body }) => {677  const s = getSession(params.id);678  if (body.restart && ['RECOVERABLE', 'STOPPED', 'FAILED'].includes(s.dto.state)) {679    startProc(s, 120, 32);680    audit('session.restart', s.dto.id);681  }682  pushSession(s);683  return s.dto;684});685R('POST', '/sessions/:id/terminate', ({ params }) => {686  const s = getSession(params.id);687  if (s.proc) s.proc.kill();688  else if (s.dto.state !== 'STOPPED') Object.assign(s.dto, { state: 'STOPPED', endedAt: now() });689  audit('session.terminate', s.dto.id);690  pushSession(s);691  return s.dto;692});693R('GET', '/sessions/:id/snapshot', ({ params }) => ({ text: Buffer.concat(getSession(params.id).replay).toString('utf8').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').split('\n').slice(-200).join('\n') }));694695R('GET', '/tasks', () => [...S.tasks.values()].map(taskDto).reverse());696R('POST', '/tasks', ({ body }) => {697  if (!body.title || !body.command) throw bad(400, 'validation', 'Title and command are required');698  const t = startTask(body);699  audit('task.create', t.id);700  return taskDto(t);701});702R('GET', '/tasks/:id', ({ params }) => taskDto(getTask(params.id)));703R('POST', '/tasks/:id/cancel', ({ params }) => {704  const t = getTask(params.id);705  if (t.child) { t.state = 'CANCELLING'; t.child.kill('SIGTERM'); pushTask(t); }706  return taskDto(t);707});708R('POST', '/tasks/:id/retry', ({ params }) => {709  const t = getTask(params.id);710  if (!t.idempotent) throw bad(409, 'not_idempotent', 'Only idempotent tasks can be retried');711  return taskDto(startTask({ ...t, artifactGlobs: t.artifactGlobs }, t.id));712});713R('GET', '/tasks/:id/log', ({ params, query, res }) => {714  const t = getTask(params.id);715  const all = Buffer.concat(t.log);716  const off = Number(query.get('offset') ?? 0);717  const chunk = all.subarray(off, off + 256 * 1024);718  res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'X-Log-Size': String(all.length), 'Content-Length': chunk.length });719  res.end(chunk);720  return undefined;721});722R('GET', '/tasks/:id/artifacts/:aid', ({ params, res }) => {723  const a = getTask(params.id).artifacts.find((x) => x.id === params.aid);724  if (!a) throw bad(404, 'not_found', 'Artifact not found');725  res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${a.name}"` });726  fs.createReadStream(a.abs).pipe(res);727  return undefined;728});729730R('GET', '/cluster/nodes', () => clusterFixture());731R('GET', '/cluster/nodes/:id', ({ params }) => clusterFixture().nodes.find((n) => n.id === params.id) ?? (() => { throw bad(404, 'not_found', 'Node not found'); })());732R('GET', '/cluster/metrics', () => ({ collectedAt: now(), nodes: [] }));733R('GET', '/cluster/models', () => S.models);734R('POST', '/cluster/models/:id/test', ({ params }) => {735  const m = S.models.find((x) => x.id === params.id);736  if (!m) throw bad(404, 'not_found', 'Model not found');737  Object.assign(m, { health: 'OFFLINE', checkedAt: now(), latencyMs: null, tokensPerSec: null }); // the mock has no model endpoint: honest failure738  return { ok: false, checkedAt: m.checkedAt, latencyMs: null, tokensPerSec: null, error: '[mock] no model endpoint exists in the dev mock' };739});740741R('GET', '/skills', () => S.skills);742R('POST', '/skills/op', ({ body }) => {743  const i = S.skills.findIndex((s) => s.name === body.name && (!body.kind || s.kind === body.kind));744  if (i < 0) throw bad(404, 'not_found', 'Skill not found');745  if (body.op === 'remove') S.skills.splice(i, 1);746  else if (body.op === 'enable' || body.op === 'disable') S.skills[i].enabled = body.op === 'enable';747  else throw bad(400, 'validation', 'Unknown operation');748  audit(`skill.${body.op}`, body.name);749  return { ok: true };750});751R('GET', '/skills/registry/search', ({ query }) => { if (!query.get('q')) throw bad(400, 'validation', 'q is required'); return { connected: false, results: [] }; });752// like the real server: configuration only unless ?live=1753R('GET', '/mcp', ({ query }) => (query.get('live') === '1' && !CODEX /* `codex mcp list` does not health-check */ ? S.mcp : S.mcp.map((m) => ({ ...m, status: 'unknown', lastError: null, checkedAt: null }))));754R('POST', '/mcp', ({ body }) => {755  if (!/^[a-zA-Z0-9_-]{1,40}$/.test(body.name ?? '')) throw bad(400, 'validation', 'Invalid name');756  if (S.mcp.some((m) => m.name === body.name)) throw bad(409, 'exists', 'A server with this name already exists');757  const m = { name: body.name, scope: body.scope ?? 'user', transport: body.transport, target: [body.target, ...(body.args ?? [])].join(' '), envKeys: [...Object.keys(body.env ?? {}), ...Object.keys(body.headers ?? {})], status: 'unknown', lastError: null, checkedAt: null };758  S.mcp.push(m);759  audit('mcp.add', m.name);760  return m;761});762R('DELETE', '/mcp/:name', ({ params }) => { S.mcp = S.mcp.filter((m) => m.name !== params.name); audit('mcp.remove', params.name); return { ok: true }; });763R('POST', '/mcp/:name/test', ({ params }) => {764  const m = S.mcp.find((x) => x.name === params.name);765  if (!m) throw bad(404, 'not_found', 'Server not found');766  Object.assign(m, { status: 'failed', lastError: '[mock] the dev mock cannot reach MCP servers', checkedAt: now() });767  return m;768});769770R('GET', '/system/status', () => systemStatus());771R('GET', '/system/audit', ({ query }) => {772  const limit = Math.min(Number(query.get('limit') ?? 50), 200);773  const before = query.get('before');774  const list = before ? S.audit.filter((e) => Number(e.id) < Number(before)) : S.audit;775  return list.slice(0, limit);776});777R('GET', '/notifications', () => S.notifications.slice(0, 100));778R('POST', '/notifications/read', ({ body }) => {779  for (const n of S.notifications) if (!n.readAt && (body.all || body.ids?.includes(n.id))) n.readAt = now();780  return { ok: true };781});782R('GET', '/settings', () => S.settings);783R('PUT', '/settings', ({ body }) => {784  const r = UserSettings.safeParse({ ...S.settings, ...body });785  if (!r.success) throw bad(400, 'validation', 'Invalid settings', r.error.issues);786  S.settings = r.data;787  return S.settings;788});789790// test controls (dev mock only)791R('POST', '/__mock/reset', ({ body }) => { reset(body.seed !== false); return { ok: true }; }, PUB);792R('POST', '/__mock/session-state', ({ body }) => {793  const s = getSession(body.id);794  if (body.state === 'RECOVERABLE') { const p = s.proc; s.proc = null; p?.kill(); for (const ws of s.clients) ws.close(4503, 'runtime unavailable'); s.clients.clear(); }795  Object.assign(s.dto, { state: body.state, waitingReason: body.waitingReason ?? null, pid: s.proc?.pid ?? null });796  pushSession(s);797  if (body.state === 'WAITING_FOR_INPUT') notify('session.waiting', 'Session waiting for input', `A ${CODEX ? 'Codex' : 'Claude'} session needs your attention.`, `/terminal/?s=${s.dto.id}`);798  return s.dto;799}, PUB);800R('POST', '/__mock/touch-file', ({ body }) => { fs.appendFileSync(inside(getProject(body.projectId), body.path), body.append ?? '\n// changed on disk\n'); return { ok: true }; }, PUB);801R('GET', '/__mock/info', () => ({ projects: [...S.projects.values()].map((p) => ({ id: p.id, name: p.name })), setupToken: SETUP_TOKEN }), PUB);802803// ---------------------------------------------------------------- http server804function readBody(req, limit = 220 * 1024 * 1024) {805  return new Promise((resolve, reject) => {806    const chunks = [];807    let n = 0;808    req.on('data', (c) => { n += c.length; if (n > limit) { reject(bad(413, 'too_large', 'Body too large')); req.destroy(); } else chunks.push(c); });809    req.on('end', () => resolve(Buffer.concat(chunks)));810    req.on('error', reject);811  });812}813const server = http.createServer(async (req, res) => {814  const url = new URL(req.url, 'http://x');815  const send = (status, obj) => {816    if (res.headersSent) return;817    res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });818    res.end(JSON.stringify(obj));819  };820  try {821    if (!url.pathname.startsWith('/api/v1')) throw bad(404, 'not_found', 'Not found');822    const p = url.pathname.slice('/api/v1'.length);823    const route = routes.find((r) => r.method === req.method && r.re.test(p));824    if (!route) throw bad(404, 'not_found', `No route ${req.method} ${p}`);825    const m = route.re.exec(p);826    const params = Object.fromEntries(route.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])]));827    const a = authOf(req);828    if (a) a.lastUsedAt = now();829    if (!route.public) {830      if (!a || a.level === 'none' || (a.level !== 'full' && !route.partial)) throw bad(401, 'unauthenticated', 'Authentication required');831    }832    if (req.method !== 'GET' && a && req.headers['x-csrf-token'] !== a.csrf && !p.startsWith('/__mock')) throw bad(403, 'csrf', 'Missing or invalid CSRF token');833    const raw = req.method === 'GET' ? Buffer.alloc(0) : await readBody(req);834    let body = {};835    if (!route.rawBody && raw.length) {836      try { body = JSON.parse(raw.toString('utf8')); } catch { throw bad(400, 'bad_json', 'Invalid JSON'); }837    }838    const out = await route.handler({ req, res, params, query: url.searchParams, body, raw });839    if (out !== undefined) send(200, out);840  } catch (e) {841    if (e instanceof HttpError) {842      if (e.details?.retryAfterSec) res.setHeader('Retry-After', String(e.details.retryAfterSec));843      send(e.status, { error: { code: e.code, message: e.message, ...(e.details ? { details: e.details } : {}) } });844    } else {845      console.error('[mock]', e);846      send(500, { error: { code: 'internal', message: 'Mock server error' } });847    }848  }849});850851// ---------------------------------------------------------------- websockets852const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });853server.on('upgrade', (req, socket, head) => {854  const url = new URL(req.url, 'http://x');855  wss.handleUpgrade(req, socket, head, (ws) => {856    const a = authOf(req);857    if (!a || a.level !== 'full') return ws.close(4401, 'unauthorized');858    const p = url.pathname.replace(/^\/api\/v1/, '');859    let m;860    if (p === '/events') {861      wsEvents.add(ws);862      ws.send(JSON.stringify({ type: 'hello', serverTime: now() }));863      ws.on('close', () => wsEvents.delete(ws));864    } else if ((m = /^\/sessions\/([^/]+)\/stream$/.exec(p))) {865      const s = S.sessions.get(m[1]);866      if (!s) return ws.close(4404, 'not found');867      if (!s.proc) {868        if (s.dto.state === 'RECOVERABLE') return ws.close(4503, 'runtime unavailable');869        ws.send(JSON.stringify({ t: 'exit', code: s.dto.exitCode }));870        return ws.close(4410, 'session ended');871      }872      const cols = Number(url.searchParams.get('cols') ?? 120), rows = Number(url.searchParams.get('rows') ?? 32);873      try { s.proc.resize(cols, rows); } catch {}874      s.clients.add(ws);875      s.dto.attachedClients = s.clients.size;876      if (s.dto.state === 'DETACHED') s.dto.state = 'RUNNING';877      ws.send(JSON.stringify({ t: 'ready', sessionId: s.dto.id, cols, rows }));878      // emulate the tmux repaint on attach: replay the recent output879      if (s.replay.length) ws.send(Buffer.concat(s.replay), { binary: true });880      pushSession(s);881      ws.on('message', (data, isBinary) => {882        if (isBinary) return s.proc?.write(data.toString('utf8'));883        try {884          const msg = JSON.parse(data.toString());885          if (msg.t === 'ping') ws.send(JSON.stringify({ t: 'pong', n: msg.n }));886          else if (msg.t === 'resize') s.proc?.resize(msg.cols, msg.rows);887        } catch {}888      });889      ws.on('close', () => {890        s.clients.delete(ws);891        s.dto.attachedClients = s.clients.size;892        if (s.proc && s.clients.size === 0 && s.dto.state === 'RUNNING') s.dto.state = 'DETACHED';893        pushSession(s);894      });895    } else if ((m = /^\/tasks\/([^/]+)\/log\/stream$/.exec(p))) {896      const t = S.tasks.get(m[1]);897      if (!t) return ws.close(4404, 'not found');898      const off = Number(url.searchParams.get('offset') ?? 0);899      const all = Buffer.concat(t.log);900      if (all.length > off) ws.send(all.subarray(off), { binary: true });901      if (!t.child) return ws.close(1000, 'task ended');902      t.followers.add(ws);903      ws.on('close', () => t.followers.delete(ws));904    } else ws.close(4404, 'not found');905  });906});907908if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {909  reset(process.env.MOCK_SEED !== '0');910  server.listen(PORT, '127.0.0.1', () => {911    console.log(`[mock] ${BRANDING.productName} DEV MOCK API on http://127.0.0.1:${PORT}  (pty: ${pty ? 'node-pty' : 'echo'})`);912    console.log(S.admin ? `[mock] seeded admin: ${SEED.username} / ${SEED.password} / TOTP secret ${SEED.totpSecret}` : `[mock] no administrator; setup token: ${SETUP_TOKEN}`);913  });914  const bye = () => { for (const s of S.sessions.values()) s.proc?.kill?.(); fs.rmSync(tmpRoot, { recursive: true, force: true }); process.exit(0); };915  process.on('SIGINT', bye);916  process.on('SIGTERM', bye);917}918
Files main
Menu

Theme