Fix isolated ACPX admission for assigned PRP tools (#17149)

## Thinking Path

- Paperclip uses Runner to execute agent tasks under Core's selected
policy.
- The ACPX sidecar can receive assigned MCP tools in its PRP catalog.
- Isolated admission still requires a separate native MCP endpoint for
those assignments.
- This check rejects a valid PRP route before the provider can start.
- This PR identifies the sidecar's PRP transport at the runtime-host
boundary.
- The benefit is that assigned tools work with the existing isolated
permission policy.

## Linked Issues or Issue Description

**What happened?**

An isolated Claude task with an assigned MCP binding fails with
`assigned native MCP launch binding is unavailable` when Core delivers
its tools through PRP.

**Expected behavior**

The sidecar must accept its authenticated PRP tool bridge as the
assigned-tool route. Callers that use the native gateway must still
supply its complete binding.

**Steps to reproduce**

1. Open an isolated Claude sidecar session with `deny-all` permissions
and a read-only policy.
2. Supply an assigned MCP binding in the runtime context and its tools
in the PRP catalog.
3. Omit native gateway environment variables. Admission fails before
provider startup.

**Paperclip version or commit**

Reproduced against `92cfd701c39bb1ea394a796023481a21decb2532`.

**Deployment mode**

Runner with an ACPX sidecar and controller-owned tool delivery. Related
contract work: #16978. The search found no existing PR or issue for this
failure.

## What Changed

- Let runtime-host callers select PRP delivery when they supply the
semantic tool bridge.
- Select that route in the sidecar. Keep native gateway validation and
direct callers' default behavior.
- Preserve exact controller operation names in PRP mode, including the
compact assigned-MCP catalog. Keep existing aliases for other bridge
callers.
- Preserve the selected permission and read-only policies.
- Add one process-level sidecar regression and one host admission
regression. The host test calls the real authenticated MCP bridge.
- Document the route and refresh the generated Copilot source hashes.
The provider and profile versions stay the same.

## Verification

- Passed 11 focused tests after the rebase across the runtime host,
sidecar, tool bridge, and Copilot identity tests. They cover both
compact catalog operations, restricted and approve-all policies, legacy
aliases, missing and invalid gateway bindings, and the existing direct
gateway path.
- The process-level test crosses the real sidecar dispatcher and stops
at workspace validation before provider startup. The host test uses real
ownership sockets and MCP HTTP, with a fake provider port.
- The generated identity tests match the current source hashes. The
historical fixture stays unchanged.
- The standard review passed on the rebased commit. [Apex passed the
required 5/5
threshold](https://github.com/paperclipai/paperclip/runs/119513362214)
on `e63a223`, with 11 files reviewed and no findings.
- Rebased onto master `734c265`. The conflicts affected generated source
identities and their test expectations. All reviewed behavior files
remain byte-for-byte unchanged. The user authorized retaining the Apex
result for this rebase. [CI passed on
`3facef5`](https://github.com/paperclipai/paperclip/actions/runs/39685163239).
The final PR checks contain 56 passes and four expected skips. The
branch is current with master, has no conflicts, and has no unresolved
review comments.
- `node packages/paperclip-runner/scripts/copilot-profile-identity.mjs
--check` passed.
- Initial local runs exposed missing test dependencies and sandbox
denial of loopback sockets. After the scoped dependency install and
socket permission, the selected tests passed.
- Broad typecheck, build, and test verification runs in GitHub CI, as
requested. No live provider or model request was made.

## Risks

- PRP callers must supply the assigned tools in the controller-owned
catalog. The existing bridge still enforces its closed catalog and
authentication.
- An explicit PRP selection without a semantic bridge still fails.
Invalid native gateway bindings still fail.
- No database, protocol-frame, provider-version, or credential-storage
change is required.

## Model Used

OpenAI GPT-6 through Codex, with source inspection, editing, and command
execution. The exact served model ID and context-window size are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-21 16:00:14 -07:00
1 parent 734c265339
commit 0081863463
11 files changed
+87 -14

No files matched your search

+8
View File
@@ -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
+1 -1
View File
@@ -37,7 +37,7 @@
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d",
"commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f",
"requiresProviderPolicy": true
},
"claude": {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:6f49e1a37bcde897703701b638dddf010a0b8e2c92c211a30d4a0bd41e48e77d",
"sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -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) => {
@@ -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,
@@ -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);
@@ -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": {
@@ -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"] = [];
@@ -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",
@@ -39,6 +39,7 @@ export interface RunnerToolBridgeOptions {
tools?: readonly Readonly<Record<string, unknown>>[];
/** 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<Record<string, unknown>>[];
handler(call: RunnerToolCall): Promise<unknown>;
@@ -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<string, AbortController>();
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<string, RunnerToolDefinition>;
validators: Map<string, ValidateFunction>;
@@ -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<Record<string, unknown>>[],
visibility: "public" | "private",
preserveNames = false,
): RunnerToolDefinition[] {
const tools: RunnerToolDefinition[] = [];
const seen = new Set<string>();
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`);
}
@@ -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",