Ctrl K
ComunidadGuías…

test/agent-mcp-config.test.js

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

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