test/mcp-endpoint.test.js
Rama main · réplica de lectura del repositorio original.
// 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 <agent>"
// 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/);
});
});