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 <ymarcus93@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-10-01 01:15:34 +02:00
committed by GitHub
co-authored by Yuval Marcus
parent 7b0cace991
commit e0a0cf990d
3 changed files with 102 additions and 1 deletions
@@ -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<ReturnType<OpenCodeAgentClient["createSession"]>> | 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",
@@ -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(
@@ -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);