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);