Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ subagents/

# Working notes and design spikes — local only, never committed
docs/plans/

# Scratch repro tests — never committed
__repro/
**/__repro/
7 changes: 7 additions & 0 deletions e2e/subagent-permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { MCPClient } from "../src/mcp/client.js";
import { getSubAgentIdentity } from "../src/subagent/identity-context.js";
import { createSubAgentSessionStore } from "../src/subagent/session-store.js";
import { workerPermissionGate } from "../src/permission/reactor-authorize.js";
import { getProcessWorkerGrantStore } from "../src/permission/worker-grant.js";
import { gateAgentTools } from "../src/plugins/permission-plugin.js";

const report =
Expand Down Expand Up @@ -69,6 +70,12 @@ async function withWorker(
}),
};
try {
// Every probe reuses worker id "worker" in a fresh tmpdir, but denied-call
// grant envelopes live in a process-shared store keyed by that session id.
// A prior probe's pending envelope (same tool + empty args, other cwd)
// would veto this probe's call via the retry-from-another-directory
// blocker, so each probe starts from a clean store.
getProcessWorkerGrantStore().clear();
await withMockedModuleDuring(
import.meta.resolve("../src/session/assemble-runtime.js"),
(real: typeof import("../src/session/assemble-runtime.js")) => ({
Expand Down
182 changes: 182 additions & 0 deletions src/agent/mcp-promote-on-execute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* CL-9704: Linear MCP discovery-to-invocation on the primary session.
*
* Regression lock for "discoverable but not callable": tool_search finds
* `mcp__linear__*`, and promote-on-execute must then commit a callable
* schema for exactly the called name and dispatch it — list_teams first,
* then save_issue. Search alone never promotes (the wire stays
* built-ins-only until a call), and promoting one name never implies its
* siblings: the primary session mounts MCP tools on demand, mirroring the
* worker requires_tools gate.
*/

import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
installMcpConnectMock,
linearHttpMcpServer,
mcpTestPermissionGate,
} from "../../testkit/mcp-connect-mock.js";

const linearTools = [
{
name: "list_teams",
description: "List Linear teams",
inputSchema: { type: "object", properties: {}, required: [] },
},
{
name: "save_issue",
description: "Save a Linear issue",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
teamId: { type: "string" },
},
required: ["title"],
},
},
];

const mock = await installMcpConnectMock(
import.meta.resolve("../mcp/client.js"),
{ initialTools: linearTools, toolCallResult: "linear-ok" },
);

const { createAgentToolset } = await import("./tools.js");
const { createToolIndex, createToolSearchTool } =
await import("./tool-search.js");
const { createAdvertisedToolset } =
await import("../session/assemble-runtime.js");

const dirs: string[] = [];

function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
dirs.push(dir);
return dir;
}

