Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import type { AgentTask, Project } from '@cr/shared';3import { Client, createHarness, setupAdmin, until, type Harness } from '../../testkit/harness.js';45let h: Harness;6let admin: Client;7let project: Project;8const UUID = '00000000-0000-4000-8000-000000000000';9const audits = async (action: string): Promise<{ target: string | null; ok: boolean; detail: Record<string, unknown> }[]> =>10 h.server.ctx.db.q('SELECT target, ok, detail FROM audit_events WHERE action = $1 ORDER BY id', [action]);1112beforeAll(async () => {13 h = await createHarness({ env: { CR_EXEC_HOSTS: 'M3U96a, M2U64' } });14 ({ client: admin } = await setupAdmin(h));15});16afterAll(async () => {17 await h.close();18});1920describe('projects', () => {21 it('lists workspace roots by label only', async () => {22 const res = await admin.get('/workspace/roots');23 expect(res.json()).toEqual([{ label: 'ws' }]);24 expect(res.body).not.toContain(h.workspace);25 });26 it('create mode makes the directory through the runtime and never exposes absolute paths', async () => {27 const res = await admin.post('/projects', { name: 'My Project', mode: 'create', tags: ['a'] });28 expect(res.statusCode, res.body).toBe(201);29 project = res.json<Project>();30 expect(project).toMatchObject({ name: 'My Project', rootLabel: 'ws', path: 'my-project', gitBranch: 'main', gitDirty: false, activeSessions: 0, tags: ['a'] });31 expect(res.body).not.toContain(h.workspace);32 expect(h.rt.files.get(`${h.workspace}/my-project`)).toBeNull();33 const call = h.rt.calls.find((c) => c.url === '/fs/op');34 expect(call?.body).toEqual({ root: h.workspace, op: 'mkdir', path: 'my-project' });35 expect((await admin.post('/projects', { name: 'dup', mode: 'existing', path: 'my-project' })).statusCode).toBe(409);36 });37 it.each([38 ['..'], ['../escape'], ['a/../../b'], ['/etc'], ['/'], ['a\u0000b'], ['~/x'],39 ])('rejects path %j before anything reaches the runtime', async (p) => {40 const before = h.rt.calls.length;41 for (const mode of ['create', 'existing', 'clone']) {42 const res = await admin.post('/projects', { name: 'x', mode, path: p, gitUrl: 'https://example.org/r.git' });43 expect(res.statusCode).toBe(400);44 expect(res.json().error.code).toBe('invalid_path');45 }46 expect(h.rt.calls.length).toBe(before);47 });48 it('existing mode requires the directory; unknown roots are refused', async () => {49 expect((await admin.post('/projects', { name: 'ghost', mode: 'existing', path: 'does-not-exist' })).statusCode).toBe(400);50 expect((await admin.post('/projects', { name: 'x', mode: 'create', rootLabel: 'nope' })).statusCode).toBe(400);51 h.rt.mkdir(`${h.workspace}/already`);52 const res = await admin.post('/projects', { name: 'Already', mode: 'existing', path: 'already' });53 expect(res.statusCode).toBe(201);54 });55 it('clone accepts only safe URL forms and scrubs credentials', async () => {56 for (const gitUrl of ['--upload-pack=touch /tmp/x', '-oProxyCommand=x', 'file:///etc', 'http://example.org/r.git', 'ext::sh -c id', '']) {57 const res = await admin.post('/projects', { name: 'c', mode: 'clone', path: 'c1', gitUrl });58 expect([gitUrl, res.statusCode]).toEqual([gitUrl, 400]);59 }60 expect(h.rt.calls.some((c) => c.url === '/git/clone')).toBe(false);61 const res = await admin.post('/projects', { name: 'cloned', mode: 'clone', path: 'cloned', gitUrl: 'https://user:tok3n@example.org/r.git' });62 expect(res.statusCode, res.body).toBe(201);63 expect(res.json().gitRemote).toBe('https://example.org/r.git');64 expect(res.body).not.toContain('tok3n');65 expect(JSON.stringify(await audits('project.create'))).not.toContain('tok3n');66 });67 it('list tolerates runtime errors (git ⇒ null) and counts live sessions', async () => {68 const s = (await admin.post('/sessions', { projectId: project.id, kind: 'shell', cwd: 'sub' })).json<{ id: string; projectName: string }>();69 expect(s.projectName).toBe('My Project');70 expect(h.rt.specs.get(s.id)?.cwd).toBe(`${h.workspace}/my-project/sub`);71 const list = (await admin.get('/projects')).json<Project[]>();72 expect(list.find((p) => p.id === project.id)?.activeSessions).toBe(1);73 await h.rt.stop();74 const h2list = await admin.get('/projects'); // cached git info is still served75 expect(h2list.statusCode).toBe(200);76 await h.rt.start();77 await admin.post(`/sessions/${s.id}/terminate`);78 });79 it('update, markup round-trip, delete unregisters only', async () => {80 const name = '<script>alert(1)</script>';81 const up = await admin.req('PATCH', `/projects/${project.id}`, { name, description: '"><img onerror=x>', pinned: true });82 expect(up.json()).toMatchObject({ name, description: '"><img onerror=x>', pinned: true });83 expect(up.headers['content-type']).toContain('application/json');84 await admin.req('PATCH', `/projects/${project.id}`, { name: 'My Project' });85 const tmp = (await admin.post('/projects', { name: 'Temp', mode: 'create' })).json<Project>();86 const before = h.rt.calls.length;87 expect((await admin.req('DELETE', `/projects/${tmp.id}`)).statusCode).toBe(200);88 expect(h.rt.calls.length).toBe(before); // no runtime call: files are never deleted89 expect(h.rt.files.has(`${h.workspace}/temp`)).toBe(true);90 expect((await admin.get(`/projects/${tmp.id}`)).statusCode).toBe(404);91 expect((await admin.get(`/projects/${project.id}/conversations`)).json()).toHaveLength(1);92 });93});9495describe('files', () => {96 const root = (): string => `${h.workspace}/my-project`;97 it('write / read / list map onto the project root, with sha conflict detection', async () => {98 const w = await admin.req('PUT', `/projects/${project.id}/file`, { path: 'src/a.txt', content: 'hello', create: true });99 expect(w.statusCode, w.body).toBe(200);100 expect(h.rt.files.get(`${root()}/src/a.txt`)?.toString()).toBe('hello');101 const r = (await admin.get(`/projects/${project.id}/file?path=src/a.txt`)).json<{ content: string; sha256: string }>();102 expect(r.content).toBe('hello');103 const stale = await admin.req('PUT', `/projects/${project.id}/file`, { path: 'src/a.txt', content: 'x', baseSha256: '0'.repeat(64) });104 expect(stale.statusCode).toBe(409);105 expect((await admin.req('PUT', `/projects/${project.id}/file`, { path: 'src/a.txt', content: 'v2', baseSha256: r.sha256 })).statusCode).toBe(200);106 h.rt.mkdir(`${root()}/src`);107 expect((await admin.get(`/projects/${project.id}/files?path=src`)).json()).toEqual([{ name: 'a.txt', path: 'src/a.txt', type: 'file', size: 2, mtimeMs: 1 }]);108 expect((await admin.get(`/projects/${project.id}/search?q=a.txt`)).json()).toEqual([{ path: 'src/a.txt', line: null, preview: null }]);109 expect((await admin.get(`/projects/${project.id}/git/status`)).json().isRepo).toBe(true);110 expect((await admin.get(`/projects/${project.id}/file?path=missing.txt`)).statusCode).toBe(404);111 });112 it('rejects traversal on every file endpoint without calling the runtime', async () => {113 const before = h.rt.calls.length;114 const id = project.id;115 const bad = encodeURIComponent('../../etc/passwd');116 const results = await Promise.all([117 admin.get(`/projects/${id}/files?path=${bad}`),118 admin.get(`/projects/${id}/file?path=${bad}`),119 admin.get(`/projects/${id}/download?path=${bad}`),120 admin.get(`/projects/${id}/git/diff?path=${bad}`),121 admin.get(`/projects/${id}/file?path=${encodeURIComponent('/etc/passwd')}`),122 admin.get(`/projects/${id}/file?path=a%00b`),123 admin.req('PUT', `/projects/${id}/file`, { path: '../x', content: '' }),124 admin.post(`/projects/${id}/fs`, { op: 'mkdir', path: '../x' }),125 admin.post(`/projects/${id}/fs`, { op: 'rename', from: 'a', to: '../../b' }),126 admin.post(`/projects/${id}/fs`, { op: 'delete', path: '..', confirm: true }),127 admin.req('POST', `/projects/${id}/upload?path=${bad}`, undefined, { raw: Buffer.from('x'), headers: { 'content-type': 'application/octet-stream' } }),128 ]);129 expect(results.map((r) => r.statusCode)).toEqual(results.map(() => 400));130 expect(h.rt.calls.length).toBe(before);131 expect((await admin.post(`/projects/${id}/fs`, { op: 'delete', path: 'src/a.txt' })).statusCode).toBe(400); // confirm:true is mandatory132 expect((await admin.get(`/projects/${UUID}/files`)).statusCode).toBe(404);133 });134 it('upload and download stream binary intact; download is an attachment; audit rows carry paths only', async () => {135 const payload = Buffer.alloc(3 * 1024 * 1024);136 for (let i = 0; i < payload.length; i++) payload[i] = (i * 31) & 0xff;137 const up = await admin.req('POST', `/projects/${project.id}/upload?path=bin/data.bin`, undefined, { raw: payload, headers: { 'content-type': 'application/octet-stream' } });138 expect(up.statusCode, up.body).toBe(201);139 expect(h.rt.files.get(`${root()}/bin/data.bin`)?.equals(payload)).toBe(true);140 // a JSON-typed upload is stored verbatim, not parsed141 const json = await admin.req('POST', `/projects/${project.id}/upload?path=cfg.json`, undefined, { raw: '{"a": 1 }', headers: { 'content-type': 'application/json' } });142 expect(json.statusCode).toBe(201);143 expect(h.rt.files.get(`${root()}/cfg.json`)?.toString()).toBe('{"a": 1 }');144 const down = await admin.get(`/projects/${project.id}/download?path=bin/data.bin`);145 expect(down.statusCode).toBe(200);146 expect(down.rawPayload.equals(payload)).toBe(true);147 expect(down.headers['content-disposition']).toBe(`attachment; filename="data.bin"; filename*=UTF-8''data.bin`);148 expect(down.headers['content-type']).toBe('application/octet-stream');149 h.rt.files.set(`${root()}/we"ird\nname é.html`, Buffer.from('<script>1</script>'));150 const odd = await admin.get(`/projects/${project.id}/download?path=${encodeURIComponent('we"ird\nname é.html')}`);151 expect(odd.headers['content-disposition']).toBe(`attachment; filename="we_ird_name _.html"; filename*=UTF-8''we%22ird%0Aname%20%C3%A9.html`);152 expect(odd.headers['content-type']).toBe('application/octet-stream'); // never rendered as HTML153 await admin.post(`/projects/${project.id}/fs`, { op: 'rename', from: 'cfg.json', to: 'cfg2.json' });154 await admin.post(`/projects/${project.id}/fs`, { op: 'delete', path: 'cfg2.json', confirm: true });155 expect(h.rt.files.has(`${root()}/cfg2.json`)).toBe(false);156 expect((await audits('fs.upload')).map((a) => a.detail)).toEqual([{ path: 'bin/data.bin' }, { path: 'cfg.json' }]);157 expect((await audits('fs.write')).filter((a) => a.ok).map((a) => a.detail)).toEqual([{ path: 'src/a.txt' }, { path: 'src/a.txt' }]);158 expect((await audits('fs.op')).map((a) => a.detail)).toEqual([{ op: 'rename', from: 'cfg.json', to: 'cfg2.json' }, { op: 'delete', path: 'cfg2.json' }]);159 const all = JSON.stringify(await h.server.ctx.db.q('SELECT detail FROM audit_events'));160 expect(all).not.toContain('hello');161 });162});163164describe('tasks', () => {165 const mk = async (body: Record<string, unknown> = {}): Promise<AgentTask> => {166 const res = await admin.post('/tasks', { title: 'build', command: 'make all', ...body });167 expect(res.statusCode, res.body).toBe(201);168 return res.json<AgentTask>();169 };170 const get = async (id: string): Promise<AgentTask> => (await admin.get(`/tasks/${id}`)).json<AgentTask>();171 beforeAll(async () => {172 h.server.startBackground({ timers: false });173 await until(() => h.rt.eventSubscribers === 1);174 });175176 it('create ⇒ QUEUED row ⇒ runtime ⇒ RUNNING', async () => {177 const t = await mk({ projectId: project.id, cwd: 'src', timeoutSec: 60 });178 expect(t).toMatchObject({ state: 'RUNNING', host: 'local', cwd: 'src', exitCode: null, retryOf: null, idempotent: false, timeoutSec: 60 });179 expect(t.startedAt).not.toBeNull();180 expect(h.rt.taskSpecs.get(t.id)).toMatchObject({ id: t.id, command: 'make all', cwd: `${h.workspace}/my-project/src`, host: 'local', timeoutSec: 60 });181 expect(JSON.stringify(t)).not.toContain(h.workspace);182 });183 it('host allowlist: local or CR_EXEC_HOSTS, anything else is 403 and never reaches the runtime', async () => {184 const ok = await mk({ host: 'M3U96a' });185 expect(h.rt.taskSpecs.get(ok.id)?.host).toBe('M3U96a');186 for (const host of ['M5M36', 'evil.example', '-oProxyCommand=x', 'm3u96a']) {187 const before = h.rt.taskSpecs.size;188 const res = await admin.post('/tasks', { title: 'x', command: 'id', host });189 expect([host, res.statusCode]).toEqual([host, 403]);190 expect(res.json().error.code).toBe('host_not_allowed');191 expect(h.rt.taskSpecs.size).toBe(before);192 }193 expect((await admin.post('/tasks', { title: 'x', command: 'id', cwd: '../..' })).statusCode).toBe(400);194 });195 it('runtime events drive the state; success collects artifacts and notifies', async () => {196 const t = await mk({ projectId: project.id, artifactGlobs: ['out/*.csv', 'report.pdf', '../secret', 'deep/**/*.log'] });197 const cwd = `${h.workspace}/my-project`;198 for (const d of ['out', 'out/nested', 'deep', 'deep/a', 'deep/a/b']) h.rt.mkdir(`${cwd}/${d}`);199 h.rt.files.set(`${cwd}/out/a.csv`, Buffer.from('1,2'));200 h.rt.files.set(`${cwd}/out/b.txt`, Buffer.from('no'));201 h.rt.files.set(`${cwd}/out/nested/c.csv`, Buffer.from('not matched by out/*.csv'));202 h.rt.files.set(`${cwd}/report.pdf`, Buffer.from('%PDF'));203 h.rt.files.set(`${cwd}/deep/a/b/x.log`, Buffer.from('log'));204 h.rt.emit({ type: 'task.updated', task: { id: t.id, state: 'SUCCEEDED', exitCode: 0, startedAtMs: Date.now() - 1000, endedAtMs: Date.now(), logBytes: 42, cpuSec: 0.5, maxRssMb: 12 } });205 // The terminal state is committed first; artifacts and the notification follow (then task.updated).206 await until(async () => (await get(t.id)).state === 'SUCCEEDED' && (await get(t.id)).artifacts.length === 3);207 const done = await get(t.id);208 expect(done).toMatchObject({ exitCode: 0, logBytes: 42, cpuSec: 0.5, maxRssMb: 12 });209 expect(done.endedAt).not.toBeNull();210 expect(done.artifacts.map((a) => [a.name, a.size])).toEqual([['a.csv', 3], ['report.pdf', 4], ['x.log', 3]]);211 const art = done.artifacts.find((a) => a.name === 'a.csv') as { id: string };212 const dl = await admin.get(`/tasks/${t.id}/artifacts/${art.id}`);213 expect(dl.body).toBe('1,2');214 expect(dl.headers['content-disposition']).toContain('attachment; filename="a.csv"');215 expect((await admin.get(`/tasks/${t.id}/artifacts/${UUID}`)).statusCode).toBe(404);216 const listNotes = async (): Promise<{ kind: string; body: string }[]> => (await admin.get('/notifications')).json();217 await until(async () => (await listNotes()).some((n) => n.kind === 'task.succeeded'));218 expect((await listNotes()).find((n) => n.kind === 'task.succeeded')).toMatchObject({ body: '' });219 // a late/duplicate event cannot move a finished task220 h.rt.emit({ type: 'task.updated', task: { id: t.id, state: 'FAILED', exitCode: 1, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });221 await new Promise((r) => setTimeout(r, 150));222 expect((await get(t.id)).state).toBe('SUCCEEDED');223 });224 it('failure and timeout are recorded with a notification', async () => {225 const a = await mk();226 const b = await mk();227 h.rt.emit({ type: 'task.updated', task: { id: a.id, state: 'FAILED', exitCode: 2, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });228 h.rt.emit({ type: 'task.updated', task: { id: b.id, state: 'TIMED_OUT', exitCode: null, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });229 await until(async () => (await get(a.id)).state === 'FAILED' && (await get(b.id)).state === 'TIMED_OUT');230 expect((await get(a.id)).exitCode).toBe(2);231 await until(async () => (await admin.get('/notifications')).json<{ kind: string }[]>().filter((n) => n.kind === 'task.failed').length >= 2);232 });233 it('cancel', async () => {234 const t = await mk();235 const res = await admin.post(`/tasks/${t.id}/cancel`);236 expect(res.json().state).toBe('CANCELLED');237 expect(h.rt.tasks.get(t.id)?.state).toBe('CANCELLED');238 expect((await admin.post(`/tasks/${t.id}/cancel`)).statusCode).toBe(409);239 });240 it('retry only when idempotent and finished; creates a new row with retryOf; never automatic', async () => {241 const plain = await mk();242 await admin.post(`/tasks/${plain.id}/cancel`);243 expect((await admin.post(`/tasks/${plain.id}/retry`)).statusCode).toBe(409);244 const idem = await mk({ idempotent: true, title: 'again' });245 expect((await admin.post(`/tasks/${idem.id}/retry`)).statusCode).toBe(409); // still running246 h.rt.emit({ type: 'task.updated', task: { id: idem.id, state: 'FAILED', exitCode: 1, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });247 await until(async () => (await get(idem.id)).state === 'FAILED');248 const count = async (): Promise<number> => (await h.server.ctx.db.one<{ n: number }>("SELECT count(*)::int AS n FROM agent_tasks WHERE title = 'again'"))?.n ?? 0;249 await h.server.reconcile();250 expect(await count()).toBe(1); // a failure alone never re-runs anything251 const retry = await admin.post(`/tasks/${idem.id}/retry`);252 expect(retry.statusCode).toBe(201);253 const next = retry.json<AgentTask>();254 expect(next).toMatchObject({ retryOf: idem.id, state: 'RUNNING', idempotent: true, title: 'again' });255 expect(next.id).not.toBe(idem.id);256 expect(h.rt.taskSpecs.get(next.id)?.command).toBe('make all');257 expect(await count()).toBe(2);258 });259 it('reconciler: runtime down ⇒ unchanged; runtime LOST or task unknown ⇒ LOST', async () => {260 const a = await mk();261 const b = await mk();262 await h.rt.stop();263 expect(await h.server.reconcile()).toBe(false);264 expect((await get(a.id)).state).toBe('RUNNING');265 expect((await admin.post(`/tasks/${a.id}/cancel`)).statusCode).toBe(503);266 expect((await get(a.id)).state).toBe('RUNNING');267 await h.rt.start();268 await until(() => h.rt.eventSubscribers === 1, 15_000);269 (h.rt.tasks.get(a.id) as { state: string }).state = 'LOST';270 h.rt.tasks.delete(b.id);271 expect(await h.server.reconcile()).toBe(true);272 expect((await get(a.id)).state).toBe('LOST');273 expect((await get(b.id)).state).toBe('LOST');274 expect((await h.server.ctx.db.q("SELECT 1 FROM agent_tasks WHERE retry_of = ANY($1::uuid[])", [[a.id, b.id]])).length).toBe(0);275 });276 it('log proxy honours the offset and passes X-Log-Size; commands are never logged or audited', async () => {277 const t = await mk({ command: 'echo SUPER-SECRET-COMMAND' });278 h.rt.taskLogs.set(t.id, Buffer.from('0123456789'));279 const res = await admin.get(`/tasks/${t.id}/log?offset=4`);280 expect(res.body).toBe('456789');281 expect(res.headers['x-log-size']).toBe('10');282 expect(res.headers['content-type']).toBe('application/octet-stream');283 expect((await admin.get(`/tasks/${UUID}/log`)).statusCode).toBe(404);284 await new Promise((r) => setTimeout(r, 50));285 expect(h.logs.join('')).not.toContain('SUPER-SECRET-COMMAND');286 expect(JSON.stringify(await h.server.ctx.db.q('SELECT detail, target FROM audit_events'))).not.toContain('SUPER-SECRET-COMMAND');287 expect((await admin.get('/tasks?limit=3')).json()).toHaveLength(3);288 });289});290