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",
|
icon: "ShieldCheck",
|
||||||
colorTier: "moderate",
|
colorTier: "moderate",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "full-access",
|
||||||
|
label: "Full Access",
|
||||||
|
description: "Automatically approves all tool permission prompts for the session",
|
||||||
|
icon: "ShieldAlert",
|
||||||
|
colorTier: "dangerous",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "plan",
|
id: "plan",
|
||||||
label: "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,
|
supportsToolInvocations: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPENCODE_BUILD_MODE_ID = "build";
|
||||||
|
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
|
||||||
|
|
||||||
const DEFAULT_MODES: AgentMode[] = [
|
const DEFAULT_MODES: AgentMode[] = [
|
||||||
{
|
{
|
||||||
id: "build",
|
id: OPENCODE_BUILD_MODE_ID,
|
||||||
label: "Build",
|
label: "Build",
|
||||||
description: "Allows edits and tool execution for implementation work",
|
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",
|
id: "plan",
|
||||||
label: "Plan",
|
label: "Plan",
|
||||||
@@ -404,11 +412,26 @@ function resolvePartDedupeKey(
|
|||||||
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
|
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
|
||||||
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
|
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
|
||||||
if (!trimmed || trimmed === "default") {
|
if (!trimmed || trimmed === "default") {
|
||||||
return "build";
|
return OPENCODE_BUILD_MODE_ID;
|
||||||
}
|
}
|
||||||
return trimmed;
|
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[] {
|
function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
|
||||||
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
|
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
|
||||||
return [...modes].sort((left, right) => {
|
return [...modes].sort((left, right) => {
|
||||||
@@ -1176,7 +1199,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
|
return mergeOpenCodeModes(discovered);
|
||||||
} finally {
|
} finally {
|
||||||
acquisition.release();
|
acquisition.release();
|
||||||
}
|
}
|
||||||
@@ -2031,7 +2054,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const model = this.parseModel(this.config.model);
|
const model = this.parseModel(this.config.model);
|
||||||
const thinkingOptionId = this.config.thinkingOptionId;
|
const thinkingOptionId = this.config.thinkingOptionId;
|
||||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||||
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||||
|
|
||||||
const turnId = this.createTurnId();
|
const turnId = this.createTurnId();
|
||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
@@ -2205,7 +2228,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const translated = this.translateEvent(event);
|
const translated = await this.translateEvent(event);
|
||||||
for (const e of translated) {
|
for (const e of translated) {
|
||||||
if (this.activeForegroundTurnId !== turnId) {
|
if (this.activeForegroundTurnId !== turnId) {
|
||||||
return;
|
return;
|
||||||
@@ -2400,23 +2423,20 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const response = await this.client.app.agents({
|
const response = await this.client.app.agents({
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
});
|
});
|
||||||
|
const agents = response.error || !response.data ? [] : response.data;
|
||||||
|
|
||||||
const discoveredModes =
|
const discoveredModes = agents
|
||||||
response.error || !response.data
|
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
||||||
? []
|
.map((agent) => ({
|
||||||
: response.data
|
id: agent.name,
|
||||||
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
||||||
.map((agent) => ({
|
description:
|
||||||
id: agent.name,
|
typeof agent.description === "string" && agent.description.trim().length > 0
|
||||||
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
? agent.description.trim()
|
||||||
description:
|
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
||||||
typeof agent.description === "string" && agent.description.trim().length > 0
|
}));
|
||||||
? agent.description.trim()
|
|
||||||
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.availableModesCache =
|
this.availableModesCache = mergeOpenCodeModes(discoveredModes);
|
||||||
discoveredModes.length > 0 ? sortOpenCodeModes(discoveredModes) : DEFAULT_MODES;
|
|
||||||
return this.availableModesCache;
|
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, {
|
const translated = translateOpenCodeEvent(event, {
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
messageRoles: this.messageRoles,
|
messageRoles: this.messageRoles,
|
||||||
@@ -2666,8 +2686,14 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
for (const translatedEvent of translated) {
|
for (const translatedEvent of translated) {
|
||||||
if (translatedEvent.type === "permission_requested") {
|
if (translatedEvent.type === "permission_requested") {
|
||||||
|
const autoApproved = await this.tryAutoApproveToolPermission(translatedEvent.request);
|
||||||
|
if (autoApproved) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
||||||
}
|
}
|
||||||
if (translatedEvent.type === "turn_completed") {
|
if (translatedEvent.type === "turn_completed") {
|
||||||
@@ -2678,9 +2704,31 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.accumulatedUsage =
|
this.accumulatedUsage =
|
||||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
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 {
|
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
|
||||||
|
|||||||
Reference in New Issue
Block a user