afterEach(() => {
mock.reset();
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

describe("CL-9704 linear MCP discovery-to-invocation", () => {
test("tool_search finds list_teams, promote-on-execute makes list_teams then save_issue callable", async () => {
const toolset = await createAgentToolset({
cwd: tempDir("corbits-cl9704-"),
permissionGate: mcpTestPermissionGate(),
onOperatorGate: async () => ({ kind: "cancel" }),
mcpServers: [linearHttpMcpServer],
});
try {
await toolset.connectMCP({
interactiveAuth: false,
onStatus: () => undefined,
onToolsChanged: () => undefined,
});

const registered = toolset.dynamicRunner
.currentDefinitions()
.map((d) => d.name);
expect(registered).toContain("mcp__linear__list_teams");
expect(registered).toContain("mcp__linear__save_issue");

// Primary-session promote-on-execute wiring (mirrors
// tui/runner/session.ts + tui/runner/exit.ts): the call gate keys off
// the advertised set, and the promoter declares exactly the called
// name, committing its schema onto the next infer's wire.
const advertised = createAdvertisedToolset({
sessionMode: "orchestrator",
toolAvailability: { languageServerAvailable: false },
getProvider: () => ({ providerName: "test", model: "test" }),
});
toolset.dynamicRunner.setCallGate(
(name) => advertised.isAdvertised(name),
{ isActivated: (name) => advertised.activated.has(name) },
);
let wire: string[] = [];
toolset.setToolPromoter((names) => {
advertised.activated.activate(names);
if (advertised.flushPromotions()) {
wire = advertised
.computeAdvertised(toolset.dynamicRunner.currentDefinitions())
.map((d) => d.name);
}
});
const wireSchemas = (): Map<string, unknown> =>
new Map(
advertised
.computeAdvertised(toolset.dynamicRunner.currentDefinitions())
.map((d) => [d.name, d.inputSchema]),
);

const index = createToolIndex(() =>
toolset.dynamicRunner.currentDefinitions(),
);
const search = createToolSearchTool({
search: (query, limit) => index.search(query, limit),
lookup: (name) =>
toolset.dynamicRunner
.currentDefinitions()
.find((d) => d.name === name),
});
if (search.kind !== "string") throw new Error("expected string tool");

// Discovery: the card names list_teams with its description.
const card = await search.handler(
{ query: "linear teams" },
new AbortController().signal,
);
expect(card).toContain("mcp__linear__list_teams");
expect(card).toContain("List Linear teams");

// Search alone promotes nothing: no Linear schema on the wire.
expect(wire).not.toContain("mcp__linear__list_teams");
expect(wire).not.toContain("mcp__linear__save_issue");

const run = (name: string, args: Record<string, unknown> = {}) =>
toolset.dynamicRunner.run(
{ id: `call-${name}`, name, arguments: args },
new AbortController().signal,
);

// Invocation 1: list_teams dispatches and promotes only itself.
const listed = await run("mcp__linear__list_teams");
expect(listed.isError).toBeUndefined();
expect(listed.content).toContain("linear-ok");
expect(wire).toContain("mcp__linear__list_teams");
expect(wire).not.toContain("mcp__linear__save_issue");
expect(wireSchemas().get("mcp__linear__list_teams")).toEqual(
linearTools[0]?.inputSchema,
);

// Invocation 2: save_issue dispatches with its args after discovery.
const saved = await run("mcp__linear__save_issue", {
title: "hello",
teamId: "t1",
});
expect(saved.isError).toBeUndefined();
expect(saved.content).toContain("linear-ok");
expect(wire).toEqual(
expect.arrayContaining([
"mcp__linear__list_teams",
"mcp__linear__save_issue",
]),
);
expect(mock.calls.map((c) => c.toolName)).toEqual([
"list_teams",
"save_issue",
]);
expect(mock.calls[1]?.args).toEqual({ title: "hello", teamId: "t1" });
} finally {
await toolset.dispose();
}
});
});
158 changes: 158 additions & 0 deletions src/subagent/agent-fleet-requires-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";

import { stringTool, type AgentTool } from "@intx/agent";
import {
createFleetMailbox,
createSpawnAgentTool,
Expand Down Expand Up @@ -38,6 +39,13 @@ const FULL_MOUNT_PROFILE: AgentProfile = {
systemPromptRole: "You do anything.",
};

// BUILD_TOOLS-like envelope: built-ins only, no MCP names.
const BUILD_LIKE_PROFILE: AgentProfile = {
id: "build-worker",
systemPromptRole: "You build.",
capabilities: { mode: "allow", tools: ["read_file", "run_shell"] },
};

function makeDeps(
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>,
capturedTelemetry: TelemetryEvent[],
Expand Down Expand Up @@ -300,6 +308,156 @@ describe("spawn_agent requires_tools preflight", () => {
expect(seenTier).toBe("leaf");
});

test("mounted mcp__linear__ tool passes dispatch preflight under a built-ins-only allowlist", async () => {
const telemetry: TelemetryEvent[] = [];
let runCalled = false;
let seenRequires: readonly string[] | undefined;
const linearTool: AgentTool = stringTool({
definition: {
name: "mcp__linear__list_teams",
description: "Inherited Linear tool",
inputSchema: {},
},
handler: async () => "teams",
});
const base = makeDeps(async (params) => {
runCalled = true;
seenRequires = params.requiresTools;
return { report: "done" };
}, telemetry);
const deps: AgentFleetDeps = {
...base,
profiles: [BUILD_LIKE_PROFILE],
inheritMcpTools: () => [linearTool],
};
const spawn = createSpawnAgentTool(deps);

const result = await callSpawn(spawn, {
description: "linear job",
prompt: "list teams",
agent: "build-worker",
requires_tools: ["mcp__linear__list_teams"],
});

expect(result.isError).not.toBe(true);
expect(runCalled).toBe(true);
const body = JSON.parse(result.content) as { agent_id: string };
expect(deps.sessions.get(body.agent_id)?.requiresTools).toEqual([
"mcp__linear__list_teams",
]);
expect(seenRequires).toEqual(["mcp__linear__list_teams"]);
});

test("unmounted mcp__linear__ tool rejects dispatch preflight as unknown_tool with no run", async () => {
const telemetry: TelemetryEvent[] = [];
let runCalled = false;
const base = makeDeps(async () => {
runCalled = true;
return { report: "done" };
}, telemetry);
const deps: AgentFleetDeps = {
...base,
profiles: [BUILD_LIKE_PROFILE],
};
const spawn = createSpawnAgentTool(deps);

const result = await callSpawn(spawn, {
description: "linear job",
prompt: "list teams",
agent: "build-worker",
requires_tools: ["mcp__linear__list_teams"],
});

expect(result.isError).toBe(true);
expect(result.content).toContain('unknown tool "mcp__linear__list_teams"');
expect(result.content).not.toContain("Did you mean");
expect(runCalled).toBe(false);
expect(deps.sessions.list()).toEqual([]);
expect(telemetry).toEqual([]);
});

test("requires_tools stamps only the requested live tool, never the inherited set", async () => {
const telemetry: TelemetryEvent[] = [];
let runCalled = false;
let seenRequires: readonly string[] | undefined;
const mcpTool = (name: string): AgentTool =>
stringTool({
definition: {
name,
description: `Inherited ${name}`,
inputSchema: {},
},
handler: async () => name,
});
const base = makeDeps(async (params) => {
runCalled = true;
seenRequires = params.requiresTools;
return { report: "done" };
}, telemetry);
const deps: AgentFleetDeps = {
...base,
profiles: [BUILD_LIKE_PROFILE],
inheritMcpTools: () => [
mcpTool("mcp__linear__list_teams"),
mcpTool("mcp__linear__create_issue"),
],
};
const spawn = createSpawnAgentTool(deps);

const result = await callSpawn(spawn, {
description: "linear job",
prompt: "list teams",
agent: "build-worker",
requires_tools: ["mcp__linear__list_teams"],
});

// On-demand: dispatch stamps exactly the requested live tool — the
// inherited sibling mounts only under its own stamp (run.ts drops it).
expect(result.isError).not.toBe(true);
expect(runCalled).toBe(true);
expect(seenRequires).toEqual(["mcp__linear__list_teams"]);
});

test("requires_tools naming a live and an unmounted mcp__ tool rejects the unmounted one with no run", async () => {
const telemetry: TelemetryEvent[] = [];
let runCalled = false;
const base = makeDeps(async () => {
runCalled = true;
return { report: "done" };
}, telemetry);
const deps: AgentFleetDeps = {
...base,
profiles: [BUILD_LIKE_PROFILE],
inheritMcpTools: () => [
stringTool({
definition: {
name: "mcp__linear__list_teams",
description: "Inherited Linear tool",
inputSchema: {},
},
handler: async () => "teams",
}),
],
};
const spawn = createSpawnAgentTool(deps);

const result = await callSpawn(spawn, {
description: "linear job",
prompt: "list teams and file",
agent: "build-worker",
requires_tools: ["mcp__linear__list_teams", "mcp__linear__create_issue"],
});

expect(result.isError).toBe(true);
expect(result.content).toContain(
'unknown tool "mcp__linear__create_issue"',
);
expect(result.content).not.toContain("Did you mean");
expect(runCalled).toBe(false);
expect(deps.sessions.list()).toEqual([]);
expect(telemetry).toEqual([]);
});

test("whitespace-only requires_tools rejects fail-closed with no session, telemetry, or run", async () => {
for (const requiresTools of [[" "], ["read_file", " "]]) {
const telemetry: TelemetryEvent[] = [];
Expand Down
Loading
Loading