Repository Public
1/**2 * Fake apps/runtime for tests: Fastify on a temp unix socket implementing the IPC rows the3 * server uses (CONTRACT.md). The attach WebSocket is an echo PTY; `/events` broadcasts whatever4 * the test emits. Nothing is spawned and nothing touches the real filesystem (in-memory files).5 */6import fs from 'node:fs';7import os from 'node:os';8import path from 'node:path';9import { createHash, randomBytes } from 'node:crypto';10import Fastify, { type FastifyInstance } from 'fastify';11import websocket from '@fastify/websocket';12import type WebSocket from 'ws';13import type { RtEvent, RtSessionInfo, RtSessionSpec, RtTaskInfo, RtTaskSpec } from '@cr/shared';1415export const FAKE_RT_TOKEN = 'fake-runtime-token-0123456789abcdef0123456789';16type Body = Record<string, unknown>;1718export class FakeRuntime {19 readonly dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-rt-'));20 readonly socketPath = path.join(this.dir, 'rt.sock');21 readonly sessions = new Map<string, RtSessionInfo>();22 readonly specs = new Map<string, RtSessionSpec>();23 readonly tasks = new Map<string, RtTaskInfo>();24 readonly taskSpecs = new Map<string, RtTaskSpec>();25 readonly taskLogs = new Map<string, Buffer>();26 /** key = `${root}/${rel}`; value = file bytes, or null for a directory. */27 readonly files = new Map<string, Buffer | null>();28 readonly controls: { sessionId: string; msg: unknown }[] = [];29 readonly calls: { method: string; url: string; body: unknown }[] = [];30 readonly attachSockets = new Map<string, Set<WebSocket>>();31 private readonly eventSockets = new Set<WebSocket>();32 private app: FastifyInstance | null = null;33 git: Body = { isRepo: true, branch: 'main', dirty: false, remote: null };34 skills: unknown[] = [];35 mcp: unknown[] = [];3637 async start(): Promise<void> {38 const app = Fastify({ bodyLimit: 300 * 1024 * 1024 });39 this.app = app;40 await app.register(websocket, { options: { maxPayload: 16 * 1024 * 1024 } });41 app.addHook('onRequest', async (req, reply) => {42 if (req.headers.authorization !== `Bearer ${FAKE_RT_TOKEN}`) {43 return reply.code(401).send({ error: { code: 'unauthorized', message: 'bad token' } });44 }45 });46 app.addHook('preHandler', async (req) => {47 this.calls.push({ method: req.method, url: req.url, body: Buffer.isBuffer(req.body) ? '<binary>' : req.body });48 });49 app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));50 const err = (reply: { code(n: number): { send(b: unknown): unknown } }, status: number, code: string): unknown =>51 reply.code(status).send({ error: { code, message: code } });5253 app.get('/health', () => ({ ok: true, version: 'fake', tmux: true, claude: { path: '/fake/claude', version: '9.9.9' }, uptimeSec: 1 }));54 app.get('/host/metrics', () => ({55 collectedAtMs: Date.now(), hostname: 'fakehost.local', cpuPct: 12.5, load: [1, 0.5, 0.25], memUsedMb: 1000,56 memTotalMb: 4000, diskFreeGb: 50, diskTotalGb: 100, uptimeSec: 3600, sessions: [],57 }));58 app.get('/claude/info', () => ({ path: '/fake/claude', version: '9.9.9', authenticated: true, method: 'claude.ai', subscription: null, configDir: '/fake/.claude' }));59 app.post('/claude/conversations', () => [{ id: 'conv-12345678', summary: 'hello', updatedAt: new Date(0).toISOString(), messageCount: 2 }]);6061 // ---- sessions ----62 app.get('/sessions', () => [...this.sessions.values()]);63 app.post('/sessions', (req, reply) => {64 const spec = req.body as RtSessionSpec;65 if (this.sessions.has(spec.id)) return err(reply, 409, 'exists');66 const info: RtSessionInfo = { id: spec.id, alive: true, pid: 4242, attached: 0, lastActivityMs: Date.now(), exitCode: null, cols: spec.cols, rows: spec.rows };67 this.sessions.set(spec.id, info);68 this.specs.set(spec.id, spec);69 return info;70 });71 app.get('/sessions/:id', (req, reply) => this.sessions.get((req.params as { id: string }).id) ?? err(reply, 404, 'not_found'));72 app.delete('/sessions/:id', (req, reply) => {73 const { id } = req.params as { id: string };74 if (!this.sessions.delete(id)) return err(reply, 404, 'not_found');75 for (const s of this.attachSockets.get(id) ?? []) s.close(4410, 'ended');76 return { ok: true };77 });78 app.get('/sessions/:id/snapshot', (req, reply) =>79 this.sessions.has((req.params as { id: string }).id) ? { text: 'snapshot-text' } : err(reply, 404, 'not_found'),80 );81 app.get('/sessions/:id/attach', { websocket: true }, (socket, req) => {82 const { id } = req.params as { id: string };83 const q = req.query as { cols?: string; rows?: string };84 const info = this.sessions.get(id);85 if (!info) return socket.close(4404, 'not found');86 const set = this.attachSockets.get(id) ?? new Set<WebSocket>();87 this.attachSockets.set(id, set);88 set.add(socket);89 info.attached = set.size;90 socket.send(JSON.stringify({ t: 'ready', sessionId: id, cols: Number(q.cols), rows: Number(q.rows) }));91 socket.on('message', (data, isBinary) => {92 if (isBinary) return socket.send(data, { binary: true }); // echo PTY93 const msg = JSON.parse(data.toString()) as { t: string; n?: number };94 this.controls.push({ sessionId: id, msg });95 if (msg.t === 'ping') socket.send(JSON.stringify({ t: 'pong', n: msg.n }));96 });97 socket.on('close', () => {98 set.delete(socket);99 info.attached = set.size;100 });101 });102103 // ---- tasks ----104 app.get('/tasks', () => [...this.tasks.values()]);105 app.post('/tasks', (req, reply) => {106 const spec = req.body as RtTaskSpec;107 if (this.tasks.has(spec.id)) return err(reply, 409, 'exists');108 const info: RtTaskInfo = { id: spec.id, state: 'RUNNING', exitCode: null, startedAtMs: Date.now(), endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null };109 this.tasks.set(spec.id, info);110 this.taskSpecs.set(spec.id, spec);111 return info;112 });113 app.get('/tasks/:id', (req, reply) => this.tasks.get((req.params as { id: string }).id) ?? err(reply, 404, 'not_found'));114 app.delete('/tasks/:id', (req, reply) => {115 const t = this.tasks.get((req.params as { id: string }).id);116 if (!t) return err(reply, 404, 'not_found');117 t.state = 'CANCELLED';118 t.endedAtMs = Date.now();119 return t;120 });121 app.get('/tasks/:id/log', (req, reply) => {122 const { id } = req.params as { id: string };123 const q = req.query as { offset?: string };124 const log = this.taskLogs.get(id) ?? Buffer.alloc(0);125 return reply.header('X-Log-Size', String(log.length)).header('Content-Type', 'application/octet-stream').send(log.subarray(Number(q.offset ?? 0)));126 });127 app.get('/tasks/:id/log/follow', { websocket: true }, (socket, req) => {128 const { id } = req.params as { id: string };129 socket.send(this.taskLogs.get(id) ?? Buffer.alloc(0), { binary: true });130 socket.close(1000, 'done');131 });132133 // ---- events ----134 app.get('/events', { websocket: true }, (socket) => {135 this.eventSockets.add(socket);136 socket.on('close', () => this.eventSockets.delete(socket));137 });138139 // ---- fs / git ----140 const key = (root: unknown, rel: unknown): string => (rel ? `${String(root)}/${String(rel)}` : String(root));141 const meta = (rel: string, buf: Buffer): Body => ({142 path: rel, size: buf.length, mtimeMs: 1, sha256: createHash('sha256').update(buf).digest('hex'), encoding: 'utf8', content: buf.toString('utf8'),143 });144 app.post('/fs/list', (req, reply) => {145 const b = req.body as Body;146 const dir = key(b.root, b.rel);147 if (this.files.get(dir) !== null) return err(reply, 404, 'not_found');148 const out: Body[] = [];149 for (const [k, v] of this.files) {150 if (!k.startsWith(`${dir}/`) || k.slice(dir.length + 1).includes('/')) continue;151 const name = k.slice(dir.length + 1);152 out.push({ name, path: b.rel ? `${String(b.rel)}/${name}` : name, type: v === null ? 'dir' : 'file', size: v?.length ?? 0, mtimeMs: 1 });153 }154 return out;155 });156 app.post('/fs/read', (req, reply) => {157 const b = req.body as Body;158 const buf = this.files.get(key(b.root, b.rel));159 return buf ? meta(String(b.rel), buf) : err(reply, 404, 'not_found');160 });161 app.post('/fs/write', (req, reply) => {162 const b = req.body as Body;163 const k = key(b.root, b.rel);164 const cur = this.files.get(k);165 // Same shapes as apps/runtime/src/fs.ts: conflict carries details, success carries no content.166 const curSha = cur ? createHash('sha256').update(cur).digest('hex') : null;167 if (b.baseSha256 && curSha !== b.baseSha256) {168 return reply.code(409).send({ error: { code: 'conflict', message: 'file changed on disk since it was loaded', details: { sha256: curSha, mtimeMs: cur ? 1 : null } } });169 }170 if (cur && b.create) return err(reply, 409, 'exists');171 const buf = Buffer.from(String(b.content), 'utf8');172 this.files.set(k, buf);173 const { content: _content, ...written } = meta(String(b.rel), buf);174 return written;175 });176 app.post('/fs/op', (req) => {177 const b = req.body as Body;178 if (b.op === 'mkdir') this.files.set(key(b.root, b.path), null);179 else if (b.op === 'delete' && b.confirm === true) this.files.delete(key(b.root, b.path));180 else if (b.op === 'rename') {181 const v = this.files.get(key(b.root, b.from));182 this.files.delete(key(b.root, b.from));183 this.files.set(key(b.root, b.to), v ?? null);184 }185 return { ok: true, path: String(b.op === 'rename' ? b.to : b.path) };186 });187 app.post('/fs/upload', (req, reply) => {188 const q = req.query as { root: string; rel: string; overwrite?: string };189 const buf = req.body as Buffer;190 if (this.files.has(key(q.root, q.rel)) && q.overwrite !== '1') return err(reply, 409, 'exists');191 this.files.set(key(q.root, q.rel), buf);192 return { path: q.rel, size: buf.length, mtimeMs: 1 };193 });194 app.get('/fs/download', (req, reply) => {195 const q = req.query as { root: string; rel: string };196 const buf = this.files.get(key(q.root, q.rel));197 return buf ? reply.header('Content-Type', 'application/octet-stream').send(buf) : err(reply, 404, 'not_found');198 });199 app.post('/fs/search', (req) => {200 const b = req.body as Body;201 const out: Body[] = [];202 for (const k of this.files.keys()) {203 if (k.startsWith(`${String(b.root)}/`) && k.includes(String(b.query))) out.push({ path: k.slice(String(b.root).length + 1), line: null, preview: null });204 }205 return out;206 });207 app.post('/git/info', () => this.git);208 app.post('/git/status', () => ({ isRepo: true, branch: 'main', ahead: 0, behind: 0, files: [] }));209 app.post('/git/diff', () => ({ diff: '', truncated: false }));210 app.post('/git/clone', (req) => {211 const b = req.body as Body;212 this.files.set(key(b.root, b.rel), null);213 return { ok: true };214 });215216 // ---- skills / mcp ----217 app.get('/skills', () => this.skills);218 app.post('/skills/op', () => ({ ok: true }));219 app.get('/mcp', () => this.mcp);220 app.post('/mcp/add', () => ({ ok: true }));221 app.post('/mcp/remove', () => ({ ok: true }));222 app.post('/mcp/test', () => ({ status: 'connected', lastError: null, checkedAt: new Date().toISOString() }));223224 await app.listen({ path: this.socketPath });225 }226227 /** Marks a directory (and its parents up to `root`) as existing. */228 mkdir(abs: string): void {229 this.files.set(abs, null);230 }231 emit(ev: RtEvent): void {232 for (const s of this.eventSockets) s.send(JSON.stringify(ev));233 }234 get eventSubscribers(): number {235 return this.eventSockets.size;236 }237 /** Simulates the runtime being down (socket gone). `start()` brings it back with its state. */238 async stop(): Promise<void> {239 for (const s of this.eventSockets) s.terminate();240 for (const set of this.attachSockets.values()) for (const s of set) s.terminate();241 await this.app?.close();242 this.app = null;243 fs.rmSync(this.socketPath, { force: true });244 }245 async destroy(): Promise<void> {246 await this.stop();247 fs.rmSync(this.dir, { recursive: true, force: true });248 }249}250export const randomId = (): string => randomBytes(6).toString('hex');251