diff --git a/packages/app/e2e/regression/server-auto-approve.spec.ts b/packages/app/e2e/regression/server-auto-approve.spec.ts new file mode 100644 index 0000000000..1ad15bd3aa --- /dev/null +++ b/packages/app/e2e/regression/server-auto-approve.spec.ts @@ -0,0 +1,167 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ServerAutoApprove" +const projectID = "proj_server_auto_approve" +const sourceID = "ses_server_auto_approve_source" +const targetID = "ses_server_auto_approve_target" +const sourceTitle = "Configure server auto-approve" +const targetTitle = "Use server auto-approve" + +type EventPayload = { + directory: string + payload: Record +} + +test("auto-approves permission requests across sessions on the same server", async ({ page }) => { + const events: EventPayload[] = [] + const replies: Array<{ path: string; body: unknown }> = [] + + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "server-auto-approve", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session(sourceID, sourceTitle, 1700000000000), session(targetID, targetTitle, 1700000001000)], + pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, + }) + await page.route("**/pty/shells*", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ) + await configurePage(page) + + page.on("request", (request) => { + const url = new URL(request.url()) + if (request.method() !== "POST" || !url.pathname.includes("/permissions/")) return + replies.push({ path: url.pathname, body: request.postDataJSON() }) + const requestID = url.pathname.split("/").at(-1)! + events.push(permissionReplied(targetID, requestID)) + }) + + await page.goto(sessionHref(sourceID)) + await expectSessionTitle(page, sourceTitle) + await page.keyboard.press("Control+Comma") + const settings = page.getByRole("dialog") + await expect(settings).toBeVisible() + const autoApprove = settings.getByRole("switch").first() + await expect(autoApprove).not.toBeChecked() + await autoApprove.focus() + await page.keyboard.press("Space") + await expect(autoApprove).toBeChecked() + await page.keyboard.press("Escape") + await expect(settings).toHaveCount(0) + + await page.getByRole("button", { name: "Home" }).click() + await expect(page).toHaveURL("/") + await page.getByText(targetTitle, { exact: true }).last().click() + await expect(page).toHaveURL(sessionHref(targetID)) + await expectSessionTitle(page, targetTitle) + events.push(permissionAsked(targetID, "permission-auto", "git status")) + + await expect + .poll(() => replies) + .toEqual([ + { + path: `/session/${targetID}/permissions/permission-auto`, + body: { response: "once" }, + }, + ]) + await expect(page.locator('[data-component="dock-prompt"][data-kind="permission"]')).toHaveCount(0) + + await page.keyboard.press("Control+Comma") + await expect(settings).toBeVisible() + await autoApprove.focus() + await page.keyboard.press("Space") + await expect(autoApprove).not.toBeChecked() + await page.keyboard.press("Escape") + await expect(settings).toHaveCount(0) + + events.push(permissionAsked(targetID, "permission-manual", "git diff")) + const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]') + await expect(dock).toBeVisible() + await expect(dock.getByText("git diff", { exact: true })).toBeVisible() + expect(replies).toHaveLength(1) +}) + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function permissionAsked(sessionID: string, id: string, pattern: string): EventPayload { + return { + directory, + payload: { + type: "permission.asked", + properties: { id, sessionID, permission: "bash", patterns: [pattern], metadata: {}, always: [] }, + }, + } +} + +function permissionReplied(sessionID: string, requestID: string): EventPayload { + return { + directory, + payload: { type: "permission.replied", properties: { sessionID, requestID, reply: "once" } }, + } +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +async function configurePage(page: Parameters[0]) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, server, sessionIDs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64: base64Encode(directory), sessionId })), + ), + ) + }, + { directory, server, sessionIDs: [sourceID, targetID] }, + ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f9a99aebb6..b89903081b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -41,7 +41,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { createSessionTabs } from "@/pages/session/helpers" @@ -204,7 +203,6 @@ export const PromptInput: Component = (props) => { const comments = useComments() const dialog = useDialog() const command = useCommand() - const permission = usePermission() const language = useLanguage() const platform = usePlatform() const tabs = () => props.controls.session.tabs @@ -1147,12 +1145,6 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) - const { abort, handleSubmit } = props.submission ?? createPromptSubmit({ @@ -1160,7 +1152,6 @@ export const PromptInput: Component = (props) => { info, imageAttachments, commentCount, - autoAccept: () => accepting(), mode: () => store.mode, working, editor: () => editorRef, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 9a65fa101d..aabd5f04c1 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -5,7 +5,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] -const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const optimistic: Array<{ directory?: string sessionID?: string @@ -117,14 +116,6 @@ beforeAll(async () => { }), })) - mock.module("@/context/permission", () => ({ - usePermission: () => ({ - enableAutoAccept(sessionID: string, directory: string) { - enabledAutoAccept.push({ sessionID, directory }) - }, - }), - })) - mock.module("@/context/server", () => ({ useServer: () => ({ key: "server-key" }), })) @@ -235,7 +226,6 @@ beforeAll(async () => { beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 - enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 @@ -256,7 +246,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "shell", working: () => false, editor: () => undefined, @@ -288,34 +277,6 @@ describe("prompt submit worktree selection", () => { expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) }) - test("applies auto-accept to newly created sessions", async () => { - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - const event = { preventDefault: () => undefined } as unknown as Event - - await submit.handleSubmit(event) - - expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) - }) - test("promotes drafts using the selected project's server", async () => { search = { draftId: "draft-1" } const submit = createPromptSubmit({ @@ -323,7 +284,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -352,7 +312,6 @@ describe("prompt submit worktree selection", () => { info: () => ({ id: "session-1" }), imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -384,7 +343,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947..c5f6fc6025 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -9,7 +9,6 @@ import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" -import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSync, type DirectorySync } from "@/context/sync" @@ -175,7 +174,6 @@ type PromptSubmitInput = { info: Accessor<{ id: string } | undefined> imageAttachments: Accessor commentCount: Accessor - autoAccept: Accessor mode: Accessor<"normal" | "shell"> working: Accessor editor: () => HTMLDivElement | undefined @@ -199,7 +197,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sync = useSync() const serverSync = useServerSync() const local = useLocal() - const permission = usePermission() const prompt = input.prompt const layout = useLayout() const language = useLanguage() @@ -314,7 +311,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const projectDirectory = sdk().directory const isNewSession = !params.id - const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory @@ -374,7 +370,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created - if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) local.session.promote(sessionDirectory, session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx index d2cc85c04e..79db538df8 100644 --- a/packages/app/src/components/settings-dialog.tsx +++ b/packages/app/src/components/settings-dialog.tsx @@ -1,4 +1,3 @@ -import { useParams } from "@solidjs/router" import { onCleanup } from "solid-js" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -6,7 +5,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" export function useSettingsDialog() { const dialog = useDialog() - const params = useParams<{ id?: string }>() let run = 0 let dead = false @@ -16,10 +14,9 @@ export function useSettingsDialog() { return () => { const current = ++run - const sessionID = params.id void import("@/components/settings-v2").then((module) => { if (dead || run !== current) return - void dialog.show(() => ) + void dialog.show(() => ) }) } } diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index cbbf33d2b1..ee0a21bddd 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -7,7 +7,6 @@ import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { usePlatform, type DisplayBackend } from "@/context/platform" @@ -26,7 +25,6 @@ import { terminalInput, useSettings, } from "@/context/settings" -import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" @@ -87,37 +85,11 @@ export const SettingsGeneral: Component = () => { const permission = usePermission() const platform = usePlatform() const dialog = useDialog() - const params = useParams() const settings = useSettings() const updater = useUpdaterAction() const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") - const dir = createMemo(() => decode64(params.dir)) - const accepting = createMemo(() => { - const value = dir() - if (!value) return false - if (!params.id) return permission.isAutoAcceptingDirectory(value) - return permission.isAutoAccepting(params.id, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value) return - - if (!params.id) { - if (permission.isAutoAcceptingDirectory(value) === checked) return - permission.toggleAutoAcceptDirectory(value) - return - } - - if (checked) { - permission.enableAutoAccept(params.id, value) - return - } - - permission.disableAutoAccept(params.id, value) - } const desktop = createMemo(() => platform.platform === "desktop") const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) @@ -273,7 +245,7 @@ export const SettingsGeneral: Component = () => { description={language.t("toast.permissions.autoaccept.on.description")} >
- +
diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index a93a10dcb2..cfe6971b12 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -11,9 +11,7 @@ import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" -export const DialogSettings: Component<{ - sessionID?: string -}> = (props) => { +export const DialogSettings: Component = () => { const language = useLanguage() const platform = usePlatform() @@ -64,7 +62,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 0dd48a5523..7a9f47f34a 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -80,9 +80,7 @@ const playDemoSound = (id: string | undefined) => { }, 100) } -export const SettingsGeneralV2: Component<{ - sessionID?: string -}> = (props) => { +export const SettingsGeneralV2: Component = () => { const theme = useTheme() const language = useLanguage() const permission = usePermission() @@ -95,27 +93,6 @@ export const SettingsGeneralV2: Component<{ const updater = useUpdaterAction() - const dir = createMemo(() => { - if (!props.sessionID) return undefined - return serverSync().session.lineage.peek(props.sessionID)?.session.directory - }) - const accepting = createMemo(() => { - const value = dir() - if (!value || !props.sessionID) return false - return permission.isAutoAccepting(props.sessionID, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value || !props.sessionID) return - - if (checked) { - permission.enableAutoAccept(props.sessionID, value) - return - } - - permission.disableAutoAccept(props.sessionID, value) - } const desktop = createMemo(() => platform.platform === "desktop") const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) @@ -251,7 +228,7 @@ export const SettingsGeneralV2: Component<{ description={language.t("toast.permissions.autoaccept.on.description")} >
- +
diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts deleted file mode 100644 index 002ae94e5b..0000000000 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" - -const session = (input: { id: string; parentID?: string }) => - ({ - id: input.id, - parentID: input.parentID, - }) as Session - -const permission = (sessionID: string) => - ({ - sessionID, - }) as Pick - -describe("autoRespondsPermission", () => { - test("uses a parent session's directory-scoped auto-accept", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/root`]: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) - }) - - test("uses a parent session's legacy auto-accept key", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - - expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true) - }) - - test("defaults to requiring approval when no lineage override exists", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })] - const autoAccept = { - other: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false) - }) - - test("inherits a parent session's false override", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/root`]: false, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false) - }) - - test("prefers a child override over parent override", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/root`]: false, - [`${base64Encode(directory)}/child`]: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) - }) - - test("falls back to directory-level auto-accept", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) - }) - - test("session-level override takes precedence over directory-level", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - [`${base64Encode(directory)}/root`]: false, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) - }) -}) - -describe("isDirectoryAutoAccepting", () => { - test("returns true when directory key is set", () => { - const directory = "/tmp/project" - const autoAccept = { [`${base64Encode(directory)}/*`]: true } - expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true) - }) - - test("returns false when directory key is not set", () => { - expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false) - }) - - test("returns false when directory key is explicitly false", () => { - const directory = "/tmp/project" - const autoAccept = { [`${base64Encode(directory)}/*`]: false } - expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) - }) -}) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts deleted file mode 100644 index 58ab75c57d..0000000000 --- a/packages/app/src/context/permission-auto-respond.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" - -export function acceptKey(sessionID: string, directory?: string) { - if (!directory) return sessionID - return `${base64Encode(directory)}/${sessionID}` -} - -export function directoryAcceptKey(directory: string) { - return `${base64Encode(directory)}/*` -} - -function accepted(autoAccept: Record, sessionID: string, directory?: string) { - const key = acceptKey(sessionID, directory) - const directoryKey = directory ? directoryAcceptKey(directory) : undefined - return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) -} - -export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { - const key = directoryAcceptKey(directory) - return autoAccept[key] ?? false -} - -function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { - const parent = session.reduce((acc, item) => { - if (item.parentID) acc.set(item.id, item.parentID) - return acc - }, new Map()) - const seen = new Set([sessionID]) - const ids = [sessionID] - - for (const id of ids) { - const parentID = parent.get(id) - if (!parentID || seen.has(parentID)) continue - seen.add(parentID) - ids.push(parentID) - } - - return ids -} - -export function autoRespondsPermission( - autoAccept: Record, - session: { id: string; parentID?: string }[], - permission: { sessionID: string }, - directory?: string, -) { - const value = sessionLineage(session, permission.sessionID) - .map((id) => accepted(autoAccept, id, directory)) - .find((item): item is boolean => item !== undefined) - return value ?? false -} diff --git a/packages/app/src/context/permission-response.test.ts b/packages/app/src/context/permission-response.test.ts new file mode 100644 index 0000000000..4a9674d419 --- /dev/null +++ b/packages/app/src/context/permission-response.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" +import { ServerConnection } from "@/context/server" +import { ServerScope } from "@/utils/server-scope" +import { respondPermissionOnce } from "./permission-response" + +const permission = (id: string): PermissionRequest => ({ + id, + sessionID: "session", + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], +}) + +const pending: Array[0]["respond"]>[0]> = [] +const respond = async (input: (typeof pending)[number]) => { + pending.push(input) +} + +beforeEach(() => { + pending.length = 0 +}) + +describe("respondPermissionOnce", () => { + test("deduplicates the same request across provider instances", () => { + const request = permission(`permission-dedupe-${Date.now()}`) + const input = { scope: ServerScope.local, permission: request, directory: "/repo", respond } + + respondPermissionOnce(input) + respondPermissionOnce(input) + + expect(pending).toEqual([{ sessionID: "session", permissionID: request.id, response: "once", directory: "/repo" }]) + }) + + test("keeps requests on different servers independent", () => { + const request = permission(`permission-server-${Date.now()}`) + + respondPermissionOnce({ scope: ServerScope.local, permission: request, respond }) + respondPermissionOnce({ + scope: ServerScope.fromServerKey(ServerConnection.Key.make("remote")), + permission: request, + respond, + }) + + expect(pending).toHaveLength(2) + }) + + test("allows a retry after a failed response", async () => { + const request = permission(`permission-retry-${Date.now()}`) + let attempts = 0 + const fail = async () => { + attempts += 1 + throw new Error("failed") + } + + respondPermissionOnce({ scope: ServerScope.local, permission: request, respond: fail }) + await Promise.resolve() + await Promise.resolve() + respondPermissionOnce({ scope: ServerScope.local, permission: request, respond: fail }) + + expect(attempts).toBe(2) + }) +}) diff --git a/packages/app/src/context/permission-response.ts b/packages/app/src/context/permission-response.ts new file mode 100644 index 0000000000..9be83b1f01 --- /dev/null +++ b/packages/app/src/context/permission-response.ts @@ -0,0 +1,51 @@ +import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" +import type { ServerScope } from "@/utils/server-scope" + +const MAX_RESPONDED = 1000 +const RESPONDED_TTL_MS = 60 * 60 * 1000 +const responded = new Map() + +function key(scope: ServerScope, permissionID: string) { + return `${scope}\0${permissionID}` +} + +function prune(now: number) { + for (const [id, ts] of responded) { + if (now - ts < RESPONDED_TTL_MS) break + responded.delete(id) + } + + for (const id of responded.keys()) { + if (responded.size <= MAX_RESPONDED) break + responded.delete(id) + } +} + +export function respondPermissionOnce(input: { + scope: ServerScope + permission: PermissionRequest + directory?: string + respond: (input: { + sessionID: string + permissionID: string + response: "once" + directory?: string + }) => Promise +}) { + const id = key(input.scope, input.permission.id) + const now = Date.now() + const hit = responded.has(id) + responded.delete(id) + responded.set(id, now) + prune(now) + if (hit) return + + void input + .respond({ + sessionID: input.permission.sessionID, + permissionID: input.permission.id, + response: "once", + directory: input.directory, + }) + .catch(() => responded.delete(id)) +} diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index ff43638bbc..be89fa9624 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,25 +1,12 @@ -import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { type Accessor, createMemo, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" -import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "./server-sync" import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" -import { - acceptKey, - directoryAcceptKey, - isDirectoryAutoAccepting, - autoRespondsPermission, -} from "./permission-auto-respond" - -type PermissionRespondFn = (input: { - sessionID: string - permissionID: string - response: "once" | "always" | "reject" - directory?: string -}) => void +import { useSettings } from "./settings" +import { respondPermissionOnce } from "./permission-response" function isNonAllowRule(rule: unknown) { if (!rule) return false @@ -51,6 +38,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() + const settings = useSettings() + const autoApprove = () => settings.permissions.autoApprove(serverSDK().scope) const permissionsEnabled = createMemo(() => { const directory = props.directory?.() ?? decode64(params.dir) @@ -59,215 +48,39 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return hasPermissionPromptRules(store.config.permission) }) - const [store, setStore, _, ready] = persisted( - { - ...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]), - migrate(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return value - - const data = value as Record - if (data.autoAccept) return value - - return { - ...data, - autoAccept: - typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) - ? data.autoAcceptEdits - : {}, - } - }, - }, - createStore({ - autoAccept: {} as Record, - }), - ) - - // When config has permission: "allow", auto-enable directory-level auto-accept - createEffect(() => { - if (!ready()) return - const directory = props.directory?.() ?? decode64(params.dir) - if (!directory) return - const [childStore] = serverSync().child(directory) - const perm = childStore.config.permission - if (typeof perm === "string" && perm === "allow") { - const key = directoryAcceptKey(directory) - if (store.autoAccept[key] === undefined) { - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - } - } - }) - - const MAX_RESPONDED = 1000 - const RESPONDED_TTL_MS = 60 * 60 * 1000 - const responded = new Map() - const enableVersion = new Map() - - function pruneResponded(now: number) { - for (const [id, ts] of responded) { - if (now - ts < RESPONDED_TTL_MS) break - responded.delete(id) - } - - for (const id of responded.keys()) { - if (responded.size <= MAX_RESPONDED) break - responded.delete(id) - } - } - - const respond: PermissionRespondFn = (input) => { - serverSDK() - .client.permission.respond(input) - .catch(() => { - responded.delete(input.permissionID) - }) - } - function respondOnce(permission: PermissionRequest, directory?: string) { - const now = Date.now() - const hit = responded.has(permission.id) - responded.delete(permission.id) - responded.set(permission.id, now) - pruneResponded(now) - if (hit) return - respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "once", + respondPermissionOnce({ + scope: serverSDK().scope, + permission, directory, + respond: (input) => serverSDK().client.permission.respond(input), }) } - function isAutoAccepting(sessionID: string, directory?: string) { - const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] - return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) - } - - function isAutoAcceptingDirectory(directory: string) { - return isDirectoryAutoAccepting(store.autoAccept, directory) - } - - function shouldAutoRespond(permission: PermissionRequest, directory?: string) { - const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] - return autoRespondsPermission(store.autoAccept, session, permission, directory) - } - - function bumpEnableVersion(sessionID: string, directory?: string) { - const key = acceptKey(sessionID, directory) - const next = (enableVersion.get(key) ?? 0) + 1 - enableVersion.set(key, next) - return next - } - const unsubscribe = serverSDK().event.listen((e) => { const event = e.details if (event?.type !== "permission.asked") return - - const perm = event.properties - if (!shouldAutoRespond(perm, e.name)) return - - respondOnce(perm, e.name) + if (!autoApprove()) return + respondOnce(event.properties, e.name) }) onCleanup(unsubscribe) - function enableDirectory(directory: string) { - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - - serverSDK() - .client.permission.list({ directory }) - .then((x) => { - if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - if (!shouldAutoRespond(perm, directory)) continue - respondOnce(perm, directory) - } - }) - .catch(() => undefined) - } - - function disableDirectory(directory: string) { - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = false - }), - ) - } - - function enable(sessionID: string, directory: string) { - const key = acceptKey(sessionID, directory) - const version = bumpEnableVersion(sessionID, directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = true - delete draft.autoAccept[sessionID] - }), - ) - - serverSDK() - .client.permission.list({ directory }) - .then((x) => { - if (enableVersion.get(key) !== version) return - if (!isAutoAccepting(sessionID, directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - if (!shouldAutoRespond(perm, directory)) continue - respondOnce(perm, directory) - } - }) - .catch(() => undefined) - } - - function disable(sessionID: string, directory?: string) { - bumpEnableVersion(sessionID, directory) - const key = directory ? acceptKey(sessionID, directory) : sessionID - setStore( - produce((draft) => { - draft.autoAccept[key] = false - if (!directory) return - delete draft.autoAccept[sessionID] - }), - ) + function setAutoApprove(value: boolean) { + settings.permissions.setAutoApprove(serverSDK().scope, value) + if (!value) return + for (const requests of Object.values(serverSync().session.data.permission)) { + for (const permission of requests ?? []) { + const directory = serverSync().session.get(permission.sessionID)?.directory + if (directory) respondOnce(permission, directory) + } + } } return { - ready, - respond, - autoResponds(permission: PermissionRequest, directory?: string) { - return shouldAutoRespond(permission, directory) - }, - isAutoAccepting, - isAutoAcceptingDirectory, - toggleAutoAccept(sessionID: string, directory: string) { - if (isAutoAccepting(sessionID, directory)) { - disable(sessionID, directory) - return - } - - enable(sessionID, directory) - }, - toggleAutoAcceptDirectory(directory: string) { - if (isAutoAcceptingDirectory(directory)) { - disableDirectory(directory) - return - } - enableDirectory(directory) - }, - enableAutoAccept(sessionID: string, directory: string) { - if (isAutoAccepting(sessionID, directory)) return - enable(sessionID, directory) - }, - disableAutoAccept(sessionID: string, directory?: string) { - disable(sessionID, directory) + autoApprove, + setAutoApprove, + autoResponds(_permission: PermissionRequest, _directory?: string) { + return autoApprove() }, permissionsEnabled, isPermissionAllowAll(directory: string) { diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 8d4f61079c..4d43bbdcec 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store" import { createEffect, createMemo } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" +import type { ServerScope } from "@/utils/server-scope" export interface NotificationSettings { agent: boolean @@ -43,7 +44,7 @@ export interface Settings { } keybinds: Record permissions: { - autoApprove: boolean + autoApprove: Record } notifications: NotificationSettings sounds: SoundSettings @@ -127,7 +128,7 @@ const defaultSettings: Settings = { }, keybinds: {}, permissions: { - autoApprove: false, + autoApprove: {}, }, notifications: { agent: true, @@ -175,6 +176,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("general", "followup", "steer") }) + createEffect(() => { + const current: unknown = store.permissions?.autoApprove + if (typeof current !== "boolean") return + setStore("permissions", "autoApprove", {}) + }) + return { ready, get current() { @@ -295,9 +302,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, permissions: { - autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), - setAutoApprove(value: boolean) { - setStore("permissions", "autoApprove", value) + autoApprove(scope: ServerScope) { + return store.permissions?.autoApprove?.[scope] ?? false + }, + setAutoApprove(scope: ServerScope, value: boolean) { + setStore("permissions", "autoApprove", scope, value) }, }, notifications: { diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index fd2e29d31c..7b086417d1 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -139,11 +139,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const mcpCommand = withCategory(language.t("command.category.mcp")) const permissionsCommand = withCategory(language.t("command.category.permissions")) - const isAutoAcceptActive = () => { - const sessionID = params.id - if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory) - return permission.isAutoAcceptingDirectory(sdk().directory) - } + const isAutoAcceptActive = permission.autoApprove const write = async (value: string) => { const body = typeof document === "undefined" ? undefined : document.body if (body) { @@ -277,13 +273,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const toggleAutoAccept = () => { - const sessionID = params.id - if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory) - else permission.toggleAutoAcceptDirectory(sdk().directory) - - const active = sessionID - ? permission.isAutoAccepting(sessionID, sdk().directory) - : permission.isAutoAcceptingDirectory(sdk().directory) + const active = !permission.autoApprove() + permission.setAutoApprove(active) showToast({ title: active ? language.t("toast.permissions.autoaccept.on.title")