From 063a498b54a9106843647ce5aaf2e6d84a4b6c3e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:49:23 -0500 Subject: [PATCH] Bound native OpenCode assigned-tool catalog projection (#17740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/execution-semantics.md | 19 ++++ maps/agent-access-map/runs-adapters.md | 12 +++ maps/feature-map/runs-adapters.md | 10 +++ .../src/drivers/opencode/mcp-bridge.test.ts | 27 ++++++ .../src/drivers/opencode/mcp-bridge.ts | 9 +- .../opencode/opencode-server-driver.test.ts | 21 +++++ .../opencode/opencode-server-driver.ts | 4 +- .../runnerd-opencode-assigned-tools.test.ts | 65 ++++++++++++++ packages/paperclip-runner/src/testing.ts | 1 + .../services/heartbeat/runtime-selection.ts | 1 + .../native-runtime/assigned-mcp-tools.test.ts | 88 +++++++++++++++++++ .../native-runtime/assigned-mcp-tools.ts | 17 ++-- .../native-session-executor.test.ts | 27 ++++-- .../native-runtime/native-session-executor.ts | 7 +- .../native-session-resume.test.ts | 40 ++++++++- .../native-runtime/native-session-resume.ts | 6 +- server/src/vendor/paperclip-runner/testing.ts | 4 + 17 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 486a33f3fa8..03f0044f38d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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 diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index a873db39633..4c732929e7e 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -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 diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index c44576823c7..32c7e51ae6a 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -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 diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts index 07681400ddd..f281fd2a55a 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts @@ -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"); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index 1bf1555dd16..cb1efd91922 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -108,7 +108,7 @@ function normalizeTools( const tools: OpenCodeMcpToolDefinition[] = []; const seen = new Set(); 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; diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 5d568eddf86..a44136bd364 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -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-")); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 4ab25b38b83..bf2a8bed9d5 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession { callId: string; arguments: unknown; }): Promise { - 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"); diff --git a/packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts b/packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts new file mode 100644 index 00000000000..e4722449c17 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts @@ -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 | 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((_, 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, +); diff --git a/packages/paperclip-runner/src/testing.ts b/packages/paperclip-runner/src/testing.ts index b220b2ea73a..5260f4f5114 100644 --- a/packages/paperclip-runner/src/testing.ts +++ b/packages/paperclip-runner/src/testing.ts @@ -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"; diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 425a2fee60a..05a7b039a2f 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -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 } diff --git a/server/src/services/native-runtime/assigned-mcp-tools.test.ts b/server/src/services/native-runtime/assigned-mcp-tools.test.ts index 7fa7b7ccb71..21cacfd0845 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.test.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.test.ts @@ -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) => { + 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"); diff --git a/server/src/services/native-runtime/assigned-mcp-tools.ts b/server/src/services/native-runtime/assigned-mcp-tools.ts index 535b265d4ad..ff43b86a6dd 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.ts @@ -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 { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 39862333f8c..c0d18a93957 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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; + 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!(); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 597c36542d5..7bea16d6e0d 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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), ]); diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index bacf0ab22e9..c0f274b08e4 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -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); diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index 3243de9980e..e3ea827cbdf 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -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; } diff --git a/server/src/vendor/paperclip-runner/testing.ts b/server/src/vendor/paperclip-runner/testing.ts index e889df919de..06a85b6f36d 100644 --- a/server/src/vendor/paperclip-runner/testing.ts +++ b/server/src/vendor/paperclip-runner/testing.ts @@ -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;