From 6b35c0a640d01a8b071f911a77b6df3714737f7d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 25 May 2026 17:11:53 +0800 Subject: [PATCH] Add persistent OpenCode permission actions (#1168) --- .../opencode-agent.permission-actions.test.ts | 90 +++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 44 ++++++++- .../opencode/event-translator.test.ts | 24 +++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts b/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts new file mode 100644 index 000000000..bf8c98b3f --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { + idleEvent, + TestOpenCodeClient, + TestOpenCodeRuntime, +} from "./opencode/test-utils/test-opencode-runtime.js"; + +function mockOpenCodeClient(events: unknown[]) { + const runtime = new TestOpenCodeRuntime(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionPromptAsyncEvents = events; + runtime.enqueueClient(openCodeClient); + + return { openCodeClient, runtime }; +} + +function toolPermissionEvent(): unknown { + return { + type: "permission.asked", + properties: { + id: "permission-1", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Inspect files outside the project", + }, + }, + }; +} + +describe("OpenCode permission actions", () => { + test("allow always sends OpenCode's always reply", async () => { + const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + modeId: "build", + }); + + await session.run("Inspect outside files"); + const permission = session.getPendingPermissions()[0]!; + await session.respondToPermission(permission.id, { + behavior: "allow", + selectedActionId: "allow_always", + }); + + expect(openCodeClient.calls.permissionReply).toEqual([ + { + requestID: "permission-1", + directory: "/tmp/project", + reply: "always", + }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + + await session.close(); + }); + + test("plain allow keeps the backward-compatible once reply", async () => { + const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + modeId: "build", + }); + + await session.run("Inspect outside files"); + const permission = session.getPendingPermissions()[0]!; + await session.respondToPermission(permission.id, { + behavior: "allow", + }); + + expect(openCodeClient.calls.permissionReply).toEqual([ + { + requestID: "permission-1", + directory: "/tmp/project", + reply: "once", + }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + + await session.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 34b7edd8a..e8e9dc3a7 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -24,6 +24,7 @@ import { type AgentLaunchContext, type AgentMode, type AgentModelDefinition, + type AgentPermissionAction, type AgentPermissionRequest, type AgentPermissionResponse, type AgentPersistenceHandle, @@ -85,6 +86,8 @@ const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; +const OPENCODE_PERMISSION_ACTION_ALLOW_ONCE = "allow_once"; +const OPENCODE_PERMISSION_ACTION_ALLOW_ALWAYS = "allow_always"; const DEFAULT_MODES: AgentMode[] = [ { @@ -104,6 +107,44 @@ const DEFAULT_MODES: AgentMode[] = [ }, ]; +function buildOpenCodePermissionActions(): AgentPermissionAction[] { + return [ + { + id: "deny", + label: "Deny", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: OPENCODE_PERMISSION_ACTION_ALLOW_ALWAYS, + label: "Allow always", + behavior: "allow", + variant: "secondary", + }, + { + id: OPENCODE_PERMISSION_ACTION_ALLOW_ONCE, + label: "Allow once", + behavior: "allow", + variant: "primary", + }, + ]; +} + +function resolveOpenCodePermissionReply( + response: AgentPermissionResponse, +): "once" | "always" | "reject" { + if (response.behavior === "deny") { + return "reject"; + } + + if (response.selectedActionId === OPENCODE_PERMISSION_ACTION_ALLOW_ALWAYS) { + return "always"; + } + + return "once"; +} + type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; type OpenCodeMessageRole = "user" | "assistant"; type OpenCodePersistedSession = OpenCodeSession | OpenCodeGlobalSession; @@ -2234,6 +2275,7 @@ function appendOpenCodePermissionAsked( ...(description ? { description } : {}), input, detail, + actions: buildOpenCodePermissionActions(), }, }); } @@ -3130,7 +3172,7 @@ class OpenCodeAgentSession implements AgentSession { return; } - const reply = response.behavior === "allow" ? "once" : "reject"; + const reply = resolveOpenCodePermissionReply(response); await this.client.permission.reply({ requestID: requestId, directory: this.config.cwd, diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 5dd55251c..ff5e5e4c0 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest"; import { translateOpenCodeEvent, type OpenCodeEventTranslationState } from "../opencode-agent.js"; +const openCodePermissionActions = [ + { + id: "deny", + label: "Deny", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "allow_always", + label: "Allow always", + behavior: "allow", + variant: "secondary", + }, + { + id: "allow_once", + label: "Allow once", + behavior: "allow", + variant: "primary", + }, +]; + function createState(sessionId = "session-1"): OpenCodeEventTranslationState { return { sessionId, @@ -306,6 +328,7 @@ describe("translateOpenCodeEvent", () => { type: "shell", command: "ls /home/user/secrets", }, + actions: openCodePermissionActions, }, }, ]); @@ -358,6 +381,7 @@ describe("translateOpenCodeEvent", () => { }, output: null, }, + actions: openCodePermissionActions, }, }, ]);