mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
fix(runner): discover assigned tools when direct catalogs exceed limits (#14218)
Keep assigned app tools accessible when the combined Runner catalog exceeds its operation or byte limits. Reserve task and completion tools, then expose bounded discovery and call tools for large catalogs. Fetch oversized schemas in reauthorized chunks without blocking later search results. Retain task ownership, work-mode restrictions, pinned assignments, current gateway authorization, approvals, and audit. Small catalogs stay direct. Validation: 46 focused server tests, two Runner capacity tests, local typecheck/build, 52 passing CI checks, and Greptile 5/5 with no open findings. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -56,6 +56,19 @@ stdin/stdout bridge admits the pinned Claude and Codex ACPX profiles. It
|
||||
validates the exact model, session identity, tool catalog, structured input,
|
||||
and terminal settlement at the process boundary. Pi remains unavailable.
|
||||
|
||||
Remote Codex sessions relay assigned app tools through the server's configured
|
||||
gateway. Small catalogs are sent directly. When a catalog would exceed the
|
||||
runner's 256-operation or 768 KiB contract limit, the server exposes
|
||||
`paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` instead.
|
||||
Search returns bounded pages of names, descriptions, and input schemas. Each
|
||||
page intersects the session's pinned assignments with current gateway grants.
|
||||
An individual schema that exceeds a page returns an `inputSchemaRef`. The same
|
||||
search tool retrieves that schema in chunks via `schemaTool` and
|
||||
`schemaOffset`; discovery can continue past the large tool.
|
||||
Calls retain task ownership, work-mode restrictions, gateway authorization,
|
||||
approvals, and audit. Core task tools and the runner's completion tools keep
|
||||
their reserved space; no assigned tools are silently removed to fit the limit.
|
||||
|
||||
Native Claude skill assignments travel in the runtime-context snapshot through
|
||||
runnerd to the ACPX sidecar. After acquiring the provider lifetime lease, the
|
||||
host materializes the assigned bundles under the isolated Claude home's
|
||||
|
||||
@@ -52,6 +52,7 @@ export * from "./drivers/acpx/sidecar-protocol.js";
|
||||
export * from "./drivers/runner-tool-bridge.js";
|
||||
export {
|
||||
createRunnerdCodexTransport,
|
||||
runnerCodexDynamicToolsFit,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
readRunnerdArtifactBinding,
|
||||
drainRetainedRunnerdMaintenanceOperations,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { runnerCodexDynamicToolsFit } from "./runnerd-codex-transport.js";
|
||||
|
||||
function tools(count: number, description = "Read a fixture") {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
name: `fixture_${i}`, description, inputSchema: { type: "object" },
|
||||
}));
|
||||
}
|
||||
|
||||
it("reserves completion tools at the runner's operation boundary", () => {
|
||||
expect(runnerCodexDynamicToolsFit(tools(254))).toBe(true);
|
||||
expect(runnerCodexDynamicToolsFit(tools(255))).toBe(false);
|
||||
});
|
||||
|
||||
it("accounts for UTF-8 catalog bytes even below the operation limit", () => {
|
||||
expect(runnerCodexDynamicToolsFit(tools(200, "x".repeat(1000)))).toBe(true);
|
||||
expect(runnerCodexDynamicToolsFit(tools(200, "🙂".repeat(1000)))).toBe(false);
|
||||
});
|
||||
@@ -3045,6 +3045,17 @@ export function authorizedToolSetForProvider(
|
||||
return authorizedToolSet(tools);
|
||||
}
|
||||
|
||||
/** Include the completion tools that the Codex driver adds at session startup. */
|
||||
export function runnerCodexDynamicToolsFit(
|
||||
tools: readonly Readonly<Record<string, unknown>>[],
|
||||
): boolean {
|
||||
const supplied = [...tools, ...codexSemanticToolSpecs()];
|
||||
// Keep these bounds aligned with runner-core/provider_bridge.rs. Project
|
||||
// large optional catalogs before run.prepare; never raise the protocol caps.
|
||||
return supplied.length <= 256 &&
|
||||
Buffer.byteLength(JSON.stringify(authorizedToolSet(supplied)), "utf8") <= 768 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw provider tracing is consumed by runnerd itself. The provider child still
|
||||
* receives the narrower allowlist enforced by Rust's `SupervisedProcess`, so
|
||||
|
||||
@@ -15,6 +15,123 @@ function fixture(tools: ToolGatewayDescriptor[]) {
|
||||
}
|
||||
|
||||
describe("assigned MCP runner tools", () => {
|
||||
const searchName = "paperclip_search_assigned_tools";
|
||||
const callName = "paperclip_call_assigned_tool";
|
||||
|
||||
it("pages through an oversized catalog and calls every tool through the original gateway", async () => {
|
||||
const f = fixture(Array.from({ length: 224 }, (_, i) => descriptor(`app.action_${i}`)));
|
||||
const assigned = await createAssignedMcpTools(f);
|
||||
const direct = assigned.definitions();
|
||||
const compact = assigned.definitions(tools => tools.length <= 200);
|
||||
expect(compact.map(tool => tool.name)).toEqual([searchName, callName]);
|
||||
expect(assigned.definitions(tools => tools.length <= 224)).toEqual(direct);
|
||||
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
const seen: string[] = [];
|
||||
let offset: number | null = 0;
|
||||
do {
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "", offset, limit: 20 } }) as {
|
||||
tools: Array<{ name: string; inputSchema: unknown }>; nextOffset: number | null;
|
||||
};
|
||||
expect(page.tools.length).toBeLessThanOrEqual(20);
|
||||
for (const tool of page.tools) {
|
||||
seen.push(tool.name);
|
||||
expect(tool.inputSchema).toEqual(descriptor("any").parametersSchema);
|
||||
await assigned.execute({ tool: callName, arguments: { name: tool.name, arguments: { query: "fixture" } } });
|
||||
}
|
||||
offset = page.nextOffset;
|
||||
} while (offset !== null);
|
||||
expect(seen).toEqual(direct.map(tool => tool.name).sort());
|
||||
expect(f.executeTool).toHaveBeenCalledTimes(224);
|
||||
expect(new Set(f.executeTool.mock.calls.map(([call]) => call.tool)).size).toBe(224);
|
||||
for (const [call] of f.executeTool.mock.calls) {
|
||||
expect(call).toMatchObject({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, parameters: { query: "fixture" } });
|
||||
expect(call).not.toHaveProperty("approvedActionRequestId");
|
||||
}
|
||||
});
|
||||
|
||||
it("searches only pinned tools still granted by fresh discovery without projecting metadata", async () => {
|
||||
const f = fixture([descriptor("calendar.search"), descriptor("mail.search"), descriptor("calendar.remove", "write")]);
|
||||
const assigned = await createAssignedMcpTools(f);
|
||||
f.listToolsForNamedGateway.mockResolvedValue([
|
||||
{ ...descriptor("calendar.search"), providerMetadata: { token: "secret-provider-token" } },
|
||||
descriptor("calendar.remove", "write"), descriptor("calendar.new_grant"),
|
||||
]);
|
||||
const result = await assigned.execute({ tool: searchName, arguments: { query: "calendar" } }, "planning");
|
||||
expect(result).toEqual({ tools: [assigned.definitions()[0]], nextOffset: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/private-|secret-provider|gateway-fixture|new_grant/);
|
||||
f.listToolsForNamedGateway.mockRejectedValue(new Error("gateway_token_revoked"));
|
||||
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).rejects.toThrow("gateway_token_revoked");
|
||||
});
|
||||
|
||||
it.each(["planning", "ask"] as const)("preserves pinned and fresh %s restrictions through the call wrapper", async mode => {
|
||||
const f = fixture([descriptor("read"), descriptor("write", "write")]);
|
||||
const assigned = await createAssignedMcpTools(f);
|
||||
const restricted = await createAssignedMcpTools({ ...f, workMode: mode });
|
||||
const call = { tool: callName, arguments: { name: assigned.definitions()[1]!.name, arguments: {} } };
|
||||
await expect(assigned.execute(call, mode)).rejects.toThrow("paperclip_runner_tool_mode_denied");
|
||||
await expect(restricted.execute(call, "standard")).rejects.toThrow("paperclip_runner_tool_mode_denied");
|
||||
expect(f.executeTool).not.toHaveBeenCalled();
|
||||
const discovery = await restricted.execute({ tool: searchName, arguments: { query: "" } }, "standard");
|
||||
expect(discovery).toEqual({ tools: [assigned.definitions()[0]], nextOffset: null });
|
||||
});
|
||||
|
||||
it.each(["approval_required", "connection_revoked", "tool_error"])("preserves %s through the call wrapper", async reason => {
|
||||
const f = fixture([descriptor("write", "write")]);
|
||||
const assigned = await createAssignedMcpTools(f);
|
||||
const error = new ToolGatewayHttpError(403, "Denied", reason);
|
||||
f.executeTool.mockRejectedValue(error);
|
||||
await expect(assigned.execute({ tool: callName, arguments: { name: assigned.definitions()[0]!.name, arguments: {} } })).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("bounds search pages by bytes and rejects invalid wrapper arguments and unassigned targets", async () => {
|
||||
const f = fixture(Array.from({ length: 4 }, (_, i) => ({
|
||||
...descriptor(`large_${i}`),
|
||||
parametersSchema: { type: "object", description: "x".repeat(250 * 1024) },
|
||||
})));
|
||||
const assigned = await createAssignedMcpTools(f);
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 20 } }) as { tools: unknown[]; nextOffset: number };
|
||||
expect(page.tools).toHaveLength(2);
|
||||
expect(page.nextOffset).toBe(2);
|
||||
for (const args of [null, [], { query: "x".repeat(201) }, { query: "", offset: -1 }, { query: "", limit: 21 }, { query: "", offset: 0.5 }]) {
|
||||
await expect(assigned.execute({ tool: searchName, arguments: args })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
|
||||
}
|
||||
for (const name of ["unassigned", searchName, callName]) {
|
||||
await expect(assigned.execute({ tool: callName, arguments: { name, arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
|
||||
}
|
||||
await expect(assigned.execute({ tool: callName, arguments: { name: "unassigned", arguments: [] } })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
|
||||
expect(f.executeTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps individually oversized schemas and later tools discoverable with bounded schema chunks", async () => {
|
||||
const schema = { type: "object", description: "\u0000🙂".repeat(150_000), properties: {} };
|
||||
const f = fixture([{ ...descriptor("a_large"), parametersSchema: schema }, descriptor("z_small")]);
|
||||
const assigned = await createAssignedMcpTools(f);
|
||||
const large = assigned.definitions()[0]!.name as string;
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as {
|
||||
tools: Array<Record<string, unknown>>; nextOffset: number;
|
||||
};
|
||||
expect(page.tools).toEqual([{ name: large, description: "a_large: Use a_large", inputSchemaRef: large }]);
|
||||
expect(page.nextOffset).toBe(1);
|
||||
await expect(assigned.execute({ tool: searchName, arguments: { query: "", offset: page.nextOffset } }))
|
||||
.resolves.toEqual({ tools: [assigned.definitions()[1]], nextOffset: null });
|
||||
let schemaOffset: number | null = 0;
|
||||
let serialized = "";
|
||||
do {
|
||||
const chunk = await assigned.execute({ tool: searchName, arguments: { query: "", schemaTool: large, schemaOffset } }) as {
|
||||
schemaJson: string; nextSchemaOffset: number | null;
|
||||
};
|
||||
expect(Buffer.byteLength(JSON.stringify(chunk))).toBeLessThan(640 * 1024);
|
||||
serialized += chunk.schemaJson;
|
||||
schemaOffset = chunk.nextSchemaOffset;
|
||||
} while (schemaOffset !== null);
|
||||
expect(JSON.parse(serialized)).toEqual(schema);
|
||||
await assigned.execute({ tool: callName, arguments: { name: large, arguments: {} } });
|
||||
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tool: "a_large" }));
|
||||
f.listToolsForNamedGateway.mockResolvedValue([descriptor("z_small")]);
|
||||
await expect(assigned.execute({ tool: searchName, arguments: { query: "", schemaTool: large } })).rejects.toThrow("assigned_mcp_tool_unknown");
|
||||
await expect(assigned.execute({ tool: searchName, arguments: { query: "", schemaOffset: 1 } })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
|
||||
});
|
||||
|
||||
it("requires a configured gateway registered for the exact database instance", () => {
|
||||
const firstDb = {} as Db;
|
||||
const secondDb = {} as Db;
|
||||
|
||||
@@ -3,6 +3,51 @@ import type { Db } from "@paperclipai/db";
|
||||
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
|
||||
|
||||
type WorkMode = "standard" | "planning" | "ask";
|
||||
type ToolDefinition = Record<string, unknown>;
|
||||
|
||||
const SEARCH_TOOL = "paperclip_search_assigned_tools";
|
||||
const CALL_TOOL = "paperclip_call_assigned_tool";
|
||||
// A single valid runner schema can be 512 KiB. Leave room for its description
|
||||
// while keeping the complete result below the 768 KiB provider-result bound.
|
||||
const SEARCH_PAGE_BYTES = 640 * 1024;
|
||||
const SCHEMA_CHUNK_CHARACTERS = 64 * 1024;
|
||||
const ON_DEMAND_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: SEARCH_TOOL,
|
||||
description: "Search your assigned app tools by name or description and read their input schemas. Use an empty query to browse. Pass nextOffset to fetch the next page. A large definition returns inputSchemaRef: set schemaTool to that name with an empty query, then pass nextSchemaOffset until null. Concatenate schemaJson chunks and parse JSON. Use paperclip_call_assigned_tool with a returned name and arguments matching its inputSchema.",
|
||||
inputSchema: {
|
||||
type: "object", additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: "string", maxLength: 200 },
|
||||
offset: { type: "integer", minimum: 0 },
|
||||
limit: { type: "integer", minimum: 1, maximum: 20 },
|
||||
schemaTool: { type: "string", description: "Exact inputSchemaRef from a search result." },
|
||||
schemaOffset: { type: "integer", minimum: 0 },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: CALL_TOOL,
|
||||
description: "Call an assigned app tool discovered with paperclip_search_assigned_tools. Use its exact returned name and arguments matching its inputSchema. The same permissions, approvals, and audit rules apply as for direct app tools.",
|
||||
inputSchema: {
|
||||
type: "object", additionalProperties: false,
|
||||
properties: { name: { type: "string" }, arguments: { type: "object", additionalProperties: true } },
|
||||
required: ["name", "arguments"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("assigned_mcp_tool_invalid_arguments");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function definition(name: string, tool: ToolGatewayDescriptor): ToolDefinition {
|
||||
return { name, description: `${tool.displayName}: ${tool.description}`, inputSchema: structuredClone(tool.parametersSchema) };
|
||||
}
|
||||
|
||||
// Execution must use the app's configured gateway, including deployment
|
||||
// restrictions, OAuth refresh, and approval delivery. Never fall back to an
|
||||
@@ -48,19 +93,82 @@ export async function createAssignedMcpTools(input: {
|
||||
}
|
||||
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
|
||||
|
||||
async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) {
|
||||
const args = object(argumentsValue);
|
||||
const offset = args.offset ?? 0;
|
||||
const limit = args.limit ?? 5;
|
||||
const schemaOffset = args.schemaOffset ?? 0;
|
||||
if (typeof args.query !== "string" || args.query.length > 200 ||
|
||||
typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 ||
|
||||
typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > 20 ||
|
||||
(args.schemaTool !== undefined && typeof args.schemaTool !== "string") ||
|
||||
typeof schemaOffset !== "number" || !Number.isSafeInteger(schemaOffset) || schemaOffset < 0 ||
|
||||
(args.schemaOffset !== undefined && args.schemaTool === undefined)) {
|
||||
throw new Error("assigned_mcp_tool_invalid_arguments");
|
||||
}
|
||||
// Intersect fresh grants with this session's pinned catalog. Revocations
|
||||
// take effect immediately; newly assigned tools require a new session.
|
||||
const current = new Map((await input.gateway.listToolsForNamedGateway({
|
||||
gatewayPublicId: input.gatewayPublicId, bearerToken: input.bearerToken,
|
||||
})).map(tool => [tool.name, tool]));
|
||||
const terms = args.query.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||
const authorized = [...tools].filter(([, tool]) => {
|
||||
const fresh = current.get(tool.name);
|
||||
return fresh && permits(tool) && permits(tool, currentWorkMode) &&
|
||||
permits(fresh) && permits(fresh, currentWorkMode);
|
||||
});
|
||||
if (typeof args.schemaTool === "string") {
|
||||
const selected = authorized.find(([name]) => name === args.schemaTool);
|
||||
if (!selected) throw new Error("assigned_mcp_tool_unknown");
|
||||
const schema = JSON.stringify(selected[1].parametersSchema);
|
||||
const end = schemaOffset + SCHEMA_CHUNK_CHARACTERS;
|
||||
return {
|
||||
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
|
||||
nextSchemaOffset: end < schema.length ? end : null,
|
||||
};
|
||||
}
|
||||
const matches = authorized.filter(([name, tool]) =>
|
||||
terms.every(term => `${name} ${tool.displayName} ${tool.description}`.toLowerCase().includes(term)))
|
||||
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
const page: ToolDefinition[] = [];
|
||||
for (const [name, tool] of matches.slice(offset, offset + limit)) {
|
||||
let next = definition(name, tool);
|
||||
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > SEARCH_PAGE_BYTES) {
|
||||
// One large schema must not block browsing the tools after it. Expose
|
||||
// a reference whose schema can be fetched in bounded, reauthorized
|
||||
// chunks, without weakening the gateway's argument validation.
|
||||
next = { name, description: String(next.description).slice(0, 2000), inputSchemaRef: name };
|
||||
}
|
||||
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > SEARCH_PAGE_BYTES) {
|
||||
break;
|
||||
}
|
||||
page.push(next);
|
||||
}
|
||||
return { tools: page, nextOffset: offset + page.length < matches.length ? offset + page.length : null };
|
||||
}
|
||||
|
||||
return {
|
||||
definitions(): Array<Record<string, unknown>> {
|
||||
return [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => ({
|
||||
name,
|
||||
description: `${tool.displayName}: ${tool.description}`,
|
||||
inputSchema: structuredClone(tool.parametersSchema),
|
||||
}));
|
||||
definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] {
|
||||
const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool));
|
||||
if (!fits || fits(direct)) return direct;
|
||||
const compact = structuredClone(ON_DEMAND_TOOLS);
|
||||
if (!fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
return compact;
|
||||
},
|
||||
has(name: string): boolean {
|
||||
return tools.has(name);
|
||||
return tools.has(name) || name === SEARCH_TOOL || name === CALL_TOOL;
|
||||
},
|
||||
async execute(call: { tool: string; arguments: unknown }, currentWorkMode?: WorkMode): Promise<unknown> {
|
||||
const descriptor = tools.get(call.tool);
|
||||
if (call.tool === SEARCH_TOOL) return search(call.arguments, currentWorkMode);
|
||||
let name = call.tool;
|
||||
let parameters = call.arguments;
|
||||
if (name === CALL_TOOL) {
|
||||
const args = object(parameters);
|
||||
if (typeof args.name !== "string") throw new Error("assigned_mcp_tool_invalid_arguments");
|
||||
name = args.name;
|
||||
parameters = object(args.arguments);
|
||||
}
|
||||
const descriptor = tools.get(name);
|
||||
if (!descriptor) throw new Error("assigned_mcp_tool_unknown");
|
||||
if (!permits(descriptor) || !permits(descriptor, currentWorkMode)) throw new Error("paperclip_runner_tool_mode_denied");
|
||||
// Reauthorize through the existing gateway on every call. Discovery is
|
||||
@@ -69,7 +177,7 @@ export async function createAssignedMcpTools(input: {
|
||||
gatewayPublicId: input.gatewayPublicId,
|
||||
sessionToken: input.bearerToken,
|
||||
tool: descriptor.name,
|
||||
parameters: call.arguments,
|
||||
parameters,
|
||||
});
|
||||
if (result.status !== "completed" && result.status !== "replayed") {
|
||||
throw new Error("assigned_mcp_tool_execution_incomplete");
|
||||
|
||||
@@ -20,8 +20,10 @@ import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity
|
||||
import { documentService } from "../documents.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
|
||||
import type { ToolGatewayService } from "../tool-gateway.js";
|
||||
import { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
describe("PaperclipRunnerToolAuthority", () => {
|
||||
let temporary: Awaited<
|
||||
@@ -610,6 +612,38 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
).resolves.toMatchObject({ approval: { id: approvalId }, tasks: [] });
|
||||
});
|
||||
|
||||
it("fits large assigned catalogs alongside workspace and completion tools without dropping task tools", async () => {
|
||||
const listToolsForNamedGateway = vi.fn().mockResolvedValue(Array.from({ length: 224 }, (_, i) => ({
|
||||
name: `app.action_${i}`, displayName: `Action ${i}`, description: "Read a fixture",
|
||||
parametersSchema: { type: "object", properties: {} }, risk: "read",
|
||||
})));
|
||||
const assignedMcpTools = await createAssignedMcpTools({
|
||||
gateway: { listToolsForNamedGateway } as unknown as ToolGatewayService,
|
||||
gatewayPublicId: "fixture", bearerToken: "fixture-token",
|
||||
});
|
||||
const binding = { companyId, agentId, issueId, runId, workspaceRoot: "/tmp/fixture-workspace" };
|
||||
const baseline = new PaperclipRunnerToolAuthority(db, binding).definitions();
|
||||
expect(runnerCodexDynamicToolsFit([...baseline, ...assignedMcpTools.definitions()])).toBe(false);
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { ...binding, assignedMcpTools });
|
||||
const tools = authority.definitions();
|
||||
expect(runnerCodexDynamicToolsFit(tools)).toBe(true);
|
||||
expect(tools).toEqual(expect.arrayContaining(baseline));
|
||||
expect(tools.filter(tool => String(tool.name).startsWith("app_"))).toEqual([]);
|
||||
expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([
|
||||
"paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "register_deliverable",
|
||||
]));
|
||||
const call = { tool: "paperclip_search_assigned_tools", callId: "discover", arguments: { query: "Action 223" } };
|
||||
await expect(authority.execute(call)).resolves.toMatchObject({ tools: [expect.objectContaining({ description: "Action 223: Read a fixture" })] });
|
||||
listToolsForNamedGateway.mockClear();
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
||||
try {
|
||||
await expect(authority.execute(call)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
|
||||
expect(listToolsForNamedGateway).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
});
|
||||
|
||||
it("relays assigned MCP calls only while the native run still owns its task", async () => {
|
||||
const tool = { name: "app_mem0_recall", description: "Recall memory", inputSchema: { type: "object" } };
|
||||
const execute = vi.fn().mockResolvedValue({ content: "synthetic memory" });
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
issues,
|
||||
issueThreadInteractions,
|
||||
} from "@paperclipai/db";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
|
||||
import { agentService } from "../agents.js";
|
||||
import { approvalService } from "../approvals.js";
|
||||
import { documentService } from "../documents.js";
|
||||
@@ -225,7 +225,11 @@ export class PaperclipRunnerToolAuthority {
|
||||
definitions.push(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION);
|
||||
definitions.push(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION);
|
||||
definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION);
|
||||
return [...RUNTIME_CONNECTION_TOOL_DEFINITIONS, ...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools), ...(this.binding.assignedMcpTools?.definitions() ?? []), ...definitions];
|
||||
const connectionTools = [...RUNTIME_CONNECTION_TOOL_DEFINITIONS,
|
||||
...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools)];
|
||||
const assignedTools = this.binding.assignedMcpTools?.definitions((tools) =>
|
||||
runnerCodexDynamicToolsFit([...connectionTools, ...tools, ...definitions])) ?? [];
|
||||
return [...connectionTools, ...assignedTools, ...definitions];
|
||||
}
|
||||
|
||||
async execute(call: {
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ const sourceUrl = new URL(
|
||||
const runner = (await import(sourceUrl.href)) as RunnerModule;
|
||||
|
||||
export const DurablePrpControlPlane = runner.DurablePrpControlPlane;
|
||||
export const runnerCodexDynamicToolsFit = runner.runnerCodexDynamicToolsFit;
|
||||
export const inspectWarmRunTransition = runner.inspectWarmRunTransition;
|
||||
export const readRunnerdArtifactBinding = runner.readRunnerdArtifactBinding;
|
||||
export const NativeSessionCleanupQuarantinedError =
|
||||
|
||||
Reference in New Issue
Block a user