Bound native OpenCode assigned-tool catalog projection (#17740)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents use assigned connection tools through a governed
gateway.
> - OpenCode received the gateway catalog directly, outside the bounded
server tool projection.
> - A large catalog can exceed a provider grammar compiler's capacity
even when it fits the transport limits.
> - Synthetic requests reproduced the Fireworks grammar timeout on the
configured DeepSeek model.
> - This pull request gives OpenCode two assigned-tool discovery and
call schemas through the existing server relay.
> - The gateway keeps the same grants, validation, approvals,
revocation, and audit rules.

## Linked Issues or Issue Description

**What happened?**

A native OpenCode run could fail during provider grammar compilation
before its first assigned-tool call. OpenCode advertised the full
assigned gateway catalog. A synthetic catalog of 741 tool schemas
reproduced `Grammar processing took too much time` on Fireworks through
OpenRouter with `deepseek/deepseek-v4.1-flash-1017`. The same model and
backend accepted a compact two-tool discovery/call catalog. This
reproduces the mechanism; it does not replay a historical private
request.

**Expected behavior**

Assigned-tool discovery must keep provider grammar size bounded. Every
call must retain the run's original gateway binding and permissions.

**Steps to reproduce**

1. Give a native OpenCode run a large assigned connection catalog.
2. Use a provider that compiles tool schemas into a constrained output
grammar.
3. Observe the full catalog in the provider tool surface before this
change.
4. After this change, observe only `paperclip_search_assigned_tools` and
`paperclip_call_assigned_tool` for assigned tools. Discover and call a
permitted tool through its pinned alias.

**Paperclip version or commit**

Source checkout based on current `master`.

**Deployment mode**

Native local or sandbox execution with OpenCode 1.18.34.

Related work: Refs #14258 (discovery paging), #16678 (local adapter MCP
launch), and #17688 (gateway capability advertisement). This change
covers native OpenCode catalog projection.

## What Changed

- Route native OpenCode assigned tools through the existing server-owned
authenticated relay.
- Use bounded discovery/call schemas for OpenCode, including small
catalogs. Preserve transport capacity checks.
- Keep assigned gateway credentials on the controller. Ignore gateway
and credential values in tool arguments.
- Preserve exact declared MCP operation names through admission and
driver dispatch. Resolve transport prefixes only against the closed
catalog. Reject unknown or shortened names.
- Refresh former OpenCode direct-catalog and shortened-name checkpoints.
Keep the existing fingerprints for other providers.
- Add local and remote wiring, catalog capacity, alias, scope,
work-mode, approval, and revocation tests.
- Allow the native test files to use a validated dedicated external test
database when embedded PostgreSQL is unavailable.
- Document the assigned-tool execution contract.

- Bound OpenCode discovery pages to 32 KiB and large-schema chunks to
16K characters. Keep other harness budgets unchanged.
- Test medium pages and escaped large schemas through the actual HTTP
MCP bridge on the explicit Runner testing surface.

## Verification

