Repository Public
1import { createHash } from 'node:crypto';2import fs from 'node:fs';3import path from 'node:path';4import { afterAll, beforeAll, describe, expect, it } from 'vitest';5import { AosSettings, MissionDto, PolicyRuleDto, RunDto, SpendSummary, StepDto, WaitDto } from '@aos/shared';6import { ModelInfoEx, SystemStatus } from '../../dto.js';7import { runJanitor } from '../../janitor.js';8import { Client, FAKE_ANTHROPIC_KEY, FAKE_OPENAI_KEY, createHarness, insertOtherUser, seedMission, setupAdmin, type Harness } from '../../testkit/harness.js';910let h: Harness;11let admin: Client;12let userId: string;13let projectId: string;14const UUID = '00000000-0000-4000-8000-000000000000';1516beforeAll(async () => {17 h = await createHarness();18 ({ client: admin } = await setupAdmin(h));19 userId = (await h.db.one<{ id: string }>('SELECT id FROM users'))?.id as string;20 projectId = (await admin.post('/projects', { name: 'Main', mode: 'create' })).json<{ id: string }>().id;21});22afterAll(async () => {23 await h.close();24});2526describe('POST /missions', () => {27 it('defaults come from the settings; no criteria ⇒ an empty list reaches the kernel (the agent proposes them)', async () => {28 const res = await admin.post('/missions', { projectId, objective: ' Fix the\nfailing test in src/a.ts ' });29 expect(res.statusCode).toBe(201);30 const m = MissionDto.parse(res.json());31 expect(m).toMatchObject({32 projectId, projectName: 'Main', title: 'Fix the failing test in src/a.ts', status: 'queued', budgetUsd: 15, spentUsd: 0,33 network: 'none', doneCriteria: [], runs: 1, pendingWaits: 0, startedAt: null, endedAt: null,34 });35 const run = await h.db.one<{ contract: { objective: string; doneCriteria: string[]; contextMode: string }; provider: string; model: string; effort: string; workspace_abs: string; budget: unknown; status: string }>(36 'SELECT contract, provider, model, effort, workspace_abs, budget, status FROM runs WHERE id = $1', [m.rootRunId as string]);37 expect(run).toMatchObject({ provider: 'anthropic', model: 'claude-opus-5-5', effort: 'high', status: 'queued', budget: { usd: 15, turns: 200, wallSec: 21600 } });38 expect(run?.contract.doneCriteria).toEqual([]);39 expect(run?.contract.contextMode).toBe('fresh');40 expect(run?.workspace_abs).toBe(fs.realpathSync(path.join(h.workspace, 'main')));41 const audit = await h.db.one<{ target: string; detail: Record<string, unknown> }>("SELECT target, detail FROM audit_events WHERE action = 'mission.create' ORDER BY id DESC LIMIT 1");42 expect(audit).toMatchObject({ target: m.id, detail: { provider: 'anthropic', model: 'claude-opus-5-5', budgetUsd: 15, network: 'none' } });43 expect(JSON.stringify(audit)).not.toContain('failing'); // the objective is not audit material44 });45 it('explicit criteria, model, effort, budget and network are honoured', async () => {46 const res = await admin.post('/missions', {47 projectId, title: 'T', objective: 'o', doneCriteria: ['tests pass', 'lint clean'], provider: 'openai', model: 'gpt-6.1-sol', effort: 'low', budgetUsd: 2.5, network: 'open',48 });49 expect(res.statusCode).toBe(201);50 const m = MissionDto.parse(res.json());51 expect(m).toMatchObject({ title: 'T', budgetUsd: 2.5, network: 'open' });52 expect(m.doneCriteria).toEqual([{ id: 'c1', text: 'tests pass', passes: false }, { id: 'c2', text: 'lint clean', passes: false }]);53 const run = RunDto.parse((await admin.get(`/runs/${m.rootRunId}`)).json());54 expect(run).toMatchObject({ provider: 'openai', model: 'gpt-6.1-sol', effort: 'low', objective: 'o', depth: 0, parentRunId: null, budget: { usd: 2.5 } });55 const contract = (await h.db.one<{ contract: { doneCriteria: string[] } }>('SELECT contract FROM runs WHERE id = $1', [run.id]))?.contract;56 expect(contract?.doneCriteria).toEqual(['tests pass', 'lint clean']);57 });58 it('model resolution: provider inferred from the catalogue, main tier for a bare provider, never a silent substitute', async () => {59 const byModel = await admin.post('/missions', { projectId, objective: 'o', model: 'claude-haiku-5-5' });60 expect(byModel.statusCode).toBe(201);61 const r1 = await h.db.one<{ provider: string; model: string; effort: string | null }>('SELECT provider, model, effort FROM runs WHERE id = $1', [byModel.json().rootRunId]);62 expect(r1).toEqual({ provider: 'anthropic', model: 'claude-haiku-5-5', effort: null }); // default effort "high" is not supported by this model ⇒ provider default63 // the mission DTO carries the root run's provider / model / effort (list and detail)64 expect(byModel.json()).toMatchObject({ provider: 'anthropic', model: 'claude-haiku-5-5', effort: null });65 expect((await admin.get('/missions')).json<{ id: string; model: string }[]>().find((x) => x.id === byModel.json().id)).toMatchObject({ provider: 'anthropic', model: 'claude-haiku-5-5', effort: null });66 const byProvider = await admin.post('/missions', { projectId, objective: 'o', provider: 'openai' });67 expect((await h.db.one<{ model: string }>('SELECT model FROM runs WHERE id = $1', [byProvider.json().rootRunId]))?.model).toBe('gpt-6.1-sol');68 for (const [body, code] of [69 [{ model: 'gpt-9-imaginary' }, 'unknown_model'],70 [{ provider: 'openai', model: 'claude-opus-5-5' }, 'unknown_model'],71 [{ model: 'claude-haiku-5-5', effort: 'max' }, 'unsupported_effort'],72 ] as const) {73 const res = await admin.post('/missions', { projectId, objective: 'o', ...body });74 expect([body, res.statusCode, res.json().error.code]).toEqual([body, 400, code]);75 }76 });77 it('refuses a provider without an API key (409) and invalid bodies (400)', async () => {78 h.config.providerKeys.openai = false;79 try {80 const res = await admin.post('/missions', { projectId, objective: 'o', provider: 'openai' });81 expect([res.statusCode, res.json().error.code]).toEqual([409, 'provider_not_configured']);82 } finally {83 h.config.providerKeys.openai = true;84 }85 for (const body of [{}, { projectId }, { projectId, objective: '' }, { projectId: 'x', objective: 'o' }, { projectId, objective: 'o', budgetUsd: 0 }, { projectId, objective: 'o', budgetUsd: 5000 }, { projectId, objective: 'o', network: 'lan' }, { projectId, objective: 'o', provider: 'mistral' }]) {86 expect([(await admin.post('/missions', body)).statusCode, body]).toEqual([400, body]);87 }88 expect((await admin.post('/missions', { projectId: UUID, objective: 'o' })).statusCode).toBe(404);89 });90 it('refuses with 409 daily_cap_reached when today\'s spend has reached the daily budget', async () => {91 const before = h.kernel.calls.createMission;92 await h.db.q('INSERT INTO spend_days (day, usd) VALUES (CURRENT_DATE, 59.99)');93 expect((await admin.post('/missions', { projectId, objective: 'under the cap' })).statusCode).toBe(201);94 await h.db.q('UPDATE spend_days SET usd = 60 WHERE day = CURRENT_DATE');95 const res = await admin.post('/missions', { projectId, objective: 'over the cap' });96 expect(res.statusCode).toBe(409);97 expect(res.json().error).toMatchObject({ code: 'daily_cap_reached', details: { todayUsd: 60, dailyBudgetUsd: 60 } });98 expect(h.kernel.calls.createMission).toBe(before + 1);99 // yesterday's spend does not count; a higher cap in the settings lifts the refusal100 const s = (await admin.get('/settings')).json();101 await admin.req('PUT', '/settings', { ...s, dailyBudgetUsd: 100, missionBudgetUsd: 7 });102 const ok = await admin.post('/missions', { projectId, objective: 'cap raised' });103 expect([ok.statusCode, ok.json().budgetUsd]).toEqual([201, 7]);104 await admin.req('PUT', '/settings', s);105 await h.db.q("UPDATE spend_days SET day = CURRENT_DATE - 1 WHERE day = CURRENT_DATE");106 expect((await admin.post('/missions', { projectId, objective: 'new day' })).statusCode).toBe(201);107 });108 it('refuses when the project directory is gone or was replaced by a symlink leaving the root', async () => {109 const p = (await admin.post('/projects', { name: 'Vanish', mode: 'create' })).json<{ id: string }>();110 const dir = path.join(h.workspace, 'vanish');111 fs.rmSync(dir, { recursive: true });112 const gone = await admin.post('/missions', { projectId: p.id, objective: 'o' });113 expect([gone.statusCode, gone.json().error.code]).toEqual([409, 'workspace_missing']);114 fs.symlinkSync('/etc', dir);115 const escaped = await admin.post('/missions', { projectId: p.id, objective: 'o' });116 expect([escaped.statusCode, escaped.json().error.code]).toEqual([409, 'workspace_missing']);117 fs.rmSync(dir);118 });119});120121describe('missions, runs, steps', () => {122 it('lists newest first with status / project filters; unknown ids are 404', async () => {123 const all = (await admin.get('/missions')).json<MissionDto[]>();124 expect(all.length).toBeGreaterThan(4);125 expect(all.every((m) => MissionDto.safeParse(m).success)).toBe(true);126 expect([...all].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((m) => m.id)).toEqual(all.map((m) => m.id));127 const { projectId: other, missionId } = await seedMission(h, admin);128 expect((await admin.get(`/missions?projectId=${other}`)).json<MissionDto[]>().map((m) => m.id)).toEqual([missionId]);129 expect((await admin.get('/missions?status=done')).json()).toEqual([]);130 expect((await admin.get('/missions?status=queued&limit=2')).json()).toHaveLength(2);131 expect((await admin.get('/missions?status=bogus')).statusCode).toBe(400);132 expect((await admin.get(`/missions/${UUID}`)).statusCode).toBe(404);133 expect((await admin.get(`/missions/${UUID}/runs`)).statusCode).toBe(404);134 expect((await admin.get(`/runs/${UUID}`)).statusCode).toBe(404);135 expect((await admin.get(`/runs/${UUID}/steps`)).statusCode).toBe(404);136 });137 it('run DTOs expose no lease, fencing, workspace or contract internals', async () => {138 const { missionId, runId } = await seedMission(h, admin);139 await h.db.q("UPDATE runs SET status = 'running', lease_owner = 'worker-LEASEOWNER', lease_expires_at = now() + interval '80 seconds', fencing_token = 7, started_at = now() WHERE id = $1", [runId]);140 const runs = (await admin.get(`/missions/${missionId}/runs`)).json<Record<string, unknown>[]>();141 expect(runs).toHaveLength(1);142 expect(RunDto.safeParse(runs[0]).success).toBe(true);143 expect(Object.keys(runs[0] ?? {}).sort()).toEqual(Object.keys(RunDto.shape).sort());144 const raw = JSON.stringify(runs);145 for (const s of ['LEASEOWNER', h.workspace, 'fencing', 'lease', 'workspace']) expect(raw).not.toContain(s);146 });147 it('steps: paginated by seq, internals hidden, bodies truncated to 20 000 chars with a flag', async () => {148 const { runId } = await seedMission(h, admin);149 await h.kernel.seedStep(runId, 0, { kind: 'now', name: 'now', effect: 'pure', request: {}, result: { iso: '2026-11-10T00:00:00.000Z' } });150 await h.kernel.seedStep(runId, 1, { kind: 'model_call', name: 'claude-opus-5-5', effect: 'model_call', request: { model: 'claude-opus-5-5' }, result: { blocks: [{ type: 'text', text: 'hello' }], stop: 'tool_use', native: [{ role: 'assistant', content: [{ type: 'thinking', thinking: 'PRIVATE-THINKING', signature: 'SIG-OPAQUE' }] }] }, usage: { usd: 0.0123, inputTokens: 100, outputTokens: 20 } });151 await h.kernel.seedStep(runId, 2, { name: 'write_file', effect: 'idempotent_write', request: { name: 'write_file', input: { path: 'big.txt', content: 'A'.repeat(300_000) } }, result: { ok: true, output: 'B'.repeat(50_000) } });152 await h.kernel.seedStep(runId, 3, { name: 'shell', effect: 'non_idempotent', status: 'failed', request: { name: 'shell', input: { command: 'false' } }, error: { message: 'exit 1' } });153 await h.kernel.seedStep(runId, 4, { name: 'git_push', effect: 'non_idempotent', status: 'started', request: { name: 'git_push', input: {} } });154 const res = await admin.get(`/runs/${runId}/steps`);155 expect(res.statusCode).toBe(200);156 expect(res.body.length).toBeLessThan(60_000);157 const steps = res.json<(StepDto & { truncated: boolean })[]>();158 expect(steps.map((s) => [s.seq, s.status, s.truncated])).toEqual([[0, 'completed', false], [1, 'completed', false], [2, 'completed', true], [3, 'failed', false], [4, 'started', false]]);159 expect(steps.every((s) => StepDto.safeParse(s).success)).toBe(true);160 expect(steps[1]).toMatchObject({ runId, kind: 'model_call', usage: { usd: 0.0123, inputTokens: 100, outputTokens: 20 }, result: { stop: 'tool_use' } });161 // the provider-native transcript (thinking, signatures, encrypted reasoning) never reaches the browser162 expect(steps[1]?.result).toEqual({ blocks: [{ type: 'text', text: 'hello' }], stop: 'tool_use' });163 expect(JSON.stringify(steps)).not.toMatch(/PRIVATE-THINKING|SIG-OPAQUE|"native"/);164 const big = steps[2] as unknown as { request: { name: string; input: { path: string; content: string } }; result: { ok?: boolean; output: string } };165 expect(big.request.name).toBe('write_file');166 expect(big.request.input.path).toBe('big.txt');167 expect(JSON.stringify(big.request).length).toBeLessThanOrEqual(20_200);168 expect(JSON.stringify(big.result).length).toBeLessThanOrEqual(20_200);169 expect(big.request.input.content).toContain('…[truncated ');170 expect(steps[3]).toMatchObject({ error: { message: 'exit 1' }, result: null, completedAt: expect.any(String) });171 expect(steps[4]).toMatchObject({ result: null, error: null, usage: null, completedAt: null });172 expect(res.body).not.toMatch(/IDEM-INTERNAL|HASH-INTERNAL|idem_key|idemKey|request_hash|requestHash/);173 expect((await admin.get(`/runs/${runId}/steps?after=2`)).json<StepDto[]>().map((s) => s.seq)).toEqual([3, 4]);174 expect((await admin.get(`/runs/${runId}/steps?after=0&limit=2`)).json<StepDto[]>().map((s) => s.seq)).toEqual([1, 2]);175 expect((await admin.get(`/runs/${runId}/steps?after=99`)).json()).toEqual([]);176 for (const q of ['after=x', 'limit=0', 'limit=501', 'after=-2']) expect((await admin.get(`/runs/${runId}/steps?${q}`)).statusCode).toBe(400);177 });178 it('cancel goes through the kernel, is idempotent, and audited', async () => {179 const { missionId, runId } = await seedMission(h, admin);180 const before = h.kernel.calls.requestCancel;181 const res = await admin.post(`/missions/${missionId}/cancel`);182 expect(res.statusCode).toBe(200);183 expect(MissionDto.parse(res.json())).toMatchObject({ status: 'cancelled', endedAt: expect.any(String) });184 expect((await admin.get(`/runs/${runId}`)).json()).toMatchObject({ status: 'cancelled' });185 expect((await admin.post(`/missions/${missionId}/cancel`)).json().status).toBe('cancelled');186 expect(h.kernel.calls.requestCancel).toBe(before + 1); // terminal ⇒ the kernel is not called again187 expect((await admin.post(`/missions/${UUID}/cancel`)).statusCode).toBe(404);188 expect((await h.db.q("SELECT 1 FROM audit_events WHERE action = 'mission.cancel' AND target = $1", [missionId])).length).toBe(1);189 });190 it('steering message: delivered to the kernel as an owner event; refused once the run ended; text never audited', async () => {191 const { missionId, runId } = await seedMission(h, admin);192 expect((await admin.post(`/runs/${runId}/message`, { text: 'Use the STEERINGTEXT approach' })).json()).toEqual({ ok: true });193 const ev = await h.db.one<{ kind: string; trust: string; payload: { text: string }; mission_id: string }>("SELECT kind, trust, payload, mission_id FROM events WHERE run_id = $1 AND kind = 'owner.message'", [runId]);194 expect(ev).toEqual({ kind: 'owner.message', trust: 'owner', payload: { text: 'Use the STEERINGTEXT approach' }, mission_id: missionId });195 const audit = await h.db.one<{ detail: unknown }>("SELECT detail FROM audit_events WHERE action = 'run.message' AND target = $1", [runId]);196 expect(audit?.detail).toEqual({ chars: 29 });197 for (const body of [{}, { text: '' }, { text: 'x'.repeat(8001) }]) expect((await admin.post(`/runs/${runId}/message`, body)).statusCode).toBe(400);198 await admin.post(`/missions/${missionId}/cancel`);199 const late = await admin.post(`/runs/${runId}/message`, { text: 'too late' });200 expect([late.statusCode, late.json().error.code]).toEqual([409, 'run_ended']);201 expect((await admin.post(`/runs/${UUID}/message`, { text: 'x' })).statusCode).toBe(404);202 });203});204205describe('artifacts', () => {206 it('lists the artifacts of a run and downloads them as attachments that cannot render', async () => {207 const { runId } = await seedMission(h, admin);208 const html = '<html><script>alert(document.cookie)</script></html>';209 const sha = await h.kernel.seedArtifact(runId, 'out/report.html', html, 'text/html', 3);210 expect(sha).toBe(createHash('sha256').update(html).digest('hex'));211 const list = (await admin.get(`/runs/${runId}/artifacts`)).json();212 expect(list).toEqual([{ sha256: sha, name: 'out/report.html', size: html.length, mime: 'text/html', stepSeq: 3, createdAt: expect.any(String) }]);213 for (const url of [`/artifacts/${sha}`, `/artifacts/${sha}?name=${encodeURIComponent('out/report.html')}`]) {214 const res = await admin.get(url);215 expect(res.statusCode).toBe(200);216 expect(res.body).toBe(html);217 expect(res.headers['content-type']).toBe('application/octet-stream'); // never the recorded text/html218 expect(res.headers['content-disposition']).toBe(`attachment; filename="report.html"; filename*=UTF-8''report.html`);219 expect(res.headers['x-content-type-options']).toBe('nosniff');220 expect(res.headers['content-security-policy']).toBe("default-src 'none'; sandbox");221 expect(res.headers['cache-control']).toBe('no-store');222 expect(res.headers['content-length']).toBe(String(html.length));223 expect(String(res.headers['content-disposition'])).not.toContain('inline');224 }225 });226 it('refuses malformed hashes, unknown names, foreign artifacts and missing blobs', async () => {227 const { runId } = await seedMission(h, admin);228 const sha = await h.kernel.seedArtifact(runId, 'a.bin', Buffer.from([0, 1, 2, 3]));229 for (const bad of ['abc', 'Z'.repeat(64), `${sha}x`, '..%2f..%2fsetup-token', sha.toUpperCase()]) expect([bad, (await admin.get(`/artifacts/${bad}`)).statusCode]).toEqual([bad, 400]);230 expect((await admin.get(`/artifacts/${'0'.repeat(64)}`)).statusCode).toBe(404);231 expect((await admin.get(`/artifacts/${sha}?name=other.bin`)).statusCode).toBe(404);232 // An artifact that exists but belongs to another owner's run is a 404, and the kernel is not asked for it.233 const other = await insertOtherUser(h, 'artifact-owner');234 const m = await h.db.one<{ id: string }>("INSERT INTO missions (owner_id, title, objective, budget_usd) VALUES ($1, 't', 'o', 1) RETURNING id", [other]);235 const r = await h.db.one<{ id: string }>(`INSERT INTO runs (mission_id, contract, provider, model, workspace_abs, budget, engine_version) VALUES ($1, '{}', 'anthropic', 'm', '/x', '{"usd":1}', '0') RETURNING id`, [m?.id as string]);236 const foreign = await h.kernel.seedArtifact(r?.id as string, 'secret.txt', 'FOREIGN-CONTENT');237 const opened = h.kernel.calls.openArtifact;238 const res = await admin.get(`/artifacts/${foreign}`);239 expect(res.statusCode).toBe(404);240 expect(res.body).not.toContain('FOREIGN-CONTENT');241 expect(h.kernel.calls.openArtifact).toBe(opened);242 expect((await admin.get(`/runs/${r?.id}/artifacts`)).statusCode).toBe(404);243 // …but the same content recorded by one of the caller's own runs is theirs (content-addressed store).244 fs.rmSync(h.kernel.artifactPath(sha));245 const gone = await admin.get(`/artifacts/${sha}`);246 expect([gone.statusCode, gone.json().error.code]).toEqual([404, 'artifact_missing']);247 });248});249250describe('waits', () => {251 it('lists pending approvals and questions, oldest first, with the mission title', async () => {252 const { missionId, runId } = await seedMission(h, admin, { mission: { title: 'Needs you' } });253 const approval = await h.kernel.seedWait(runId, 5, 'approval', { tool: 'git_push', input: { remote: 'origin' }, risk: 'external', title: 'Push', detail: 'git push origin', diff: '+a' }, new Date(Date.now() + 3600_000));254 await h.kernel.seedWait(runId, 6, 'timer', { ms: 1000 });255 const list = (await admin.get(`/waits?status=pending&missionId=${missionId}`)).json<WaitDto[]>();256 expect(list).toHaveLength(1);257 expect(WaitDto.parse(list[0])).toMatchObject({ id: approval, runId, missionId, missionTitle: 'Needs you', stepSeq: 5, kind: 'approval', status: 'pending', resolution: null, payload: { tool: 'git_push', risk: 'external', diff: '+a' } });258 expect(list[0]?.resolveBy).not.toBeNull();259 expect((await admin.get(`/waits?missionId=${missionId}&kind=all`)).json()).toHaveLength(2);260 expect((await admin.get(`/missions/${missionId}`)).json()).toMatchObject({ status: 'waiting', pendingWaits: 1 });261 expect((await admin.get(`/runs/${runId}`)).json().pendingWaits).toBe(1);262 expect((await admin.get('/waits?status=nope')).statusCode).toBe(400);263 });264 it('approve with remember: the kernel resolves it with the exact resolution; the server inserts no rule', async () => {265 const { missionId, runId } = await seedMission(h, admin);266 const id = await h.kernel.seedWait(runId, 1, 'approval', { tool: 'shell', input: { command: 'npm test' }, risk: 'network' });267 const rules = (await h.db.q('SELECT 1 FROM policy_rules')).length;268 const res = await admin.post(`/waits/${id}/resolve`, { kind: 'approval', decision: 'approve', remember: true, note: 'go ahead' });269 expect(res.statusCode).toBe(200);270 expect(WaitDto.parse(res.json())).toMatchObject({ status: 'resolved', resolution: { decision: 'approve', remember: true, note: 'go ahead' }, resolvedAt: expect.any(String) });271 expect((await h.db.one<{ status: string }>('SELECT status FROM runs WHERE id = $1', [runId]))?.status).toBe('queued'); // re-queued by the kernel272 const ev = await h.db.one<{ trust: string; payload: { actor: string } }>("SELECT trust, payload FROM events WHERE mission_id = $1 AND kind = 'wait.resolved'", [missionId]);273 expect(ev).toMatchObject({ trust: 'owner', payload: { actor: 'admin', status: 'resolved' } });274 expect((await h.db.q('SELECT 1 FROM policy_rules')).length).toBe(rules);275 const again = await admin.post(`/waits/${id}/resolve`, { kind: 'approval', decision: 'deny' });276 expect([again.statusCode, again.json().error.code]).toEqual([409, 'wait_not_pending']);277 expect((await admin.get(`/waits?status=resolved&missionId=${missionId}`)).json()).toHaveLength(1);278 });279 it('deny never remembers; a question takes an answer; kinds must match; the answer is not audited', async () => {280 const { runId } = await seedMission(h, admin);281 const a = await h.kernel.seedWait(runId, 1, 'approval', { tool: 'git_push' });282 const denied = await admin.post(`/waits/${a}/resolve`, { kind: 'approval', decision: 'deny', remember: true });283 expect(denied.json().resolution).toEqual({ decision: 'deny', remember: false });284 const q = await h.kernel.seedWait(runId, 2, 'question', { text: 'Which branch?', options: ['main', 'dev'] });285 const mismatch = await admin.post(`/waits/${q}/resolve`, { kind: 'approval', decision: 'approve' });286 expect([mismatch.statusCode, mismatch.json().error.code]).toEqual([400, 'kind_mismatch']);287 for (const body of [{}, { kind: 'question' }, { kind: 'approval', decision: 'maybe' }, { kind: 'timer' }]) expect((await admin.post(`/waits/${q}/resolve`, body)).statusCode).toBe(400);288 const answered = await admin.post(`/waits/${q}/resolve`, { kind: 'question', answer: 'ANSWERTEXT main' });289 expect(answered.json()).toMatchObject({ status: 'resolved', resolution: { answer: 'ANSWERTEXT main' } });290 expect(JSON.stringify(await h.db.q("SELECT detail FROM audit_events WHERE action = 'wait.resolve'"))).not.toContain('ANSWERTEXT');291 });292 it('kernel-internal waits, foreign waits and lost races are refused cleanly', async () => {293 const { runId } = await seedMission(h, admin);294 const timer = await h.kernel.seedWait(runId, 1, 'timer', { ms: 5 });295 const res = await admin.post(`/waits/${timer}/resolve`, { kind: 'question', answer: 'x' });296 expect([res.statusCode, res.json().error.code]).toEqual([409, 'not_resolvable']);297 expect((await admin.post(`/waits/${UUID}/resolve`, { kind: 'question', answer: 'x' })).statusCode).toBe(404);298 // Expired between the check and the kernel call: the kernel rejects, the server reports the state (409), not a 500.299 const q = await h.kernel.seedWait(runId, 2, 'question', { text: '?' });300 const real = h.kernel.resolveWait.bind(h.kernel);301 h.kernel.resolveWait = async (id, resolution, actor) => {302 await h.db.q("UPDATE waits SET status = 'expired', resolved_at = now() WHERE id = $1", [id]);303 return real(id, resolution, actor); // ⇒ {resolved: false}304 };305 try {306 const raced = await admin.post(`/waits/${q}/resolve`, { kind: 'question', answer: 'x' });307 expect([raced.statusCode, raced.json().error.code]).toEqual([409, 'wait_not_pending']);308 } finally {309 h.kernel.resolveWait = real;310 }311 });312});313314describe('policy rules', () => {315 it('CRUD, global and per project, validated', async () => {316 const g = await admin.post('/policy/rules', { projectId: null, decision: 'deny', tool: 'git_push', match: {}, note: 'never push' });317 expect(g.statusCode).toBe(201);318 const p = await admin.post('/policy/rules', { projectId, decision: 'allow', tool: 'shell', match: { commandPrefix: 'npm test' }, note: '' });319 expect(PolicyRuleDto.parse(p.json())).toMatchObject({ projectId, decision: 'allow', tool: 'shell', match: { commandPrefix: 'npm test' } });320 const star = await admin.post('/policy/rules', { projectId: null, decision: 'ask', tool: '*', match: {} });321 expect(star.json()).toMatchObject({ tool: '*', note: '' });322 const all = (await admin.get('/policy/rules')).json<{ id: string }[]>();323 expect(all.map((r) => r.id).sort()).toEqual([g.json().id, p.json().id, star.json().id].sort());324 expect((await admin.get(`/policy/rules?projectId=${projectId}`)).json()).toHaveLength(1);325 for (const body of [326 { projectId: null, decision: 'maybe', tool: 'shell', match: {} }, { projectId: null, decision: 'allow', tool: 'rm -rf', match: {} },327 { projectId: null, decision: 'allow', tool: '', match: {} }, { decision: 'allow', tool: 'shell', match: {} },328 { projectId: null, decision: 'allow', tool: 'shell', match: { blob: 'x'.repeat(5000) } }, { projectId: null, decision: 'allow', tool: 'shell', match: 'x' },329 ]) expect([(await admin.post('/policy/rules', body)).statusCode, body.tool]).toEqual([400, body.tool]);330 expect((await admin.post('/policy/rules', { projectId: UUID, decision: 'allow', tool: 'shell', match: {} })).statusCode).toBe(404);331 expect((await admin.req('DELETE', `/policy/rules/${g.json().id}`)).json()).toEqual({ ok: true });332 expect((await admin.req('DELETE', `/policy/rules/${g.json().id}`)).statusCode).toBe(404);333 expect((await admin.get('/policy/rules')).json()).toHaveLength(2);334 const actions = (await h.db.q<{ action: string }>("SELECT action FROM audit_events WHERE action LIKE 'policy.%'")).map((r) => r.action);335 expect(actions.filter((a) => a === 'policy.rule.create')).toHaveLength(3);336 expect(actions.filter((a) => a === 'policy.rule.delete')).toHaveLength(1);337 });338});339340describe('spend', () => {341 it('reports real numbers: today vs the cap, seven days, by model', async () => {342 await h.db.q('DELETE FROM spend_days');343 const empty = SpendSummary.parse((await admin.get('/spend')).json());344 expect(empty.todayUsd).toBe(0);345 expect(empty.dailyBudgetUsd).toBe(60);346 expect(empty.last7Days).toHaveLength(7);347 expect(empty.last7Days.every((d) => d.usd === 0 && /^\d{4}-\d{2}-\d{2}$/.test(d.day))).toBe(true);348 await h.db.q('INSERT INTO spend_days (day, usd) VALUES (CURRENT_DATE, 1.25), (CURRENT_DATE - 2, 3.5), (CURRENT_DATE - 30, 99)');349 const { runId } = await seedMission(h, admin, { mission: { model: 'claude-haiku-5-5' } });350 await h.kernel.seedStep(runId, 0, { kind: 'model_call', name: 'claude-haiku-5-5', effect: 'model_call', usage: { usd: 0.5, inputTokens: 1000, outputTokens: 200 } });351 await h.kernel.seedStep(runId, 1, { kind: 'model_call', name: 'claude-haiku-5-5', effect: 'model_call', usage: { usd: 0.75, inputTokens: 3000, outputTokens: 300 } });352 await h.kernel.seedStep(runId, 2, { kind: 'tool_call', name: 'shell', effect: 'read' });353 const s = SpendSummary.parse((await admin.get('/spend')).json());354 expect(s.todayUsd).toBe(1.25);355 expect(s.last7Days.map((d) => d.usd)).toEqual([0, 0, 0, 0, 3.5, 0, 1.25]);356 expect(s.byModel.find((m) => m.model === 'claude-haiku-5-5')).toEqual({ provider: 'anthropic', model: 'claude-haiku-5-5', usd: 1.25, inputTokens: 4000, outputTokens: 500 });357 await h.db.q('DELETE FROM spend_days');358 });359});360361describe('models', () => {362 it('live listing per provider, cached 10 minutes', async () => {363 h.providers.calls = [];364 const a = (await admin.get('/models')).json<ModelInfoEx[]>();365 expect(a.every((m) => ModelInfoEx.safeParse(m).success)).toBe(true);366 expect(a.map((m) => m.id)).toEqual(['claude-opus-5-5', 'claude-haiku-5-5', 'gpt-6.1-sol']);367 expect(a.every((m) => m.available && m.stale === undefined)).toBe(true);368 await admin.get('/models');369 expect(h.providers.calls.sort()).toEqual(['anthropic', 'openai']);370 h.clock.offsetMs += 9 * 60_000;371 await admin.get('/models');372 expect(h.providers.calls).toHaveLength(2);373 h.clock.offsetMs += 2 * 60_000;374 await admin.get('/models');375 expect(h.providers.calls).toHaveLength(4);376 });377 it('a failing, hanging or unconfigured provider falls back to the catalogue: unavailable + stale, no error text', async () => {378 h.clock.offsetMs += 11 * 60_000;379 h.providers.fail.anthropic = true;380 h.config.providerKeys.openai = false;381 h.providers.calls = [];382 const res = await admin.get('/models');383 expect(res.statusCode).toBe(200);384 const list = res.json<ModelInfoEx[]>();385 expect(list.filter((m) => m.provider === 'anthropic').map((m) => [m.id, m.available, m.stale, m.note])).toEqual([386 ['claude-opus-5-5', false, true, 'live model listing unavailable'], ['claude-haiku-5-5', false, true, 'live model listing unavailable'],387 ]);388 expect(list.filter((m) => m.provider === 'openai').map((m) => [m.available, m.stale, m.note])).toEqual([[false, true, 'API key not configured']]);389 expect(h.providers.calls).toEqual(['anthropic']); // no key ⇒ the provider is not even called390 expect(res.body).not.toContain('x-api-key');391 expect(h.logs.join('')).not.toContain(FAKE_ANTHROPIC_KEY);392 // a failure is retried after one minute, not ten393 h.providers.fail.anthropic = false;394 h.config.providerKeys.openai = true;395 h.clock.offsetMs += 61_000;396 expect((await admin.get('/models')).json<ModelInfoEx[]>().every((m) => m.available)).toBe(true);397 // a provider that never answers is cut at the timeout398 h.clock.offsetMs += 11 * 60_000;399 h.providers.hang.openai = true;400 h.config.modelsTimeoutMs = 150;401 const t0 = Date.now();402 const hung = (await admin.get('/models')).json<ModelInfoEx[]>();403 expect(Date.now() - t0).toBeLessThan(3000);404 expect(hung.find((m) => m.provider === 'openai')).toMatchObject({ available: false, stale: true });405 expect(hung.find((m) => m.id === 'claude-opus-5-5')?.available).toBe(true);406 h.providers.hang.openai = false;407 h.clock.offsetMs += 61_000;408 });409});410411describe('system status', () => {412 it('real checks; provider keys as booleans only; no worker has registered ⇒ not alive', async () => {413 await h.db.q("UPDATE runs SET lease_expires_at = NULL, status = 'queued' WHERE status = 'running'");414 const res = await admin.get('/system/status');415 const s = SystemStatus.parse(res.json());416 expect(s.db.ok).toBe(true);417 expect(s.db.latencyMs).toBeGreaterThanOrEqual(0);418 expect(s.version).toBe(h.config.version);419 expect(s.providers).toEqual({ anthropic: true, openai: true });420 expect(s.worker).toEqual({ alive: false, lastSeenAt: null, source: 'workers', workers: [] });421 const expected = await h.db.one<{ queued: number; running: number; waiting: number; waits: number }>(422 `SELECT (SELECT count(*)::int FROM missions WHERE status='queued') AS queued, (SELECT count(*)::int FROM missions WHERE status='running') AS running,423 (SELECT count(*)::int FROM missions WHERE status='waiting') AS waiting, (SELECT count(*)::int FROM waits WHERE status='pending' AND kind IN ('approval','question')) AS waits`);424 expect(s.counts).toEqual({ missionsQueued: expected?.queued, missionsRunning: expected?.running, missionsWaiting: expected?.waiting, pendingWaits: expected?.waits, wsClients: 0 });425 expect(expected?.waiting).toBeGreaterThan(0);426 expect(res.body).not.toContain(FAKE_ANTHROPIC_KEY);427 expect(res.body).not.toContain(FAKE_OPENAI_KEY);428 expect(res.body).not.toMatch(/sk-/);429 h.config.providerKeys.openai = false;430 expect((await admin.get('/system/status')).json().providers).toEqual({ anthropic: true, openai: false });431 h.config.providerKeys.openai = true;432 });433 it('worker liveness: the workers table first, lease heartbeats otherwise', async () => {434 const { runId } = await seedMission(h, admin);435 // lease 90 s, heartbeat 30 s: expiring in 80 s ⇒ last heartbeat 10 s ago ⇒ alive436 await h.db.q("UPDATE runs SET status = 'running', lease_owner = 'w1', lease_expires_at = now() + interval '80 seconds' WHERE id = $1", [runId]);437 let s = SystemStatus.parse((await admin.get('/system/status')).json());438 expect(s.worker).toMatchObject({ alive: true, source: 'leases' });439 expect(Date.now() - Date.parse(s.worker.lastSeenAt as string)).toBeGreaterThan(5000);440 // expiring in 10 s ⇒ last heartbeat 80 s ago ⇒ not alive441 await h.db.q("UPDATE runs SET lease_expires_at = now() + interval '10 seconds' WHERE id = $1", [runId]);442 expect(SystemStatus.parse((await admin.get('/system/status')).json()).worker).toEqual({ alive: false, lastSeenAt: null, source: 'workers', workers: [] });443 // a database without the workers table (older schema): leases only, and "unknown" is null, not false444 await h.db.q('ALTER TABLE workers RENAME TO workers_hidden');445 try {446 expect(SystemStatus.parse((await admin.get('/system/status')).json()).worker).toMatchObject({ alive: false, source: 'leases' });447 await h.db.q("UPDATE runs SET status = 'queued', lease_expires_at = NULL WHERE id = $1", [runId]);448 expect(SystemStatus.parse((await admin.get('/system/status')).json()).worker).toEqual({ alive: null, lastSeenAt: null, source: 'none', workers: [] });449 } finally {450 await h.db.q('ALTER TABLE workers_hidden RENAME TO workers');451 }452 await h.db.q("INSERT INTO workers (id, last_seen, version) VALUES ('host-1', now() - interval '5 seconds', '0.1.0'), ('host-2', now() - interval '5 minutes', '')");453 s = SystemStatus.parse((await admin.get('/system/status')).json());454 expect(s.worker).toMatchObject({ alive: true, source: 'workers' });455 expect(s.worker.workers.map((w) => [w.id, w.version])).toEqual([['host-1', '0.1.0'], ['host-2', null]]);456 await h.db.q("UPDATE workers SET last_seen = now() - interval '61 seconds' WHERE id = 'host-1'");457 expect((await admin.get('/system/status')).json().worker.alive).toBe(false);458 await h.db.q('DELETE FROM workers');459 });460});461462describe('audit, settings, notifications, janitor', () => {463 it('audit is paginated newest-first', async () => {464 const page1 = (await admin.get('/system/audit?limit=3')).json<{ id: string; action: string }[]>();465 expect(page1).toHaveLength(3);466 expect(Number(page1[0]?.id)).toBeGreaterThan(Number(page1[2]?.id));467 const page2 = (await admin.get(`/system/audit?limit=3&before=${page1[2]?.id}`)).json<{ id: string }[]>();468 expect(Number(page2[0]?.id)).toBeLessThan(Number(page1[2]?.id));469 expect((await admin.get('/system/audit?before=1%3BDROP')).statusCode).toBe(400);470 expect((await admin.get("/system/audit?action=auth.setup'%20OR%201=1")).json()).toEqual([]);471 });472 it('settings: AosSettings defaults merged, validated, persisted', async () => {473 const defaults = (await admin.get('/settings')).json();474 expect(defaults).toEqual(AosSettings.parse({}));475 expect(defaults).toMatchObject({ theme: 'light', defaultProvider: 'anthropic', defaultModel: 'claude-opus-5-5', defaultEffort: 'high', missionBudgetUsd: 15, dailyBudgetUsd: 60, approvalTimeoutHours: 72 });476 for (const bad of [{ dailyBudgetUsd: 0 }, { theme: 'neon' }, { defaultProvider: 'mistral' }, { maxConcurrentRuns: 99 }, { approvalTimeoutHours: 0 }]) {477 expect([bad, (await admin.req('PUT', '/settings', { ...defaults, ...bad })).statusCode]).toEqual([bad, 400]);478 }479 const saved = await admin.req('PUT', '/settings', { ...defaults, theme: 'dark', missionBudgetUsd: 3 });480 expect(saved.statusCode).toBe(200);481 expect((await admin.get('/settings')).json()).toMatchObject({ theme: 'dark', missionBudgetUsd: 3, dailyBudgetUsd: 60 });482 await h.db.q(`UPDATE application_settings SET settings = '{"dailyBudgetUsd": "lots"}'`);483 expect((await admin.get('/settings')).json()).toEqual(defaults); // corrupt stored value ⇒ defaults484 });485 it('notifications: in-app only when enabled, detail level respected, list and mark read', async () => {486 const n = h.server.ctx.notifier;487 await h.db.q('DELETE FROM notifications');488 await n.notify(userId, 'system', 'One', 'detail-one', null);489 await n.notify(userId, 'wait.approval', 'Two', 'detail-two', '/mission/?id=x');490 const list = (await admin.get('/notifications')).json<{ id: string; kind: string; title: string; body: string; link: string | null; readAt: string | null }[]>();491 expect(list.map((x) => [x.kind, x.title, x.body, x.link, x.readAt])).toEqual([['wait.approval', 'Two', 'detail-two', '/mission/?id=x', null], ['system', 'One', 'detail-one', null, null]]);492 expect((await admin.post('/notifications/read', { ids: [list[1]?.id] })).json()).toEqual({ ok: true, updated: 1 });493 expect((await admin.get('/notifications?unread=1')).json()).toHaveLength(1);494 expect((await admin.post('/notifications/read', { all: true })).json().updated).toBe(1);495 const s = (await admin.get('/settings')).json();496 await admin.req('PUT', '/settings', { ...s, notifications: { inApp: true, detail: 'generic' } });497 await n.notify(userId, 'system', 'Generic', 'hidden-detail', null);498 expect((await admin.get('/notifications?limit=1')).json()[0]).toMatchObject({ title: 'Generic', body: '' });499 await admin.req('PUT', '/settings', { ...s, notifications: { inApp: false, detail: 'generic' } });500 await n.notify(userId, 'system', 'Never', null, null);501 expect((await admin.get('/notifications')).json()).toHaveLength(3);502 await admin.req('PUT', '/settings', s);503 });504 it('janitor applies the retention windows of the server tables and never touches the journal', async () => {505 const db = h.db;506 await db.q(`INSERT INTO login_attempts (key, ok, at) VALUES ('ip:j', false, now() - interval '31 days'), ('ip:j', false, now() - interval '29 days')`);507 await db.q(`INSERT INTO audit_events (actor, action, at) VALUES ('j', 'old', now() - interval '401 days'), ('j', 'new', now() - interval '399 days')`);508 await db.q(`INSERT INTO notifications (user_id, kind, title, created_at) VALUES ($1, 'system', 'old', now() - interval '91 days')`, [userId]);509 await db.q(`INSERT INTO auth_sessions (user_id, token_hash, csrf_token, expires_at) VALUES ($1, 'old', 'c', now() - interval '8 days'), ($1, 'recent', 'c', now() - interval '6 days')`, [userId]);510 await db.q(`INSERT INTO preauth_challenges (token_hash, challenge, expires_at) VALUES ('a', 'c', now() - interval '1 minute'), ('b', 'c', now() + interval '1 minute')`);511 await db.q("UPDATE events SET ts = now() - interval '900 days'");512 const journal = async (): Promise<unknown> => db.one('SELECT (SELECT count(*)::int FROM events) AS e, (SELECT count(*)::int FROM steps) AS s, (SELECT count(*)::int FROM waits) AS w, (SELECT count(*)::int FROM missions) AS m');513 const before = await journal();514 expect(await runJanitor(db)).toEqual({ login_attempts: 1, audit_events: 1, notifications: 1, auth_sessions: 1, preauth_challenges: 1 });515 expect(await journal()).toEqual(before);516 expect((await admin.get('/missions')).statusCode).toBe(200); // the current session survived517 });518});519