// What a spawned agent inherits from the server: its environment without // Agent 007's own settings (lib/helpers.js, ptyEnv), and for a board worker on // Claude Code, no channel plugins (server/agent-mcp.js, withBoardWorkerSettings). import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { readdirSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { EventEmitter } from 'events'; const spawn = vi.fn(() => ({ pid: 1, onData() {}, onExit() {}, write() {}, resize() {}, kill() {} })); vi.mock('node-pty', () => ({ spawn })); vi.mock('../server/command-path.js', async (orig) => ({ ...(await orig()), commandExists: () => true, resolveExecutable: (f) => f, })); const { createSessionFromConfig } = await import('../server/pty.js'); const { setCodexHookHash, mcpConfigPath } = await import('../server/agent-mcp.js'); const SECRETS = { TELEGRAM_BOT_TOKEN: '123:fake', TELEGRAM_CHAT_ID: '42', WHISPER_MODEL: '/m.bin', SAY_VOICE: 'Ava' }; const KEPT = { CODEX_HOME: '/tmp/codex-home', CLAUDE_CONFIG_DIR: '/tmp/claude-config' }; let n = 0; function spawnWith(fields) { const result = createSessionFromConfig({ sessionId: `env-${++n}`, name: 'Falcon', color: 'red', command: 'claude', cwd: tmpdir(), ...fields }); clearInterval(result.session?.stateCheckInterval); const [, args, opts] = spawn.mock.calls.at(-1); return { args, env: opts.env }; } const settingsOf = (args) => (args.includes('--settings') ? JSON.parse(args[args.indexOf('--settings') + 1]) : null); beforeEach(() => { Object.assign(process.env, SECRETS, KEPT); spawn.mockClear(); }); afterEach(() => { for (const k of Object.keys({ ...SECRETS, ...KEPT })) delete process.env[k]; }); // The spawns server.js and server/ws.js make, by the fields that tell them apart. const PATHS = { 'a board dispatch': { spawnedBy: 'board', jobId: 'j1', autoTrust: true }, "a dispatch of Billion's card": { spawnedBy: 'board', jobId: 'j1', approvalsToBillion: true }, 'a re-spawned board worker': { spawnedBy: 'user', origin: 'board' }, 'the + Agent form': { spawnedBy: 'user' }, 'Billion (and a Billion switch)': { isBillion: true }, 'Billion on Codex': { isBillion: true, command: 'codex' }, }; describe('the environment a spawned agent gets', () => { for (const [path, fields] of Object.entries(PATHS)) { it(`has none of the server's settings: ${path}`, () => { const { env } = spawnWith(fields); for (const key of Object.keys(SECRETS)) expect(env).not.toHaveProperty(key); expect(Object.keys(env).filter(k => k.startsWith('AGENT007_'))).toEqual([]); expect(env).toMatchObject(KEPT); expect(env.HOME ?? env.USERPROFILE).toBeTruthy(); expect(env.PATH ?? env.Path).toBeTruthy(); }); } it('comes from one spawn call, which scrubs it', () => { const users = readdirSync('server').filter(f => readFileSync(`server/${f}`, 'utf8').includes("from 'node-pty'")); expect(users).toEqual(['pty.js']); const calls = readFileSync('server/pty.js', 'utf8').match(/spawnPty\([^]*?\}\);/g); expect(calls).toHaveLength(1); expect(calls[0]).toContain('env: { ...ptyEnv(process.env), ...ghEnv, ...hookEnv }'); }); }); describe('a Codex worker on Billion\'s card', () => { const fields = { spawnedBy: 'board', jobId: 'j1', approvalsToBillion: true, command: 'codex' }; afterEach(() => setCodexHookHash(null)); it('names its own MCP config to the hook, and gets the hook, once the hash is known', () => { setCodexHookHash(`sha256:${'d'.repeat(64)}`); const { args, env } = spawnWith(fields); expect(args.some(a => a.startsWith('hooks.PermissionRequest='))).toBe(true); expect(env.AGENT007_HOOK_CONFIG).toBe(mcpConfigPath(`env-${n}`)); }); it('gets neither without it, and asks the owner', () => { const { args, env } = spawnWith(fields); expect(args.some(a => a.startsWith('hooks.'))).toBe(false); expect(env).not.toHaveProperty('AGENT007_HOOK_CONFIG'); }); }); describe('channel plugins', () => { it('are off in a Claude Code board worker, fresh or re-spawned, hooked or not', () => { for (const fields of [PATHS['a board dispatch'], PATHS["a dispatch of Billion's card"], PATHS['a re-spawned board worker']]) { const settings = settingsOf(spawnWith(fields).args); expect(settings.enabledPlugins['telegram@claude-plugins-official']).toBe(false); } // The hooked one keeps its hook in the same --settings. expect(settingsOf(spawnWith(PATHS["a dispatch of Billion's card"]).args).hooks).toBeTruthy(); }); it('are left alone for hand-started agents, Billion and Codex', () => { expect(settingsOf(spawnWith(PATHS['the + Agent form']).args)).toBeNull(); expect(settingsOf(spawnWith(PATHS['Billion (and a Billion switch)']).args)).toBeNull(); expect(spawnWith({ ...PATHS['a board dispatch'], command: 'codex' }).args).not.toContain('--settings'); }); }); describe('gh accounts', () => { it('reach the agent as the GH_TOKEN it was given, and only then', () => { expect(spawnWith({ ...PATHS['a board dispatch'], ghEnv: { GH_TOKEN: 'tok-a' } }).env.GH_TOKEN).toBe('tok-a'); delete process.env.GH_TOKEN; expect(spawnWith(PATHS['a board dispatch']).env).not.toHaveProperty('GH_TOKEN'); }); it('cannot be switched, logged into or out of by a Claude Code board worker', () => { for (const fields of [PATHS['a board dispatch'], PATHS["a dispatch of Billion's card"], PATHS['a re-spawned board worker']]) { const deny = settingsOf(spawnWith(fields).args).permissions.deny; for (const sub of ['switch', 'login', 'logout']) expect(deny).toContain(`Bash(gh auth ${sub}:*)`); } }); }); describe('input to a Windows terminal', () => { it('drops a failed write instead of throwing it at the process', () => { // node-pty on Windows writes input through this socket and never listens // for its errors; an unheard 'error' emit throws, as the real one would. const inSocket = new EventEmitter(); spawn.mockReturnValueOnce({ pid: 1, onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, _agent: { inSocket } }); const quiet = vi.spyOn(console, 'error').mockImplementation(() => {}); spawnWith({}); expect(() => inSocket.emit('error', Object.assign(new Error('write EAGAIN'), { code: 'EAGAIN' }))).not.toThrow(); expect(quiet).toHaveBeenCalledWith('Input to "Falcon" was dropped: write EAGAIN'); quiet.mockRestore(); }); });