diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 78f0e8434..fcc94705e 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -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", diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts new file mode 100644 index 000000000..f383ececa --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 2a1d649a6..c7bade6d8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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 { 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 { + 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 {