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;