fix(server): support OpenCode full-access approvals (#595)

* fix(server): support OpenCode full-access approvals

* fix(opencode): unconditionally auto-approve tool permissions in full-access mode

OpenCode permissions that resolve to allow do not surface prompts in the first place, so checking runtime permission rules before replying made Paseo full-access misleading. Permissions that default to ask, like external_directory and doom_loop, would still surface even in full-access.

Make full-access match OpenCode's own --dangerously-skip-permissions behavior by replying "once" to every tool permission prompt, while still letting reply failures fall back to surfacing the permission to the user.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Thanh Minh
2026-04-28 20:47:12 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 2a4945ad0b
commit fbf10ba4c6
3 changed files with 312 additions and 22 deletions
@@ -107,6 +107,13 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
icon: "ShieldCheck",
colorTier: "moderate",
},
{
id: "full-access",
label: "Full Access",
description: "Automatically approves all tool permission prompts for the session",
icon: "ShieldAlert",
colorTier: "dangerous",
},
{
id: "plan",
label: "Plan",
@@ -0,0 +1,235 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
interface MockOpenCodeClientOptions {
agents?: unknown[];
events?: unknown[];
}
function createEventStream(events: unknown[]): AsyncGenerator<never> {
return (async function* () {
for (const event of events) {
yield event as never;
}
})();
}
function mockServerManager(): void {
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
}
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const promptAsync = vi.fn().mockResolvedValue({});
const permissionReply = vi.fn().mockResolvedValue({});
const questionReply = vi.fn().mockResolvedValue({});
const questionReject = vi.fn().mockResolvedValue({});
const appAgents = vi.fn().mockResolvedValue({ data: options.agents ?? [] });
const events = options.events ?? [idleEvent()];
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
promptAsync,
abort: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
},
provider: {
list: vi.fn().mockResolvedValue({ data: { connected: [], all: [] } }),
},
event: {
subscribe: vi.fn().mockResolvedValue({ stream: createEventStream(events) }),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: appAgents,
},
permission: {
reply: permissionReply,
},
question: {
reply: questionReply,
reject: questionReject,
},
} as never);
return { appAgents, permissionReply, promptAsync, questionReject, questionReply };
}
function idleEvent(): unknown {
return {
type: "session.idle",
properties: { sessionID: "session-1" },
};
}
function toolPermissionEvent(): unknown {
return {
type: "permission.asked",
properties: {
id: "permission-1",
sessionID: "session-1",
permission: "bash",
patterns: [],
metadata: {
command: "npm test",
reason: "Run verification",
},
},
};
}
function questionEvent(): unknown {
return {
type: "question.asked",
properties: {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Which option should OpenCode use?",
header: "Decision",
options: [{ label: "Proceed", description: "Continue with the change" }],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
},
};
}
describe("OpenCode full-access mode", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
mockServerManager();
mockOpenCodeClient({
agents: [
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
{ name: "paseo-custom", mode: "primary", hidden: false, description: "Custom agent" },
],
});
const client = new OpenCodeAgentClient(createTestLogger());
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "full-access", "plan", "paseo-custom"]);
expect(modes.find((mode) => mode.id === "full-access")).toMatchObject({
label: "Full Access",
description: "Automatically approves all tool permission prompts for the session",
});
});
test("reports full-access but sends prompts through OpenCode build agent", async () => {
mockServerManager();
const { promptAsync } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger());
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
});
expect(await session.getCurrentMode()).toBe("full-access");
await session.run("Implement the change");
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ agent: "build" }));
await session.close();
});
test("auto-approves tool permissions in full-access without surfacing them", async () => {
mockServerManager();
const { permissionReply } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger());
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
});
session.subscribe((event) => receivedEvents.push(event));
await session.run("Run verification");
expect(permissionReply).toHaveBeenCalledTimes(1);
expect(permissionReply).toHaveBeenCalledWith({
requestID: "permission-1",
directory: "/tmp/project",
reply: "once",
});
expect(receivedEvents.filter((event) => event.type === "permission_requested")).toEqual([]);
expect(session.getPendingPermissions()).toEqual([]);
await session.close();
});
test("keeps questions separate from full-access tool auto-approval", async () => {
mockServerManager();
const { permissionReply, questionReply } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger());
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
});
session.subscribe((event) => receivedEvents.push(event));
await session.run("Ask a question");
expect(receivedEvents.filter((event) => event.type === "permission_requested")).toEqual([
expect.objectContaining({
request: expect.objectContaining({
id: "question-1",
kind: "question",
}),
}),
]);
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("question-1", {
behavior: "allow",
updatedInput: { answers: { Decision: "Proceed" } },
});
expect(questionReply).toHaveBeenCalledTimes(1);
expect(questionReply).toHaveBeenCalledWith({
requestID: "question-1",
directory: "/tmp/project",
answers: [["Proceed"]],
});
expect(permissionReply).not.toHaveBeenCalled();
expect(session.getPendingPermissions()).toEqual([]);
await session.close();
});
});
@@ -67,12 +67,20 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true,
};
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const DEFAULT_MODES: AgentMode[] = [
{
id: "build",
id: OPENCODE_BUILD_MODE_ID,
label: "Build",
description: "Allows edits and tool execution for implementation work",
},
{
id: OPENCODE_FULL_ACCESS_MODE_ID,
label: "Full Access",
description: "Automatically approves all tool permission prompts for the session",
},
{
id: "plan",
label: "Plan",
@@ -404,11 +412,26 @@ function resolvePartDedupeKey(
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
if (!trimmed || trimmed === "default") {
return "build";
return OPENCODE_BUILD_MODE_ID;
}
return trimmed;
}
function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string {
const normalizedModeId = normalizeOpenCodeModeId(modeId);
return normalizedModeId === OPENCODE_FULL_ACCESS_MODE_ID
? OPENCODE_BUILD_MODE_ID
: normalizedModeId;
}
function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] {
const modesById = new Map(DEFAULT_MODES.map((mode) => [mode.id, mode]));
for (const mode of discoveredModes) {
modesById.set(mode.id, mode);
}
return sortOpenCodeModes(Array.from(modesById.values()));
}
function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
return [...modes].sort((left, right) => {
@@ -1176,7 +1199,7 @@ export class OpenCodeAgentClient implements AgentClient {
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
return mergeOpenCodeModes(discovered);
} finally {
acquisition.release();
}
@@ -2031,7 +2054,7 @@ class OpenCodeAgentSession implements AgentSession {
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined;
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
@@ -2205,7 +2228,7 @@ class OpenCodeAgentSession implements AgentSession {
break;
}
const translated = this.translateEvent(event);
const translated = await this.translateEvent(event);
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
return;
@@ -2400,23 +2423,20 @@ class OpenCodeAgentSession implements AgentSession {
const response = await this.client.app.agents({
directory: this.config.cwd,
});
const agents = response.error || !response.data ? [] : response.data;
const discoveredModes =
response.error || !response.data
? []
: response.data
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
const discoveredModes = agents
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
this.availableModesCache =
discoveredModes.length > 0 ? sortOpenCodeModes(discoveredModes) : DEFAULT_MODES;
this.availableModesCache = mergeOpenCodeModes(discoveredModes);
return this.availableModesCache;
}
@@ -2649,7 +2669,7 @@ class OpenCodeAgentSession implements AgentSession {
);
}
private translateEvent(event: OpenCodeEvent): AgentStreamEvent[] {
private async translateEvent(event: OpenCodeEvent): Promise<AgentStreamEvent[]> {
const translated = translateOpenCodeEvent(event, {
sessionId: this.sessionId,
messageRoles: this.messageRoles,
@@ -2666,8 +2686,14 @@ class OpenCodeAgentSession implements AgentSession {
},
});
const events: AgentStreamEvent[] = [];
for (const translatedEvent of translated) {
if (translatedEvent.type === "permission_requested") {
const autoApproved = await this.tryAutoApproveToolPermission(translatedEvent.request);
if (autoApproved) {
continue;
}
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
}
if (translatedEvent.type === "turn_completed") {
@@ -2678,9 +2704,31 @@ class OpenCodeAgentSession implements AgentSession {
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
}
events.push(translatedEvent);
}
return translated;
return events;
}
private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise<boolean> {
if (this.currentMode !== OPENCODE_FULL_ACCESS_MODE_ID || request.kind !== "tool") {
return false;
}
try {
await this.client.permission.reply({
requestID: request.id,
directory: this.config.cwd,
reply: "once",
});
return true;
} catch (error) {
this.logger.warn(
{ err: error, requestId: request.id },
"Failed to auto-approve OpenCode tool permission",
);
return false;
}
}
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {