Ctrl K
ComunidadGuías…

server/agents-bridge-gates.test.ts

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

Archivo original ↗Preguntar a KiBot
// A tool selection and the agents tool bridge never meet. A selection is
// enforced by gates that match the name in the call, and a call made through
// the bridge is named use_tool, so wherever a selection is mounted in front
// of the agents server that server is started unbridged, whatever the turn
// decided when it built the integration. These are the mounts, each given an
// integration that WAS asked to bridge: the case of a selection saved between
// a turn's bridge decision and its dispatch (a room turn reads the selection
// last), which nothing but the mount can catch.
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import type { ToolScope } from "../shared/tool-scope.ts";
import type { SendTurnInput } from "./contracts.ts";
import { TOOL_BRIDGE_ENV } from "./drivers/agents-bridge.ts";
import { mountChatTools, type ChatToolSession } from "./drivers/chat-mcp-tools.ts";
import { buildMcpServers } from "./drivers/pi-mcp-servers.ts";
import { turnStdioMcpServers } from "./drivers/turn-mcp.ts";
import { gateServer } from "./mcp-gate-config.ts";
import { SPAWNED_PROXIES } from "./proxy-paths.ts";
import { waitForExit } from "./testing/cleanup.ts";
import { childEnv } from "./testing/omb-env.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
type Server = { command: string; args: string[]; env: Record<string, string> };
/** A turn's agents integration as the harness builds it, asked to bridge.
 * Its harness is nowhere: nothing below gets as far as one. */
const BRIDGED: Server = {
  command: process.execPath,
  args: [join(HERE, "drivers", "agents-proxy.ts")],
  env: { OMB_HARNESS_URL: "http://127.0.0.1:9", OMB_BOT_ID: "bot-gated", OMB_THREAD_ID: "thread-gated", OMB_COMMS_TOKEN: "unused", OMB_TURN_DEPTH: "0", [TOOL_BRIDGE_ENV]: "1" },
};
const DENY_VM: ToolScope = { deny: ["mcp:agents:vm_exec"] };
const ONLY_LIST_BOTS: ToolScope = { allow: ["mcp:agents:list_bots"] };
const NO_NEW_TRIGGERS: ToolScope = { deny: ["mcp:agents:create_trigger"] };
const EXCLUDED = { code: -32602, message: "Tool selection excludes this tool. Check the bot's Access settings." };

type Answer = { id: number; result?: { tools?: Array<{ name: string }>; content?: Array<{ text: string }>; isError?: boolean }; error?: { code: number; message: string } };
type Ask = (method: string, params?: unknown) => Promise<Answer>;

