// POST /mcp and POST /api/jobs against a real Express server — the auth // boundary between a person's token and one agent's session token, and the // round trip that puts a card on the board. import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; import express from 'express'; import { createServer } from 'http'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { basename, join } from 'path'; const REPO = mkdtempSync(join(tmpdir(), 'a007-mcprepo-')); const REPO2 = mkdtempSync(join(tmpdir(), 'a007-mcprepo2-')); const USERS = join(mkdtempSync(join(tmpdir(), 'a007-mcpusers-')), 'users.json'); process.env.AGENT007_USERS_PATH = USERS; const { config, sessions } = await import('../server/state.js'); const { setupRoutes } = await import('../server/http.js'); const { allJobs, boardSettings, updateSettings, closeJobForAgent } = await import('../server/jobs.js'); const { mintAgentToken, hashToken } = await import('../server/auth.js'); const { dropMessages } = await import('../server/messages.js'); const AGENT_TOKEN = mintAgentToken(); const broadcasts = []; const killed = []; let baseUrl; const server = createServer((() => { const app = express(); setupRoutes(app, mkdtempSync(join(tmpdir(), 'a007-static-')), { broadcast: (msg) => broadcasts.push(msg), killSession: async (id) => { killed.push(id); }, }); return app; })()); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; afterAll(() => { server.close(); rmSync(USERS, { force: true }); }); // No users file => auth disabled, which is the default single-player shape. function noUsers() { rmSync(USERS, { force: true }); } function withUser() { const token = 'usertoken-abcdefghijklmnop'; writeFileSync(USERS, JSON.stringify([ { id: 'u1', displayName: 'Bill', tokenHash: hashToken(token), color: '#fff' }, ])); return token; } beforeEach(() => { config.repos = [{ path: REPO }, { path: REPO2 }]; config.jobs = []; config.jobBoard = null; boardSettings(); sessions.clear(); sessions.set('session-1', { id: 'session-1', name: 'Onyx', repoPath: REPO, ownerId: null, exited: false, agentToken: AGENT_TOKEN, }); broadcasts.length = 0; noUsers(); }); const rpc = (body, token = AGENT_TOKEN) => fetch(`${baseUrl}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }); const callNamed = (name, args, token = AGENT_TOKEN) => rpc({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args }, }, token); const callTool = (args, token = AGENT_TOKEN) => callNamed('post_job', args, token); // What an agent actually reads back: the text, and whether it was a refusal. // One read of the body — a Response can only be consumed once. const toolResult = async (res) => { const { result } = await res.json(); return { text: result.content[0].text, failed: result.isError }; }; const toolText = async (res) => (await toolResult(res)).text; describe('who may reach /mcp', () => { it('turns away a caller with no token, even with auth off', () => { // Unlike /api, this endpoint is not relaxed in single-player: the token is // not keeping strangers out of loopback, it is the only thing that says // WHICH agent is calling. Without it there is nobody to attribute a card to. return rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, null) .then(res => expect(res.status).toBe(401)); }); it('turns away a token belonging to a session that has exited', async () => { sessions.get('session-1').exited = true; expect((await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).status).toBe(401); }); it('turns away a token for a session that is simply gone', async () => { sessions.clear(); expect((await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).status).toBe(401); }); it('does not accept a user token here', async () => { const userToken = withUser(); expect((await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, userToken)).status).toBe(401); }); it('rejects a cross-origin call from a browser page', async () => { const res = await fetch(`${baseUrl}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AGENT_TOKEN}`, Origin: 'https://evil.example', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), }); expect(res.status).toBe(403); }); }); describe('the handshake over HTTP', () => { it('answers initialize and lists the tool', async () => { const init = await (await rpc({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18' } })).json(); expect(init.result.serverInfo.name).toBe('agent-007-board'); const list = await (await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).json(); expect(list.result.tools.map(t => t.name)).toEqual(['post_job', 'list_jobs', 'read_job', 'edit_job', 'finish_job', 'list_agents', 'send_message', 'withdraw_message']); }); it('answers a notification with 202 and an empty body', async () => { const res = await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' }); expect(res.status).toBe(202); expect(await res.text()).toBe(''); }); it('rejects an oversized body as JSON with a 413', async () => { // A detail longer than the cap is the realistic way to hit this — an agent // piping a whole log file into the tool. Express's default handler would // answer an API client with an HTML error page. const res = await fetch(`${baseUrl}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AGENT_TOKEN}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'post_job', arguments: { title: 'x', detail: 'y'.repeat(200_000) } }, }), }); expect(res.status).toBe(413); expect((await res.json()).error).toMatch(/too large/i); expect(allJobs()).toHaveLength(0); }); it('answers a malformed body as JSON, not as an HTML error page', async () => { const res = await fetch(`${baseUrl}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AGENT_TOKEN}` }, body: '{not json', }); expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/Invalid JSON/); }); }); describe('posting a job through the tool', () => { it('lands a card in To do, credited to the agent that typed it', async () => { const res = await callTool({ title: 'Add rate limiting', detail: 'Token bucket.' }); expect(res.status).toBe(200); const body = await res.json(); expect(body.result.isError).toBe(false); expect(allJobs()).toHaveLength(1); const job = allJobs()[0]; expect(job.title).toBe('Add rate limiting'); expect(job.state).toBe('todo'); expect(job.postedByAgent).toBe('Onyx'); expect(job.repoPath).toBe(REPO); }); it('defaults to the repo that agent is working in', async () => { await callTool({ title: 'No repo named' }); expect(allJobs()[0].repoPath).toBe(REPO); }); it('reaches another configured repo by folder name', async () => { // Deliberate: an agent noticing work elsewhere is the case this exists for. await callTool({ title: 'Elsewhere', repo: basename(REPO2) }); expect(allJobs()[0].repoPath).toBe(REPO2); }); it('refuses a repo the board does not know, instead of queueing a job that never runs', async () => { const body = await (await callTool({ title: 'Nowhere', repo: '/not/a/repo' })).json(); expect(body.result.isError).toBe(true); expect(body.result.content[0].text).toMatch(/Unknown repository/); expect(allJobs()).toHaveLength(0); }); it('refuses an empty title', async () => { const body = await (await callTool({ title: ' ' })).json(); expect(body.result.isError).toBe(true); expect(allJobs()).toHaveLength(0); }); it('does not stringify a non-string title into the card', async () => { // This body comes off the wire; {} would otherwise land as "[object Object]". const body = await (await callTool({ title: { nope: 1 } })).json(); expect(body.result.isError).toBe(true); expect(allJobs()).toHaveLength(0); }); it('carries postedByAgent out to the client on the board payload', async () => { // The seam between the stored job and the rendered card. jobsPayload spreads // the job today, but if it ever grows a field whitelist the "via " // credit would vanish silently and both other tests would still pass. await callTool({ title: 'Add rate limiting' }); const payload = broadcasts.find(m => m.type === 'jobs-list'); expect(payload.jobs[0].postedByAgent).toBe('Onyx'); }); it('repaints every open board and announces the card', async () => { // The user may be looking at a terminal, not the board tab. await callTool({ title: 'Add rate limiting' }); expect(broadcasts.some(m => m.type === 'jobs-list')).toBe(true); const note = broadcasts.find(m => m.type === 'notification'); expect(note.message).toContain('Onyx posted a job'); }); it('warns the agent when the dispatcher is stopped', async () => { updateSettings({ running: false }, () => {}); const body = await (await callTool({ title: 'Queued but idle' })).json(); expect(body.result.content[0].text).toMatch(/dispatcher is stopped/); }); it('credits the human who owns the terminal', async () => { withUser(); sessions.get('session-1').ownerId = 'u1'; await callTool({ title: 'On behalf of Bill' }); const job = allJobs()[0]; expect(job.postedByName).toBe('Bill'); expect(job.postedByAgent).toBe('Onyx'); }); }); describe('POST /api/jobs — the door for agents that cannot take an MCP server', () => { const post = (body, token) => fetch(`${baseUrl}/api/jobs`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }); it('accepts an agent token and returns the created card', async () => { const res = await post({ title: 'From a plain HTTP agent' }, AGENT_TOKEN); expect(res.status).toBe(201); const body = await res.json(); expect(body.job.title).toBe('From a plain HTTP agent'); expect(body.repoName).toBe(basename(REPO)); expect(allJobs()).toHaveLength(1); }); it('rejects a bad repo with a 400 rather than a tool-shaped error', async () => { const res = await post({ title: 'x', repo: '/nope' }, AGENT_TOKEN); expect(res.status).toBe(400); }); it('needs a credential once users exist', async () => { withUser(); expect((await post({ title: 'x' }, null)).status).toBe(401); }); it('carries the agent field through, and refuses one it does not know with a 400', async () => { // No schema at this door, unlike the MCP tool's enum, so the allowlist in // lib/jobs.js is the only thing between the body and the argv. const res = await post({ title: 'On codex', agent: 'codex' }, AGENT_TOKEN); expect(res.status).toBe(201); expect((await res.json()).job.agent).toBe('codex'); const bad = await post({ title: 'x', agent: 'gemini' }, AGENT_TOKEN); expect(bad.status).toBe(400); expect((await bad.json()).error).toMatch(/Unknown agent/); expect(allJobs()).toHaveLength(1); }); }); describe('the CLI a posted card runs on', () => { it('defaults to the calling session\'s own, and an explicit agent overrides it', async () => { sessions.get('session-1').command = 'codex --model o3'; await callTool({ title: 'Same as me' }); await callTool({ title: 'But this one on claude', agent: 'claude' }); expect(allJobs().map(j => j.agent)).toEqual(['codex', 'claude']); }); it('is readable back through list_jobs and read_job, and only when it is codex', async () => { sessions.get('session-1').command = 'codex'; await callTool({ title: 'On codex' }); await callTool({ title: 'On claude', agent: 'claude' }); const [codex, claude] = allJobs(); const list = await toolText(await callNamed('list_jobs', {})); expect(list).toMatch(/On codex\n\s+\S+ · codex/); expect(list).not.toMatch(/On claude\n\s+\S+ · codex/); expect(await toolText(await callNamed('read_job', { id: codex.id }))).toContain('runs on: codex'); expect(await toolText(await callNamed('read_job', { id: claude.id }))).not.toContain('runs on:'); }); }); describe('the agent token reaches nothing else', () => { it('cannot browse the filesystem', async () => { // The gate ordering is the access-control decision: /api/browse sits below // requireUser, so an agent token is identified and then refused. If this // ever returns 200, a route has drifted above the gate. withUser(); const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`, { headers: { Authorization: `Bearer ${AGENT_TOKEN}` }, }); expect(res.status).toBe(403); expect((await res.json()).error).toMatch(/needs a user token/); }); it('still tells an anonymous caller they are unauthenticated, not forbidden', async () => { withUser(); expect((await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`)).status).toBe(401); }); it('lets a real user through', async () => { const userToken = withUser(); const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`, { headers: { Authorization: `Bearer ${userToken}` }, }); expect(res.status).toBe(200); }); }); describe('reading and editing the board through the tools', () => { // The ids are generated, so every test here starts by posting through the // same door an agent would and reading the id back off the board. const post = async (args) => { await callTool(args); return allJobs()[allJobs().length - 1]; }; it('lists the board grouped by column, with the id needed to act on a card', async () => { const todo = await post({ title: 'Add rate limiting' }); const running = await post({ title: 'Already going' }); running.state = 'in-progress'; running.agentName = 'Slate'; const text = await toolText(await callNamed('list_jobs', {})); expect(text).toMatch(/2 card\(s\)/); expect(text).toMatch(/To do \(1\)[\s\S]*Add rate limiting/); expect(text).toMatch(/In progress \(1\)[\s\S]*Already going/); expect(text).toContain(todo.id); expect(text).toContain('Slate'); }); it('leaves finished cards off the board but says they are there', async () => { const done = await post({ title: 'Long since merged' }); done.state = 'done'; const board = await toolText(await callNamed('list_jobs', {})); expect(board).not.toContain('Long since merged'); expect(board).toMatch(/1 finished card\(s\) are archived/); const archive = await toolText(await callNamed('list_jobs', { state: 'done' })); expect(archive).toContain('Long since merged'); }); it('filters by repo, by folder name, the way posting does', async () => { await post({ title: 'Here' }); await post({ title: 'Elsewhere', repo: basename(REPO2) }); const text = await toolText(await callNamed('list_jobs', { repo: basename(REPO2) })); expect(text).toContain('Elsewhere'); expect(text).not.toContain('Here'); }); it('refuses a state that is not a column, rather than answering with nothing', async () => { const { text, failed } = await toolResult(await callNamed('list_jobs', { state: 'backlog' })); expect(failed).toBe(true); expect(text).toMatch(/Unknown state/); }); it('reads one card back in full, detail and all', async () => { const job = await post({ title: 'Add rate limiting', detail: 'Token bucket, 100/min.' }); job.branchName = 'board/add-rate-limiting'; job.lastError = 'worktree busy'; const text = await toolText(await callNamed('read_job', { id: job.id })); expect(text).toContain('Token bucket, 100/min.'); expect(text).toContain('board/add-rate-limiting'); expect(text).toContain('worktree busy'); expect(text).toMatch(/column: To do/); }); it('hands back an unknown id as a tool error naming what to do', async () => { const { text, failed } = await toolResult(await callNamed('read_job', { id: 'job-nope' })); expect(failed).toBe(true); expect(text).toMatch(/No job with id .*list the board/); }); it('edits a To do card and repaints every open board', async () => { const job = await post({ title: 'Add rate limiting', detail: 'Token bucket.' }); broadcasts.length = 0; const text = await toolText(await callNamed('edit_job', { id: job.id, title: 'Add rate limiting to /api', detail: 'Token bucket, 100/min.', })); expect(text).toMatch(/Updated title, detail/); // A one-time card gets no "It runs ..." tail — that only applies to a // scheduled edit. expect(text).not.toContain('It runs'); expect(allJobs()[0].title).toBe('Add rate limiting to /api'); expect(allJobs()[0].detail).toBe('Token bucket, 100/min.'); expect(broadcasts.some(m => m.type === 'jobs-list')).toBe(true); }); it('leaves the fields it was not given alone', async () => { const job = await post({ title: 'Add rate limiting', detail: 'Token bucket.' }); await callNamed('edit_job', { id: job.id, title: 'Retitled' }); expect(allJobs()[0].detail).toBe('Token bucket.'); expect(allJobs()[0].repoPath).toBe(REPO); }); it('turns a card into a scheduled one and back again', async () => { const job = await post({ title: 'Sweep the logs' }); await callNamed('edit_job', { id: job.id, schedule: '@daily' }); expect(allJobs()[0].type).toBe('scheduled'); expect(allJobs()[0].nextRunAt).toBeTruthy(); await callNamed('edit_job', { id: job.id, schedule: '' }); expect(allJobs()[0].type).toBe('one-time'); expect(allJobs()[0].schedule).toBeNull(); expect(allJobs()[0].nextRunAt).toBeNull(); }); it('refuses to edit a card that has left To do, and changes nothing', async () => { // The whole rule: the agent working this card was handed its text at // dispatch, so a later edit would leave the card describing work nobody // was asked to do. for (const state of ['in-progress', 'review', 'done']) { const job = await post({ title: `Gone to ${state}`, detail: 'Original.' }); job.state = state; const { text, failed } = await toolResult( await callNamed('edit_job', { id: job.id, title: 'Rewritten', detail: 'Rewritten.' })); expect(failed, state).toBe(true); expect(text, state).toMatch(/only cards still in To do can be edited/); expect(job.title, state).toBe(`Gone to ${state}`); expect(job.detail, state).toBe('Original.'); } }); it('refuses an edit that changes nothing, rather than reporting a save', async () => { const job = await post({ title: 'Add rate limiting' }); const { text, failed } = await toolResult( await callNamed('edit_job', { id: job.id, title: 'Add rate limiting' })); expect(failed).toBe(true); expect(text).toMatch(/Nothing to change/); }); it('refuses to blank a title', async () => { const job = await post({ title: 'Add rate limiting' }); const { failed } = await toolResult(await callNamed('edit_job', { id: job.id, title: ' ' })); expect(failed).toBe(true); expect(allJobs()[0].title).toBe('Add rate limiting'); }); it('turns a user token away from the read and edit tools too', async () => { // /mcp is the agent door: a person reads and edits through the board's own // API, and their token does not resolve here at all. const userToken = withUser(); for (const name of ['list_jobs', 'read_job', 'edit_job', 'finish_job', 'list_agents', 'send_message']) { expect((await callNamed(name, { id: 'job-1' }, userToken)).status, name).toBe(401); } }); it('will not let an agent rewrite a card someone else queued', async () => { // A To do card's detail is the next agent's prompt, handed to an unattended // run. Every board agent holds one of these tokens, so an agent in one repo // must not be able to rewrite what runs in another person's. withUser(); const job = await post({ title: 'Theirs', detail: 'Original.' }); job.postedBy = 'someone-else'; job.postedByName = 'Ada'; const { text, failed } = await toolResult( await callNamed('edit_job', { id: job.id, detail: 'Do something else entirely.' })); expect(failed).toBe(true); expect(text).toMatch(/was queued by Ada/); expect(job.detail).toBe('Original.'); }); it('leaves an unowned card alone on a single-player board', async () => { // No users file: postedBy is null on every card and the agent has no owner // either, so the ownership rule must not fire at all. const job = await post({ title: 'Mine', detail: 'Original.' }); expect(job.postedBy).toBeNull(); const { failed } = await toolResult(await callNamed('edit_job', { id: job.id, detail: 'Updated.' })); expect(failed).toBeFalsy(); expect(allJobs()[0].detail).toBe('Updated.'); }); it('says out loud that an agent edited a card, and leaves its name on it', async () => { // The same reasoning as the toast on a posted card, and more so: an edit // destroys text rather than adding a card, and the card otherwise still // reads as the work of whoever queued it. const job = await post({ title: 'Add rate limiting' }); broadcasts.length = 0; await callNamed('edit_job', { id: job.id, detail: 'Token bucket, 100/min.' }); const toast = broadcasts.find(m => m.type === 'notification'); expect(toast.message).toMatch(/Onyx edited a job: "Add rate limiting"/); expect(allJobs()[0].editedByAgent).toBe('Onyx'); expect(allJobs()[0].editedAt).toBeTruthy(); expect(await toolText(await callNamed('read_job', { id: job.id }))).toMatch(/edited by Onyx on /); }); it('refuses a repo filter that is not a string, rather than listing everything', async () => { await post({ title: 'Here' }); const { text, failed } = await toolResult(await callNamed('list_jobs', { repo: 42 })); expect(failed).toBe(true); expect(text).toMatch(/repo must be a string/); }); it('lists the board in the order the board itself shows', async () => { const first = await post({ title: 'Queued first' }); const second = await post({ title: 'Queued second' }); // Stored out of order: the reply is sorted by when each was posted, the // way the board's To do column is. first.postedAt = '2026-09-03T10:00:00.000Z'; second.postedAt = '2026-09-03T09:00:00.000Z'; const text = await toolText(await callNamed('list_jobs', {})); expect(text.indexOf('Queued second')).toBeLessThan(text.indexOf('Queued first')); }); it('moves a card to another repository by folder name', async () => { const job = await post({ title: 'Move me' }); const text = await toolText(await callNamed('edit_job', { id: job.id, repo: basename(REPO2) })); expect(text).toMatch(/Updated repo on "Move me"/); expect(allJobs()[0].repoPath).toBe(REPO2); }); it('refuses to move a card to an unknown repository, and leaves it in place', async () => { const job = await post({ title: 'Stay put' }); const { text, failed } = await toolResult(await callNamed('edit_job', { id: job.id, repo: '/nope' })); expect(failed).toBe(true); expect(text).toMatch(/Unknown repository/); expect(allJobs()[0].repoPath).toBe(REPO); }); it("treats resending the card's current repo as no change", async () => { const job = await post({ title: 'Stay put' }); const { text, failed } = await toolResult(await callNamed('edit_job', { id: job.id, repo: basename(REPO) })); expect(failed).toBe(true); expect(text).toMatch(/Nothing to change/); }); it('refuses a non-string detail or schedule from the wire', async () => { const job = await post({ title: 'Add rate limiting' }); let r = await toolResult(await callNamed('edit_job', { id: job.id, detail: 5 })); expect(r.failed).toBe(true); expect(r.text).toMatch(/detail must be a string/); r = await toolResult(await callNamed('edit_job', { id: job.id, schedule: 5 })); expect(r.failed).toBe(true); expect(r.text).toMatch(/schedule must be a string/); }); it('propagates a bad cron from updateJob as a tool error, unapplied', async () => { const job = await post({ title: 'Digest' }); const { text, failed } = await toolResult( await callNamed('edit_job', { id: job.id, schedule: 'every friday' })); expect(failed).toBe(true); expect(text).toMatch(/five fields/i); expect(allJobs()[0].type).toBe('one-time'); }); it('hands an unknown card id to edit_job back as a tool error', async () => { const { text, failed } = await toolResult(await callNamed('edit_job', { id: 'job-nope', title: 'X' })); expect(failed).toBe(true); expect(text).toMatch(/No job with id/); }); it('answers a no-op edit on a dispatched card with the To do rule, not "nothing to change"', async () => { // The gate runs before the fields are diffed, so a same-value edit to a // card that has left To do reports the rule that actually stopped it. const job = await post({ title: 'Add rate limiting' }); job.state = 'in-progress'; const { text, failed } = await toolResult( await callNamed('edit_job', { id: job.id, title: 'Add rate limiting' })); expect(failed).toBe(true); expect(text).toMatch(/only cards still in To do can be edited/); expect(text).not.toMatch(/Nothing to change/); }); it('reports nothing archived when a column filter is applied, even with finished cards on the board', async () => { const done = await post({ title: 'Wrapped up' }); done.state = 'done'; const text = await toolText(await callNamed('list_jobs', { state: 'todo' })); expect(text).not.toMatch(/archived/i); }); it('refuses to list an unknown repository too', async () => { const { text, failed } = await toolResult(await callNamed('list_jobs', { repo: '/nope' })); expect(failed).toBe(true); expect(text).toMatch(/Unknown repository/); }); it('shows the schedule and pull request in the one-line list summary', async () => { const job = await post({ title: 'Nightly sweep' }); Object.assign(job, { type: 'scheduled', schedule: '@daily', nextRunAt: '2026-09-04T09:00:00.000Z', prUrl: 'https://github.com/x/y/pull/9', }); const text = await toolText(await callNamed('list_jobs', {})); expect(text).toMatch(/schedule @daily, next/); expect(text).toContain('https://github.com/x/y/pull/9'); }); it('reads the agent, schedule and pull request fields when a card carries them', async () => { const job = await post({ title: 'Nightly sweep' }); Object.assign(job, { state: 'in-progress', type: 'scheduled', schedule: '@daily', nextRunAt: '2026-09-04T09:00:00.000Z', runCount: 3, lastRunAt: '2026-09-01T09:00:00.000Z', agentName: 'Slate', startedAt: '2026-09-01T08:00:00.000Z', worktreePath: '/wt/9', prUrl: 'https://github.com/x/y/pull/9', prMergedAt: '2026-09-02T00:00:00.000Z', prCheckError: 'checks failing', }); const text = await toolText(await callNamed('read_job', { id: job.id })); expect(text).toMatch(/schedule: @daily.*posted 3 run\(s\)/); expect(text).toMatch(/agent: Slate, started/); expect(text).toMatch(/pull request: https:\/\/github\.com\/x\/y\/pull\/9 \(merged/); expect(text).toMatch(/pull request check: checks failing/); // Never the absolute paths: this reply goes to whatever agent asked, about // every card on the board, and those describe the user's disk. expect(text).not.toContain('/wt/9'); expect(text).not.toContain(REPO); expect(text).toContain(basename(REPO)); }); }); describe('schedules and their runs through the tools', () => { it('reads a PR-run schedule\'s last hold-off, and names the schedule a run belongs to', async () => { await callTool({ title: 'Bump deps', schedule: '@daily', requires_pr: true }); await callTool({ title: 'Bump deps run' }); const [schedule, run] = allJobs(); Object.assign(schedule, { lastSkipReason: 'waiting on PR #7' }); Object.assign(run, { scheduleId: schedule.id, requiresPr: false }); const read = await toolText(await callNamed('read_job', { id: schedule.id })); expect(read).toMatch(/each run opens a pull request/); expect(read).toMatch(/last held off: waiting on PR #7/); const runText = await toolText(await callNamed('read_job', { id: run.id })); expect(runText).toContain(`(a run of schedule ${schedule.id})`); expect(await toolText(await callNamed('list_jobs', {}))).toMatch(/a scheduled run/); }); }); describe('finishing a no-PR job through the tool', () => { it('posts it, lets its own agent finish it with a summary, and reads the result back', async () => { const posted = await toolText(await callTool({ title: 'Why is pty.js leaking', requires_pr: false })); expect(posted).toMatch(/\(To do, no pull request\)/); const job = allJobs()[0]; expect(job.requiresPr).toBe(false); // As if the board had dispatched it to session-1. Object.assign(job, { state: 'in-progress', agentSessionId: 'session-1', agentName: 'Onyx', branchName: 'board/why' }); const done = await toolResult(await callNamed('finish_job', { summary: 'An unclosed onData listener.' })); expect(done.failed).toBeFalsy(); expect(done.text).toMatch(/is in Review/); expect(job.state).toBe('review'); const text = await toolText(await callNamed('read_job', { id: job.id })); expect(text).toContain('schedule: runs once, no pull request'); expect(text).toContain('result: An unclosed onData listener.'); }); }); describe('messaging another agent through the tools', () => { // The route builds list_agents' rows and binds send_message to the calling // session; mcp-protocol and messages.test.js stub both, so only this proves // the wiring picks the right sender and does not hand a session over whole. const peer = (id, name, fields = {}) => ({ id, name, command: 'codex', agent: 'codex', repoSlug: 'agent-007', branchName: `b-${name}`, state: 'WAITING', stateChangedAt: 0, isTUI: true, lastOutputAt: 0, recentStrippedLines: [], ownerId: null, exited: false, agentToken: `secret-${id}`, pty: { write: vi.fn() }, ...fields, }); beforeEach(() => { for (const id of ['session-1', 'session-2', 'session-3', 'session-4']) dropMessages(id); sessions.get('session-1').command = 'claude'; }); it('lists other agents with their job title and backlog, and not shell tabs', async () => { config.jobs = [{ id: 'job-9', title: 'Fix cron', repoPath: REPO, state: 'running' }]; sessions.set('session-2', peer('session-2', 'Viper', { state: 'WORKING', jobId: 'job-9' })); sessions.set('session-3', peer('session-3', 'Asp', { command: 'bash' })); await callNamed('send_message', { to: 'Viper', message: 'hi' }); const text = await toolText(await callNamed('list_agents', {})); expect(text).toContain('1 agent(s) you can message'); expect(text).toContain('Viper\n codex · agent-007 · b-Viper · working · job: Fix cron · 1 message(s) waiting'); expect(text).not.toContain('Asp'); expect(text).not.toContain('Onyx'); // never itself expect(text).not.toContain('secret'); }); it('types the message into the named agent, signed by the caller', async () => { const viper = peer('session-2', 'Viper'); sessions.set('session-2', viper); const { text, failed } = await toolResult(await callNamed('send_message', { to: 'Viper', message: 'can you rebase?' })); expect(failed).toBeFalsy(); expect(text).toMatch(/^Delivered to Viper/); const typed = () => viper.pty.write.mock.calls.map(c => c[0]).join(''); expect(typed()).toContain('[Message from agent Onyx]'); await vi.waitFor(() => expect(typed()).toContain('can you rebase?')); // typed in small pastes }); it('refuses an agent owned by someone else, as a tool error', async () => { sessions.get('session-1').ownerId = 'u1'; const theirs = peer('session-4', 'Mamba', { ownerId: 'u2' }); sessions.set('session-4', theirs); const { text, failed } = await toolResult(await callNamed('send_message', { to: 'Mamba', message: 'hi' })); expect(failed).toBe(true); expect(text).toMatch(/No agent named "Mamba"/); expect(theirs.pty.write).not.toHaveBeenCalled(); }); }); describe('notify_owner through the route', () => { it('files Billion\'s question with the project and type it named', async () => { const { waitingItems } = await import('../server/owner.js'); sessions.get('session-1').isBillion = true; await callNamed('notify_owner', { text: 'Ship it?', project: 'general', type: 'product' }); expect(waitingItems().at(-1)).toMatchObject({ text: 'Ship it?', project: 'general', type: 'product' }); }); }); describe('a card\'s poster over HTTP: read, close, message', () => { const OTHER_TOKEN = mintAgentToken(); const call = (method, path, body, token) => fetch(`${baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); const postCard = async (token = AGENT_TOKEN, fields = {}) => { const res = await call('POST', '/api/jobs', { title: 'Render order 42', repo: REPO, requires_pr: false, ...fields }, token); expect(res.status).toBe(201); const { job } = await res.json(); return allJobs().find(j => j.id === job.id); }; const worker = (fields = {}) => ({ id: 'w1', name: 'Viper', command: 'codex', agent: 'codex', state: 'WAITING', stateChangedAt: 0, isTUI: true, lastOutputAt: 0, recentStrippedLines: [], ownerId: null, exited: false, pty: { write: vi.fn() }, ...fields, }); const working = (job, state = 'review') => { Object.assign(job, { state, agentSessionId: 'w1', agentName: 'Viper', branchName: 'board/x', resultSummary: 'done' }); const w = worker(); sessions.set('w1', w); return w; }; beforeEach(() => { killed.length = 0; dropMessages('w1'); sessions.set('session-2', { id: 'session-2', name: 'Mamba', repoPath: REPO, ownerId: null, exited: false, agentToken: OTHER_TOKEN }); }); it('records the poster on the card, from the credential that posted it', async () => { const job = await postCard(); expect(job.postedById).toBe('agent:session-1'); const userToken = withUser(); expect((await postCard(userToken, { title: 'by a person' })).postedById).toBe('user:u1'); noUsers(); expect((await postCard(null, { title: 'anon' })).postedById).toBe('anonymous'); // MCP post_job stamps the same key as the HTTP door. await callTool({ title: 'via mcp' }); expect(allJobs().find(j => j.title === 'via mcp').postedById).toBe('agent:session-1'); }); it('lets the poster accept a Review card: Done, worker retired', async () => { const job = await postCard(); working(job); const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: true }, AGENT_TOKEN); expect(res.status).toBe(200); expect((await res.json()).accepted).toBe(true); expect(job.state).toBe('done'); expect(killed).toEqual(['w1']); }); it('lets the poster send a Review card back with its note', async () => { const job = await postCard(); working(job); const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: false, note: 'Shorter intro.' }, AGENT_TOKEN); expect(res.status).toBe(200); expect(job.state).toBe('todo'); expect(job.detail).toContain('Sent back by Onyx: Shorter intro.'); }); it('archives a To do card on accept, and answers bad input with 400', async () => { const job = await postCard(); expect((await call('POST', `/api/jobs/${job.id}/close`, {}, AGENT_TOKEN)).status).toBe(400); expect((await call('POST', `/api/jobs/${job.id}/close`, { accept: true }, AGENT_TOKEN)).status).toBe(400); // no note const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: true, note: 'Order cancelled.' }, AGENT_TOKEN); expect(res.status).toBe(200); expect((await res.json()).archived).toBe(true); expect(job.state).toBe('done'); expect((await call('POST', '/api/jobs/nope/close', { accept: true }, AGENT_TOKEN)).status).toBe(404); }); it('refuses every route to a session that did not post the card, with 403', async () => { const job = await postCard(); const w = working(job); for (const [method, path, body] of [ ['GET', `/api/jobs/${job.id}`], ['POST', `/api/jobs/${job.id}/close`, { accept: true }], ['POST', `/api/jobs/${job.id}/message`, { message: 'rm -rf' }], ]) { const res = await call(method, path, body, OTHER_TOKEN); expect(res.status, `${method} ${path}`).toBe(403); } // Nor a card the UI posted, from an anonymous caller on an auth-off board. job.postedById = null; expect((await call('POST', `/api/jobs/${job.id}/close`, { accept: true })).status).toBe(403); expect(job.state).toBe('review'); expect(w.pty.write).not.toHaveBeenCalled(); }); it('keeps the poster across a restart: the id is on the persisted card', async () => { const job = await postCard(null); working(job); // As a restart reloads it: the stored card, a new object. config.jobs = JSON.parse(JSON.stringify(config.jobs)); const reloaded = allJobs().find(j => j.id === job.id); expect(reloaded.postedById).toBe('anonymous'); const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: true }); expect(res.status).toBe(200); expect(reloaded.state).toBe('done'); }); it('types a message into the card\'s worker, marked as from the poster', async () => { const job = await postCard(); const w = working(job, 'in-progress'); const res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'Make the logo bigger.' }, AGENT_TOKEN); expect(res.status).toBe(202); const body = await res.json(); expect(body.id).toMatch(/^msg-/); expect(body).not.toHaveProperty('to'); const typed = () => w.pty.write.mock.calls.map(c => c[0]).join(''); expect(typed()).toContain('[Message from Onyx, who posted your card]'); await vi.waitFor(() => expect(typed()).toContain('> Make the logo bigger.')); }); it('queues a message while the worker is busy, and caps its length', async () => { const job = await postCard(); const w = working(job); w.state = 'WORKING'; const res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'Again, please.' }, AGENT_TOKEN); expect(res.status).toBe(202); expect((await res.json()).queued).toBe(1); expect(w.pty.write).not.toHaveBeenCalled(); const long = await call('POST', `/api/jobs/${job.id}/message`, { message: 'x'.repeat(9000) }, AGENT_TOKEN); expect(long.status).toBe(400); }); it('answers 409 with the state when the card has no live worker', async () => { const job = await postCard(); let res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'hi' }, AGENT_TOKEN); expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: 'no live worker', state: 'todo' }); working(job).exited = true; res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'hi' }, AGENT_TOKEN); expect(res.status).toBe(409); expect((await res.json()).state).toBe('review'); }); it('reads the card back for its poster, with whether a worker is alive', async () => { const job = await postCard(); let body = await (await call('GET', `/api/jobs/${job.id}`, null, AGENT_TOKEN)).json(); expect(body.job.state).toBe('todo'); expect(body.workerAlive).toBe(false); working(job); body = await (await call('GET', `/api/jobs/${job.id}`, null, AGENT_TOKEN)).json(); expect(body.workerAlive).toBe(true); expect(body.job.resultSummary).toBe('done'); }); it('leaves Billion\'s rights as they were', async () => { const billion = { id: 'b1', name: 'Billion', isBillion: true }; const job = await postCard(); working(job); // Not its card: refused, even though Billion may close its own. expect((await closeJobForAgent({ session: billion, id: job.id, accept: true }, () => {})).forbidden).toBe(true); job.postedByBillion = true; expect((await closeJobForAgent({ session: billion, id: job.id, accept: true }, () => {}, { killSession: async () => {} })).accepted).toBe(true); // And the MCP door still refuses any other agent outright. expect((await closeJobForAgent({ session: { id: 'session-1' }, id: job.id, accept: true })).error).toMatch(/Only Billion/); }); });