Compare commits

...

1 Commits

Author SHA1 Message Date
Test 1300e59c1a feat(app): auto-approve permissions per server 2026-06-26 02:23:26 +00:00
15 changed files with 329 additions and 499 deletions
@@ -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<string, unknown>
}
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<typeof mockOpenCodeServer>[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] },
)
}
@@ -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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
@@ -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,
@@ -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<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell">
working: Accessor<boolean>
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
@@ -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(() => <module.DialogSettings sessionID={sessionID} />)
void dialog.show(() => <module.DialogSettings />)
})
}
}
@@ -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<ThemeOption[]>(() => 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")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
<Switch checked={permission.autoApprove()} onChange={permission.setAutoApprove} />
</div>
</SettingsRow>
@@ -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<{
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} />
<SettingsGeneralV2 />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
@@ -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<ThemeOption[]>(() => 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")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
<Switch checked={permission.autoApprove()} onChange={permission.setAutoApprove} />
</div>
</SettingsRowV2>
@@ -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<PermissionRequest, "sessionID">
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)
})
})
@@ -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<string, boolean>, 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<string, boolean>, 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<string, string>())
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<string, boolean>,
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
}
@@ -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<Parameters<Parameters<typeof respondPermissionOnce>[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)
})
})
@@ -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<string, number>()
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<unknown>
}) {
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))
}
+24 -211
View File
@@ -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<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
// 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<string, number>()
const enableVersion = new Map<string, number>()
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) {
+14 -5
View File
@@ -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<string, string>
permissions: {
autoApprove: boolean
autoApprove: Record<string, boolean>
}
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: {
@@ -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")