/** One MCP server as an engine would start it, asked one request at a time. */
async function withServer(server: Server, run: (ask: Ask) => Promise<void>): Promise<void> {
  const child = spawn(server.command, server.args, { env: childEnv(server.env), stdio: ["pipe", "pipe", "inherit"] });
  const waiting = new Map<number, { resolve(answer: Answer): void; reject(error: Error): void }>();
  let buffer = "";
  let nextId = 1;
  child.stdout.setEncoding("utf8");
  child.stdout.on("data", (chunk: string) => {
    buffer += chunk;
    let newline;
    while ((newline = buffer.indexOf("\n")) !== -1) {
      const answer = JSON.parse(buffer.slice(0, newline)) as Answer;
      buffer = buffer.slice(newline + 1);
      waiting.get(answer.id)?.resolve(answer);
      waiting.delete(answer.id);
    }
  });
  const stopped = (why: string) => { for (const entry of waiting.values()) entry.reject(new Error(why)); waiting.clear(); };
  child.on("error", (error) => stopped(String(error)));
  child.on("exit", (code) => stopped(`the server exited (${code}) before answering`));
  try {
    await run((method, params) => new Promise<Answer>((resolve, reject) => {
      const id = nextId++;
      waiting.set(id, { resolve, reject });
      child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })}\n`);
    }));
  } finally {
    await waitForExit(child, { signal: "SIGTERM" });
  }
}

const namesOf = (answer: Answer) => answer.result!.tools!.map((tool) => tool.name);
const call = (ask: Ask, name: string, args: unknown) => ask("tools/call", { name, arguments: args });
const use = (ask: Ask, name: string, args: unknown = {}) => call(ask, "use_tool", { name, arguments: args });
/** The server a gate was told to start. */
const upstreamOf = (gate: { env: Record<string, string> }, configEnvName?: string): Server =>
  JSON.parse((configEnvName ? JSON.parse(gate.env[configEnvName]!) as Record<string, string> : gate.env).OMB_GATE_UPSTREAM!) as Server;

describe("a gate's upstream", () => {
  const mount = (toolScope: ToolScope | undefined, extra: Partial<Parameters<typeof gateServer>[0]> = {}) =>
    gateServer({ name: "agents", server: BRIDGED, threadId: "thread-gated", budget: 0, toolScope, ...extra });

  it("is the agents server unbridged whenever there is a selection, whatever the integration says", () => {
    for (const toolScope of [DENY_VM, ONLY_LIST_BOTS, { allow: ["mcp:agents:*"] }, { allow: [] }, {}] satisfies ToolScope[]) {
      const upstream = upstreamOf(mount(toolScope)!);
      expect(upstream.env[TOOL_BRIDGE_ENV], JSON.stringify(toolScope)).toBe("0");
      // and nothing else about it is touched
      expect(upstream, JSON.stringify(toolScope)).toEqual({ ...BRIDGED, env: { ...BRIDGED.env, [TOOL_BRIDGE_ENV]: "0" } });
    }
    // Codex hands each mount its settings under a private name
    const configEnvName = `OMB_GATE_CONFIG_${"a".repeat(64)}`;
    expect(upstreamOf(mount(DENY_VM, { configEnvName })!, configEnvName).env[TOOL_BRIDGE_ENV]).toBe("0");
    // the integration the turn holds is not rewritten: the prompt was built from it
    expect(BRIDGED.env[TOOL_BRIDGE_ENV]).toBe("1");
  });

  it("is left alone without a selection: there is no name to match, so nothing to protect", () => {
    // no selection and nothing to trim: no gate at all
    expect(mount(undefined)).toBeNull();
    // a gate that only trims results relays every call as it came
    expect(upstreamOf(mount(undefined, { budget: 12_000 })!)).toEqual(BRIDGED);
  });

  it("touches no other server, whatever is in its environment", () => {
    const other: Server = { command: "npx", args: ["some-server"], env: { TOKEN: "t", [TOOL_BRIDGE_ENV]: "1" } };
    expect(upstreamOf(gateServer({ name: "notes", server: other, threadId: "thread-gated", budget: 0, toolScope: { allow: ["mcp:notes:*"] } })!)).toEqual(other);
    expect(other.env[TOOL_BRIDGE_ENV]).toBe("1");
  });
});

describe("every mount that puts a selection in front of the agents server", () => {
  const turn = (toolScope?: ToolScope): SendTurnInput => ({ threadId: "thread-gated", text: "hi", integrations: { agents: BRIDGED }, ...(toolScope ? { toolScope } : {}) });

  it("the ACP mounts start it unbridged, and bridged as built without one", () => {
    const gated = turnStdioMcpServers(turn(DENY_VM)).find((server) => server.name === "agents")!;
    expect(gated.args).toEqual([SPAWNED_PROXIES.mcpGate]);
    expect(upstreamOf(gated).env[TOOL_BRIDGE_ENV]).toBe("0");
    expect(turnStdioMcpServers(turn()).find((server) => server.name === "agents")!.env[TOOL_BRIDGE_ENV]).toBe("1");
  });

  it("Pi's mount starts it unbridged, and bridged as built without one", () => {
    const gated = buildMcpServers(turn(DENY_VM))!.agents as Server;
    expect(gated.args).toEqual([SPAWNED_PROXIES.mcpGate]);
    expect(upstreamOf(gated).env[TOOL_BRIDGE_ENV]).toBe("0");
    expect((buildMcpServers(turn())!.agents as Server).env[TOOL_BRIDGE_ENV]).toBe("1");
  });

  describe("the chat runtime's own selection", () => {
    let session: ChatToolSession | undefined;
    afterEach(async () => { await session?.close(); session = undefined; });
    const mounted = async (toolScope?: ToolScope) => {
      session = await mountChatTools({ agents: BRIDGED }, new AbortController().signal, false, toolScope);
      return session.definitions.map((definition) => definition.function.name);
    };

    it("lists the whole catalog but what the selection leaves out, with no bridge tools", async () => {
      const names = await mounted(DENY_VM);
      for (const absent of ["agents_find_tool", "agents_use_tool", "agents_vm_exec"]) expect(names, absent).not.toContain(absent);
      expect(names).toEqual(expect.arrayContaining(["agents_coordinate_bots", "agents_trigger", "agents_trigger_info", "agents_session_search"]));
      expect(names).not.toContain("agents_list_triggers");
      // still OpenMausBot's own server, which never asks
      expect(session!.view("agents_trigger_info", {})).toMatchObject({ ask: false });
      expect(() => session!.validate("agents_use_tool", { name: "vm_exec" })).toThrow("not advertised for this turn");
    });

    it("answers an action's own name without listing it, and holds both names to the selection", async () => {
      const names = await mounted(NO_NEW_TRIGGERS);
      expect(names).toContain("agents_trigger");
      expect(names).not.toContain("agents_create_trigger");
      const create = { when: "webhook", name: "Leads", instructions: "Draft a reply." };
      expect(() => session!.validate("agents_trigger", { action: "create", ...create })).toThrow("Tool selection excludes this tool");
      expect(() => session!.validate("agents_create_trigger", create)).toThrow("Tool selection excludes this tool");
      expect(() => session!.validate("agents_trigger", { action: "pause", trigger_id: "t-1" })).not.toThrow();
      expect(() => session!.validate("agents_manage_trigger", { action: "pause", trigger_id: "t-1" })).not.toThrow();
      expect(() => session!.validate("agents_made_up", {})).toThrow("not advertised for this turn");
    });

    it("lists only what an allow-list names, so the bridge tools cannot be lost to it", async () => {
      expect(await mounted(ONLY_LIST_BOTS)).toEqual(["agents_list_bots"]);
    });

    it("is bridged as built when the bot has no selection", async () => {
      const names = await mounted();
      expect(names.slice(-2)).toEqual(["agents_find_tool", "agents_use_tool"]);
      expect(names).not.toContain("agents_trigger");
      expect(session!.view("agents_use_tool", { name: "list_triggers" })).toMatchObject({ ask: false });
    });
  });
});

describe("the gate and the proxy as processes", () => {
  it("a deny over a proxy that was asked to bridge: no bridge tools, the denied tool refused, use_tool unknown", async () => {
    // The reproduction from the review: before the mount unbridged the
    // proxy, use_tool({ name: "vm_exec" }) passed this gate and ran.
    await withServer(gateServer({ name: "agents", server: BRIDGED, threadId: "thread-gated", budget: 0, toolScope: DENY_VM })!, async (ask) => {
      const names = namesOf(await ask("tools/list"));
      for (const absent of ["find_tool", "use_tool", "vm_exec"]) expect(names, absent).not.toContain(absent);
      expect(names).toEqual(expect.arrayContaining(["coordinate_bots", "trigger", "trigger_info", "post_to_room"]));
      expect(names.length).toBeGreaterThan(20);
      expect((await call(ask, "vm_exec", { command: "true" })).error).toEqual(EXCLUDED);
      // through use_tool the gate refuses it by the tool it names, and for any
      // other tool there is no use_tool behind the gate to call
      expect((await use(ask, "vm_exec", { command: "true" })).error).toEqual(EXCLUDED);
      expect((await use(ask, "list_triggers")).error).toEqual({ code: -32602, message: "Unknown tool: use_tool" });
      expect((await call(ask, "find_tool", { query: "run a shell command" })).error).toEqual({ code: -32602, message: "Unknown tool: find_tool" });
    });
  });

  it("a deny of one action: the tool still listed, that action refused under either name, the rest let through", async () => {
    await withServer(gateServer({ name: "agents", server: BRIDGED, threadId: "thread-gated", budget: 0, toolScope: NO_NEW_TRIGGERS })!, async (ask) => {
      expect(namesOf(await ask("tools/list"))).toEqual(expect.arrayContaining(["trigger", "trigger_info"]));
      const create = { when: "webhook", name: "Leads", instructions: "Draft a reply." };
      expect((await call(ask, "trigger", { action: "create", ...create })).error).toEqual(EXCLUDED);
      expect((await call(ask, "create_trigger", create)).error).toEqual(EXCLUDED);
      for (const [name, args] of [["trigger", { action: "pause", trigger_id: "t-1" }], ["manage_trigger", { action: "pause", trigger_id: "t-1" }]] as const) {
        const passed = await call(ask, name, args);
        expect(passed.error, name).toBeUndefined();
        expect(passed.result!.isError, name).toBe(true);
      }
    });
  });

  it("an allow-list over one: exactly the allowed tools, and neither bridge tool to be hidden by it", async () => {
    await withServer(gateServer({ name: "agents", server: BRIDGED, threadId: "thread-gated", budget: 0, toolScope: ONLY_LIST_BOTS })!, async (ask) => {
      expect(namesOf(await ask("tools/list"))).toEqual(["list_bots"]);
      expect((await use(ask, "list_triggers")).error).toEqual(EXCLUDED);
      expect((await call(ask, "find_tool", { query: "routines" })).error).toEqual(EXCLUDED);
    });
  });

  it("judges a use_tool call by the tool it names too, should a bridged proxy ever answer behind a selection", async () => {
    // Not reachable through gateServer any more. Started by hand, as a mount
    // that forgot would start it.
    const forgetful = (toolScope: ToolScope): Server => ({
      command: process.execPath,
      args: [SPAWNED_PROXIES.mcpGate],
      env: { OMB_GATE_NAME: "agents", OMB_GATE_UPSTREAM: JSON.stringify(BRIDGED), OMB_GATE_BUDGET: "0", OMB_GATE_TOOL_SCOPE: JSON.stringify(toolScope) },
    });
    await withServer(forgetful(DENY_VM), async (ask) => {
      expect(namesOf(await ask("tools/list")).slice(-2)).toEqual(["find_tool", "use_tool"]);
      expect((await use(ask, "vm_exec", { command: "true" })).error).toEqual(EXCLUDED);
      // a tool the selection does not exclude goes through to the proxy, which has its own answer
      const unknown = await use(ask, "made_up");
      expect(unknown.error).toBeUndefined();
      expect(unknown.result).toMatchObject({ isError: true, content: [{ type: "text", text: "Unknown tool: made_up" }] });
      // a search runs nothing and names no tool to judge
      expect((await call(ask, "find_tool", { query: "run a shell command in the VM" })).result!.isError).toBe(false);
    });
    // with an allow-list, naming the two bridge tools does not let a tool outside it through
    await withServer(forgetful({ allow: ["mcp:agents:use_tool", "mcp:agents:find_tool", "mcp:agents:list_triggers"] }), async (ask) => {
      expect((await use(ask, "vm_exec", { command: "true" })).error).toEqual(EXCLUDED);
      expect((await use(ask, "start_thread", { title: "t", message: "m" })).error).toEqual(EXCLUDED);
    });
  });
});