test/agent-mcp-config.test.js
Rama main · réplica de lectura del repositorio original.
// Handing a spawned agent the board's MCP tool: the config file it reads at
// startup, and the flags that point it there.
//
// This is the half of the feature that decides whether an agent can see the
// tool at all, and it is the half that must not break a spawn when the command
// is neither Claude Code nor Codex.
import { describe, it, expect, afterEach } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { removeTempDir } from './temp-dir.js';
// Windows has no POSIX mode bits: writeFileSync's mode lands as 0o666 and chmod
// is a no-op, so the owner-only property can only be asserted on POSIX.
const POSIX = process.platform !== 'win32';
const DIR = mkdtempSync(join(tmpdir(), 'a007-mcp-'));
process.env.AGENT007_MCP_DIR = DIR;
const {
writeMcpConfig, removeMcpConfig, sweepMcpConfigs, mcpConfigPath, mcpConfigBody,
withMcpConfig, takesMcpConfig, boardBaseUrl, MCP_SERVER_NAME,
} = await import('../server/agent-mcp.js');
afterEach(() => { removeTempDir(DIR); });
describe('the config an agent reads at startup', () => {
it('points at this board over loopback with the session token in a header', () => {
const body = mcpConfigBody('a007a_secret');
const server = body.mcpServers[MCP_SERVER_NAME];
expect(server.type).toBe('http');
expect(server.url).toBe(`${boardBaseUrl()}/mcp`);
expect(server.headers.Authorization).toBe('Bearer a007a_secret');
});
it('keeps the token out of the URL', () => {
// A ?token= lands in every proxy and access log between here and nowhere.
const server = mcpConfigBody('a007a_secret').mcpServers[MCP_SERVER_NAME];
expect(server.url).not.toContain('a007a_secret');
});
it('addresses the board over loopback, not the bind host', () => {
// HOST may be a tailnet address; sending an agent's credential out over the
// network to reach a server on the same machine would be gratuitous.
expect(boardBaseUrl()).toMatch(/^http:\/\/(127\.0\.0\.1|localhost|\[?::1\]?)/);
});
it('writes the token into the file the agent will read', () => {
const path = writeMcpConfig('session-1', 'a007a_secret');
expect(path).toBe(mcpConfigPath('session-1'));
expect(JSON.parse(readFileSync(path, 'utf8')).mcpServers[MCP_SERVER_NAME].headers.Authorization)
.toBe('Bearer a007a_secret');
});
it.skipIf(!POSIX)('writes the file readable only by its owner', () => {
// It holds a live credential.
expect(statSync(writeMcpConfig('session-1', 'a007a_secret')).mode & 0o777).toBe(0o600);
});
it.skipIf(!POSIX)('tightens the mode even when a file was already there', () => {
// writeFileSync ignores its mode argument entirely for an existing file, so
// a leftover world-readable file from an earlier run would stay that way.
mkdirSync(DIR, { recursive: true });
writeFileSync(mcpConfigPath('session-2'), '{}', { mode: 0o644 });
writeMcpConfig('session-2', 'a007a_secret');
expect(statSync(mcpConfigPath('session-2')).mode & 0o777).toBe(0o600);
});
it('removes the file, and does not complain when it is already gone', () => {
writeMcpConfig('session-3', 'tok');
removeMcpConfig('session-3');
expect(existsSync(mcpConfigPath('session-3'))).toBe(false);
expect(() => removeMcpConfig('session-3')).not.toThrow();
});
});
describe('which commands receive the board MCP server', () => {
it('accepts claude, by name or by absolute path', () => {
expect(takesMcpConfig('claude')).toBe(true);
expect(takesMcpConfig('/opt/homebrew/bin/claude')).toBe(true);
});
it.each(['codex', '/opt/bin/codex', 'codex.cmd', 'codex.EXE'])('accepts %s', cmd => {
expect(takesMcpConfig(cmd)).toBe(true);
});
it('refuses every other agent', () => {
for (const cmd of ['gemini', 'aider', 'bash', '/usr/local/bin/gemini', '']) {
expect(takesMcpConfig(cmd)).toBe(false);
}
});
it('recognises the Windows launcher', () => {
// On Windows the thing on PATH is claude.cmd. Spelling it out would
// otherwise silently get no tool at all.
// Backslash paths are deliberately not asserted: path.basename only splits
// on them when running ON Windows, so such a case would test Node, not this.
for (const cmd of ['claude.cmd', 'claude.exe', 'claude.CMD']) {
expect(takesMcpConfig(cmd)).toBe(true);
}
expect(takesMcpConfig('gemini.cmd')).toBe(false);
});
});
describe('stale configs from a previous run', () => {
it('are swept at boot, so dead credentials do not pile up', () => {
// Files are removed when their PTY exits, but a crash or a plain restart
// never runs that handler. Without a sweep every run leaves its tokens
// behind for ever.
writeMcpConfig('session-1', 'tok-1');
writeMcpConfig('session-2', 'tok-2');
sweepMcpConfigs();
expect(existsSync(mcpConfigPath('session-1'))).toBe(false);
expect(existsSync(mcpConfigPath('session-2'))).toBe(false);
});
it('sweeps cleanly when nothing is there yet', () => {
expect(() => sweepMcpConfigs()).not.toThrow();
});
it('leaves the directory usable afterwards', () => {
sweepMcpConfigs();
expect(writeMcpConfig('session-9', 'tok')).toBe(mcpConfigPath('session-9'));
});
});
describe('injecting the flag', () => {
it('goes immediately after the binary, ahead of everything typed', () => {
expect(withMcpConfig('claude', ['--permission-mode', 'auto', 'do the thing'], '/cfg.json'))
.toEqual(['--mcp-config', '/cfg.json', '--permission-mode', 'auto', 'do the thing']);
});
it('never lands after a positional prompt', () => {
// Claude Code does accept flags after a positional, but placing it first
// means the question never comes up.
const args = withMcpConfig('claude', ['a long prompt'], '/cfg.json');
expect(args.indexOf('--mcp-config')).toBeLessThan(args.indexOf('a long prompt'));
});
it('leaves a non-claude command completely alone', () => {
const typed = ['--yolo', '-p', 'hello'];
expect(withMcpConfig('gemini', typed, '/cfg.json')).toEqual(typed);
expect(withMcpConfig('aider', [], '/cfg.json')).toEqual([]);
});
it('extends the user\'s own --mcp-config instead of adding a second one', () => {
// The flag is variadic (`<configs...>`), so two occurrences are ambiguous.
const args = withMcpConfig('claude', ['--mcp-config', 'theirs.json', '-p', 'x'], '/ours.json');
expect(args).toEqual(['--mcp-config', '/ours.json', 'theirs.json', '-p', 'x']);
expect(args.filter(a => a === '--mcp-config')).toHaveLength(1);
});
it('spawns unchanged when the config could not be written', () => {
// A missing config is a missing convenience, never a failed spawn.
expect(withMcpConfig('claude', ['-p', 'x'], null)).toEqual(['-p', 'x']);
});
});
describe('Codex launch overrides', () => {
it('adds a stdio bridge before existing options and positional arguments', () => {
const typed = ['-c', 'mcp_servers.other.url="http://localhost:9000"', 'exec', '--', 'prompt'];
const args = withMcpConfig('codex', typed, 'C:\\a path\\config.json');
expect(args[0]).toBe('-c');
expect(args[1]).toContain(`mcp_servers.${MCP_SERVER_NAME}={command=${JSON.stringify(process.execPath)}`);
expect(args[1]).toContain('agent-mcp-bridge.js');
expect(args[1]).toContain(JSON.stringify('C:\\a path\\config.json'));
expect(args.slice(2)).toEqual(typed);
expect(typed).toHaveLength(5);
});
it('keeps the session token out of the Codex arguments', () => {
const path = writeMcpConfig('codex-session', 'a007a_secret');
const args = withMcpConfig('codex.cmd', ['hello'], path);
expect(args.join(' ')).not.toContain('a007a_secret');
expect(args.at(-1)).toBe('hello');
removeMcpConfig('codex-session');
expect(existsSync(path)).toBe(false);
});
it('leaves Codex unchanged if writing the config failed', () => {
expect(withMcpConfig('codex', ['hello'], null)).toEqual(['hello']);
});
});