From e0a0cf990d27f9a40cb1ef2fc490a1d2d7a7e4ef Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:15:34 +0200 Subject: [PATCH] Ask OpenCode before running a Paseo tool (#5801) OpenCode checks permissions for its own tools and wraps MCP tools in an ask, but leaves plugin tools to call ctx.ask themselves. The bridge plugin never did, so an "ask" rule for paseo_* tools never produced a prompt and the tool ran. The bridge now asks with the tool's catalog name before dispatching, the same shape OpenCode uses for MCP tools. With no paseo_* rule OpenCode's default allow applies, so nothing new prompts. Fixes #5324 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Yuval Marcus --- .../opencode-bridge.local.e2e.test.ts | 70 +++++++++++++++++++ .../providers/opencode/bridge-plugin.mjs | 7 ++ .../agent/providers/opencode/bridge.test.ts | 26 ++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index a441cebcb5..d3d295cc8e 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -8,6 +8,10 @@ import { expect, test } from "vitest"; import { execCommand } from "../../../utils/spawn.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + getRealProviderConfig, + getRealProviderRuntimeSettings, +} from "../../daemon-e2e/real-provider-test-config.js"; import type { PaseoToolCatalog } from "../tools/types.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; @@ -199,6 +203,72 @@ test("real OpenCode server shares one process while shell.env stays session-scop } }, 240_000); +test("real OpenCode server asks before running a Paseo tool with an ask rule", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-ask-")); + const cwd = path.join(root, "repo"); + const logger = createTestLogger(); + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + await bridge.start(); + const catalog = createCallerCatalog("ask-rule-agent"); + bridge.setManifestCatalog(catalog); + const realSettings = getRealProviderRuntimeSettings("opencode"); + const runtimeSettings = { + ...realSettings, + env: { + ...realSettings.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: { "paseo_*": "ask" } }), + }, + }; + const manager = new OpenCodeServerManager({ + logger, + runtimeSettings, + resolveHomeDir: () => root, + decorateServerEnv: (env) => bridge.decorateServerEnv(env), + }); + const client = new OpenCodeAgentClient(logger, runtimeSettings, { + serverManager: manager, + bridge, + }); + let session: Awaited> | undefined; + + try { + await mkdir(cwd); + session = await client.createSession( + { ...getRealProviderConfig("opencode"), cwd }, + { agentId: "ask-rule-agent", paseoTools: catalog }, + { persistSession: false }, + ); + const asked: string[] = []; + const completedTools: string[] = []; + const activeSession = session; + session.subscribe((event) => { + if (event.type === "permission_requested") { + asked.push(event.request.name); + void activeSession.respondToPermission(event.request.id, { behavior: "allow" }); + } + if ( + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed" + ) { + completedTools.push(event.item.name); + } + }); + + await session.run( + "Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.", + ); + + expect(completedTools).toContain("paseo_report_caller_agent_id"); + expect(asked).toContain("paseo_report_caller_agent_id"); + } finally { + await session?.close(); + await manager.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); + function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { const tool = { name: "report_caller_agent_id", diff --git a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs index 058ade60f8..82e4166492 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs @@ -33,6 +33,13 @@ export default async function paseoPlugin(input, options) { description: definition.description, args: jsonSchemaObjectToZodShape(definition.inputSchema), execute: async (args, context) => { + // OpenCode leaves permission checks to plugin tools, so ask it the way it asks for MCP tools. + await context.ask({ + permission: `paseo_${definition.name}`, + patterns: ["*"], + always: ["*"], + metadata: {}, + }); let result; try { result = await request( diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 2df81b3241..13cd6aa4d8 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -102,6 +102,12 @@ describe("OpenCodeBridge", () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-")); temporaryDirectories.push(paseoHome); const catalog = createCatalog(); + const executedInputs: unknown[] = []; + const executeTool = catalog.executeTool; + catalog.executeTool = async (name, input, context) => { + executedInputs.push(input); + return await executeTool(name, input, context); + }; const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); await bridge.start(); bridge.setManifestCatalog(catalog); @@ -173,12 +179,30 @@ describe("OpenCodeBridge", () => { token: plugin.token, }, ); + const asked: unknown[] = []; await expect( hooks.tool.paseo_echo_context.execute( { value: "through bundled plugin" }, - { sessionID: "ses_one" }, + { sessionID: "ses_one", ask: async (request: unknown) => void asked.push(request) }, ), ).resolves.toMatchObject({ output: "through bundled plugin" }); + expect(asked).toEqual([ + { permission: "paseo_echo_context", patterns: ["*"], always: ["*"], metadata: {} }, + ]); + + executedInputs.length = 0; + await expect( + hooks.tool.paseo_echo_context.execute( + { value: "rejected" }, + { + sessionID: "ses_one", + ask: async () => { + throw new Error("The user rejected permission to use this specific tool call."); + }, + }, + ), + ).rejects.toThrow("rejected permission"); + expect(executedInputs).toEqual([]); release(); const pluginError = vi.spyOn(console, "error").mockImplementation(() => undefined);