diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index f935e3ada71..243b7928bba 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -192,6 +192,14 @@ and terminal settlement at the process boundary. Copilot and Pi remain gated. Verified distributions are build-owned; no provider accepts an arbitrary executable. See [the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md). +If the controller sends assigned MCP tools in the PRP catalog, the sidecar uses +its authenticated tool bridge. +Isolated provider admission accepts this route without a separate native MCP endpoint. +Direct runtime-host callers must supply the native gateway binding unless they select +PRP tool delivery and supply the semantic tool bridge. +PRP delivery keeps the controller's exact operation names, including compact MCP catalog tools. +The selected permission and read-only policies still apply. + Install Cursor explicitly with `paperclipai runtime setup cursor`; npm installation does not download it. Run setup as the OS user that runs Paperclip (the service account for a managed service). Setup writes to that account's diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 9ad42bdaf31..aa022d1fe27 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -37,7 +37,7 @@ "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d", + "commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index ed11dcc6584..9b9b6fe2707 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d", + "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index 248950de9d8..62dc7e15321 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -1,3 +1,4 @@ +import { canonicalNativeRuntimeContextDigest } from "../contracts/runtime-context.js"; import { acpxProfileActivity } from "../drivers/acpx/profile-activity.js"; import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js"; import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "../drivers/semantic-tool-receipt.js"; @@ -1129,6 +1130,23 @@ describe("qualified ACPX runtime sidecar", () => { }, ); + it("routes assigned MCP through PRP in the actual restricted Claude open dispatcher", async () => { + const sidecar = startSidecar(), model = "claude-sonnet-5", digest = "a".repeat(64); + const snapshot = { prompt: { revision: "fixture", text: "Instructions", digest: createHash("sha256").update("Instructions").digest("hex") }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: "paperclip.runtime-asset.v1" as const, + rootPath: "/unused", digest, manifestDigest: digest, fileCount: 0, totalBytes: 0 } }, + skills: [], mcp: { assignmentSetId: "assigned", digest, bindingId: "assigned-run" } }; + sidecar.write(initializeRequest(1, "claude", model)); + expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true }); + sidecar.write({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open", + params: { runtimeDirectory: "/unused", workingDirectory: "/", normalizedSessionId: "assigned-open", + agent: "claude", model, permissionMode: "deny-all", providerPolicy: { readOnly: true }, systemInstructions: "Instructions", + runtimeContext: { ...snapshot, aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot) }, + tools: [{ name: "assigned_lookup", inputSchema: { type: "object" } }] } }); + expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({ ok: false, + error: { message: "ACPX working directory must be a non-root directory" } }); + }); + it.each(["off", "low", "high", "max"])( "accepts Pi thinking level %s through the actual session.open dispatcher", async (piThinkingLevel) => { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index e6c131fe6b0..2b728b2027e 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -307,6 +307,7 @@ async function dispatch( runtimeContext: params.runtimeContext, environment: createAcpxSidecarHostEnvironment(process.env, params.agent, params.normalizedSessionId), expectedIdentity: params.expectedIdentity, + assignedMcpTransport: "prp", semanticTools: { tools: params.tools, handler: waitForTool, diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 961571a5ef4..f723fc4ce2a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -89,14 +89,14 @@ describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(58); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f"); }); it("preserves v57 while binding the shared Pi setup and launch repairs in v58", () => { const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v57-identity.json", import.meta.url), "utf8")); const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v58-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ - "agentProfileVersion", "guardedInstallationSourceSha256", "profileCompatibilitySourceSha256", "profileInstallationSourceSha256", "runtimeRequestBridgeSourceSha256", + "agentProfileVersion", "guardedInstallationSourceSha256", "profileCompatibilitySourceSha256", "profileInstallationSourceSha256", "runtimeHostSourceSha256", "runtimeRequestBridgeSourceSha256", "semanticBridgeSourceSha256", "semanticSidecarSourceSha256", ]); expect(isSupportedAcpxProfileVersion("copilot", 57)).toBe(true); expect(current.commandDigest).not.toBe(prior.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 925ab67ba73..2666dbd7c1b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -53,7 +53,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d", + "commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 9de2b8c6413..f97924870e9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -379,6 +379,45 @@ describe("ACPX runtime host", () => { await nextOwner.close({ reason: "cancelled initializer ownership released" }); }); + it.each(["deny-all", "approve-all"] as const)("admits the compact assigned MCP catalog through PRP with %s policy", async permissionMode => { + const fixture = await hostFixture(); + const runtimeContext = { skills: [], mcp: { bindingId: "assigned-run" } } as unknown as NativeRuntimeContextSnapshot; + const catalog = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool"].map(name => ({ name, inputSchema: { type: "object" } })); + const readOnly = permissionMode === "deny-all"; + const handler = vi.fn(async () => ({ value: "assigned result" })); + let bridge: { url: string; bearerToken: string } | undefined; + const options = { ...fixture.options, agent: "claude" as const, model: "claude-sonnet-5", + permissionMode, providerPolicy: { readOnly }, runtimeContext, + semanticTools: { tools: catalog, handler } }; + const openRuntime = vi.fn(async (launch: AcpxRuntimePortOpenOptions) => { + expect(launch.permissionMode).toBe(permissionMode); + expect(launch.launchEnvironment.PAPERCLIP_RUNNER_HOME_MODE).toBeUndefined(); + expect(launch.mcpServers.map(server => server.name)).toEqual(["paperclip"]); + if (!("url" in launch.mcpServers[0])) throw new Error("missing tool bridge"); + bridge = launch.mcpServers[0]; + return runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }); + }); + const dependencies = fixture.dependencies({ openRuntime }); + const prepare = dependencies.prepareSandbox!; + dependencies.prepareSandbox = input => { expect(input.providerPolicy?.readOnly).toBe(readOnly); return prepare(input); }; + await expect(AcpxRuntimeHost.open(options, dependencies)).rejects.toThrow("assigned native MCP launch binding is unavailable"); + await expect(AcpxRuntimeHost.open({ ...options, assignedMcpTransport: "prp", semanticTools: undefined }, dependencies)).rejects.toThrow("assigned native MCP launch binding is unavailable"); + expect(openRuntime).not.toHaveBeenCalled(); + const host = await AcpxRuntimeHost.open({ ...options, assignedMcpTransport: "prp" }, dependencies); + try { + for (const tool of catalog) { + const response = await fetch(bridge!.url, { method: "POST", headers: { + Authorization: `Bearer ${bridge!.bearerToken}`, "Content-Type": "application/json", + }, body: JSON.stringify({ jsonrpc: "2.0", id: tool.name, method: "tools/call", params: { name: tool.name, arguments: {} } }) }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ id: tool.name, result: { content: expect.any(Array) } }); + expect(handler).toHaveBeenLastCalledWith(expect.objectContaining({ tool: tool.name, arguments: {} })); + } + expect(handler).toHaveBeenCalledTimes(catalog.length); + } finally { await host.close({ reason: "PRP assignment verified" }); } + expect(openRuntime).toHaveBeenCalledTimes(1); + }); + it("registers the assigned connection gateway alongside the Claude task bridge", async () => { const fixture = await hostFixture(); let servers: AcpxRuntimePortOpenOptions["mcpServers"] = []; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 43217962a74..a58bfe2ea75 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -254,6 +254,7 @@ export interface OpenAcpxRuntimeHostOptions { /** Abort admission without admitting resources that resolve afterward. */ signal?: AbortSignal; semanticTools?: AcpxSemanticToolSession; + assignedMcpTransport?: "prp"; onGoalUpdate?: (goal: AcpxRuntimeGoalSnapshot | null) => void; } @@ -349,7 +350,8 @@ export class AcpxRuntimeHost { if (nativeMcp?.name === "paperclip") { throw new Error("assigned native MCP name conflicts with the task bridge"); } - if (!preconfigured && options.runtimeContext?.mcp.bindingId && !nativeMcp) { + if (!preconfigured && options.runtimeContext?.mcp.bindingId && !nativeMcp + && !(options.assignedMcpTransport === "prp" && options.semanticTools)) { throw new Error("assigned native MCP launch binding is unavailable"); } const profile = resolveQualifiedAcpxProfile(options.agent, options.model); @@ -588,6 +590,7 @@ export class AcpxRuntimeHost { signal: options.signal, acquire: () => startRunnerToolBridge({ ...options.semanticTools!, + preserveToolNames: options.assignedMcpTransport === "prp", preloadClaudeTaskTools: options.agent === "claude", }), resource: "tool_bridge", diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts index 4c3a49a873a..3a82d69d209 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts @@ -39,6 +39,7 @@ export interface RunnerToolBridgeOptions { tools?: readonly Readonly>[]; /** Claude-only: avoid tool search for task naming and terminal disposition. */ preloadClaudeTaskTools?: boolean; + preserveToolNames?: boolean; /** Runner-owned operations that are callable but never model-visible. */ privateTools?: readonly Readonly>[]; handler(call: RunnerToolCall): Promise; @@ -110,8 +111,8 @@ export async function startRunnerToolBridge( const secret = options.secret ?? randomBytes(32).toString("base64url"); if (secret.length === 0) throw new Error("Runner tool bridge secret is empty"); - const tools = normalizeTools(options.tools ?? [], "public"); - const privateTools = normalizeTools(options.privateTools ?? [], "private"); + const tools = normalizeTools(options.tools ?? [], "public", options.preserveToolNames); + const privateTools = normalizeTools(options.privateTools ?? [], "private", options.preserveToolNames); const publicNames = new Set(tools.map((tool) => tool.name)); for (const tool of privateTools) { if (publicNames.has(tool.name)) { @@ -134,6 +135,7 @@ export async function startRunnerToolBridge( const controllers = new Map(); const context = { secret, + preserveToolNames: options.preserveToolNames === true, visibleTools, admittedTools: new Map(admittedTools.map((tool) => [tool.name, tool])), validators, @@ -204,6 +206,7 @@ async function handleRequest( response: ServerResponse, context: { secret: string; + preserveToolNames: boolean; visibleTools: RunnerToolDefinition[]; admittedTools: Map; validators: Map; @@ -289,7 +292,7 @@ async function handleRequest( const params = isRecord(message.params) ? message.params : {}; const rawName = typeof params.name === "string" ? params.name : ""; - const tool = canonicalRunnerToolName(rawName); + const tool = context.preserveToolNames ? rawName : canonicalRunnerToolName(rawName); if (!context.admittedTools.has(tool)) { writeToolError(response, id, "Unsupported tool."); return; @@ -413,13 +416,14 @@ async function handleRequest( function normalizeTools( input: readonly Readonly>[], visibility: "public" | "private", + preserveNames = false, ): RunnerToolDefinition[] { const tools: RunnerToolDefinition[] = []; const seen = new Set(); const reservedNames = new Set(RESERVED_TOOLS.map((tool) => tool.name)); for (const raw of input) { const name = - typeof raw.name === "string" ? canonicalRunnerToolName(raw.name) : ""; + typeof raw.name === "string" ? (preserveNames ? raw.name : canonicalRunnerToolName(raw.name)) : ""; if (!/^[A-Za-z0-9_.:-]{1,256}$/.test(name)) { throw new Error(`Runner ${visibility} tool has an invalid name`); } diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json index 73ea4781ee6..b0846c6641a 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d", + "commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -26,10 +26,10 @@ "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", - "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "semanticBridgeSourceSha256": "8823475a90f5da3477e86eb2d4d5a221733f1dda7117d0f6ff00e3b6bebabf6b", "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", - "semanticSidecarSourceSha256": "cc90b59bdfe6a82f7fa3e819e8671f71f7b14047e68942e8086618ffbaacf59f", + "semanticSidecarSourceSha256": "6165e9c3e544c6991b2703ba406f83617d3a939d988355da9d46eded09c4679a", "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", @@ -75,7 +75,7 @@ "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", "semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0", - "runtimeHostSourceSha256": "60e5454f030e87f2afa8b1fb120b26adb126b6b17d0e5e82f49c77f76f466cbb", + "runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec", "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",