SPB Git

Repository Public

missions.test.ts 39.7 KB 519 lines typescript

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

Theme