- Red before the catalog fix: OpenCode did not create the server relay,
and forced compact delivery advertised 1 or 741 direct schemas.
- Red before the alias repair: HTTP MCP changed declared operation
names, and the driver sent `search_assigned_tools` instead of the
authorized `paperclip_search_assigned_tools`.
- Current repair tests: 112 OpenCode/proxy tests pass, plus one actual
Rust/proxy/HTTP MCP regression. Two checkpoint regressions fail with the
former scoped revision and pass with the new revision. Other providers
keep their prior fingerprints.
- The combined QA candidate with this exact PR head passes all 916
native tests and all 113 transport tests on supported Node 24.22. An
earlier Node 26.4/macOS run passed the 113 assertions but had an
uncaught networking error. That non-clean run remains recorded; its
stack matches [the upstream Undici
defect](https://github.com/nodejs/undici/issues/5544). No production
workaround or error suppression was added.
- `pnpm -r typecheck`, `pnpm build`, and `git diff --check` pass on
`bc41ba72be2195eabeb0d7727227ee0cd02e8532`. The maps structural check
passes.
- The local `pnpm test:run` attempt stopped after host embedded
PostgreSQL initialization failures. The relevant database suites pass
with isolated PostgreSQL 18. All repository CI gates, including
aggregate `ci / verify`, pass on the current head.
- Synthetic live provider comparison: 741 invented schemas produced the
exact Fireworks grammar timeout in 20.9 seconds. Two compact invented
schemas produced a discovery call in 585 ms on the same model and
backend.
- The first hosted QA attempt exposed the alias failure described above.
On the patched source, a real local Rust runner → proxy → OpenCode
1.18.34 → exact DeepSeek smoke completed discovery, one synthetic
gateway read, and `paperclip_finish`, then emitted `turn/completed`. It
used the production compact projection and unchanged Rust allowlist.
- Two earlier local smoke attempts exposed missing terminal authority
and completion-contract fields in the smoke harness. Those failures
remain recorded. The corrected ordinary bindings produced the clean
result.
- Fresh Greptile: 5/5 on `bc41ba72be2195eabeb0d7727227ee0cd02e8532`,
with no new actionable findings or unresolved threads.
- Hosted QA publication and lifecycle acceptance remain pending. The
separate deployed-agent `Paperclip Review` check is also pending; it
follows a task that failed on the earlier deployed runtime.

- Local OpenCode/OpenRouter acceptance at `bc41ba72`: 530 synthetic
tools were authorized and installed. A simple model-ID reply completed
in 17.3 seconds with no app calls. A discovery task completed in 37.6
seconds, used the search/call relay, made one upstream call, and
returned the exact synthetic value. This is local source acceptance, not
a staging sandbox timing comparison.
- The large-result review finding has a failing-before/passing-after
HTTP MCP regression. Five medium schemas now page without invalid JSON.
A large schema with escaped text reconstructs exactly through bounded
chunks. All 35 assigned-tool tests and the server typecheck pass after
this follow-up.
- Final head `d84ce4856a6e65a35ad5fb8b561492598b525f4e`: all CI gates
pass. Greptile and Paperclip Review both rate the change 5/5. No review
threads remain open. The final full build, server typecheck, and 35
assigned-tool tests pass locally.

## Risks

- OpenCode uses discovery before it calls an assigned tool, including
for small catalogs. This can add a model turn.
- An existing OpenCode provider checkpoint rotates once because it
retains the former tool declarations. Paperclip task history remains
available.
- The historical filtered catalog was not captured. The synthetic
comparison proves the failure mechanism, not the exact cardinality of an
earlier private request.
- No model, provider route, permission, or automatic retry policy
changes.

## Model Used

The bounded-result follow-up also used OpenAI Codex (GPT-6 family). The
exact backend model ID and context-window size were not exposed for that
session. It used reasoning, code execution, and tool use.

OpenAI `gpt-6.1-sol` in Codex, with reasoning, tool use, code execution,
and source inspection. The runtime does not expose the context-window
size.

## 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:
DottaandPaperclip authored and GitHub committed 2026-10-26 10:49:23 -05:00
1 parent df5a78cf91
commit 063a498b54
17 files changed
+337 -21

No files matched your search

+19
View File
@@ -2015,6 +2015,25 @@ not grant execution authority: calls still use the existing gateway policy,
approval, revocation, and audit paths.
Native OpenCode sessions use the server-owned assigned-tool relay for local and
remote execution. The provider receives only bounded search and call schemas,
even when its protocol can fit the full catalog. This bounds provider grammar
compilation independently of the transport's tool-count and byte limits. Search
returns pinned tool aliases and schemas, intersects fresh grants, and respects
planning/ask restrictions. OpenCode discovery pages use a 32 KiB budget; larger
schemas use 16K-character chunks so JSON escaping and the result envelope fit
its HTTP MCP bridge. Other harnesses keep the existing PRP discovery budgets.
Calls retain the original gateway binding and its
argument validation, approvals, revocation, and audit rules; prompt arguments
cannot choose a gateway or credential. Assigned gateway launch credentials stay
on the controller. The MCP bridge preserves exact declared operation names,
including names that start with `paperclip_`; only a catalog-bound transport
prefix may resolve to that declared name. Shortened or unknown names do not
gain authority. OpenCode sessions with the former direct catalog rotate their
provider checkpoint before reuse; other providers retain their existing tool
contract fingerprints.
### Use real connection requests (2026-09-14)
When a user asks to connect a known service, the agent searches for that service
+12
View File
@@ -162,6 +162,18 @@ No grant, token, authority cache or provider dispatch bypass is added. The
[executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
remain separate from live latency and permitted/denied agent acceptance.
Scoped OpenCode assigned-tool source evidence: local and remote native sessions
use the server-owned authenticated relay. The provider sees bounded
`paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` schemas,
then uses pinned aliases. Search intersects fresh grants and work modes; calls
retain the run's original gateway policy, validation, approvals, revocation and
audit path. Prompt arguments cannot select gateway authority. Assigned gateway
launch credentials stay on the controller. Former direct-catalog OpenCode
checkpoints refresh; other provider fingerprints stay unchanged. The
[projection controls](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
and [wiring controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
are source evidence, not live permitted/denied sandbox acceptance.
For a linked Codex subscription with server-resolved managed credential provenance,
setup checks the resolved owner and stages a private probe copy before local or
remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is
+10
View File
@@ -145,6 +145,16 @@ is released before failure returns. Repeat a warm follow-up and retain its actua
reply time. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
cover ordering, failure drain and credential projection; they do not qualify a
live response-time target.
For native OpenCode with assigned connections, inspect the provider tool surface
on both local and sandbox execution. It should contain bounded assigned-tool
search/call schemas. Discover an allowed tool, call its returned alias, revoke
its grant, and confirm discovery and execution enforce that revocation. Repeat
in planning/ask mode with a write tool and confirm denial. The controller must
retain the assigned gateway URL and token. An older direct-catalog OpenCode
checkpoint must refresh without changing another provider's fingerprint. The
[assigned-tool](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
and executor controls cover source behavior; live sandbox discovery and a real
call remain separate acceptance evidence.
Missing dependencies, failed authentication, invalid models and
incompatible custom settings must remain actionable errors without silent runner
switching. Saved unchanged execution can still be tested without applying new
@@ -94,6 +94,33 @@ describe("OpenCode MCP bridge", () => {
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
});
it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => {
const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"];
const handler = vi.fn(async call => ({ operation: call.tool }));
const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler });
bridges.push(bridge);
const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]);
for (const [i, name] of names.entries()) {
for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) {
const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name;
const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json();
expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } });
}
}
const before = handler.mock.calls.length;
expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
expect(handler).toHaveBeenCalledTimes(before);
});
it("does not authorize a shortened operation when only its prefixed name is declared", async () => {
const handler = vi.fn(async () => ({ ok: true }));
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler });
bridges.push(bridge);
expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
expect(handler).not.toHaveBeenCalled();
});
it("normalizes all supported OpenCode MCP prefixes", () => {
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
@@ -108,7 +108,7 @@ function normalizeTools(
const tools: OpenCodeMcpToolDefinition[] = [];
const seen = new Set<string>();
for (const raw of input) {
const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : "";
const name = typeof raw.name === "string" ? raw.name.trim() : "";
if (!name || seen.has(name)) continue;
const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" };
tools.push({
@@ -218,7 +218,12 @@ async function handleRequest(
}
const params = isRecord(message.params) ? message.params : {};
const rawName = typeof params.name === "string" ? params.name : "";
const tool = canonicalOpenCodeMcpToolName(rawName);
// A declared operation may itself start with paperclip_. Preserve that
// authority identity before accepting an OpenCode transport prefix alias.
const exactName = rawName.trim();
const tool = context.admittedTools.some(entry => entry.name === exactName)
? exactName
: canonicalOpenCodeMcpToolName(exactName);
if (!context.admittedTools.some((entry) => entry.name === tool)) {
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
return;
@@ -2219,6 +2219,27 @@ describe("OpenCodeServerDriver", () => {
} finally { await session.close({ reason: "monitor default test complete" }); }
});
it("keeps assigned discovery operation IDs at the authenticated MCP and controller boundary", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-assigned-"));
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
roots.push(root, workspace);
const calls: string[] = [];
const operation = "paperclip_search_assigned_tools";
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4.1-flash-1017", runtimeDirectory: root, command: fixture,
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
dynamicTools: [{ name: operation, inputSchema: { type: "object" } }],
dynamicToolHandler: async call => { calls.push(call.tool); if (call.tool !== operation) throw new Error("unauthorized operation"); return { tools: [] }; },
});
const session = await driver.openSession({ runId: "assigned-name", normalizedSessionId: "assigned-name", workingDirectory: workspace });
try {
await session.startTurn({ message: { role: "user", text: "Discover assigned tools." } });
const events = await collectTurnEvents(session.events());
expect(calls).toEqual([operation]);
expect(events.some(event => event.eventType === "item.completed" && (event.payload as { item?: { is_error?: boolean } }).item?.is_error)).toBe(false);
} finally { await session.close({ reason: "assigned operation identity test" }); }
});
it("normalizes a structured block result", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
@@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession {
callId: string;
arguments: unknown;
}): Promise<unknown> {
const tool = canonicalOpenCodeMcpToolName(call.tool);
// The authenticated MCP bridge has resolved this against its closed
// declared catalog. A second prefix strip changes legitimate operation IDs.
const tool = call.tool;
const turnId = this.#activeTurnId;
if (turnId === null)
throw new Error("OpenCode tool call is not bound to an active turn");
@@ -0,0 +1,65 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it } from "vitest";
import { codexSemanticToolSpecs, dynamicToolResponse } from "../drivers/codex/codex-driver-values.js";
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
const runner = defaultCapabilityRunnerdBinary();
const proxy = resolve("dist/cli/opencode-app-server-proxy.cjs");
const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
it.skipIf(process.platform === "win32" || !existsSync(runner) || !existsSync(proxy))(
"retains assigned discovery authority across Rust, the proxy, and HTTP MCP",
async () => {
const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-assigned-"));
const workspace = join(root, "workspace");
const executable = join(root, "fake-opencode-server.mjs");
await mkdir(workspace);
await copyFile(resolve("test/fixtures/fake-opencode-server.mjs"), executable);
await chmod(executable, 0o755);
const operation = "paperclip_search_assigned_tools";
const seen: string[] = [];
const { transport } = createRunnerdCodexTransport({
provider: "opencode", runnerBinary: runner, stateDirectory: join(root, "state"), sourceCodexHome: "",
providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath),
opencodeCommand: executable, opencodeCommandSha256: digest(executable),
opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy),
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" },
});
transport.setServerRequestHandler(async request => {
expect(request.method).toBe("item/tool/call");
const tool = request.params.tool;
expect([operation, "paperclip_finish"]).toContain(tool);
seen.push(String(tool));
return dynamicToolResponse(tool === operation ? { tools: [] } : "Accepted completion.");
});
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await transport.request("thread/start", {
cwd: workspace, model: "openrouter/deepseek/deepseek-v4.1-flash-1017",
completionContract: { revision: "assigned-tools-v1", criterionIds: ["objective"] },
dynamicTools: [{ name: operation, description: "Search assigned synthetic tools.", inputSchema: { type: "object" } }, ...codexSemanticToolSpecs()],
});
const completed = (async () => {
for await (const notification of transport.notifications()) {
if (notification.method === "turn/completed") return;
if (notification.method === "turn/failed") throw new Error("Native OpenCode turn failed");
}
throw new Error("Native OpenCode notifications ended before completion");
})();
await transport.request("turn/start", { input: [{ type: "text", text: "Discover assigned synthetic tools." }] });
await Promise.race([completed, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Native assigned-tool smoke timed out")), 20_000);
})]);
expect(seen).toEqual([operation, "paperclip_finish"]);
} finally {
if (timer) clearTimeout(timer);
await transport.close();
await rm(root, { recursive: true, force: true });
}
},
30_000,
);
+1
View File
@@ -9,6 +9,7 @@ export * from "./index.js";
export * from "./conformance/control-plane-port.js";
export * from "./conformance/capability-semantic-conformance.js";
export * from "./conformance/harness-driver.js";
export { startOpenCodeMcpBridge } from "./drivers/opencode/mcp-bridge.js";
export * from "./conformance/semantic-conformance.js";
export * from "./mock-core/deterministic-harness-driver.js";
export * from "./mock-core/mock-control-plane-adapter.js";
@@ -971,6 +971,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
nativeToolContractFingerprint:
nativeToolContractFingerprintForTarget(
executionTarget?.kind ?? "local",
nativeExecution!.provider.kind,
),
...(lockedProfile.sessionCheckpoint != null
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js";
import { startOpenCodeMcpBridge } from "../../vendor/paperclip-runner/testing.js";
function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor {
return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk };
@@ -18,6 +19,93 @@ describe("assigned MCP runner tools", () => {
const searchName = "paperclip_search_assigned_tools";
const callName = "paperclip_call_assigned_tool";
it.each(["page", "escaped schema"])("keeps on-demand %s results valid through the OpenCode MCP bridge", async variant => {
const schema = { type: "object", description: variant === "page" ? "x".repeat(20_000) : "\"\\\u0000🙂".repeat(30_000) };
const f = fixture(Array.from({ length: variant === "page" ? 5 : 1 }, (_, i) => ({
...descriptor(`large_${i}`), parametersSchema: schema,
})));
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) });
let id = 0;
const search = async (args: Record<string, unknown>) => {
const response = await fetch(bridge.url, {
method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name: searchName, arguments: args } }),
});
const rpc = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } };
expect(rpc.result.isError).not.toBe(true);
return JSON.parse(rpc.result.content[0]!.text);
};
try {
let page = await search({ query: "", limit: 20 });
if (variant === "page") {
const seen = [...page.tools];
while (page.nextOffset !== null) {
page = await search({ query: "", offset: page.nextOffset, limit: 20 });
seen.push(...page.tools);
}
expect(seen).toHaveLength(5);
expect(seen.every(tool => JSON.stringify(tool.inputSchema) === JSON.stringify(schema))).toBe(true);
} else {
const name = page.tools[0]!.inputSchemaRef;
expect(name).toBeTypeOf("string");
let offset: number | null = 0;
let reconstructed = "";
do {
const chunk = await search({ query: "", schemaTool: name, schemaOffset: offset });
reconstructed += chunk.schemaJson;
offset = chunk.nextSchemaOffset;
} while (offset !== null);
expect(JSON.parse(reconstructed)).toEqual(schema);
}
} finally {
await bridge.close();
}
});
it.each([1, 741])("uses bounded on-demand delivery for %i tools even when the direct catalog fits", async count => {
const f = fixture(Array.from({ length: count }, (_, i) => descriptor(`app.operation_${i}`)));
const direct = await createAssignedMcpTools(f);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const fits = vi.fn(() => true);
expect(assigned.definitions(fits).map(tool => tool.name)).toEqual([searchName, callName]);
expect(fits).toHaveBeenCalledExactlyOnceWith(assigned.definitions());
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as { tools: Array<{ name: string }> };
expect(page.tools[0]!.name).toBe(direct.definitions().map(tool => String(tool.name)).sort()[0]);
await assigned.execute({ tool: callName, arguments: { name: page.tools[0]!.name, arguments: { query: "fixture" }, gatewayPublicId: "untrusted", bearerToken: "untrusted" } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken }));
f.listToolsForNamedGateway.mockResolvedValue([]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).resolves.toEqual({ tools: [], nextOffset: null });
});
it("keeps on-demand aliases pinned, mode restricted, revocable and approval gated", async () => {
const read = descriptor("mcp.first:search");
const write = descriptor("mcp.second:search", "write");
const f = fixture([read, write]);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const page = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string; description: string }> };
expect(new Set(page.tools.map(tool => tool.name)).size).toBe(2);
const writeAlias = page.tools.find(tool => tool.description.includes(write.name))!.name;
const call = { tool: callName, arguments: { name: writeAlias, arguments: {} } };
await expect(assigned.execute(call, "planning")).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(assigned.execute(call, "ask")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
const denied = new ToolGatewayHttpError(403, "Approval required", "approval_required");
f.executeTool.mockRejectedValueOnce(denied);
await expect(assigned.execute(call)).rejects.toBe(denied);
f.listToolsForNamedGateway.mockResolvedValue([read, descriptor("newly.granted")]);
const refreshed = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string }> };
expect(refreshed.tools).toHaveLength(1);
expect(refreshed.tools[0]!.name).not.toBe(writeAlias);
// Execution still goes through the original gateway, which reauthorizes
// each call; discovery never grants an added tool to this pinned session.
const revoked = new ToolGatewayHttpError(403, "Revoked", "connection_revoked");
f.executeTool.mockRejectedValueOnce(revoked);
await expect(assigned.execute(call)).rejects.toBe(revoked);
await expect(assigned.execute({ tool: callName, arguments: { name: "newly.granted", arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
});
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
const read = descriptor("memory.search");
const write = descriptor("memory.store", "write");
@@ -86,6 +86,8 @@ export async function createAssignedMcpTools(input: {
gatewayPublicId: string;
bearerToken: string;
workMode?: WorkMode;
/** Bound provider grammar complexity independently of protocol byte capacity. */
delivery?: "on_demand";
}) {
const listed = await input.gateway.listToolsForNamedGateway({
gatewayPublicId: input.gatewayPublicId,
@@ -103,6 +105,11 @@ export async function createAssignedMcpTools(input: {
tools.set(name, descriptor);
}
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
// OpenCode's HTTP bridge returns at most 64K JSON characters. Leave room
// for the envelope and for JSON escaping when returning a schema chunk.
// Other harnesses retain the larger PRP page and chunk budgets.
const searchPageBytes = input.delivery === "on_demand" ? 32 * 1024 : SEARCH_PAGE_BYTES;
const schemaChunkCharacters = input.delivery === "on_demand" ? 16 * 1024 : SCHEMA_CHUNK_CHARACTERS;
async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) {
const args = object(argumentsValue);
@@ -132,7 +139,7 @@ export async function createAssignedMcpTools(input: {
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;
const end = schemaOffset + schemaChunkCharacters;
return {
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
nextSchemaOffset: end < schema.length ? end : null,
@@ -144,13 +151,13 @@ export async function createAssignedMcpTools(input: {
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) {
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > searchPageBytes) {
// 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) {
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > searchPageBytes) {
break;
}
page.push(next);
@@ -161,9 +168,9 @@ export async function createAssignedMcpTools(input: {
return {
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;
if (input.delivery !== "on_demand" && (!fits || fits(direct))) return direct;
const compact = structuredClone(ON_DEMAND_TOOLS);
if (!fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
if (fits && !fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
return compact;
},
has(name: string): boolean {
@@ -1471,6 +1471,7 @@ describe("remote provider pack manifest", () => {
try {
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true,
...(provider === "opencode" ? { runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "assigned-fixture", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1/mcp/gateways/assigned-fixture", PAPERCLIP_NATIVE_MCP_TOKEN: "private-fixture" } } : {}),
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
environmentId: "environment", leaseId: "lease", providerKey: "daytona",
effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never });
@@ -1478,8 +1479,13 @@ describe("remote provider pack manifest", () => {
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
environment?: NodeJS.ProcessEnv;
};
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
if (provider === "opencode") {
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-fixture", bearerToken: "private-fixture", delivery: "on_demand" }));
for (const name of ["PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN"]) expect(transport.environment?.[name]).toBeUndefined();
}
if (claude) {
expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined }));
const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js");
@@ -7800,7 +7806,7 @@ describe("bounded stopped instruction collection", () => {
describe("native warm session supervision", () => {
it.each(["collect", "close-failure", "stopped", "destroyed", "missing-lease", "foreign-run", "foreign-environment", "deleted-environment", "claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical", "claim-config", "claim-uncertain"])("composes remote warm ownership with real DB successor leases and current-run collection: %s", async ending => {
const tables = await import("@paperclipai/db");
const { eq } = await import("drizzle-orm");
const { eq, sql } = await import("drizzle-orm");
const { randomUUID } = await import("node:crypto");
const { promisify } = await import("node:util");
const { execFile } = await import("node:child_process");
@@ -7813,7 +7819,11 @@ describe("native warm session supervision", () => {
const home = await realpath(await mkdtemp(join(tmpdir(), "warm-remote-db-")));
const previousHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = home;
const database = await startEmbeddedPostgresTestDatabase("warm-remote-db-");
const { resolveExternalTestDatabaseUrl } = await import("../../__tests__/helpers/external-test-database.js");
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
const database = externalUrl
? { connectionString: externalUrl, cleanup: () => tables.closeRegisteredClients(externalUrl) }
: await startEmbeddedPostgresTestDatabase("warm-remote-db-");
const db = tables.createDb(database.connectionString);
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), environmentId = randomUUID(), issueId = randomUUID();
const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js");
@@ -8049,6 +8059,9 @@ describe("native warm session supervision", () => {
finally {
const cleanupFailures: unknown[] = [];
try { await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" }); } catch (error) { cleanupFailures.push(error); }
if (externalUrl) {
try { await db.execute(sql`TRUNCATE TABLE ${tables.companies}, ${tables.environments}, ${tables.authUsers} CASCADE`); } catch (error) { cleanupFailures.push(error); }
}
try { await database.cleanup(); } catch (error) { cleanupFailures.push(error); }
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
try {
@@ -11828,7 +11841,10 @@ describe("runnerd provider runtime wiring", () => {
} finally {owner.mockRestore();await rm(home,{recursive:true,force:true});}
});
it("keeps assigned MCP credentials on the control plane for remote Codex", async () => {
it.each(["codex", "opencode"] as const)("keeps assigned MCP credentials on the control plane for %s", async (providerKind) => {
const selectedExecution: NativeExecutionInput = providerKind === "opencode"
? { ...execution, provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" } }
: execution;
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.test");
const exporter = new InMemorySpanExporter();
@@ -11841,13 +11857,13 @@ describe("runnerd provider runtime wiring", () => {
state.createTransport.mockClear();
state.toolAuthorityDefinitions.mockClear();
await createRunnerdBackend({
db: leaseDb(execution), execution, runnerInstanceId: "runner-assigned-mcp",
db: leaseDb(selectedExecution), execution: selectedExecution, runnerInstanceId: "runner-assigned-mcp",
runnerEnvironment: {
PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned",
PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-test",
PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token",
},
runnerExecutionTarget: {
runnerExecutionTarget: providerKind === "opencode" ? undefined : {
kind: "remote", transport: "sandbox", providerKey: "daytona",
leaseId: "lease-assigned-mcp", remoteCwd: "/home/daytona/paperclip-workspace",
runner: { execute: vi.fn() },
@@ -11855,6 +11871,7 @@ describe("runnerd provider runtime wiring", () => {
});
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({
gatewayPublicId: "assigned-test", bearerToken: "private-run-token",
...(providerKind === "opencode" ? { delivery: "on_demand" } : {}),
}));
expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools }));
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
@@ -5563,7 +5563,7 @@ function nativeSessionConfigDigest(
// changes so an older thread is rotated instead of falsely resuming
// without newly required tools.
nativeToolContractFingerprint:
nativeToolContractFingerprintForTarget(executionTargetKind),
nativeToolContractFingerprintForTarget(executionTargetKind, execution.provider.kind),
runtimeContract: nativeRuntimeContractForProvider(execution.provider),
}),
)
@@ -11828,10 +11828,10 @@ async function createRunnerdBackendWithinSessionClaim(
if (nativeReview && !await getNativeReviewAssignment(input.db, {
...input.execution.binding, contextSnapshot: nativeReview,
})) throw new Error("native_review_assignment_no_longer_available");
// Remote Codex already sends dynamic tool calls over authenticated PRP. Keep
// Codex and OpenCode send dynamic tool calls over authenticated PRP. Keep
// the assigned gateway on the control plane instead of asking the sandbox to
// reach the host's HTTP origin (which may be private or loopback-only).
const relayAssignedMcp = Boolean(pluginTask) || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
const relayAssignedMcp = Boolean(pluginTask) || input.execution.provider.kind === "opencode" || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL;
const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN;
const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME;
@@ -11853,6 +11853,7 @@ async function createRunnerdBackendWithinSessionClaim(
gatewayPublicId: assignedGatewayPublicId!,
bearerToken: assignedMcpToken,
workMode: input.execution.task.workMode,
...(input.execution.provider.kind === "opencode" ? { delivery: "on_demand" as const } : {}),
}))
: undefined),
]);
@@ -32,7 +32,7 @@ import {
} from "@paperclipai/paperclip-runner";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
startEmbeddedPostgresTestDatabase as startEmbeddedDatabase,
} from "../../__tests__/helpers/embedded-postgres.js";
import {
canonicalNativeRuntimeContextDigest,
@@ -72,6 +72,20 @@ import {
selectNativeSessionResumeRun,
} from "./native-session-resume.js";
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
async function startEmbeddedPostgresTestDatabase(prefix: string) {
const externalUrl = externalTestDatabaseUrl;
if (!externalUrl) return startEmbeddedDatabase(prefix);
const { closeRegisteredClients } = await import("@paperclipai/db");
return { connectionString: externalUrl, cleanup: async () => {
const db = createDb(externalUrl);
try { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }
finally { await closeRegisteredClients(externalUrl); }
} };
}
const companyId = "10000000-0000-4000-8000-000000000001";
const issueId = "20000000-0000-4000-8000-000000000002";
@@ -283,12 +297,13 @@ it("wires exact-session recovery and guarded selected identity into heartbeat pe
});
const embeddedSupport = await getEmbeddedPostgresTestSupport();
const databaseSupported = Boolean(externalTestDatabaseUrl) || embeddedSupport.supported;
const recoveryFakeCodex = resolve(
import.meta.dirname,
"../../../../packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs",
);
(embeddedSupport.supported && existsSync(defaultCapabilityRunnerdBinary())
(databaseSupported && existsSync(defaultCapabilityRunnerdBinary())
? it
: it.skip)(
"keeps actual normalized driver events separate from raw maintenance receipts",
@@ -520,7 +535,7 @@ const recoveryFakeCodex = resolve(
30_000,
);
(embeddedSupport.supported &&
(databaseSupported &&
existsSync(defaultCapabilityRunnerdBinary()) &&
existsSync(recoveryFakeCodex)
? it
@@ -1062,7 +1077,7 @@ const recoveryFakeCodex = resolve(
60_000,
);
(embeddedSupport.supported ? describe : describe.skip)(
(databaseSupported ? describe : describe.skip)(
"native session recovery database orchestration",
() => {
it("uses scoped latest checkpoints, preserves progress barriers, and guards bootstrap persistence after the row lock", async () => {
@@ -1825,6 +1840,23 @@ describe("rebindNativeSessionCheckpoint", () => {
it.each(["local", "remote"] as const)("refreshes only OpenCode's former direct MCP catalog on %s", target => {
const legacy = nativeToolContractFingerprintForTarget(target);
expect(nativeToolContractFingerprintForTarget(target, "codex")).toBe(legacy);
expect(nativeToolContractFingerprintForTarget(target, "acpx")).toBe(legacy);
const current = nativeToolContractFingerprintForTarget(target, "opencode");
expect(current).not.toBe(legacy);
const shortenedCatalog = target === "local"
? "sha256:2ae35faccbb63cfe009d94170ef1ef13f0d57e120af6160540598a3ec72001d7"
: "sha256:ee5285ca5118b8468c0afd1df4ef9c05d85b0934e882bccd0b193dc8722dbb7a";
expect(current).not.toBe(shortenedCatalog);
const currentExecution = { ...execution(currentRunId), provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, session: { ...execution(currentRunId).session, driverKind: "opencode_server" } } as NativeExecutionInput;
const previousExecution = { ...execution(previousRunId), provider: currentExecution.provider, session: currentExecution.session };
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: shortenedCatalog, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: legacy, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: current, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).not.toBeNull();
});
it("binds the provider tool catalog to the local or remote execution target", () => {
const remoteFingerprint = nativeToolContractFingerprintForTarget("remote");
expect(remoteFingerprint).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT);
@@ -29,6 +29,7 @@ export function nativeRuntimeContractForProvider(
*/
export function nativeToolContractFingerprintForTarget(
executionTargetKind: NativeToolExecutionTargetKind,
providerKind?: NativeExecutionInput["provider"]["kind"],
): string {
return `sha256:${createHash("sha256")
.update(
@@ -47,6 +48,9 @@ export function nativeToolContractFingerprintForTarget(
structuredHumanInput:
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
...(providerKind === "opencode"
? { opencodeAssignedMcpTools: "server_gateway_prp_on_demand_exact_names.v2" }
: {}),
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
chatTaskTools: "durable_task_binding_and_accepted_origin.v1",
githubResponses: "bound_working_comment_progress_and_final.v1",
@@ -482,7 +486,7 @@ export function rebindNativeSessionCheckpoint(input: {
const previousProfile = record(input.previousRun.runnerProfileJson);
if (
previousProfile.nativeToolContractFingerprint !==
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local")
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local", input.currentExecution.provider.kind)
) {
return null;
}
+4
View File
@@ -26,6 +26,10 @@ const sourceUrl = new URL(
);
const runnerTesting = await import(sourceUrl.href) as RunnerTestingModule;
export const startOpenCodeMcpBridge:
RunnerTestingModule["startOpenCodeMcpBridge"] =
runnerTesting.startOpenCodeMcpBridge;
export const CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS:
RunnerTestingModule["CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS"] =
runnerTesting.CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS;