Repository Public
1#!/usr/bin/env node2/**3 * DEV-ONLY mock of the Agent OS browser API (docs/CONTRACT.md §8). Never imported by the app,4 * never part of the static export. It exists so the console can be developed and tested without5 * apps/server, apps/worker, Postgres or a model provider.6 *7 * EVERYTHING it serves is FIXTURE data, labelled as such: projects are named `fixture-*`, every8 * objective starts with "[fixture]", the version is `0.1.0-mock`. No model is ever called: a9 * scripted "agent" journals a fixed sequence of steps and emits the matching events10 * deltas → model step → read_file → edit_file (diff) → shell test run → approval wait for11 * git_push → resolution → finish12 * with realistic timing, cursor replay on the events WebSocket, steering messages, cancel,13 * approval / question resolution, settings, spend, models (one unavailable), policy rules.14 *15 * - auth: setup token, password + TOTP (real RFC 6238), cookie session, CSRF, lockout16 *17 * Env: MOCK_PORT (8641) · MOCK_SEED=0 to start without an administrator · MOCK_SETUP_TOKEN ·18 * MOCK_LOCK_SEC (lockout duration, default 30) · MOCK_SPEED (delay multiplier of the scripted19 * agent, default 1; tests use a smaller value)20 */21import http from 'node:http';22import path from 'node:path';23import crypto from 'node:crypto';24import { fileURLToPath } from 'node:url';25import { WebSocketServer } from 'ws';26import { AosSettings, CreateMission, CreateProject, CreatePolicyRule, ResolveWait } from '../node_modules/@aos/shared/dist/index.js';2728const PORT = Number(process.env.MOCK_PORT ?? 8641);29const SETUP_TOKEN = process.env.MOCK_SETUP_TOKEN ?? 'mock-setup-token-0000';30const LOCK_SEC = Number(process.env.MOCK_LOCK_SEC ?? 30);31const SPEED = Number(process.env.MOCK_SPEED ?? 1);32const BRANDING = { agent: 'aos', productName: 'Agent OS', agentName: 'Agent' };33export const SEED = { username: 'admin', password: 'correct-horse-battery', totpSecret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' };3435// ---------------------------------------------------------------- helpers36const uuid = () => crypto.randomUUID();37const now = () => new Date().toISOString();38const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';39function b32decode(s) {40 let bits = '';41 for (const c of s.replace(/=+$/, '').toUpperCase()) bits += B32.indexOf(c).toString(2).padStart(5, '0');42 const out = [];43 for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2));44 return Buffer.from(out);45}46function b32encode(buf) {47 let bits = '';48 for (const b of buf) bits += b.toString(2).padStart(8, '0');49 let out = '';50 for (let i = 0; i < bits.length; i += 5) out += B32[parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)];51 return out;52}53export function totp(secret, t = Date.now()) {54 const counter = Buffer.alloc(8);55 counter.writeBigUInt64BE(BigInt(Math.floor(t / 30000)));56 const h = crypto.createHmac('sha1', b32decode(secret)).update(counter).digest();57 const o = h[h.length - 1] & 0xf;58 return String((h.readUInt32BE(o) & 0x7fffffff) % 1e6).padStart(6, '0');59}60const totpOk = (secret, code) => [-1, 0, 1].some((d) => totp(secret, Date.now() + d * 30000) === code);6162class HttpError extends Error {63 constructor(status, code, message, details) {64 super(message);65 Object.assign(this, { status, code, details });66 }67}68const bad = (status, code, message, details) => new HttpError(status, code, message, details);697071// ---------------------------------------------------------------- catalogue (CONTRACT §3 prices, USD per MTok)72const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];73const MODELS = [74 { provider: 'anthropic', id: 'claude-fable-5-2', label: 'Claude Fable 5.2', tier: 'frontier', contextTokens: 1_000_000, maxOutputTokens: 64_000, inputUsd: 8, outputUsd: 40, efforts: EFFORTS, available: true },75 { provider: 'anthropic', id: 'claude-opus-5-5', label: 'Claude Opus 5.5', tier: 'main', contextTokens: 1_000_000, maxOutputTokens: 64_000, inputUsd: 4, outputUsd: 20, efforts: EFFORTS, available: true },76 { provider: 'anthropic', id: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5', tier: 'worker', contextTokens: 1_000_000, maxOutputTokens: 64_000, inputUsd: 2, outputUsd: 10, efforts: EFFORTS, available: true },77 { provider: 'anthropic', id: 'claude-haiku-5-5', label: 'Claude Haiku 5.5', tier: 'volume', contextTokens: 200_000, maxOutputTokens: 64_000, inputUsd: 0.1, outputUsd: 0.5, efforts: ['low', 'medium', 'high'], available: true },78 // FIXTURE: one model the key cannot use, to exercise the disabled state of the picker.79 { provider: 'openai', id: 'gpt-6-astra', label: 'GPT-6 Astra', tier: 'frontier', contextTokens: null, maxOutputTokens: null, inputUsd: 10, outputUsd: 50, efforts: EFFORTS.slice(0, 4), available: false },80 { provider: 'openai', id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', tier: 'main', contextTokens: 400_000, maxOutputTokens: 128_000, inputUsd: 2, outputUsd: 10, efforts: EFFORTS.slice(0, 4), available: true },81 { provider: 'openai', id: 'gpt-6.1-terra', label: 'GPT-6.1 Terra', tier: 'worker', contextTokens: 400_000, maxOutputTokens: 128_000, inputUsd: 1, outputUsd: 5, efforts: EFFORTS.slice(0, 4), available: true },82 { provider: 'openai', id: 'gpt-6-luna', label: 'GPT-6 Luna', tier: 'volume', contextTokens: 400_000, maxOutputTokens: 128_000, inputUsd: 0.1, outputUsd: 0.5, efforts: ['low', 'medium', 'high'], available: true },83];84const modelOf = (id) => MODELS.find((m) => m.id === id);85const day = (t) => new Date(t).toISOString().slice(0, 10);86const round = (n, d = 6) => Math.round(n * 10 ** d) / 10 ** d;8788// ---------------------------------------------------------------- state89let S;90let GEN = 0; // bumped on reset: scripts of the previous state stop91const wsClients = new Set(); // { ws, missionId }92function audit(action, target = null, ok = true, detail = {}) {93 S.audit.unshift({ id: String(++S.auditSeq), at: now(), actor: S.admin?.username ?? 'anonymous', action, target, ip: '127.0.0.1', ok, detail });94}95function notify(kind, title, body, link = null) {96 S.notifications.unshift({ id: uuid(), kind, title, body, link, readAt: null, createdAt: now() });97}98const frame = (ev) => JSON.stringify(ev);99function push(ev) {100 const s = frame(ev);101 for (const c of wsClients) if (c.ws.readyState === 1 && (!c.missionId || c.missionId === ev.missionId)) c.ws.send(s);102}103/** Persisted event: appended (the id is the cursor) and pushed. */104function emit(run, kind, payload = {}, { seq = null, trust = 'system', ts = null } = {}) {105 const ev = { id: ++S.eventSeq, missionId: run.missionId, runId: run.id, seq, ts: ts ?? now(), kind, trust, payload };106 S.events.push(ev);107 push(ev);108 return ev;109}110/** Transient event (model.delta): pushed only, id -1. */111const emitTransient = (run, payload) => push({ id: -1, missionId: run.missionId, runId: run.id, seq: null, ts: now(), kind: 'model.delta', trust: 'agent', payload });112113// ---------------------------------------------------------------- DTOs114const missionDto = (m) => {115 const runs = [...S.runs.values()].filter((r) => r.missionId === m.id);116 return {117 id: m.id, projectId: m.projectId, projectName: S.projects.get(m.projectId)?.name ?? null, title: m.title, objective: m.objective,118 doneCriteria: m.doneCriteria, status: m.status, budgetUsd: m.budgetUsd, spentUsd: round(m.spentUsd), network: m.network, rootRunId: m.rootRunId,119 runs: runs.length, pendingWaits: [...S.waits.values()].filter((w) => w.missionId === m.id && w.status === 'pending').length,120 createdAt: m.createdAt, startedAt: m.startedAt, endedAt: m.endedAt,121 provider: S.runs.get(m.rootRunId)?.provider ?? null, model: S.runs.get(m.rootRunId)?.model ?? null, effort: S.runs.get(m.rootRunId)?.effort ?? null,122 };123};124const runDto = (r) => ({125 id: r.id, missionId: r.missionId, parentRunId: null, depth: 0, role: 'agent', objective: r.objective, provider: r.provider, model: r.model, effort: r.effort,126 status: r.status, usage: { ...r.usage, usd: round(r.usage.usd) }, budget: { usd: r.budgetUsd, turns: 200, wallSec: 21600 }, sandboxTier: 1, result: r.result,127 createdAt: r.createdAt, startedAt: r.startedAt, endedAt: r.endedAt, pendingWaits: [...S.waits.values()].filter((w) => w.runId === r.id && w.status === 'pending').length,128});129const waitDto = (w) => ({ id: w.id, runId: w.runId, missionId: w.missionId, missionTitle: S.missions.get(w.missionId)?.title ?? '', stepSeq: w.stepSeq, kind: w.kind, payload: w.payload, status: w.status, resolution: w.resolution, resolveBy: w.resolveBy, createdAt: w.createdAt, resolvedAt: w.resolvedAt });130const projectDto = (p) => ({ ...p, missions: [...S.missions.values()].filter((m) => m.projectId === p.id).length });131const getMission = (id) => S.missions.get(id) ?? (() => { throw bad(404, 'not_found', 'Mission not found'); })();132const getRun = (id) => S.runs.get(id) ?? (() => { throw bad(404, 'not_found', 'Run not found'); })();133const todayUsd = () => S.spendDays.get(day(Date.now())) ?? 0;134135// ---------------------------------------------------------------- the scripted agent (FIXTURE — no model is called)136// The journal, the events and the payloads below reproduce EXACTLY what the real kernel + engine write137// (packages/kernel, packages/engine): step kinds/names, request digests, result fields, wait payloads.138// Only the content (texts, numbers, hashes) is fixture data.139const FILE = 'src/invoice.ts';140const OLD = `export function total(lines: Line[]): number {\n let sum = 0;\n for (const l of lines) sum += l.qty * l.unitPrice;\n return Math.round(sum * 100) / 100;\n}`;141const NEW = `export function total(lines: Line[]): number {\n // sum in integer cents: binary floats cannot represent 0.1 + 0.2\n let cents = 0;\n for (const l of lines) cents += Math.round(l.qty * l.unitPrice * 100);\n return cents / 100;\n}`;142const SOURCE = `import type { Line } from './types';\n\n${OLD}\n\nexport function format(n: number): string {\n return n.toFixed(2);\n}\n`;143const PUSH_DIFF = `diff --git a/${FILE} b/${FILE}\nindex 3f1c2aa..9b04e17 100644\n--- a/${FILE}\n+++ b/${FILE}\n@@ -1,9 +1,10 @@\n import type { Line } from './types';\n \n export function total(lines: Line[]): number {\n- let sum = 0;\n- for (const l of lines) sum += l.qty * l.unitPrice;\n- return Math.round(sum * 100) / 100;\n+ // sum in integer cents: binary floats cannot represent 0.1 + 0.2\n+ let cents = 0;\n+ for (const l of lines) cents += Math.round(l.qty * l.unitPrice * 100);\n+ return cents / 100;\n }\n \n export function format(n: number): string {\ndiff --git a/test/invoice.test.ts b/test/invoice.test.ts\nindex 77aa310..c41d9e2 100644\n--- a/test/invoice.test.ts\n+++ b/test/invoice.test.ts\n@@ -12,3 +12,7 @@ describe('total', () => {\n it('adds lines', () => {\n expect(total([{ qty: 2, unitPrice: 5 }])).toBe(10);\n });\n+\n+ it('does not drift on repeating decimals', () => {\n+ expect(total([{ qty: 3, unitPrice: 0.1 }, { qty: 1, unitPrice: 0.2 }])).toBe(0.5);\n+ });\n });\n`;144class Stop extends Error {}145const TEST_OUT = ` RUN v5.1.0 /workspace/fixture-billing\n\n ✓ test/invoice.test.ts (4 tests) 6ms\n ✓ total > adds lines\n ✓ total > rounds to cents\n ✓ total > does not drift on repeating decimals\n ✓ format > keeps two decimals\n ✓ test/tax.test.ts (9 tests) 11ms\n\n Test Files 2 passed (2)\n Tests 13 passed (13)\n Start at ${'10:42:07'}\n Duration 412ms (transform 61ms, setup 0ms, collect 96ms, tests 17ms)`;146const PROPOSED = ['`npm test` passes, including a new regression test for repeating decimals', 'The fix is committed on the mission branch with a descriptive message', 'The branch is pushed to origin'];147const ACCEPT_CRITERIA_OPTION = 'Yes — use these criteria';148const TOOL_NAMES = ['read_file', 'list_dir', 'search', 'edit_file', 'write_file', 'shell', 'git_status', 'git_diff', 'git_commit', 'git_push', 'ask_owner', 'propose_criteria', 'update_criteria', 'read_artifact', 'finish'];149const QUIET = new Set(['now', 'uuid', 'owner_messages']); // kernel bookkeeping steps emit no step.* event150const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');151const EDIT_DIFF = `--- a/${FILE}\n+++ b/${FILE}\n@@ -1,9 +1,10 @@\n import type { Line } from './types';\n \n export function total(lines: Line[]): number {\n- let sum = 0;\n- for (const l of lines) sum += l.qty * l.unitPrice;\n- return Math.round(sum * 100) / 100;\n+ // sum in integer cents: binary floats cannot represent 0.1 + 0.2\n+ let cents = 0;\n+ for (const l of lines) cents += Math.round(l.qty * l.unitPrice * 100);\n+ return cents / 100;\n }\n \n export function format(n: number): string {\n`;152const TEST_DIFF = `--- a/test/invoice.test.ts\n+++ b/test/invoice.test.ts\n@@ -12,3 +12,7 @@\n it('adds lines', () => {\n expect(total([{ qty: 2, unitPrice: 5 }])).toBe(10);\n });\n+\n+ it('does not drift on repeating decimals', () => {\n+ expect(total([{ qty: 3, unitPrice: 0.1 }, { qty: 1, unitPrice: 0.2 }])).toBe(0.5);\n+ });\n });\n`;153const numbered = (text) => text.replace(/\n$/, '').split('\n').map((l, i) => `${String(i + 1).padStart(6)}\t${l}`).join('\n');154const RISK = { read_file: 'read', git_status: 'read', git_diff: 'read', edit_file: 'workspace_write', write_file: 'workspace_write', shell: 'workspace_write', git_commit: 'workspace_write', git_push: 'external' };155156/** One run of the fixture script. `instant` journals without delays (used to seed past state). */157function makeCtx(run, opts = {}) {158 const gen = GEN;159 const m = S.missions.get(run.missionId);160 const ctx = {161 run, m, instant: !!opts.instant, clock: opts.startAt ?? Date.now(), auto: opts.auto ?? null, reached: null, turn: 0, ownerCursor: 0,162 now() { return new Date(ctx.instant ? ctx.clock : Date.now()).toISOString(); },163 alive() { if (gen !== GEN || run.cancelled) throw new Stop(); },164 async sleep(ms) {165 ctx.alive();166 if (ctx.instant) ctx.clock += ms;167 else await new Promise((r) => setTimeout(r, ms * SPEED));168 ctx.alive();169 },170 status(status) {171 if (run.status === status) return;172 run.status = status; m.status = status;173 if (status === 'running' && !run.startedAt) { run.startedAt = ctx.now(); m.startedAt = run.startedAt; }174 emit(run, 'run.status', { status }, { ts: ctx.now() });175 emit(run, 'mission.status', { status }, { ts: ctx.now() });176 },177 begin(kind, name, effect, request) {178 const step = { runId: run.id, seq: ++run.seq, kind, name, effect, status: 'started', request, result: null, error: null, usage: null, startedAt: ctx.now(), completedAt: null };179 S.steps.get(run.id).push(step);180 if (!QUIET.has(kind)) emit(run, 'step.started', { kind, name, effect, request, truncated: false }, { seq: step.seq, ts: step.startedAt });181 return step;182 },183 end(step, result, usage = null) {184 step.status = 'completed'; step.result = result; step.usage = usage; step.completedAt = ctx.now();185 if (!QUIET.has(step.kind)) emit(run, 'step.completed', { kind: step.kind, name: step.name, effect: step.effect, usage, result, truncated: false }, { seq: step.seq, ts: step.completedAt });186 if (usage) {187 for (const k of Object.keys(usage)) run.usage[k] = (run.usage[k] ?? 0) + usage[k];188 m.spentUsd += usage.usd ?? 0;189 const d = day(ctx.instant ? ctx.clock : Date.now());190 S.spendDays.set(d, (S.spendDays.get(d) ?? 0) + (usage.usd ?? 0));191 const bm = S.byModel.get(run.model) ?? { provider: run.provider, model: run.model, usd: 0, inputTokens: 0, outputTokens: 0 };192 bm.usd += usage.usd ?? 0; bm.inputTokens += usage.inputTokens ?? 0; bm.outputTokens += usage.outputTokens ?? 0;193 S.byModel.set(run.model, bm);194 emit(run, 'run.usage', { usage: { ...run.usage, usd: round(run.usage.usd) }, budget: { usd: run.budgetUsd, turns: 200, wallSec: 21600 }, missionSpentUsd: usage.usd ? round(m.spentUsd) : null }, { seq: step.seq, ts: step.completedAt });195 }196 },197 quick(kind, name, effect, request, result) { const s = ctx.begin(kind, name, effect, request); ctx.end(s, result); return s; },198 /** The three journaled setup steps of every run (engine/setup.ts). */199 setup(branch) {200 ctx.quick('setup', 'run_meta', 'read', {}, { provider: run.provider, model: run.model, effort: run.effort, budget: { usd: run.budgetUsd, turns: 200, wallSec: 21600 }, ownerId: S.admin?.id ?? null, projectId: m.projectId, network: m.network, projectNotes: '', criteria: m.doneCriteria.map((c) => ({ ...c })) });201 ctx.quick('setup', 'prefix', 'idempotent_write', {}, { instructionsFile: null, promptHash: sha(`fixture-prompt:${run.model}`) });202 ctx.quick('setup', 'git_branch', 'idempotent_write', { branch }, { repo: true, branch, base: 'main', error: null });203 },204 /** A model turn: owner messages are read first (journaled), deltas are streamed, then the step commits. */205 async model({ reasoning = '', text = '', calls = [], inTok, outTok }) {206 const unread = run.inbox.splice(0);207 ctx.quick('owner_messages', 'owner_messages', 'read', { afterId: ctx.ownerCursor }, { messages: unread.map((u) => ({ id: u.id, text: u.text })) });208 for (const u of unread) ctx.ownerCursor = Math.max(ctx.ownerCursor, u.id);209 if (unread.length) text = `${unread.map((u) => `Noted — “${u.text}”. I will take that into account.`).join('\n')}\n\n${text}`.trim();210 const turn = ++ctx.turn;211 const step = ctx.begin('model_call', run.model, 'model_call', { model: run.model, effort: run.effort, turn, transcriptSha256: sha(`fixture-transcript:${run.id}:${turn}`), toolNames: TOOL_NAMES.filter((n) => n !== 'propose_criteria' || ctx.proposing) });212 emitTransient(run, { reset: true, turn });213 await ctx.sleep(350);214 if (!ctx.instant) {215 for (const [type, full] of [['reasoning', reasoning], ['text', text]]) {216 for (let i = 0; i < full.length; i += 14) { emitTransient(run, { type, text: full.slice(i, i + 14), turn }); await ctx.sleep(28); }217 }218 } else ctx.clock += 1800;219 const mi = modelOf(run.model);220 const usd = round(((inTok * (mi?.inputUsd ?? 0)) + (outTok * (mi?.outputUsd ?? 0))) / 1e6);221 const blocks = [...(reasoning ? [{ type: 'reasoning', summary: reasoning }] : []), ...(text ? [{ type: 'text', text }] : []), ...calls.map((c, i) => ({ type: 'tool_call', id: `call_${step.seq}_${i}`, name: c.name, input: c.input }))];222 const usage = { usd, inputTokens: inTok, outputTokens: outTok, cacheReadTokens: 0, cacheWriteTokens: 0, turns: 1, toolCalls: 0 };223 // `native` (the provider transcript) is stripped by the server before anything reaches the browser: never present here224 ctx.end(step, { blocks, stop: calls.length ? 'tool_use' : 'end_turn', usage, model: run.model, nativeStop: calls.length ? 'tool_use' : 'end_turn', attempts: 1 }, { usd, inputTokens: inTok, outputTokens: outTok, cacheReadTokens: 0, cacheWriteTokens: 0, turns: 1 });225 ctx.calls = blocks.filter((b) => b.type === 'tool_call');226 ctx.batch = false;227 return ctx.calls;228 },229 /** The policy snapshot is read once per tool batch (journaled 'policy' step). */230 policy() {231 if (ctx.batch) return;232 ctx.batch = true;233 ctx.quick('policy', 'policy_rules', 'pure', {}, { rules: [], approvalTimeoutHours: S.settings.approvalTimeoutHours });234 },235 callId(name) { return ctx.calls?.find((c) => c.name === name)?.id ?? `call_${run.seq}_x`; },236 decision(name, decision, reason, source = 'default') {237 emit(run, 'policy.decision', { tool: name, callId: ctx.callId(name), risk: RISK[name] ?? 'read', decision, reason, source }, { ts: ctx.now() });238 },239 /** An executed tool call: policy verdict "allow", then the journaled tool_call step. */240 async tool(name, input, { effect = 'read', ms = 300, result, gated = false }) {241 ctx.policy();242 if (!gated) ctx.decision(name, 'allow', RISK[name] === 'read' ? 'default: read' : 'default: workspace write (sandboxed, mission branch)');243 const step = ctx.begin('tool_call', name, effect, { name, input });244 await ctx.sleep(ms);245 const r = typeof result === 'function' ? result(step) : result;246 ctx.end(step, { ok: true, artifact: null, trust: 'tool', ...r }, { toolCalls: 1 });247 return step;248 },249 /** A control tool step (update_criteria / propose_criteria / finish): no policy gate, result without trust/artifact. */250 control(name, input, effect, result) {251 ctx.policy();252 return ctx.quick('tool_call', name, effect, { name, input }, result);253 },254 criteria(message) { emit(run, 'system.notice', { code: 'criteria', message, doneCriteria: m.doneCriteria.map((c) => ({ ...c })) }, { ts: ctx.now() }); },255 /** Durable wait: journals a wait step `{kind, payload, timeoutSec}`, suspends the run until the owner resolves it. */256 async wait(kind, payload) {257 ctx.policy();258 ctx.quick('now', 'now', 'pure', {}, { iso: ctx.now() });259 const timeoutSec = kind === 'approval' ? Math.round(S.settings.approvalTimeoutHours * 3600) : null;260 const step = ctx.begin('wait', kind, 'pure', { kind, payload, timeoutSec });261 const w = { id: uuid(), runId: run.id, missionId: run.missionId, stepSeq: step.seq, kind, payload, status: 'pending', resolution: null, resolveBy: timeoutSec ? new Date(Date.now() + timeoutSec * 1000).toISOString() : null, createdAt: ctx.now(), resolvedAt: null, settle: null };262 S.waits.set(w.id, w);263 emit(run, 'wait.created', { waitId: w.id, kind, payload, resolveBy: w.resolveBy }, { seq: step.seq, ts: w.createdAt });264 ctx.status('waiting');265 if (ctx.auto) { ctx.clock += 40_000; settleWait(w, kind === 'approval' ? { decision: 'approve', remember: false } : { answer: ctx.auto.answer }, ctx.now()); }266 else {267 notify(kind === 'question' ? 'wait.question' : 'wait.approval', kind === 'question' ? 'The agent has a question' : 'Approval needed', m.title, `/mission/?id=${m.id}`);268 const reached = ctx.reached; ctx.reached = null; ctx.instant = false; reached?.();269 await new Promise((r) => { w.settle = r; });270 }271 ctx.alive();272 ctx.status('running');273 step.status = 'completed'; step.result = w.resolution; step.completedAt = ctx.now();274 emit(run, 'step.completed', { kind: 'wait', name: kind, effect: 'pure', usage: null, result: w.resolution, truncated: false }, { seq: step.seq, ts: step.completedAt });275 ctx.quick('now', 'now', 'pure', {}, { iso: ctx.now() });276 return w.resolution;277 },278 finish(status, summary, extra = {}) {279 run.result = { status, summary, payload: null, artifacts: [], evidence: extra.evidence ?? [], openQuestions: extra.openQuestions ?? [] };280 run.endedAt = ctx.now(); m.endedAt = run.endedAt;281 ctx.status(status);282 },283 };284 return ctx;285}286function settleWait(w, resolution, ts = now()) {287 w.status = 'resolved'; w.resolution = resolution; w.resolvedAt = ts;288 emit(S.runs.get(w.runId), 'wait.resolved', { waitId: w.id, kind: w.kind, status: 'resolved', resolution, actor: { name: S.admin?.username ?? 'admin', via: 'console' } }, { seq: w.stepSeq, trust: 'owner', ts });289 w.settle?.();290}291/** update_criteria as the engine journals it: `{updates:[{id, passes, evidence}]}` → `{ok, content, criteria, changed}` + a criteria notice. */292function flip(ctx, idx, evidence) {293 const c = ctx.m.doneCriteria[idx];294 if (!c) return;295 c.passes = true; c.evidence = evidence;296 const all = ctx.m.doneCriteria;297 ctx.control('update_criteria', { updates: [{ id: c.id, passes: true, evidence }] }, 'idempotent_write', { ok: true, content: `Recorded. ${c.id}: passing. ${all.filter((x) => x.passes).length}/${all.length} criteria passing.`, criteria: all.map((x) => ({ ...x })), changed: true });298 ctx.criteria('Done criteria updated');299}300const acceptsProposal = (answer) => answer.trim() === ACCEPT_CRITERIA_OPTION || /^(yes|y|ok|okay|oui|accept(ed)?|approved?|lgtm)\b/i.test(answer.trim());301302async function fixtureScript(ctx, variant = 'full') {303 const { run, m } = ctx;304 const branch = `aos/${m.id.slice(0, 8)}`;305 await ctx.sleep(400);306 ctx.status('running');307 ctx.setup(branch);308309 if (variant === 'provider_error') {310 ctx.quick('owner_messages', 'owner_messages', 'read', { afterId: 0 }, { messages: [] });311 const step = ctx.begin('model_call', run.model, 'model_call', { model: run.model, effort: run.effort, turn: 1, transcriptSha256: sha(`fixture-transcript:${run.id}:1`), toolNames: TOOL_NAMES });312 await ctx.sleep(900);313 step.status = 'failed'; step.error = { name: 'StepError', message: '[fixture] Provider returned 529 overloaded after 6 attempts', code: 'overloaded', status: 529, retryable: true }; step.completedAt = ctx.now();314 emit(run, 'step.failed', { kind: 'model_call', name: run.model, effect: 'model_call', error: step.error }, { seq: step.seq, ts: step.completedAt });315 return ctx.finish('failed', 'The model call failed (code overloaded, HTTP 529): [fixture] Provider returned 529 overloaded after 6 attempts. Done criteria: 0/1 criteria passing (open: c1).');316 }317318 if (m.doneCriteria.length === 0) {319 ctx.proposing = true;320 let proposal = PROPOSED;321 for (;;) {322 const first = proposal === PROPOSED;323 await ctx.model({324 reasoning: first ? 'No done criteria were given. I must propose verifiable ones and get them confirmed before any write.' : 'The owner asked for a change to the criteria. Revise and propose again.',325 text: first ? 'Before I change anything, here are the criteria I propose for “done”. Please confirm or tell me what to change.' : 'Understood. Here is the revised list.',326 calls: [{ name: 'propose_criteria', input: { criteria: proposal, rationale: null } }], inTok: 2100, outTok: 260,327 });328 const res = await ctx.wait('question', { text: 'The agent proposes these done criteria. Do they describe the outcome you want?', detail: proposal.map((c, i) => `${i + 1}. ${c}`).join('\n'), options: [ACCEPT_CRITERIA_OPTION], source: 'propose_criteria', proposed: proposal });329 if (acceptsProposal(String(res.answer ?? ''))) break;330 // not accepted: no step is journaled for the call; the owner's reply goes back to the model, which revises331 proposal = PROPOSED.slice(0, 2);332 ctx.noPush = true;333 }334 m.doneCriteria = proposal.map((text, i) => ({ id: `c${i + 1}`, text, passes: false }));335 ctx.control('propose_criteria', { criteria: proposal, rationale: null }, 'idempotent_write', { ok: true, content: `The owner confirmed the done criteria:\n${m.doneCriteria.map((c) => `- ${c.id}: ${c.text}`).join('\n')}\n\nYou may now modify the workspace. Record each criterion with update_criteria once verified.`, criteria: m.doneCriteria.map((c) => ({ ...c })), changed: true });336 ctx.criteria('Done criteria confirmed by the owner');337 }338339 const read = { path: FILE, offset: null, limit: null };340 await ctx.model({341 reasoning: 'The objective mentions totals that are off by a cent. The likely place is the invoice total computation; read it before deciding anything.',342 text: 'I will start by reading the total computation in `src/invoice.ts`.',343 calls: [{ name: 'read_file', input: read }], inTok: 3400, outTok: 310,344 });345 await ctx.tool('read_file', read, { result: { content: numbered(SOURCE), fileStateOut: { [FILE]: sha(SOURCE) } } });346347 if (variant === 'stuck') {348 const ci = { command: 'npm ci', timeoutSec: 600 };349 await ctx.model({ text: 'Dependencies are missing; installing them before running the tests.', calls: [{ name: 'shell', input: ci }], inTok: 4100, outTok: 180 });350 ctx.policy();351 ctx.decision('shell', 'allow', 'default: workspace write (sandboxed, mission branch)');352 ctx.begin('tool_call', 'shell', 'idempotent_write', { name: 'shell', input: ci });353 const reached = ctx.reached; ctx.reached = null; ctx.instant = false; reached?.();354 await new Promise(() => {}); // FIXTURE: stays "running" until cancelled or reset355 }356357 const edit1 = { path: FILE, oldString: OLD, newString: NEW, replaceAll: null };358 const edit2 = { path: 'test/invoice.test.ts', oldString: ` });\n});`, newString: ` });\n\n it('does not drift on repeating decimals', () => {\n expect(total([{ qty: 3, unitPrice: 0.1 }, { qty: 1, unitPrice: 0.2 }])).toBe(0.5);\n });\n});`, replaceAll: null };359 // The model also asks for a write to a protected path: the engine refuses it (hard deny) and journals NO step for it.360 const protectedWrite = { path: '.git/hooks/pre-commit', content: '#!/bin/sh\nnpm test\n' };361 await ctx.model({362 reasoning: 'Found it: amounts are summed as binary floats and rounded once at the end. 3 × 0.1 + 0.2 gives 0.5000000000000001 before rounding, and other inputs land on the wrong side of .5. Summing integer cents removes the drift.',363 text: 'The bug is in `total()`:\n\n- amounts are added as **binary floats**, then rounded once at the end\n- for some inputs the accumulated error crosses a half-cent boundary\n\nI will sum integer cents instead:\n\n```ts\ncents += Math.round(l.qty * l.unitPrice * 100);\n```\n\nThen I add a regression test and run the suite.',364 calls: [{ name: 'edit_file', input: edit1 }, { name: 'edit_file', input: edit2 }, { name: 'write_file', input: protectedWrite }], inTok: 5200, outTok: 640,365 });366 await ctx.tool('edit_file', edit1, { effect: 'idempotent_write', result: { content: `Edited ${FILE}: 1 replacement.`, diff: EDIT_DIFF, fileStateOut: { [FILE]: sha(SOURCE.replace(OLD, NEW)) } } });367 await ctx.tool('edit_file', edit2, { effect: 'idempotent_write', result: { content: 'Edited test/invoice.test.ts: 1 replacement.', diff: TEST_DIFF, fileStateOut: { 'test/invoice.test.ts': sha('fixture-test-file') } } });368 emit(run, 'policy.decision', { tool: 'write_file', callId: ctx.calls[2].id, risk: 'workspace_write', decision: 'deny', reason: 'protected path: .git internals are only changed through the git tools', source: 'hard' }, { ts: ctx.now() });369370 const test = { command: 'npm test', timeoutSec: 300 };371 await ctx.model({ text: 'The hook was refused (protected path), which is fine. Running the test suite in the sandbox.', calls: [{ name: 'shell', input: test }], inTok: 6100, outTok: 150 });372 await ctx.tool('shell', test, {373 effect: 'idempotent_write', ms: 1100,374 result: (step) => {375 // FIXTURE: a real output this short is not offloaded (the threshold is 12 000 chars); the artifact is kept so the download path stays exercised.376 const full = `[exit code 0]\n> fixture-billing@1.4.2 test\n> vitest run\n\n${TEST_OUT}`;377 const h = sha(full);378 const name = `shell-${h.slice(0, 16)}.txt`;379 S.artifacts.set(`${run.id}:${h}`, { sha256: h, name, size: Buffer.byteLength(full), mime: 'text/plain; charset=utf-8', runId: run.id, stepSeq: step.seq, createdAt: ctx.now(), body: full });380 emit(run, 'artifact.created', { sha256: h, name, size: Buffer.byteLength(full), mime: 'text/plain; charset=utf-8' }, { seq: step.seq, ts: ctx.now() });381 return { content: full, artifact: { name, sha256: h, size: Buffer.byteLength(full) } };382 },383 });384 await ctx.model({ text: 'All 13 tests pass. Recording the first criterion, then committing the fix on the mission branch.', calls: [{ name: 'update_criteria', input: { updates: [{ id: 'c1', passes: true, evidence: 'npm test: 13 passed (13), exit 0' }] } }, { name: 'git_commit', input: { message: 'fix(invoice): sum totals in integer cents', paths: [FILE, 'test/invoice.test.ts'] } }], inTok: 6900, outTok: 210 });385 flip(ctx, 0, 'npm test: 13 passed (13), exit 0');386 await ctx.tool('git_commit', { message: 'fix(invoice): sum totals in integer cents', paths: [FILE, 'test/invoice.test.ts'] }, { effect: 'idempotent_write', result: { content: `Committed.\n9b04e17 fix(invoice): sum totals in integer cents\n\n ${FILE} | 7 ++++---\n test/invoice.test.ts | 4 ++++\n 2 files changed, 8 insertions(+), 3 deletions(-)` } });387388 let pushed = false;389 if (!ctx.noPush) {390 const push = { remote: 'origin', branch };391 await ctx.model({ text: 'The work is committed. Pushing the branch needs your approval because it has an effect outside the workspace.', calls: [{ name: 'update_criteria', input: { updates: [{ id: 'c2', passes: true, evidence: `commit 9b04e17 on ${branch}` }] } }, { name: 'git_push', input: push }], inTok: 7300, outTok: 140 });392 flip(ctx, 1, `commit 9b04e17 on ${branch}`);393 ctx.decision('git_push', 'ask', 'default: external effect needs approval');394 const res = await ctx.wait('approval', { tool: 'git_push', input: push, risk: 'external', title: `Push ${branch} to origin`, detail: `git push origin ${branch}\n\nPublishes the commits of the mission branch to the remote "origin". Nothing is merged.`, reason: 'default: external effect needs approval', diff: PUSH_DIFF });395 if (res.decision === 'approve') {396 ctx.decision('git_push', 'allow', res.remember ? 'approved by owner; rule created: allow git_push in this project' : 'approved by owner', 'owner');397 await ctx.tool('git_push', push, { effect: 'non_idempotent', ms: 700, gated: true, result: { content: `Pushed ${branch} to origin.\nTo origin\n * [new branch] ${branch} -> ${branch}` } });398 if (m.doneCriteria[2]) await ctx.model({ text: 'Pushed. Recording the last criterion.', calls: [{ name: 'update_criteria', input: { updates: [{ id: 'c3', passes: true, evidence: `origin/${branch} is at 9b04e17` }] } }], inTok: 7450, outTok: 90 });399 flip(ctx, 2, `origin/${branch} is at 9b04e17`);400 pushed = true;401 } else {402 // denied: NO tool step is journaled; the denial is the model's next context and a policy.decision event403 ctx.decision('git_push', 'deny', res.note ? `denied by owner: ${res.note}` : 'denied by owner', 'owner');404 }405 } else {406 await ctx.model({ text: 'The work is committed. Recording the second criterion.', calls: [{ name: 'update_criteria', input: { updates: [{ id: 'c2', passes: true, evidence: `commit 9b04e17 on ${branch}` }] } }], inTok: 7300, outTok: 90 });407 flip(ctx, 1, `commit 9b04e17 on ${branch}`);408 }409 const allPass = m.doneCriteria.every((c) => c.passes);410 const status = allPass ? 'done' : 'partial';411 const summary = `Totals are now summed in **integer cents**, which removes the float drift. A regression test covers the repeating-decimal case.\n\n- changed \`${FILE}\` and \`test/invoice.test.ts\`\n- commit \`9b04e17\` on \`${branch}\`${pushed ? ', pushed to origin' : ' (not pushed)'}`;412 const evidence = [{ criterion: null, kind: 'test', detail: 'npm test — 13 passed (13), exit 0' }, { criterion: null, kind: 'command', detail: `git log -1 --oneline → 9b04e17 fix(invoice): sum totals in integer cents` }];413 const openQuestions = pushed || ctx.noPush ? [] : ['The branch was not pushed: push it yourself or start a follow-up mission.'];414 const fin = { status, summary, reason: allPass ? null : 'The push was denied by the owner.', evidence, openQuestions };415 await ctx.model({416 text: pushed || ctx.noPush ? 'Everything asked for is done and verified.' : 'The push was denied, so the branch stays local. Finishing with what is verified.',417 calls: [{ name: 'finish', input: fin }], inTok: 7600, outTok: 230,418 });419 ctx.control('finish', fin, 'pure', { ok: true, content: `Run ending with status "${status}".` });420 ctx.finish(status, allPass ? summary : `${summary}\n\nReason: ${fin.reason}`, {421 evidence: [...evidence.map(({ kind, detail }) => ({ kind, detail })), ...m.doneCriteria.filter((c) => c.passes).map((c) => ({ criterion: c.id, kind: 'note', detail: c.evidence }))],422 openQuestions,423 });424}425426function createMission(input, opts = {}) {427 const t = opts.startAt ? new Date(opts.startAt).toISOString() : now();428 const model = input.model ?? S.settings.defaultModel;429 const mi = modelOf(model);430 const m = { id: uuid(), projectId: input.projectId, title: input.title ?? input.objective.slice(0, 80), objective: input.objective, doneCriteria: (input.doneCriteria ?? []).map((text, i) => ({ id: `c${i + 1}`, text, passes: false })), status: 'queued', budgetUsd: input.budgetUsd ?? S.settings.missionBudgetUsd, spentUsd: 0, network: input.network ?? 'none', rootRunId: null, createdAt: t, startedAt: null, endedAt: null };431 const run = { id: uuid(), missionId: m.id, objective: m.objective, provider: mi?.provider ?? input.provider ?? S.settings.defaultProvider, model, effort: input.effort ?? S.settings.defaultEffort, status: 'queued', usage: { usd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turns: 0, toolCalls: 0 }, budgetUsd: m.budgetUsd, result: null, createdAt: t, startedAt: null, endedAt: null, seq: -1, inbox: [], cancelled: false };432 m.rootRunId = run.id;433 S.missions.set(m.id, m); S.runs.set(run.id, run); S.steps.set(run.id, []);434 emit(run, 'mission.created', { title: m.title, projectId: m.projectId, rootRunId: run.id, status: 'queued' }, { ts: t });435 emit(run, 'run.created', { parentRunId: null, role: 'agent', provider: run.provider, model, status: 'queued' }, { ts: t });436 const ctx = makeCtx(run, opts);437 const reached = opts.instant && !opts.auto ? new Promise((r) => { ctx.reached = r; }) : null;438 const done = fixtureScript(ctx, opts.variant).catch((e) => { if (!(e instanceof Stop)) console.error('[mock] script', e); });439 return { m, run, settled: reached ? Promise.race([reached, done]) : done };440}441function cancelMission(m, t = now()) {442 for (const run of [...S.runs.values()].filter((r) => r.missionId === m.id)) {443 if (['done', 'partial', 'blocked', 'failed', 'cancelled', 'budget_exhausted'].includes(run.status)) continue;444 run.cancelled = true;445 // As the kernel does: pending waits → cancelled; a step that was executing stays 'started' (it has no result).446 for (const w of S.waits.values()) if (w.runId === run.id && w.status === 'pending') { w.status = 'cancelled'; w.resolvedAt = now(); emit(run, 'wait.resolved', { waitId: w.id, kind: w.kind, status: 'cancelled' }, { seq: w.stepSeq, ts: t }); w.settle?.(); }447 run.status = 'cancelled'; run.endedAt = now(); run.result = { status: 'cancelled', summary: 'Cancelled by the owner.', payload: null, artifacts: [], evidence: [], openQuestions: [] };448 m.status = 'cancelled'; m.endedAt = run.endedAt;449 emit(run, 'run.status', { status: 'cancelled' }, { ts: t });450 emit(run, 'mission.status', { status: 'cancelled' }, { ts: t });451 }452}453454async function reset(seed = true) {455 GEN++;456 S = {457 admin: seed ? { id: uuid(), username: SEED.username, password: SEED.password, totpSecret: SEED.totpSecret, pendingTotp: null, passkeys: [] } : null,458 auth: new Map(), // sid -> {id, csrf, level:'none'|'password'|'full', createdAt, lastUsedAt, ua}459 fails: 0, lockedUntil: 0,460 projects: new Map(), missions: new Map(), runs: new Map(), steps: new Map(), waits: new Map(), artifacts: new Map(),461 events: [], eventSeq: 0, rules: [], notifications: [], audit: [], auditSeq: 0,462 spendDays: new Map(), byModel: new Map(),463 settings: AosSettings.parse({}),464 };465 if (!seed) return;466 const H = 3600_000;467 const t0 = Date.now();468 const mkProject = (name, description, gitRemote, instructions = '') => { const p = { id: uuid(), name, description, path: name, gitRemote, gitBranch: 'main', instructions, createdAt: new Date(t0 - 9 * 24 * H).toISOString() }; S.projects.set(p.id, p); return p; };469 const billing = mkProject('fixture-billing', '[fixture] Invoice and tax computation service seeded by the dev mock server', 'git@example.invalid:fixture/billing.git', 'Run `npm test` before every commit. Never touch migrations/.');470 const docs = mkProject('fixture-docs', '[fixture] Documentation site seeded by the dev mock server', null);471 // FIXTURE spend of the previous six days (today's comes from the seeded missions below)472 [3.42, 11.08, 0, 7.61, 18.94, 5.27].forEach((usd, i) => S.spendDays.set(day(t0 - (6 - i) * 24 * H), usd));473 S.byModel.set('claude-sonnet-5-5', { provider: 'anthropic', model: 'claude-sonnet-5-5', usd: 12.9, inputTokens: 4_310_000, outputTokens: 428_000 });474 S.byModel.set('gpt-6.1-sol', { provider: 'openai', model: 'gpt-6.1-sol', usd: 6.74, inputTokens: 2_120_000, outputTokens: 250_000 });475 S.byModel.set('claude-opus-5-5', { provider: 'anthropic', model: 'claude-opus-5-5', usd: 26.68, inputTokens: 4_890_000, outputTokens: 356_000 });476 S.rules.push({ id: uuid(), projectId: billing.id, decision: 'allow', tool: 'shell', match: { commandPrefix: 'npm test' }, note: '[fixture] Created from an approval', createdAt: new Date(t0 - 3 * 24 * H).toISOString() });477 S.rules.push({ id: uuid(), projectId: null, decision: 'deny', tool: 'shell', match: { commandPrefix: 'rm -rf' }, note: '[fixture] Never, in any project', createdAt: new Date(t0 - 8 * 24 * H).toISOString() });478 const mk = (input, opts) => createMission(input, { instant: true, ...opts }).settled;479 const crit = ['npm test passes, including a new regression test', 'The fix is committed on the mission branch', 'The branch is pushed to origin'];480 await mk({ projectId: billing.id, title: 'Add tax-inclusive totals to the invoice API', objective: '[fixture] Add a `totalInclTax` field to the invoice API response and cover it with tests.', doneCriteria: crit, model: 'claude-sonnet-5-5', effort: 'medium', budgetUsd: 8 }, { startAt: t0 - 26 * H, auto: { answer: '' } });481 await mk({ projectId: docs.id, title: 'Regenerate the API reference', objective: '[fixture] Regenerate the API reference from the OpenAPI file and fix broken links.', doneCriteria: ['The reference builds without warnings'], model: 'gpt-6.1-sol', effort: 'medium', budgetUsd: 5 }, { startAt: t0 - 7 * H, variant: 'provider_error' });482 const cancelled = createMission({ projectId: billing.id, title: 'Migrate the test runner to the new major', objective: '[fixture] Upgrade the test runner to its next major version and fix what breaks.', doneCriteria: crit.slice(0, 2), model: 'claude-opus-5-5', effort: 'high', budgetUsd: 15 }, { instant: true, startAt: t0 - 5 * H, variant: 'stuck' });483 await cancelled.settled;484 cancelMission(cancelled.m, new Date(t0 - 4.6 * H).toISOString());485 // live fixtures: one suspended on an approval, one on a question, one "running" in a long command486 await mk({ projectId: billing.id, title: 'Fix the rounding error in invoice totals', objective: '[fixture] Some invoice totals are off by one cent. Find the cause, fix it with a regression test, commit and push the branch.', doneCriteria: crit, model: 'claude-opus-5-5', effort: 'high', budgetUsd: 15 }, { startAt: t0 - 14 * 60_000 });487 await mk({ projectId: docs.id, title: 'Document the public invoice API', objective: '[fixture] Write reference documentation for the public invoice endpoints.', doneCriteria: [], model: 'claude-sonnet-5-5', effort: 'medium', budgetUsd: 6 }, { startAt: t0 - 6 * 60_000 });488 await mk({ projectId: billing.id, title: 'Speed up the tax table lookup', objective: '[fixture] The tax table lookup is linear; make it constant time and prove it with a benchmark.', doneCriteria: ['A benchmark shows the lookup is at least 10× faster', 'npm test passes'], model: 'claude-opus-5-5', effort: 'high', budgetUsd: 12, network: 'open' }, { startAt: t0 - 3 * 60_000, variant: 'stuck' });489 S.notifications = S.notifications.slice(0, 2);490}491492// ---------------------------------------------------------------- auth493function cookies(req) {494 return Object.fromEntries((req.headers.cookie ?? '').split(';').map((c) => c.trim().split('=')).filter((a) => a[0]));495}496const authOf = (req) => S.auth.get(cookies(req).aos_sid) ?? null;497function newAuth(req, res, level) {498 const sid = crypto.randomBytes(24).toString('hex');499 const a = { id: uuid(), sid, csrf: crypto.randomBytes(16).toString('hex'), level, createdAt: now(), lastUsedAt: now(), ua: req.headers['user-agent'] ?? null };500 S.auth.set(sid, a);501 res.setHeader('Set-Cookie', `aos_sid=${sid}; HttpOnly; SameSite=Strict; Path=/`);502 return a;503}504function hasSecondFactor() {505 return !!S.admin && (!!S.admin.totpSecret || S.admin.passkeys.length > 0);506}507function authStatus(req) {508 const a = authOf(req);509 return {510 setupRequired: !S.admin,511 authenticated: a?.level === 'full',512 user: a && a.level !== 'none' && S.admin ? { id: S.admin.id, username: S.admin.username } : null,513 methods: { password: !!S.admin, passkeys: S.admin?.passkeys.length ?? 0, totp: !!S.admin?.totpSecret },514 csrfToken: a?.csrf ?? null,515 sessionExpiresAt: a?.level === 'full' ? new Date(Date.now() + 12 * 3600_000).toISOString() : null,516 branding: BRANDING,517 };518}519function lockCheck() {520 const left = Math.ceil((S.lockedUntil - Date.now()) / 1000);521 if (left > 0) throw bad(429, 'locked', 'Too many attempts. Try again later.', { retryAfterSec: left });522}523function fail() {524 if (++S.fails >= 5) {525 S.fails = 0;526 S.lockedUntil = Date.now() + LOCK_SEC * 1000;527 }528 audit('auth.login', null, false);529 lockCheck();530 throw bad(401, 'invalid_credentials', 'Invalid credentials');531}532533// ---------------------------------------------------------------- router534const routes = [];535const R = (method, pattern, handler, opts = {}) => {536 const keys = [];537 const re = new RegExp('^' + pattern.replace(/:(\w+)/g, (_, k) => (keys.push(k), '([^/]+)')) + '/?$');538 routes.push({ method, re, keys, handler, ...opts });539};540const PUB = { public: true };541const PART = { partial: true }; // password verified, second factor pending (enrolment endpoints)542543R('GET', '/health', () => ({ ok: true, version: '0.1.0-mock', agent: 'aos' }), PUB);544R('GET', '/auth/status', ({ req }) => authStatus(req), PUB);545R('POST', '/auth/setup', ({ req, res, body }) => {546 if (S.admin) throw bad(409, 'already_configured', 'Setup has already been completed');547 if (body.setupToken !== SETUP_TOKEN) throw bad(403, 'bad_setup_token', 'Invalid setup token');548 if (!/^[a-z0-9_.-]{3,32}$/i.test(body.username ?? '') || (body.password ?? '').length < 12) throw bad(400, 'validation', 'Invalid username or password');549 S.admin = { id: uuid(), username: body.username, password: body.password, totpSecret: null, pendingTotp: null, passkeys: [] };550 newAuth(req, res, 'password');551 audit('auth.setup');552 return { ok: true, next: 'enroll_second_factor' };553}, PUB);554R('POST', '/auth/login', ({ req, res, body }) => {555 lockCheck();556 if (!S.admin || body.username !== S.admin.username || body.password !== S.admin.password) fail();557 if (!hasSecondFactor()) return newAuth(req, res, 'password'), { ok: true, next: 'enroll_second_factor' };558 if (body.totp) {559 if (!S.admin.totpSecret || !totpOk(S.admin.totpSecret, body.totp)) fail();560 S.fails = 0;561 newAuth(req, res, 'full');562 audit('auth.login');563 return { ok: true, next: 'done' };564 }565 newAuth(req, res, 'password');566 return { ok: true, next: S.admin.totpSecret ? 'totp' : 'passkey' };567}, PUB);568R('POST', '/auth/totp', ({ req, body }) => {569 lockCheck();570 const a = authOf(req);571 if (!a || a.level === 'none') throw bad(401, 'unauthenticated', 'Sign in first');572 if (!S.admin.totpSecret || !totpOk(S.admin.totpSecret, body.code ?? '')) fail();573 S.fails = 0;574 a.level = 'full';575 audit('auth.login');576 return { ok: true, next: 'done' };577}, PUB);578R('POST', '/auth/logout', ({ req, res }) => {579 S.auth.delete(cookies(req).aos_sid);580 res.setHeader('Set-Cookie', 'aos_sid=; Max-Age=0; Path=/');581 return { ok: true };582}, PUB);583R('POST', '/auth/password', ({ body }) => {584 if (body.currentPassword !== S.admin.password) throw bad(403, 'invalid_credentials', 'Current password is incorrect');585 if ((body.newPassword ?? '').length < 12) throw bad(400, 'validation', 'Password must be at least 12 characters');586 S.admin.password = body.newPassword;587 audit('auth.password');588 return { ok: true };589});590R('POST', '/auth/totp/enroll/start', () => {591 S.admin.pendingTotp = b32encode(crypto.randomBytes(20));592 return { secret: S.admin.pendingTotp, otpauthUrl: `otpauth://totp/${encodeURIComponent(BRANDING.productName)}:${S.admin.username}?secret=${S.admin.pendingTotp}&issuer=${encodeURIComponent(BRANDING.productName)}` };593}, PART);594R('POST', '/auth/totp/enroll/finish', ({ req, body }) => {595 if (!S.admin.pendingTotp || !totpOk(S.admin.pendingTotp, body.code ?? '')) throw bad(400, 'invalid_code', 'The code does not match');596 S.admin.totpSecret = S.admin.pendingTotp;597 S.admin.pendingTotp = null;598 authOf(req).level = 'full';599 audit('auth.totp.enroll');600 return { ok: true };601}, PART);602R('DELETE', '/auth/totp', () => {603 if (!S.admin.passkeys.length) throw bad(409, 'last_factor', 'Add a passkey before removing TOTP');604 S.admin.totpSecret = null;605 return { ok: true };606});607const pkOptions = (extra) => ({ challenge: crypto.randomBytes(32).toString('base64url'), rp: { name: `${BRANDING.productName} (mock)`, id: 'localhost' }, rpId: 'localhost', timeout: 60000, userVerification: 'preferred', ...extra });608R('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);609R('POST', '/auth/passkey/register/verify', ({ req, body }) => {610 // The mock does not verify attestation; the real server does.611 S.admin.passkeys.push({ id: uuid(), credId: (body.credential ?? body).id ?? null, label: body.label || 'Passkey', createdAt: now(), lastUsedAt: null });612 authOf(req).level = 'full';613 return { ok: true };614}, PART);615R('POST', '/auth/passkey/login/options', () => pkOptions({ allowCredentials: [] }), PUB);616R('POST', '/auth/passkey/login/verify', ({ req, res, body }) => {617 const pk = S.admin?.passkeys.find((k) => k.credId && k.credId === (body.credential ?? body).id);618 if (!pk) fail();619 pk.lastUsedAt = now();620 newAuth(req, res, 'full');621 return { ok: true, next: 'done' };622}, PUB);623R('GET', '/auth/devices', ({ req }) => [624 ...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 })),625 ...[...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' })),626]);627R('DELETE', '/auth/devices/:id', ({ params }) => {628 S.admin.passkeys = S.admin.passkeys.filter((k) => k.id !== params.id);629 for (const [sid, a] of S.auth) if (a.id === params.id) S.auth.delete(sid);630 audit('auth.device.revoke', params.id);631 return { ok: true };632});633634// ---------------------------------------------------------------- Agent OS API (CONTRACT §8)635const parse = (schema, body) => { const r = schema.safeParse(body); if (!r.success) throw bad(400, 'validation', 'Invalid request body', r.error.issues); return r.data; };636R('GET', '/system/status', () => {637 const ms = [...S.missions.values()];638 const seen = new Date(Date.now() - 4000).toISOString();639 return {640 collectedAt: now(), version: '0.1.0-mock',641 db: { ok: true, latencyMs: 1 }, // FIXTURE: there is no database behind the mock642 worker: { alive: true, lastSeenAt: seen, source: 'workers', workers: [{ id: 'fixture-worker-1', lastSeenAt: seen, version: '0.1.0' }] }, // FIXTURE: no worker process exists643 providers: { anthropic: true, openai: true },644 counts: { missionsQueued: ms.filter((m) => m.status === 'queued').length, missionsRunning: ms.filter((m) => m.status === 'running').length, missionsWaiting: ms.filter((m) => m.status === 'waiting').length, pendingWaits: [...S.waits.values()].filter((w) => w.status === 'pending').length, wsClients: wsClients.size },645 };646});647R('GET', '/system/audit', ({ query }) => S.audit.slice(0, Math.min(Number(query.get('limit') ?? 50), 200)));648R('GET', '/notifications', () => S.notifications.slice(0, 100));649R('POST', '/notifications/read', ({ body }) => {650 for (const n of S.notifications) if (!n.readAt && (body.all || body.ids?.includes(n.id))) n.readAt = now();651 return { ok: true };652});653R('GET', '/settings', () => S.settings);654R('PUT', '/settings', ({ body }) => {655 S.settings = parse(AosSettings, { ...S.settings, ...body });656 audit('settings.update');657 return S.settings;658});659660R('GET', '/projects', () => [...S.projects.values()].map(projectDto));661R('POST', '/projects', ({ body }) => {662 const b = parse(CreateProject, body);663 if ([...S.projects.values()].some((p) => p.name === b.name)) throw bad(409, 'exists', 'A project with this name already exists');664 if (b.mode === 'clone' && !/^(https:\/\/|ssh:\/\/|git@|gitsrv:)/.test(b.gitUrl ?? '')) throw bad(400, 'validation', 'Git URL must start with https://, ssh://, git@ or gitsrv:');665 const rel = b.mode === 'existing' ? (b.path ?? '') : b.name;666 if (!rel || rel.includes('..') || rel.startsWith('/')) throw bad(400, 'invalid_path', 'The path must stay inside the workspace root');667 const p = { id: uuid(), name: b.name, description: b.description, path: rel, gitRemote: b.mode === 'clone' ? b.gitUrl : null, gitBranch: 'main', instructions: b.instructions, createdAt: now() };668 S.projects.set(p.id, p);669 audit('project.create', p.id);670 return projectDto(p);671});672const getProject = (id) => S.projects.get(id) ?? (() => { throw bad(404, 'not_found', 'Project not found'); })();673R('GET', '/projects/:id', ({ params }) => projectDto(getProject(params.id)));674R('PATCH', '/projects/:id', ({ params, body }) => {675 const p = getProject(params.id);676 for (const k of ['name', 'description', 'instructions']) if (typeof body[k] === 'string') p[k] = body[k];677 audit('project.update', p.id);678 return projectDto(p);679});680R('DELETE', '/projects/:id', ({ params }) => { getProject(params.id); S.projects.delete(params.id); audit('project.unregister', params.id); return { ok: true }; });681682R('GET', '/models', () => MODELS);683684R('GET', '/missions', ({ query }) => {685 const status = query.get('status'), projectId = query.get('projectId');686 return [...S.missions.values()].filter((m) => (!status || m.status === status) && (!projectId || m.projectId === projectId)).map(missionDto).sort((a, b) => b.createdAt.localeCompare(a.createdAt));687});688R('POST', '/missions', ({ body }) => {689 const b = parse(CreateMission, body);690 getProject(b.projectId);691 if (todayUsd() >= S.settings.dailyBudgetUsd) throw bad(409, 'daily_cap_reached', "Today's spend has reached the daily cap; no new mission can start.");692 if (b.model && !modelOf(b.model)?.available) throw bad(400, 'model_unavailable', 'This model is not available');693 const { m } = createMission(b);694 audit('mission.create', m.id);695 return missionDto(m);696});697R('GET', '/missions/:id', ({ params }) => missionDto(getMission(params.id)));698R('POST', '/missions/:id/cancel', ({ params }) => { const m = getMission(params.id); cancelMission(m); audit('mission.cancel', m.id); return missionDto(m); });699R('GET', '/missions/:id/runs', ({ params }) => [...S.runs.values()].filter((r) => r.missionId === getMission(params.id).id).map(runDto));700R('GET', '/runs/:id', ({ params }) => runDto(getRun(params.id)));701const CAP = 20_000;702const capBody = (v) => { const s = typeof v === 'string' ? v : null; return s && s.length > CAP ? s.slice(0, CAP) : v; };703R('GET', '/runs/:id/steps', ({ params, query }) => {704 getRun(params.id);705 const after = query.get('after') == null ? -Infinity : Number(query.get('after'));706 const limit = Math.max(1, Math.min(Number(query.get('limit') ?? 200), 500));707 return S.steps.get(params.id).filter((s) => s.seq > after).slice(0, limit).map((s) => ({ ...s, result: capBody(s.result) }));708});709R('POST', '/runs/:id/message', ({ params, body }) => {710 const run = getRun(params.id);711 const text = String(body.text ?? '').trim();712 if (!text || text.length > 8000) throw bad(400, 'validation', 'Message must be 1–8000 characters');713 if (run.endedAt) throw bad(409, 'run_ended', 'This run has ended');714 const ev = emit(run, 'owner.message', { text }, { trust: 'owner' });715 run.inbox.push({ id: ev.id, text });716 audit('run.message', run.id);717 return { ok: true, id: ev.id };718});719R('GET', '/runs/:id/artifacts', ({ params }) => [...S.artifacts.values()].filter((a) => a.runId === getRun(params.id).id).map(({ body: _b, ...a }) => a));720R('GET', '/artifacts/:sha', ({ params, query, res }) => {721 const a = [...S.artifacts.values()].find((x) => x.sha256 === params.sha);722 if (!a) throw bad(404, 'not_found', 'Artifact not found');723 const name = (query.get('name') ?? a.name).replace(/[^\w.-]/g, '_');724 res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${name}"`, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'private, max-age=0' });725 res.end(a.body);726 return undefined;727});728729R('GET', '/waits', ({ query }) => { const status = query.get('status'); return [...S.waits.values()].filter((w) => !status || w.status === status).map(waitDto); });730R('POST', '/waits/:id/resolve', ({ params, body }) => {731 const w = S.waits.get(params.id);732 if (!w) throw bad(404, 'not_found', 'Wait not found');733 if (w.status !== 'pending') throw bad(409, 'not_pending', 'This request is no longer pending');734 const b = parse(ResolveWait, body);735 if (b.kind !== w.kind) throw bad(400, 'validation', `This is a ${w.kind}, not a ${b.kind}`);736 if (b.kind === 'approval' && b.decision === 'approve' && b.remember) {737 const tool = String(w.payload.tool ?? '*');738 const cmd = typeof w.payload.input?.command === 'string' ? w.payload.input.command.split(/\s+/).slice(0, 2).join(' ') : null;739 S.rules.push({ id: uuid(), projectId: S.missions.get(w.missionId)?.projectId ?? null, decision: 'allow', tool, match: tool === 'shell' && cmd ? { commandPrefix: cmd } : {}, note: 'Created from an approval', createdAt: now() });740 }741 settleWait(w, b.kind === 'approval' ? { decision: b.decision, remember: b.remember, note: b.note ?? null } : { answer: b.answer });742 audit('wait.resolve', w.id);743 return waitDto(w);744});745746R('GET', '/policy/rules', () => S.rules);747R('POST', '/policy/rules', ({ body }) => {748 const b = parse(CreatePolicyRule, body);749 const r = { id: uuid(), ...b, createdAt: now() };750 S.rules.push(r);751 audit('policy.create', r.id);752 return r;753});754R('DELETE', '/policy/rules/:id', ({ params }) => {755 if (!S.rules.some((r) => r.id === params.id)) throw bad(404, 'not_found', 'Rule not found');756 S.rules = S.rules.filter((r) => r.id !== params.id);757 audit('policy.delete', params.id);758 return { ok: true };759});760761R('GET', '/spend', () => {762 const t = Date.now();763 return {764 todayUsd: round(todayUsd(), 4), dailyBudgetUsd: S.settings.dailyBudgetUsd,765 last7Days: Array.from({ length: 7 }, (_, i) => { const d = day(t - (6 - i) * 86400_000); return { day: d, usd: round(S.spendDays.get(d) ?? 0, 4) }; }),766 byModel: [...S.byModel.values()].map((b) => ({ ...b, usd: round(b.usd, 4) })),767 };768});769770// test controls (dev mock only)771R('POST', '/__mock/reset', async ({ body }) => { await reset(body.seed !== false); return { ok: true }; }, PUB);772R('GET', '/__mock/info', () => ({ setupToken: SETUP_TOKEN, eventSeq: S.eventSeq, missions: [...S.missions.values()].map((m) => ({ id: m.id, title: m.title, status: m.status, rootRunId: m.rootRunId })) }), PUB);773R('POST', '/__mock/gap', () => { for (const c of wsClients) c.ws.send(frame({ id: -1, missionId: '00000000-0000-4000-8000-000000000000', runId: null, seq: null, ts: now(), kind: 'system.notice', trust: 'system', payload: { code: 'gap', message: 'gap, refetch' } })); return { ok: true }; }, PUB);774R('POST', '/__mock/spend', ({ body }) => { S.spendDays.set(day(Date.now()), Number(body.todayUsd)); return { ok: true }; }, PUB);775776// ---------------------------------------------------------------- http server777function readBody(req, limit = 4 * 1024 * 1024) {778 return new Promise((resolve, reject) => {779 const chunks = [];780 let n = 0;781 req.on('data', (c) => { n += c.length; if (n > limit) { reject(bad(413, 'too_large', 'Body too large')); req.destroy(); } else chunks.push(c); });782 req.on('end', () => resolve(Buffer.concat(chunks)));783 req.on('error', reject);784 });785}786const server = http.createServer(async (req, res) => {787 const url = new URL(req.url, 'http://x');788 const send = (status, obj) => {789 if (res.headersSent) return;790 res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });791 res.end(JSON.stringify(obj));792 };793 try {794 if (!url.pathname.startsWith('/api/v1')) throw bad(404, 'not_found', 'Not found');795 const p = url.pathname.slice('/api/v1'.length);796 const route = routes.find((r) => r.method === req.method && r.re.test(p));797 if (!route) throw bad(404, 'not_found', `No route ${req.method} ${p}`);798 const m = route.re.exec(p);799 const params = Object.fromEntries(route.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])]));800 const a = authOf(req);801 if (a) a.lastUsedAt = now();802 if (!route.public) {803 if (!a || a.level === 'none' || (a.level !== 'full' && !route.partial)) throw bad(401, 'unauthenticated', 'Authentication required');804 }805 if (req.method !== 'GET' && a && req.headers['x-csrf-token'] !== a.csrf && !p.startsWith('/__mock')) throw bad(403, 'csrf', 'Missing or invalid CSRF token');806 const raw = req.method === 'GET' ? Buffer.alloc(0) : await readBody(req);807 let body = {};808 if (raw.length) {809 try { body = JSON.parse(raw.toString('utf8')); } catch { throw bad(400, 'bad_json', 'Invalid JSON'); }810 }811 const out = await route.handler({ req, res, params, query: url.searchParams, body, raw });812 if (out !== undefined) send(200, out);813 } catch (e) {814 if (e instanceof HttpError) {815 if (e.details?.retryAfterSec) res.setHeader('Retry-After', String(e.details.retryAfterSec));816 send(e.status, { error: { code: e.code, message: e.message, ...(e.details ? { details: e.details } : {}) } });817 } else {818 console.error('[mock]', e);819 send(500, { error: { code: 'internal', message: 'Mock server error' } });820 }821 }822});823824// ---------------------------------------------------------------- events WebSocket (cursor replay, then live)825const REPLAY_MAX = 5000;826const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });827server.on('upgrade', (req, socket, head) => {828 const url = new URL(req.url, 'http://x');829 wss.handleUpgrade(req, socket, head, (ws) => {830 const a = authOf(req);831 if (!a || a.level !== 'full') return ws.close(4401, 'unauthorized');832 if (url.pathname.replace(/^\/api\/v1/, '') !== '/events') return ws.close(4404, 'not found');833 const missionId = url.searchParams.get('missionId');834 const cursor = url.searchParams.get('cursor');835 if (cursor != null && cursor !== '') {836 const missed = S.events.filter((e) => e.id > Number(cursor) && (!missionId || e.missionId === missionId));837 for (const e of missed.slice(0, REPLAY_MAX)) ws.send(frame(e));838 if (missed.length > REPLAY_MAX) ws.send(frame({ id: -1, missionId: missed[0].missionId, runId: null, seq: null, ts: now(), kind: 'system.notice', trust: 'system', payload: { code: 'gap', message: 'gap, refetch' } }));839 }840 const client = { ws, missionId };841 wsClients.add(client);842 ws.on('close', () => wsClients.delete(client));843 });844});845846if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {847 await reset(process.env.MOCK_SEED !== '0');848 server.listen(PORT, '127.0.0.1', () => {849 console.log(`[mock] ${BRANDING.productName} DEV MOCK API on http://127.0.0.1:${PORT} (fixture data only; no model is called)`);850 console.log(S.admin ? `[mock] seeded admin: ${SEED.username} / ${SEED.password} / TOTP secret ${SEED.totpSecret}` : `[mock] no administrator; setup token: ${SETUP_TOKEN}`);851 });852 const bye = () => process.exit(0);853 process.on('SIGINT', bye);854 process.on('SIGTERM', bye);855}856