mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Bound native OpenCode assigned-tool catalog projection (#17740)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents use assigned connection tools through a governed gateway. > - OpenCode received the gateway catalog directly, outside the bounded server tool projection. > - A large catalog can exceed a provider grammar compiler's capacity even when it fits the transport limits. > - Synthetic requests reproduced the Fireworks grammar timeout on the configured DeepSeek model. > - This pull request gives OpenCode two assigned-tool discovery and call schemas through the existing server relay. > - The gateway keeps the same grants, validation, approvals, revocation, and audit rules. ## Linked Issues or Issue Description **What happened?** A native OpenCode run could fail during provider grammar compilation before its first assigned-tool call. OpenCode advertised the full assigned gateway catalog. A synthetic catalog of 741 tool schemas reproduced `Grammar processing took too much time` on Fireworks through OpenRouter with `deepseek/deepseek-v4.1-flash-1017`. The same model and backend accepted a compact two-tool discovery/call catalog. This reproduces the mechanism; it does not replay a historical private request. **Expected behavior** Assigned-tool discovery must keep provider grammar size bounded. Every call must retain the run's original gateway binding and permissions. **Steps to reproduce** 1. Give a native OpenCode run a large assigned connection catalog. 2. Use a provider that compiles tool schemas into a constrained output grammar. 3. Observe the full catalog in the provider tool surface before this change. 4. After this change, observe only `paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` for assigned tools. Discover and call a permitted tool through its pinned alias. **Paperclip version or commit** Source checkout based on current `master`. **Deployment mode** Native local or sandbox execution with OpenCode 1.18.34. Related work: Refs #14258 (discovery paging), #16678 (local adapter MCP launch), and #17688 (gateway capability advertisement). This change covers native OpenCode catalog projection. ## What Changed - Route native OpenCode assigned tools through the existing server-owned authenticated relay. - Use bounded discovery/call schemas for OpenCode, including small catalogs. Preserve transport capacity checks. - Keep assigned gateway credentials on the controller. Ignore gateway and credential values in tool arguments. - Preserve exact declared MCP operation names through admission and driver dispatch. Resolve transport prefixes only against the closed catalog. Reject unknown or shortened names. - Refresh former OpenCode direct-catalog and shortened-name checkpoints. Keep the existing fingerprints for other providers. - Add local and remote wiring, catalog capacity, alias, scope, work-mode, approval, and revocation tests. - Allow the native test files to use a validated dedicated external test database when embedded PostgreSQL is unavailable. - Document the assigned-tool execution contract. - Bound OpenCode discovery pages to 32 KiB and large-schema chunks to 16K characters. Keep other harness budgets unchanged. - Test medium pages and escaped large schemas through the actual HTTP MCP bridge on the explicit Runner testing surface. ## Verification - Red before the catalog fix: OpenCode did not create the server relay, and forced compact delivery advertised 1 or 741 direct schemas. - Red before the alias repair: HTTP MCP changed declared operation names, and the driver sent `search_assigned_tools` instead of the authorized `paperclip_search_assigned_tools`. - Current repair tests: 112 OpenCode/proxy tests pass, plus one actual Rust/proxy/HTTP MCP regression. Two checkpoint regressions fail with the former scoped revision and pass with the new revision. Other providers keep their prior fingerprints. - The combined QA candidate with this exact PR head passes all 916 native tests and all 113 transport tests on supported Node 24.22. An earlier Node 26.4/macOS run passed the 113 assertions but had an uncaught networking error. That non-clean run remains recorded; its stack matches [the upstream Undici defect](https://github.com/nodejs/undici/issues/5544). No production workaround or error suppression was added. - `pnpm -r typecheck`, `pnpm build`, and `git diff --check` pass on `bc41ba72be2195eabeb0d7727227ee0cd02e8532`. The maps structural check passes. - The local `pnpm test:run` attempt stopped after host embedded PostgreSQL initialization failures. The relevant database suites pass with isolated PostgreSQL 18. All repository CI gates, including aggregate `ci / verify`, pass on the current head. - Synthetic live provider comparison: 741 invented schemas produced the exact Fireworks grammar timeout in 20.9 seconds. Two compact invented schemas produced a discovery call in 585 ms on the same model and backend. - The first hosted QA attempt exposed the alias failure described above. On the patched source, a real local Rust runner → proxy → OpenCode 1.18.34 → exact DeepSeek smoke completed discovery, one synthetic gateway read, and `paperclip_finish`, then emitted `turn/completed`. It used the production compact projection and unchanged Rust allowlist. - Two earlier local smoke attempts exposed missing terminal authority and completion-contract fields in the smoke harness. Those failures remain recorded. The corrected ordinary bindings produced the clean result. - Fresh Greptile: 5/5 on `bc41ba72be2195eabeb0d7727227ee0cd02e8532`, with no new actionable findings or unresolved threads. - Hosted QA publication and lifecycle acceptance remain pending. The separate deployed-agent `Paperclip Review` check is also pending; it follows a task that failed on the earlier deployed runtime. - Local OpenCode/OpenRouter acceptance at `bc41ba72`: 530 synthetic tools were authorized and installed. A simple model-ID reply completed in 17.3 seconds with no app calls. A discovery task completed in 37.6 seconds, used the search/call relay, made one upstream call, and returned the exact synthetic value. This is local source acceptance, not a staging sandbox timing comparison. - The large-result review finding has a failing-before/passing-after HTTP MCP regression. Five medium schemas now page without invalid JSON. A large schema with escaped text reconstructs exactly through bounded chunks. All 35 assigned-tool tests and the server typecheck pass after this follow-up. - Final head `d84ce4856a6e65a35ad5fb8b561492598b525f4e`: all CI gates pass. Greptile and Paperclip Review both rate the change 5/5. No review threads remain open. The final full build, server typecheck, and 35 assigned-tool tests pass locally. ## Risks - OpenCode uses discovery before it calls an assigned tool, including for small catalogs. This can add a model turn. - An existing OpenCode provider checkpoint rotates once because it retains the former tool declarations. Paperclip task history remains available. - The historical filtered catalog was not captured. The synthetic comparison proves the failure mechanism, not the exact cardinality of an earlier private request. - No model, provider route, permission, or automatic retry policy changes. ## Model Used The bounded-result follow-up also used OpenAI Codex (GPT-6 family). The exact backend model ID and context-window size were not exposed for that session. It used reasoning, code execution, and tool use. OpenAI `gpt-6.1-sol` in Codex, with reasoning, tool use, code execution, and source inspection. The runtime does not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
df5a78cf91
commit
063a498b54
17 files changed
+337
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,6 +145,16 @@ is released before failure returns. Repeat a warm follow-up and retain its actua
|
||||
reply time. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
|
||||
cover ordering, failure drain and credential projection; they do not qualify a
|
||||
live response-time target.
|
||||
For native OpenCode with assigned connections, inspect the provider tool surface
|
||||
on both local and sandbox execution. It should contain bounded assigned-tool
|
||||
search/call schemas. Discover an allowed tool, call its returned alias, revoke
|
||||
its grant, and confirm discovery and execution enforce that revocation. Repeat
|
||||
in planning/ask mode with a write tool and confirm denial. The controller must
|
||||
retain the assigned gateway URL and token. An older direct-catalog OpenCode
|
||||
checkpoint must refresh without changing another provider's fingerprint. The
|
||||
[assigned-tool](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
|
||||
and executor controls cover source behavior; live sandbox discovery and a real
|
||||
call remain separate acceptance evidence.
|
||||
Missing dependencies, failed authentication, invalid models and
|
||||
incompatible custom settings must remain actionable errors without silent runner
|
||||
switching. Saved unchanged execution can still be tested without applying new
|
||||
|
||||
@@ -94,6 +94,33 @@ describe("OpenCode MCP bridge", () => {
|
||||
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
|
||||
});
|
||||
|
||||
it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => {
|
||||
const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"];
|
||||
const handler = vi.fn(async call => ({ operation: call.tool }));
|
||||
const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler });
|
||||
bridges.push(bridge);
|
||||
const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
|
||||
expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]);
|
||||
for (const [i, name] of names.entries()) {
|
||||
for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) {
|
||||
const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name;
|
||||
const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json();
|
||||
expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } });
|
||||
}
|
||||
}
|
||||
const before = handler.mock.calls.length;
|
||||
expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
||||
expect(handler).toHaveBeenCalledTimes(before);
|
||||
});
|
||||
|
||||
it("does not authorize a shortened operation when only its prefixed name is declared", async () => {
|
||||
const handler = vi.fn(async () => ({ ok: true }));
|
||||
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler });
|
||||
bridges.push(bridge);
|
||||
expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes all supported OpenCode MCP prefixes", () => {
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
|
||||
|
||||
@@ -108,7 +108,7 @@ function normalizeTools(
|
||||
const tools: OpenCodeMcpToolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of input) {
|
||||
const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : "";
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||
if (!name || seen.has(name)) continue;
|
||||
const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" };
|
||||
tools.push({
|
||||
@@ -218,7 +218,12 @@ async function handleRequest(
|
||||
}
|
||||
const params = isRecord(message.params) ? message.params : {};
|
||||
const rawName = typeof params.name === "string" ? params.name : "";
|
||||
const tool = canonicalOpenCodeMcpToolName(rawName);
|
||||
// A declared operation may itself start with paperclip_. Preserve that
|
||||
// authority identity before accepting an OpenCode transport prefix alias.
|
||||
const exactName = rawName.trim();
|
||||
const tool = context.admittedTools.some(entry => entry.name === exactName)
|
||||
? exactName
|
||||
: canonicalOpenCodeMcpToolName(exactName);
|
||||
if (!context.admittedTools.some((entry) => entry.name === tool)) {
|
||||
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
|
||||
return;
|
||||
|
||||
@@ -2219,6 +2219,27 @@ describe("OpenCodeServerDriver", () => {
|
||||
} finally { await session.close({ reason: "monitor default test complete" }); }
|
||||
});
|
||||
|
||||
it("keeps assigned discovery operation IDs at the authenticated MCP and controller boundary", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-assigned-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
const calls: string[] = [];
|
||||
const operation = "paperclip_search_assigned_tools";
|
||||
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4.1-flash-1017", runtimeDirectory: root, command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
dynamicTools: [{ name: operation, inputSchema: { type: "object" } }],
|
||||
dynamicToolHandler: async call => { calls.push(call.tool); if (call.tool !== operation) throw new Error("unauthorized operation"); return { tools: [] }; },
|
||||
});
|
||||
const session = await driver.openSession({ runId: "assigned-name", normalizedSessionId: "assigned-name", workingDirectory: workspace });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "Discover assigned tools." } });
|
||||
const events = await collectTurnEvents(session.events());
|
||||
expect(calls).toEqual([operation]);
|
||||
expect(events.some(event => event.eventType === "item.completed" && (event.payload as { item?: { is_error?: boolean } }).item?.is_error)).toBe(false);
|
||||
} finally { await session.close({ reason: "assigned operation identity test" }); }
|
||||
});
|
||||
|
||||
it("normalizes a structured block result", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
||||
|
||||
@@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
callId: string;
|
||||
arguments: unknown;
|
||||
}): Promise<unknown> {
|
||||
const tool = canonicalOpenCodeMcpToolName(call.tool);
|
||||
// The authenticated MCP bridge has resolved this against its closed
|
||||
// declared catalog. A second prefix strip changes legitimate operation IDs.
|
||||
const tool = call.tool;
|
||||
const turnId = this.#activeTurnId;
|
||||
if (turnId === null)
|
||||
throw new Error("OpenCode tool call is not bound to an active turn");
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { codexSemanticToolSpecs, dynamicToolResponse } from "../drivers/codex/codex-driver-values.js";
|
||||
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
|
||||
|
||||
const runner = defaultCapabilityRunnerdBinary();
|
||||
const proxy = resolve("dist/cli/opencode-app-server-proxy.cjs");
|
||||
const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
|
||||
|
||||
it.skipIf(process.platform === "win32" || !existsSync(runner) || !existsSync(proxy))(
|
||||
"retains assigned discovery authority across Rust, the proxy, and HTTP MCP",
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-assigned-"));
|
||||
const workspace = join(root, "workspace");
|
||||
const executable = join(root, "fake-opencode-server.mjs");
|
||||
await mkdir(workspace);
|
||||
await copyFile(resolve("test/fixtures/fake-opencode-server.mjs"), executable);
|
||||
await chmod(executable, 0o755);
|
||||
const operation = "paperclip_search_assigned_tools";
|
||||
const seen: string[] = [];
|
||||
const { transport } = createRunnerdCodexTransport({
|
||||
provider: "opencode", runnerBinary: runner, stateDirectory: join(root, "state"), sourceCodexHome: "",
|
||||
providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath),
|
||||
opencodeCommand: executable, opencodeCommandSha256: digest(executable),
|
||||
opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy),
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" },
|
||||
});
|
||||
transport.setServerRequestHandler(async request => {
|
||||
expect(request.method).toBe("item/tool/call");
|
||||
const tool = request.params.tool;
|
||||
expect([operation, "paperclip_finish"]).toContain(tool);
|
||||
seen.push(String(tool));
|
||||
return dynamicToolResponse(tool === operation ? { tools: [] } : "Accepted completion.");
|
||||
});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await transport.request("thread/start", {
|
||||
cwd: workspace, model: "openrouter/deepseek/deepseek-v4.1-flash-1017",
|
||||
completionContract: { revision: "assigned-tools-v1", criterionIds: ["objective"] },
|
||||
dynamicTools: [{ name: operation, description: "Search assigned synthetic tools.", inputSchema: { type: "object" } }, ...codexSemanticToolSpecs()],
|
||||
});
|
||||
const completed = (async () => {
|
||||
for await (const notification of transport.notifications()) {
|
||||
if (notification.method === "turn/completed") return;
|
||||
if (notification.method === "turn/failed") throw new Error("Native OpenCode turn failed");
|
||||
}
|
||||
throw new Error("Native OpenCode notifications ended before completion");
|
||||
})();
|
||||
await transport.request("turn/start", { input: [{ type: "text", text: "Discover assigned synthetic tools." }] });
|
||||
await Promise.race([completed, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Native assigned-tool smoke timed out")), 20_000);
|
||||
})]);
|
||||
expect(seen).toEqual([operation, "paperclip_finish"]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
await transport.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
@@ -9,6 +9,7 @@ export * from "./index.js";
|
||||
export * from "./conformance/control-plane-port.js";
|
||||
export * from "./conformance/capability-semantic-conformance.js";
|
||||
export * from "./conformance/harness-driver.js";
|
||||
export { startOpenCodeMcpBridge } from "./drivers/opencode/mcp-bridge.js";
|
||||
export * from "./conformance/semantic-conformance.js";
|
||||
export * from "./mock-core/deterministic-harness-driver.js";
|
||||
export * from "./mock-core/mock-control-plane-adapter.js";
|
||||
|
||||
@@ -971,6 +971,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
|
||||
nativeToolContractFingerprint:
|
||||
nativeToolContractFingerprintForTarget(
|
||||
executionTarget?.kind ?? "local",
|
||||
nativeExecution!.provider.kind,
|
||||
),
|
||||
...(lockedProfile.sessionCheckpoint != null
|
||||
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
|
||||
import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js";
|
||||
import { startOpenCodeMcpBridge } from "../../vendor/paperclip-runner/testing.js";
|
||||
|
||||
function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor {
|
||||
return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk };
|
||||
@@ -18,6 +19,93 @@ describe("assigned MCP runner tools", () => {
|
||||
const searchName = "paperclip_search_assigned_tools";
|
||||
const callName = "paperclip_call_assigned_tool";
|
||||
|
||||
it.each(["page", "escaped schema"])("keeps on-demand %s results valid through the OpenCode MCP bridge", async variant => {
|
||||
const schema = { type: "object", description: variant === "page" ? "x".repeat(20_000) : "\"\\\u0000🙂".repeat(30_000) };
|
||||
const f = fixture(Array.from({ length: variant === "page" ? 5 : 1 }, (_, i) => ({
|
||||
...descriptor(`large_${i}`), parametersSchema: schema,
|
||||
})));
|
||||
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
|
||||
const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) });
|
||||
let id = 0;
|
||||
const search = async (args: Record<string, unknown>) => {
|
||||
const response = await fetch(bridge.url, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name: searchName, arguments: args } }),
|
||||
});
|
||||
const rpc = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } };
|
||||
expect(rpc.result.isError).not.toBe(true);
|
||||
return JSON.parse(rpc.result.content[0]!.text);
|
||||
};
|
||||
try {
|
||||
let page = await search({ query: "", limit: 20 });
|
||||
if (variant === "page") {
|
||||
const seen = [...page.tools];
|
||||
while (page.nextOffset !== null) {
|
||||
page = await search({ query: "", offset: page.nextOffset, limit: 20 });
|
||||
seen.push(...page.tools);
|
||||
}
|
||||
expect(seen).toHaveLength(5);
|
||||
expect(seen.every(tool => JSON.stringify(tool.inputSchema) === JSON.stringify(schema))).toBe(true);
|
||||
} else {
|
||||
const name = page.tools[0]!.inputSchemaRef;
|
||||
expect(name).toBeTypeOf("string");
|
||||
let offset: number | null = 0;
|
||||
let reconstructed = "";
|
||||
do {
|
||||
const chunk = await search({ query: "", schemaTool: name, schemaOffset: offset });
|
||||
reconstructed += chunk.schemaJson;
|
||||
offset = chunk.nextSchemaOffset;
|
||||
} while (offset !== null);
|
||||
expect(JSON.parse(reconstructed)).toEqual(schema);
|
||||
}
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 741])("uses bounded on-demand delivery for %i tools even when the direct catalog fits", async count => {
|
||||
const f = fixture(Array.from({ length: count }, (_, i) => descriptor(`app.operation_${i}`)));
|
||||
const direct = await createAssignedMcpTools(f);
|
||||
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
|
||||
const fits = vi.fn(() => true);
|
||||
expect(assigned.definitions(fits).map(tool => tool.name)).toEqual([searchName, callName]);
|
||||
expect(fits).toHaveBeenCalledExactlyOnceWith(assigned.definitions());
|
||||
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as { tools: Array<{ name: string }> };
|
||||
expect(page.tools[0]!.name).toBe(direct.definitions().map(tool => String(tool.name)).sort()[0]);
|
||||
await assigned.execute({ tool: callName, arguments: { name: page.tools[0]!.name, arguments: { query: "fixture" }, gatewayPublicId: "untrusted", bearerToken: "untrusted" } });
|
||||
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken }));
|
||||
f.listToolsForNamedGateway.mockResolvedValue([]);
|
||||
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).resolves.toEqual({ tools: [], nextOffset: null });
|
||||
});
|
||||
|
||||
it("keeps on-demand aliases pinned, mode restricted, revocable and approval gated", async () => {
|
||||
const read = descriptor("mcp.first:search");
|
||||
const write = descriptor("mcp.second:search", "write");
|
||||
const f = fixture([read, write]);
|
||||
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string; description: string }> };
|
||||
expect(new Set(page.tools.map(tool => tool.name)).size).toBe(2);
|
||||
const writeAlias = page.tools.find(tool => tool.description.includes(write.name))!.name;
|
||||
const call = { tool: callName, arguments: { name: writeAlias, arguments: {} } };
|
||||
await expect(assigned.execute(call, "planning")).rejects.toThrow("paperclip_runner_tool_mode_denied");
|
||||
await expect(assigned.execute(call, "ask")).rejects.toThrow("paperclip_runner_tool_mode_denied");
|
||||
expect(f.executeTool).not.toHaveBeenCalled();
|
||||
const denied = new ToolGatewayHttpError(403, "Approval required", "approval_required");
|
||||
f.executeTool.mockRejectedValueOnce(denied);
|
||||
await expect(assigned.execute(call)).rejects.toBe(denied);
|
||||
f.listToolsForNamedGateway.mockResolvedValue([read, descriptor("newly.granted")]);
|
||||
const refreshed = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string }> };
|
||||
expect(refreshed.tools).toHaveLength(1);
|
||||
expect(refreshed.tools[0]!.name).not.toBe(writeAlias);
|
||||
// Execution still goes through the original gateway, which reauthorizes
|
||||
// each call; discovery never grants an added tool to this pinned session.
|
||||
const revoked = new ToolGatewayHttpError(403, "Revoked", "connection_revoked");
|
||||
f.executeTool.mockRejectedValueOnce(revoked);
|
||||
await expect(assigned.execute(call)).rejects.toBe(revoked);
|
||||
await expect(assigned.execute({ tool: callName, arguments: { name: "newly.granted", arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
|
||||
});
|
||||
|
||||
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
|
||||
const read = descriptor("memory.search");
|
||||
const write = descriptor("memory.store", "write");
|
||||
|
||||
@@ -86,6 +86,8 @@ export async function createAssignedMcpTools(input: {
|
||||
gatewayPublicId: string;
|
||||
bearerToken: string;
|
||||
workMode?: WorkMode;
|
||||
/** Bound provider grammar complexity independently of protocol byte capacity. */
|
||||
delivery?: "on_demand";
|
||||
}) {
|
||||
const listed = await input.gateway.listToolsForNamedGateway({
|
||||
gatewayPublicId: input.gatewayPublicId,
|
||||
@@ -103,6 +105,11 @@ export async function createAssignedMcpTools(input: {
|
||||
tools.set(name, descriptor);
|
||||
}
|
||||
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
|
||||
// OpenCode's HTTP bridge returns at most 64K JSON characters. Leave room
|
||||
// for the envelope and for JSON escaping when returning a schema chunk.
|
||||
// Other harnesses retain the larger PRP page and chunk budgets.
|
||||
const searchPageBytes = input.delivery === "on_demand" ? 32 * 1024 : SEARCH_PAGE_BYTES;
|
||||
const schemaChunkCharacters = input.delivery === "on_demand" ? 16 * 1024 : SCHEMA_CHUNK_CHARACTERS;
|
||||
|
||||
async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) {
|
||||
const args = object(argumentsValue);
|
||||
@@ -132,7 +139,7 @@ export async function createAssignedMcpTools(input: {
|
||||
const selected = authorized.find(([name]) => name === args.schemaTool);
|
||||
if (!selected) throw new Error("assigned_mcp_tool_unknown");
|
||||
const schema = JSON.stringify(selected[1].parametersSchema);
|
||||
const end = schemaOffset + SCHEMA_CHUNK_CHARACTERS;
|
||||
const end = schemaOffset + schemaChunkCharacters;
|
||||
return {
|
||||
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
|
||||
nextSchemaOffset: end < schema.length ? end : null,
|
||||
@@ -144,13 +151,13 @@ export async function createAssignedMcpTools(input: {
|
||||
const page: ToolDefinition[] = [];
|
||||
for (const [name, tool] of matches.slice(offset, offset + limit)) {
|
||||
let next = definition(name, tool);
|
||||
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > SEARCH_PAGE_BYTES) {
|
||||
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > searchPageBytes) {
|
||||
// One large schema must not block browsing the tools after it. Expose
|
||||
// a reference whose schema can be fetched in bounded, reauthorized
|
||||
// chunks, without weakening the gateway's argument validation.
|
||||
next = { name, description: String(next.description).slice(0, 2000), inputSchemaRef: name };
|
||||
}
|
||||
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > SEARCH_PAGE_BYTES) {
|
||||
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > searchPageBytes) {
|
||||
break;
|
||||
}
|
||||
page.push(next);
|
||||
@@ -161,9 +168,9 @@ export async function createAssignedMcpTools(input: {
|
||||
return {
|
||||
definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] {
|
||||
const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool));
|
||||
if (!fits || fits(direct)) return direct;
|
||||
if (input.delivery !== "on_demand" && (!fits || fits(direct))) return direct;
|
||||
const compact = structuredClone(ON_DEMAND_TOOLS);
|
||||
if (!fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
if (fits && !fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
return compact;
|
||||
},
|
||||
has(name: string): boolean {
|
||||
|
||||
@@ -1471,6 +1471,7 @@ describe("remote provider pack manifest", () => {
|
||||
try {
|
||||
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
|
||||
runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true,
|
||||
...(provider === "opencode" ? { runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "assigned-fixture", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1/mcp/gateways/assigned-fixture", PAPERCLIP_NATIVE_MCP_TOKEN: "private-fixture" } } : {}),
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
|
||||
environmentId: "environment", leaseId: "lease", providerKey: "daytona",
|
||||
effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never });
|
||||
@@ -1478,8 +1479,13 @@ describe("remote provider pack manifest", () => {
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
|
||||
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
};
|
||||
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
|
||||
if (provider === "opencode") {
|
||||
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-fixture", bearerToken: "private-fixture", delivery: "on_demand" }));
|
||||
for (const name of ["PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN"]) expect(transport.environment?.[name]).toBeUndefined();
|
||||
}
|
||||
if (claude) {
|
||||
expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined }));
|
||||
const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js");
|
||||
@@ -7800,7 +7806,7 @@ describe("bounded stopped instruction collection", () => {
|
||||
describe("native warm session supervision", () => {
|
||||
it.each(["collect", "close-failure", "stopped", "destroyed", "missing-lease", "foreign-run", "foreign-environment", "deleted-environment", "claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical", "claim-config", "claim-uncertain"])("composes remote warm ownership with real DB successor leases and current-run collection: %s", async ending => {
|
||||
const tables = await import("@paperclipai/db");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { eq, sql } = await import("drizzle-orm");
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const { promisify } = await import("node:util");
|
||||
const { execFile } = await import("node:child_process");
|
||||
@@ -7813,7 +7819,11 @@ describe("native warm session supervision", () => {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "warm-remote-db-")));
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
const database = await startEmbeddedPostgresTestDatabase("warm-remote-db-");
|
||||
const { resolveExternalTestDatabaseUrl } = await import("../../__tests__/helpers/external-test-database.js");
|
||||
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
|
||||
const database = externalUrl
|
||||
? { connectionString: externalUrl, cleanup: () => tables.closeRegisteredClients(externalUrl) }
|
||||
: await startEmbeddedPostgresTestDatabase("warm-remote-db-");
|
||||
const db = tables.createDb(database.connectionString);
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), environmentId = randomUUID(), issueId = randomUUID();
|
||||
const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js");
|
||||
@@ -8049,6 +8059,9 @@ describe("native warm session supervision", () => {
|
||||
finally {
|
||||
const cleanupFailures: unknown[] = [];
|
||||
try { await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" }); } catch (error) { cleanupFailures.push(error); }
|
||||
if (externalUrl) {
|
||||
try { await db.execute(sql`TRUNCATE TABLE ${tables.companies}, ${tables.environments}, ${tables.authUsers} CASCADE`); } catch (error) { cleanupFailures.push(error); }
|
||||
}
|
||||
try { await database.cleanup(); } catch (error) { cleanupFailures.push(error); }
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||||
try {
|
||||
@@ -11828,7 +11841,10 @@ describe("runnerd provider runtime wiring", () => {
|
||||
} finally {owner.mockRestore();await rm(home,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
it("keeps assigned MCP credentials on the control plane for remote Codex", async () => {
|
||||
it.each(["codex", "opencode"] as const)("keeps assigned MCP credentials on the control plane for %s", async (providerKind) => {
|
||||
const selectedExecution: NativeExecutionInput = providerKind === "opencode"
|
||||
? { ...execution, provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" } }
|
||||
: execution;
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.test");
|
||||
const exporter = new InMemorySpanExporter();
|
||||
@@ -11841,13 +11857,13 @@ describe("runnerd provider runtime wiring", () => {
|
||||
state.createTransport.mockClear();
|
||||
state.toolAuthorityDefinitions.mockClear();
|
||||
await createRunnerdBackend({
|
||||
db: leaseDb(execution), execution, runnerInstanceId: "runner-assigned-mcp",
|
||||
db: leaseDb(selectedExecution), execution: selectedExecution, runnerInstanceId: "runner-assigned-mcp",
|
||||
runnerEnvironment: {
|
||||
PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned",
|
||||
PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-test",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token",
|
||||
},
|
||||
runnerExecutionTarget: {
|
||||
runnerExecutionTarget: providerKind === "opencode" ? undefined : {
|
||||
kind: "remote", transport: "sandbox", providerKey: "daytona",
|
||||
leaseId: "lease-assigned-mcp", remoteCwd: "/home/daytona/paperclip-workspace",
|
||||
runner: { execute: vi.fn() },
|
||||
@@ -11855,6 +11871,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||
});
|
||||
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({
|
||||
gatewayPublicId: "assigned-test", bearerToken: "private-run-token",
|
||||
...(providerKind === "opencode" ? { delivery: "on_demand" } : {}),
|
||||
}));
|
||||
expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools }));
|
||||
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
|
||||
|
||||
@@ -5563,7 +5563,7 @@ function nativeSessionConfigDigest(
|
||||
// changes so an older thread is rotated instead of falsely resuming
|
||||
// without newly required tools.
|
||||
nativeToolContractFingerprint:
|
||||
nativeToolContractFingerprintForTarget(executionTargetKind),
|
||||
nativeToolContractFingerprintForTarget(executionTargetKind, execution.provider.kind),
|
||||
runtimeContract: nativeRuntimeContractForProvider(execution.provider),
|
||||
}),
|
||||
)
|
||||
@@ -11828,10 +11828,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
if (nativeReview && !await getNativeReviewAssignment(input.db, {
|
||||
...input.execution.binding, contextSnapshot: nativeReview,
|
||||
})) throw new Error("native_review_assignment_no_longer_available");
|
||||
// Remote Codex already sends dynamic tool calls over authenticated PRP. Keep
|
||||
// Codex and OpenCode send dynamic tool calls over authenticated PRP. Keep
|
||||
// the assigned gateway on the control plane instead of asking the sandbox to
|
||||
// reach the host's HTTP origin (which may be private or loopback-only).
|
||||
const relayAssignedMcp = Boolean(pluginTask) || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
|
||||
const relayAssignedMcp = Boolean(pluginTask) || input.execution.provider.kind === "opencode" || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
|
||||
const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL;
|
||||
const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN;
|
||||
const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME;
|
||||
@@ -11853,6 +11853,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
gatewayPublicId: assignedGatewayPublicId!,
|
||||
bearerToken: assignedMcpToken,
|
||||
workMode: input.execution.task.workMode,
|
||||
...(input.execution.provider.kind === "opencode" ? { delivery: "on_demand" as const } : {}),
|
||||
}))
|
||||
: undefined),
|
||||
]);
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
startEmbeddedPostgresTestDatabase as startEmbeddedDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import {
|
||||
canonicalNativeRuntimeContextDigest,
|
||||
@@ -72,6 +72,20 @@ import {
|
||||
selectNativeSessionResumeRun,
|
||||
} from "./native-session-resume.js";
|
||||
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
||||
|
||||
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
|
||||
|
||||
async function startEmbeddedPostgresTestDatabase(prefix: string) {
|
||||
const externalUrl = externalTestDatabaseUrl;
|
||||
if (!externalUrl) return startEmbeddedDatabase(prefix);
|
||||
const { closeRegisteredClients } = await import("@paperclipai/db");
|
||||
return { connectionString: externalUrl, cleanup: async () => {
|
||||
const db = createDb(externalUrl);
|
||||
try { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }
|
||||
finally { await closeRegisteredClients(externalUrl); }
|
||||
} };
|
||||
}
|
||||
|
||||
const companyId = "10000000-0000-4000-8000-000000000001";
|
||||
const issueId = "20000000-0000-4000-8000-000000000002";
|
||||
@@ -283,12 +297,13 @@ it("wires exact-session recovery and guarded selected identity into heartbeat pe
|
||||
});
|
||||
|
||||
const embeddedSupport = await getEmbeddedPostgresTestSupport();
|
||||
const databaseSupported = Boolean(externalTestDatabaseUrl) || embeddedSupport.supported;
|
||||
const recoveryFakeCodex = resolve(
|
||||
import.meta.dirname,
|
||||
"../../../../packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs",
|
||||
);
|
||||
|
||||
(embeddedSupport.supported && existsSync(defaultCapabilityRunnerdBinary())
|
||||
(databaseSupported && existsSync(defaultCapabilityRunnerdBinary())
|
||||
? it
|
||||
: it.skip)(
|
||||
"keeps actual normalized driver events separate from raw maintenance receipts",
|
||||
@@ -520,7 +535,7 @@ const recoveryFakeCodex = resolve(
|
||||
30_000,
|
||||
);
|
||||
|
||||
(embeddedSupport.supported &&
|
||||
(databaseSupported &&
|
||||
existsSync(defaultCapabilityRunnerdBinary()) &&
|
||||
existsSync(recoveryFakeCodex)
|
||||
? it
|
||||
@@ -1062,7 +1077,7 @@ const recoveryFakeCodex = resolve(
|
||||
60_000,
|
||||
);
|
||||
|
||||
(embeddedSupport.supported ? describe : describe.skip)(
|
||||
(databaseSupported ? describe : describe.skip)(
|
||||
"native session recovery database orchestration",
|
||||
() => {
|
||||
it("uses scoped latest checkpoints, preserves progress barriers, and guards bootstrap persistence after the row lock", async () => {
|
||||
@@ -1825,6 +1840,23 @@ describe("rebindNativeSessionCheckpoint", () => {
|
||||
|
||||
|
||||
|
||||
it.each(["local", "remote"] as const)("refreshes only OpenCode's former direct MCP catalog on %s", target => {
|
||||
const legacy = nativeToolContractFingerprintForTarget(target);
|
||||
expect(nativeToolContractFingerprintForTarget(target, "codex")).toBe(legacy);
|
||||
expect(nativeToolContractFingerprintForTarget(target, "acpx")).toBe(legacy);
|
||||
const current = nativeToolContractFingerprintForTarget(target, "opencode");
|
||||
expect(current).not.toBe(legacy);
|
||||
const shortenedCatalog = target === "local"
|
||||
? "sha256:2ae35faccbb63cfe009d94170ef1ef13f0d57e120af6160540598a3ec72001d7"
|
||||
: "sha256:ee5285ca5118b8468c0afd1df4ef9c05d85b0934e882bccd0b193dc8722dbb7a";
|
||||
expect(current).not.toBe(shortenedCatalog);
|
||||
const currentExecution = { ...execution(currentRunId), provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, session: { ...execution(currentRunId).session, driverKind: "opencode_server" } } as NativeExecutionInput;
|
||||
const previousExecution = { ...execution(previousRunId), provider: currentExecution.provider, session: currentExecution.session };
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: shortenedCatalog, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: legacy, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: current, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("binds the provider tool catalog to the local or remote execution target", () => {
|
||||
const remoteFingerprint = nativeToolContractFingerprintForTarget("remote");
|
||||
expect(remoteFingerprint).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT);
|
||||
|
||||
@@ -29,6 +29,7 @@ export function nativeRuntimeContractForProvider(
|
||||
*/
|
||||
export function nativeToolContractFingerprintForTarget(
|
||||
executionTargetKind: NativeToolExecutionTargetKind,
|
||||
providerKind?: NativeExecutionInput["provider"]["kind"],
|
||||
): string {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(
|
||||
@@ -47,6 +48,9 @@ export function nativeToolContractFingerprintForTarget(
|
||||
structuredHumanInput:
|
||||
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
|
||||
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
|
||||
...(providerKind === "opencode"
|
||||
? { opencodeAssignedMcpTools: "server_gateway_prp_on_demand_exact_names.v2" }
|
||||
: {}),
|
||||
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
|
||||
chatTaskTools: "durable_task_binding_and_accepted_origin.v1",
|
||||
githubResponses: "bound_working_comment_progress_and_final.v1",
|
||||
@@ -482,7 +486,7 @@ export function rebindNativeSessionCheckpoint(input: {
|
||||
const previousProfile = record(input.previousRun.runnerProfileJson);
|
||||
if (
|
||||
previousProfile.nativeToolContractFingerprint !==
|
||||
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local")
|
||||
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local", input.currentExecution.provider.kind)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user