Ctrl K
ComunidadGuías…

test/billion-approvals.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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);
  });
});