// 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); }); });