Repository Public
1import fs from 'node:fs';2import path from 'node:path';3import { afterAll, beforeAll, describe, expect, it } from 'vitest';4import { type Client, createHarness, setupAdmin, type Harness } from './testkit/harness.js';56/** The ONLY routes reachable without a session. Adding one here must be a deliberate decision. */7const DOCUMENTED_PUBLIC = [8 'GET /api/v1/health',9 'GET /api/v1/auth/status',10 'POST /api/v1/auth/setup',11 'POST /api/v1/auth/login',12 'POST /api/v1/auth/passkey/login/options',13 'POST /api/v1/auth/passkey/login/verify',14];15const UUID = '00000000-0000-4000-8000-000000000000';1617let h: Harness;18let admin: Client;1920beforeAll(async () => {21 h = await createHarness({22 tweak: (c) => {23 c.webDir = path.join(c.dataDir, 'web');24 fs.mkdirSync(path.join(c.webDir, 'missions'), { recursive: true });25 fs.mkdirSync(path.join(c.webDir, '_next', 'static'), { recursive: true });26 fs.writeFileSync(path.join(c.webDir, 'index.html'), '<html><script>self.__next_f=[]</script><script src="/_next/static/a.js"></script></html>');27 fs.writeFileSync(path.join(c.webDir, 'missions', 'index.html'), '<html><script>window.T=1</script>missions-page</html>');28 fs.writeFileSync(path.join(c.webDir, '404.html'), '<html>missing</html>');29 fs.writeFileSync(path.join(c.webDir, '_next', 'static', 'a.js'), 'console.log(1)');30 fs.writeFileSync(path.join(c.webDir, 'robots.txt'), 'User-agent: *\nDisallow: /\n');31 },32 });33 ({ client: admin } = await setupAdmin(h));34});35afterAll(async () => {36 await h.close();37});3839describe('route table', () => {40 it('every registered API route refuses unauthenticated callers, except the documented public ones', async () => {41 const routes = h.server.ctx.routes.filter((r) => r.url.startsWith('/api/'));42 expect(routes.length).toBeGreaterThan(45);43 const open: string[] = [];44 for (const r of routes) {45 if (r.method === 'HEAD' || r.method === 'OPTIONS') continue;46 const url = r.url.replace(/:[A-Za-z0-9]+/g, UUID);47 const key = `${r.method} ${r.url}`;48 // No cookie; a correct Origin and JSON body so that ONLY authentication can refuse it.49 const res = await h.server.app.inject({50 method: r.method as 'GET', url,51 headers: { origin: 'http://localhost:8640', 'content-type': 'application/json', 'x-csrf-token': 'x' },52 payload: r.method === 'GET' || r.method === 'DELETE' ? undefined : '{}',53 });54 if (res.statusCode !== 401) open.push(key);55 else expect(res.json().error.code).toBe('unauthenticated');56 // A garbage cookie is no better than none.57 const forged = await h.server.app.inject({ method: r.method as 'GET', url, headers: { origin: 'http://localhost:8640', cookie: 'aos_sid=' + 'A'.repeat(43) } });58 if (!DOCUMENTED_PUBLIC.includes(key)) expect([key, forged.statusCode]).toEqual([key, 401]);59 }60 expect(open.sort()).toEqual([...DOCUMENTED_PUBLIC].sort());61 });62 it('the declared auth levels match the documented public list', () => {63 const declared = h.server.ctx.routes64 .filter((r) => r.url.startsWith('/api/') && (r.auth === 'public' || r.auth === 'preauth') && r.method !== 'HEAD')65 .map((r) => `${r.method} ${r.url}`);66 expect(declared.sort()).toEqual([...DOCUMENTED_PUBLIC].sort());67 });68 it('unknown API paths are 404 JSON (401 first when the static wildcard would match)', async () => {69 const res = await h.server.app.inject({ method: 'POST', url: '/api/v1/nope', headers: { origin: 'http://localhost:8640' } });70 expect(res.statusCode).toBe(404);71 expect(res.json().error.code).toBe('not_found');72 const get = await h.server.app.inject({ method: 'GET', url: '/api/v1/nope' });73 expect([401, 404]).toContain(get.statusCode);74 expect((await admin.get('/nope')).statusCode).toBe(404);75 });76 it('health is public and discloses only {ok, version, agent}', async () => {77 const res = await h.server.app.inject({ method: 'GET', url: '/api/v1/health' });78 expect(res.statusCode).toBe(200);79 expect(res.json()).toEqual({ ok: true, version: h.config.version, agent: 'aos' });80 });81 it('auth status carries the single Agent OS brand, also for anonymous callers', async () => {82 const anon = await h.server.app.inject({ method: 'GET', url: '/api/v1/auth/status' });83 expect(anon.json().branding).toEqual({ agent: 'aos', productName: 'Agent OS', agentName: 'Agent' });84 expect((await admin.get('/auth/status')).json().branding).toEqual({ agent: 'aos', productName: 'Agent OS', agentName: 'Agent' });85 });86 it('every mutating route refuses a missing CSRF token and a foreign Origin, even with a full session', async () => {87 const mutating = h.server.ctx.routes.filter((r) => r.url.startsWith('/api/') && r.auth === 'full' && !['GET', 'HEAD', 'OPTIONS'].includes(r.method));88 expect(mutating.length).toBeGreaterThan(12);89 for (const r of mutating) {90 const url = r.url.replace(/:[A-Za-z0-9]+/g, UUID);91 const key = `${r.method} ${r.url}`;92 const base = { method: r.method as 'POST', url, payload: r.method === 'DELETE' ? undefined : '{}' };93 const noCsrf = await h.server.app.inject({ ...base, headers: { cookie: admin.cookieHeader, origin: 'http://localhost:8640', 'content-type': 'application/json' } });94 expect([key, noCsrf.statusCode, noCsrf.json().error.code]).toEqual([key, 403, 'bad_csrf']);95 const badOrigin = await h.server.app.inject({ ...base, headers: { cookie: admin.cookieHeader, origin: 'https://evil.example', 'content-type': 'application/json', 'x-csrf-token': admin.csrf as string } });96 expect([key, badOrigin.statusCode, badOrigin.json().error.code]).toEqual([key, 403, 'bad_origin']);97 const noOrigin = await h.server.app.inject({ ...base, headers: { cookie: admin.cookieHeader, 'content-type': 'application/json', 'x-csrf-token': admin.csrf as string } });98 expect([key, noOrigin.statusCode]).toEqual([key, 403]);99 }100 });101});102103describe('response headers', () => {104 it('API responses are no-store and carry the hardening headers', async () => {105 const res = await admin.get('/settings');106 expect(res.headers['cache-control']).toBe('no-store');107 expect(res.headers['x-content-type-options']).toBe('nosniff');108 expect(res.headers['referrer-policy']).toBe('no-referrer');109 expect(res.headers['strict-transport-security']).toContain('max-age=63072000');110 expect(res.headers['x-frame-options']).toBe('DENY');111 expect(String(res.headers['permissions-policy'])).toContain('camera=()');112 const csp = String(res.headers['content-security-policy']);113 const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';114 expect(script).toBe("script-src 'self' 'wasm-unsafe-eval'");115 expect(csp).toContain("frame-ancestors 'none'");116 const unauth = await h.server.app.inject({ method: 'GET', url: '/api/v1/missions' });117 expect(unauth.headers['cache-control']).toBe('no-store');118 expect(unauth.headers['content-security-policy']).toBeDefined();119 expect(unauth.statusCode).toBe(401);120 const forbidden = await admin.req('PUT', '/settings', {}, { headers: { origin: 'https://evil.example' } });121 expect(forbidden.statusCode).toBe(403);122 expect(forbidden.headers['content-security-policy']).toBeDefined();123 expect(forbidden.headers['x-content-type-options']).toBe('nosniff');124 });125 it('JSON bodies above 1 MiB are refused', async () => {126 const big = JSON.stringify({ projectId: UUID, objective: 'x'.repeat(1024 * 1024 + 10) });127 const res = await admin.req('POST', '/missions', undefined, { raw: big, headers: { 'content-type': 'application/json' } });128 expect(res.statusCode).toBe(413);129 });130});131132describe('static web app', () => {133 it('serves trailing-slash routes with a per-document CSP that hashes its inline scripts', async () => {134 const root = await h.server.app.inject({ method: 'GET', url: '/' });135 expect(root.statusCode).toBe(200);136 expect(root.headers['cache-control']).toBe('no-cache');137 const script = String(root.headers['content-security-policy']).split('; ').find((d) => d.startsWith('script-src')) ?? '';138 expect(script).toMatch(/^script-src 'self' 'wasm-unsafe-eval' 'sha256-[A-Za-z0-9+/=]+'$/);139 expect(script).not.toContain('unsafe-inline');140 const term = await h.server.app.inject({ method: 'GET', url: '/missions/' });141 expect(term.statusCode).toBe(200);142 expect(term.body).toContain('missions-page');143 expect(term.headers['content-security-policy']).not.toBe(root.headers['content-security-policy']);144 const redirect = await h.server.app.inject({ method: 'GET', url: '/missions' });145 expect(redirect.statusCode).toBe(301);146 expect(redirect.headers.location).toBe('/missions/');147 });148 it('immutable caching only under /_next/static, and 404.html for unknown pages', async () => {149 const asset = await h.server.app.inject({ method: 'GET', url: '/_next/static/a.js' });150 expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');151 expect((await h.server.app.inject({ method: 'GET', url: '/robots.txt' })).headers['cache-control']).not.toContain('immutable');152 const missing = await h.server.app.inject({ method: 'GET', url: '/nope/' });153 expect(missing.statusCode).toBe(404);154 expect(missing.body).toContain('missing');155 expect(missing.headers['content-type']).toContain('text/html');156 const traversal = await h.server.app.inject({ method: 'GET', url: '/..%2f..%2fsetup-token' });157 expect(traversal.statusCode).toBeGreaterThanOrEqual(400);158 expect(traversal.body).not.toMatch(/^[A-Za-z0-9_-]{43}$/);159 });160});161162describe('OpenAPI', () => {163 it('lists every registered route with its auth requirement', async () => {164 const doc = (await admin.get('/openapi.json')).json<{ paths: Record<string, Record<string, { 'x-auth': string; requestBody?: unknown }>>; components: { schemas: Record<string, unknown> } }>();165 for (const r of h.server.ctx.routes.filter((x) => x.url.startsWith('/api/v1') && x.method !== 'HEAD' && x.method !== 'OPTIONS')) {166 const op = doc.paths[r.url.replace(/:([A-Za-z0-9_]+)/g, '{$1}')]?.[r.method.toLowerCase()];167 expect(op, `${r.method} ${r.url}`).toBeDefined();168 expect(op?.['x-auth']).toBe(r.auth);169 }170 expect(doc.paths['/api/v1/missions']?.post?.requestBody).toBeDefined();171 expect((doc as unknown as { info: { title: string } }).info.title).toBe('Agent OS API');172 for (const name of ['CreateMission', 'Mission', 'RunList', 'StepList', 'ResolveWait', 'PolicyRule', 'SpendSummary', 'SystemStatus', 'ModelInfoList', 'AosSettings', 'ApiError', 'LoginStep']) {173 expect(doc.components.schemas[name], name).toBeDefined();174 }175 });176});177