test/billion-approvals.test.js
Rama main · réplica de lectura del repositorio original.
// Billion answers workers' permission dialogs (docs/BILLION.md, part 4). The
// rule everything here protects: only Billion's explicit answer ever reaches
// the worker as allow or deny; anything else — no Billion, silence, an error —
// is "no decision", and the dialog goes to a person.
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import express from 'express';
import { createServer } from 'http';
import { spawn } from 'child_process';
import { mkdtempSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { fileURLToPath } from 'url';
const { config, sessions } = await import('../server/state.js');
const { setupRoutes } = await import('../server/http.js');
const { requestApproval, answerApproval, clearApprovals, APPROVAL_WAIT_MS } = await import('../server/approvals.js');
const { dropMessages } = await import('../server/messages.js');
const { withApprovalHook, withCodexWorkerTools, withMcpConfig, setCodexHookHash, pickCodexHookHash, lookupCodexHookHash, codexHookCommand, CODEX_HOOK_KEY, HOOK_TIMEOUT_S } = await import('../server/agent-mcp.js');
const { mintAgentToken } = await import('../server/auth.js');
const { addJob, dispatchOnce, boardSettings } = await import('../server/jobs.js');
const { BILLION_NAME } = await import('../lib/jobs.js');
const HOOK = fileURLToPath(new URL('../server/permission-hook.js', import.meta.url));
function fake(name, fields = {}) {
return {
id: `ap-${name}`, name, command: 'claude', agent: 'claude', state: 'WAITING', exited: false, ownerId: null,
stateChangedAt: 0, recentStrippedLines: [], isTUI: true, lastOutputAt: 0, pty: { write: vi.fn() }, ...fields,
};
}
const typed = (s) => s.pty.write.mock.calls.map(c => c[0]).join('');
const request = { tool_name: 'Write', tool_input: { file_path: '/elsewhere/x.txt', content: 'hi' } };
let billion, worker;
beforeEach(() => {
sessions.clear();
billion = fake('Billion', { isBillion: true, command: 'claude --dangerously-skip-permissions' });
worker = fake('Falcon', { approvalsToBillion: true, repoSlug: 'app', branchName: 'fix-login' });
sessions.set(billion.id, billion);
sessions.set(worker.id, worker);
});
afterEach(() => {
clearApprovals();
for (const s of [billion, worker]) dropMessages(s.id);
vi.useRealTimers();
});
const idOf = (s) => typed(s).match(/\[Approval ([0-9a-f]+)\]/)[1];
describe('asking Billion', () => {
it('types the request into Billion, and returns its allow', async () => {
const answer = requestApproval(worker, request, { jobTitle: 'Fix login' });
expect(typed(billion)).toContain('Falcon (card "Fix login", app · fix-login) asks to use Write:');
await vi.waitFor(() => expect(typed(billion)).toContain('> "file_path": "/elsewhere/x.txt",')); // typed in small pastes
expect(answerApproval(idOf(billion), 'allow')).toEqual({ worker: 'Falcon', choice: 'allow', cut: false });
expect(await answer).toEqual({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow' } } });
});
it('passes a deny on with Billion\'s reason', async () => {
const answer = requestApproval(worker, request);
answerApproval(idOf(billion), 'deny', 'Write inside your worktree.');
expect((await answer).hookSpecificOutput.decision).toEqual({ behavior: 'deny', message: 'Write inside your worktree.' });
});
it('gives no decision when Billion leaves it to the owner', async () => {
const left = requestApproval(worker, request);
answerApproval(idOf(billion), 'owner');
expect(await left).toEqual({});
});
it('gives no decision when Billion stays silent, and turns away a late answer', async () => {
vi.useFakeTimers();
const silent = requestApproval(worker, request);
const id = idOf(billion);
vi.advanceTimersByTime(APPROVAL_WAIT_MS);
expect(await silent).toEqual({});
expect(answerApproval(id, 'allow').error).toMatch(/ran out of time/);
});
it('never asks a Billion that is missing, stopped, or still introducing itself', async () => {
billion.messagesHeld = true;
expect(await requestApproval(worker, request)).toEqual({});
billion.messagesHeld = false;
billion.exited = true;
expect(await requestApproval(worker, request)).toEqual({});
sessions.delete(billion.id);
expect(await requestApproval(worker, request)).toEqual({});
});
it('answers only for workers hooked to Billion', async () => {
expect(await requestApproval(fake('Viper'), request)).toEqual({});
expect(billion.pty.write).not.toHaveBeenCalled();
});
});
describe('the hook in the worker\'s command line', () => {
it('goes into a Claude Code worker, pointing at its own MCP config', () => {
const args = withApprovalHook('claude', ['--permission-mode', 'auto', 'do it'], '/cfg/s1.json');
expect(args[0]).toBe('--settings');
const hook = JSON.parse(args[1]).hooks.PermissionRequest[0].hooks[0];
expect(hook.command).toContain('permission-hook.js" "/cfg/s1.json"');
expect(hook.timeout * 1000).toBeGreaterThan(APPROVAL_WAIT_MS);
// Its own instructions need no one's permission.
expect(JSON.parse(args[1]).permissions.allow).toEqual(['mcp__agent-007-board__finish_job', 'mcp__agent-007-board__send_message']);
expect(args.slice(2)).toEqual(['--permission-mode', 'auto', 'do it']);
});
it('stays out of Codex until its hash is known, and out of a command with its own --settings', () => {
// The same array, not a copy: pty.js records "hooked" by identity.
setCodexHookHash(null);
const codex = ['x'];
expect(withApprovalHook('codex', codex, '/cfg/s1.json')).toBe(codex);
const own = ['--settings', '{}'];
expect(withApprovalHook('claude', own, '/cfg/s1.json')).toBe(own);
});
it('goes into a Codex worker as the hook and its trusted hash, the same for every worker', () => {
const hash = `sha256:${'a'.repeat(64)}`;
setCodexHookHash(hash);
try {
const args = withApprovalHook('codex.exe', ['x'], '/cfg/s1.json');
expect(args).toEqual([
'-c', `hooks.PermissionRequest=[{matcher="*",hooks=[{type="command",command=${JSON.stringify(codexHookCommand())},timeout=${HOOK_TIMEOUT_S}}]}]`,
'-c', `hooks.state={"${CODEX_HOOK_KEY}"={trusted_hash="${hash}"}}`,
'x',
]);
// No per-session path in the hashed command: the MCP config goes by env (pty.js).
expect(withApprovalHook('codex', [], '/cfg/s2.json')).toEqual(args.slice(0, 4));
expect(codexHookCommand()).not.toContain('s1.json');
expect(HOOK_TIMEOUT_S * 1000).toBeGreaterThan(APPROVAL_WAIT_MS);
expect(withApprovalHook('codex', [], null)).toEqual([]);
} finally {
setCodexHookHash(null);
}
});
it('takes the hash from hooks/list only when the one session-flags entry is exactly ours', () => {
const hash = `sha256:${'b'.repeat(64)}`;
const ours = {
key: CODEX_HOOK_KEY, eventName: 'permissionRequest', source: 'sessionFlags', command: codexHookCommand(),
timeoutSec: HOOK_TIMEOUT_S, statusMessage: null, currentHash: hash, trustStatus: 'untrusted',
};
const user = { ...ours, key: '/h/.codex/hooks.json:permission_request:0:0', source: 'user', command: 'mine', currentHash: `sha256:${'c'.repeat(64)}` };
const list = (...hooks) => ({ data: [{ cwd: '/h', hooks, warnings: [], errors: [] }] });
expect(pickCodexHookHash(list(user, ours))).toBe(hash);
for (const bad of [{ command: 'other' }, { timeoutSec: 60 }, { statusMessage: 'x' }, { key: `${CODEX_HOOK_KEY.slice(0, -1)}1` }, { currentHash: 'md5:x' }]) {
expect(pickCodexHookHash(list({ ...ours, ...bad }))).toBe(null);
}
expect(pickCodexHookHash(list(ours, { ...ours, key: 'second' }))).toBe(null);
expect(pickCodexHookHash(list(user))).toBe(null);
expect(pickCodexHookHash(undefined)).toBe(null);
expect(pickCodexHookHash({ data: 'oops' })).toBe(null);
expect(pickCodexHookHash({ data: [{ hooks: {} }, null] })).toBe(null);
});
it('finds no hash, so hooks no Codex worker, without codex on the PATH', async () => {
expect(await lookupCodexHookHash({ env: { PATH: mkdtempSync(join(tmpdir(), 'a007-ap-nopath-')) } })).toBe(null);
});
it('pre-allows the same board tools for a Codex worker, one -c per tool', () => {
const claudeAllow = JSON.parse(withApprovalHook('claude', [], '/cfg/s1.json')[1]).permissions.allow;
const args = withCodexWorkerTools('codex', ['x'], '/cfg/s1.json');
expect(args).toEqual([
'-c', 'mcp_servers.agent-007-board.tools.finish_job.approval_mode="approve"',
'-c', 'mcp_servers.agent-007-board.tools.send_message.approval_mode="approve"',
'x',
]);
expect(args.filter((a, i) => args[i - 1] === '-c').map(a => `mcp__agent-007-board__${a.split('.')[3]}`)).toEqual(claudeAllow);
const claude = ['y'];
expect(withCodexWorkerTools('claude', claude, '/cfg/s1.json')).toBe(claude);
expect(withCodexWorkerTools('codex.exe', [], null)).toEqual([]);
// Wrapped as pty.js does: the server table must come first, since a later
// `mcp_servers.agent-007-board={...}` would replace the tools under it.
const spawn = withMcpConfig('codex', args, '/cfg/s1.json');
expect(spawn.findIndex(a => a.startsWith('mcp_servers.agent-007-board=')))
.toBeLessThan(spawn.findIndex(a => a.includes('.tools.')));
});
it('is asked for on Billion\'s cards only', async () => {
// One repo per card, so the per-repo limit dispatches all three.
config.repos = [1, 2, 3].map(() => ({ path: mkdtempSync(join(tmpdir(), 'a007-ap-repo-')) }));
config.jobs = [];
config.jobBoard = null;
boardSettings();
sessions.clear();
const cards = [BILLION_NAME, 'Viper', undefined].map((postedByAgent, i) =>
addJob({ title: `Card ${i}`, repoPath: config.repos[i].path, postedByAgent, postedByBillion: i === 0 }, () => {}).job);
const metas = [];
await dispatchOnce(async (command, name, repoPath, branch, ownerId, meta) => {
metas.push(meta);
const s = fake(`w${metas.length}`, { repoPath, jobId: meta.jobId });
sessions.set(s.id, s);
return { session: s };
}, () => {});
const hooked = Object.fromEntries(metas.map(m => [m.jobId, m.approvalsToBillion]));
expect(cards.map(c => hooked[c.id])).toEqual([true, false, false]);
});
});
describe('the hook script', () => {
const run = (configPath, stdin) => new Promise((resolve) => {
const child = spawn(process.execPath, [HOOK, configPath]);
let out = '';
child.stdout.on('data', d => { out += d; });
child.on('close', () => resolve(out));
child.stdin.end(stdin);
});
let reply = {};
let seen = null;
const server = createServer((req, res) => {
let body = '';
req.on('data', d => { body += d; });
req.on('end', () => {
seen = { url: req.url, auth: req.headers.authorization, body: JSON.parse(body) };
res.writeHead(reply.status || 200, { 'Content-Type': 'application/json' });
res.end(typeof reply.body === 'string' ? reply.body : JSON.stringify(reply.body ?? {}));
});
});
const cfg = join(mkdtempSync(join(tmpdir(), 'a007-ap-cfg-')), 's.json');
beforeAll(async () => {
await new Promise(r => server.listen(0, '127.0.0.1', r));
writeFileSync(cfg, JSON.stringify({ mcpServers: { 'agent-007-board': {
url: `http://127.0.0.1:${server.address().port}/mcp`, headers: { Authorization: 'Bearer tok' } } } }));
});
afterAll(() => server.close());
it('posts the request with the worker\'s token and prints a real answer', async () => {
reply = { body: { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow' } } } };
const out = await run(cfg, JSON.stringify(request));
expect(seen).toEqual({ url: '/hook/permission', auth: 'Bearer tok', body: request });
expect(JSON.parse(out).hookSpecificOutput.decision.behavior).toBe('allow');
});
it('prints nothing — no decision — for anything else', async () => {
for (const r of [{ body: {} }, { status: 500, body: { hookSpecificOutput: { decision: { behavior: 'allow' } } } }, { body: 'not json' },
{ body: { hookSpecificOutput: { decision: { behavior: 'maybe' } } } }]) {
reply = r;
expect(await run(cfg, JSON.stringify(request))).toBe('');
}
expect(await run('/no/such/config.json', JSON.stringify(request))).toBe('');
});
it('reads a Codex worker\'s MCP config from the environment when given no argument', async () => {
reply = { body: { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: 'no' } } } };
const out = await new Promise((resolve) => {
const child = spawn(process.execPath, [HOOK], { env: { ...process.env, AGENT007_HOOK_CONFIG: cfg } });
let o = '';
child.stdout.on('data', d => { o += d; });
child.on('close', () => resolve(o));
child.stdin.end(JSON.stringify(request));
});
expect(seen.auth).toBe('Bearer tok');
expect(JSON.parse(out).hookSpecificOutput.decision).toEqual({ behavior: 'deny', message: 'no' });
});
});
describe('POST /hook/permission and answer_permission', () => {
const WORKER_TOKEN = mintAgentToken();
const BILLION_TOKEN = mintAgentToken();
const app = express();
setupRoutes(app, mkdtempSync(join(tmpdir(), 'a007-ap-static-')), { broadcast: () => {} });
const http = createServer(app);
let base;
beforeAll(async () => {
await new Promise(r => http.listen(0, '127.0.0.1', r));
base = `http://127.0.0.1:${http.address().port}`;
});
afterAll(() => http.close());
const post = (path, token, body) => fetch(`${base}${path}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(body),
}).then(r => r.json());
const answer = (token, args) => post('/mcp', token, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'answer_permission', arguments: args } });
it('holds the worker\'s request until Billion answers over MCP', async () => {
worker.agentToken = WORKER_TOKEN;
billion.agentToken = BILLION_TOKEN;
const pending = post('/hook/permission', WORKER_TOKEN, request);
await vi.waitFor(() => expect(typed(billion)).toContain('[Approval '));
const id = idOf(billion);
expect((await answer(WORKER_TOKEN, { id, decision: 'allow' })).error.message).toMatch(/Unknown tool/);
expect((await answer(BILLION_TOKEN, { id, decision: 'deny', reason: 'Not this card.' })).result.isError).toBe(false);
expect((await pending).hookSpecificOutput.decision).toEqual({ behavior: 'deny', message: 'Not this card.' });
});
it('honours an allow on a cut-short request once Billion has read it over MCP', async () => {
worker.agentToken = WORKER_TOKEN;
billion.agentToken = BILLION_TOKEN;
const long = { tool_name: 'Bash', tool_input: { command: Array.from({ length: 400 }, (_, i) => `echo ${i}`).join('\n') } };
const pending = post('/hook/permission', WORKER_TOKEN, long);
await vi.waitFor(() => expect(typed(billion)).toContain('read_approval before allowing'));
const id = idOf(billion);
const read = (token) => post('/mcp', token, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'read_approval', arguments: { id } } });
expect((await read(WORKER_TOKEN)).error.message).toMatch(/Unknown tool/);
expect((await read(BILLION_TOKEN)).result.content[0].text).toContain('> "command": "echo 0\\necho 1');
expect((await answer(BILLION_TOKEN, { id, decision: 'allow' })).result.content[0].text).toBe('Falcon has your answer: allow.');
expect((await pending).hookSpecificOutput.decision).toEqual({ behavior: 'allow' });
});
it('refuses a request without an agent token', async () => {
const res = await fetch(`${base}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
expect(res.ok).toBe(false);
});
});