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:
co-authored by
Mohamed Boudra
parent
2a4945ad0b
commit
fbf10ba4c6
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user