feat(app): per-project auto-accept permissions overriding a default (#54839)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-13 14:16:45 +08:00
committed by GitHub
Unverified
parent e65080dd08
commit b8f925e51c
11 changed files with 276 additions and 44 deletions
@@ -74,14 +74,32 @@ async function setup(page: Page, input: { tabs: TabSeed[]; a?: Partial<MockServe
)
.toBe(true)
const enableAutoAccept = async () => {
await page.keyboard.press("Control+,")
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
return {
replies,
sessionGets,
transport: servers[SERVER]!.transport,
listed,
enableAutoAccept: () => enableAutoAccept(page),
}
}
return { replies, sessionGets, transport: servers[SERVER]!.transport, listed, enableAutoAccept }
async function enableAutoAccept(page: Page) {
await page.keyboard.press("Control+,")
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
}
async function chooseProjectAutoAccept(page: Page, project: string, current: string, choice: "On" | "Off") {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project, exact: true }).click()
const autoAccept = settings.locator('[data-action="settings-project-auto-accept-permissions"]')
await expect(autoAccept).toHaveText(current)
await autoAccept.click()
await page.getByRole("option", { name: choice, exact: true }).click()
await expect(autoAccept).toHaveText(choice)
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
}
const reply = (sessionID: string, permissionID: string) => ({
@@ -201,3 +219,60 @@ test("auto-accept approves a request discovered by opening a session", async ({
await view.enableAutoAccept()
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-synced-a")])
})
test("a project's auto-accept choice overrides the default in both directions", async ({ page }) => {
const api = session({ id: "ses_project_api", directory: "/work/api", title: "API session", projectID: "proj_api" })
const web = session({ id: "ses_project_web", directory: "/work/web", title: "Web session", projectID: "proj_web" })
const requests = { [api.id]: [pending("permission-api", api.id)], [web.id]: [pending("permission-web", web.id)] }
const replies: string[] = []
await mockServers(page, {
[SERVER]: {
directory: "/work/api",
project: project({ id: "proj_api", directory: "/work/api" }),
projects: [
project({ id: "proj_api", directory: "/work/api" }),
project({ id: "proj_web", directory: "/work/web" }),
],
provider: { all: [], connected: [], default: {} },
sessions: [api, web],
pageMessages: () => ({ items: [] }),
sessionStatus: { [api.id]: { type: "running" }, [web.id]: { type: "running" } },
permissions: () => Object.values(requests).flat(),
sessionPermissions: requests,
onPermissionReply: (reply) => replies.push(reply.permissionID),
},
})
await seed(page, {
projects: { local: [{ worktree: "/work/api" }, { worktree: "/work/web" }] },
tabs: [api.id, web.id],
})
const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
const settings = page.getByTestId("settings-screen")
await page.goto(sessionHref(web.id))
await expectSessionTitle(page, web.title)
await expect(dock.getByText("git status")).toBeVisible()
// On for api while the default is off; off for web before the default turns on.
await page.keyboard.press("Control+,")
await chooseProjectAutoAccept(page, "api", "Use default (Off)", "On")
await expect.poll(() => replies).toEqual(["permission-api"])
await chooseProjectAutoAccept(page, "web", "Use default (Off)", "Off")
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await settings.getByRole("button", { name: "Back to app" }).click()
await expectSessionTitle(page, web.title)
await expect(dock.getByText("git status")).toBeVisible()
expect(replies).toEqual(["permission-api"])
// Back on the default, web follows it.
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "web", exact: true }).click()
await settings.locator('[data-action="settings-project-auto-accept-permissions"]').click()
await page.getByRole("option", { name: "Use default (On)" }).click()
await expect.poll(() => replies).toEqual(["permission-api", "permission-web"])
})
+11
View File
@@ -68,6 +68,7 @@ export const dict = {
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Auto-accept permissions",
"command.permissions.autoaccept.disable": "Stop auto-accepting permissions",
"command.permissions.autoaccept.project": "Auto-accept permissions for this project",
"command.session.undo": "Undo",
"command.session.undo.description": "Undo the last message",
"command.session.redo": "Redo",
@@ -397,6 +398,9 @@ export const dict = {
"toast.permissions.autoaccept.on.title": "Auto-accepting permissions",
"toast.permissions.autoaccept.on.description": "Permission requests will be automatically approved",
"toast.permissions.autoaccept.project.title": "Auto-accepting permissions for this project",
"toast.permissions.autoaccept.project.description":
"Permission requests in this project will be automatically approved",
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting permissions",
"toast.permissions.autoaccept.off.description": "Permission requests will require approval",
@@ -720,6 +724,11 @@ export const dict = {
"settings.extensions.manageConfig": "Manage in opencode.json",
"settings.extensions.addSkills": "How to add skills",
"project.settings.title": "Edit project",
"project.settings.autoAccept.description": "Approve this project's permission requests without asking",
"project.settings.autoAccept.default.on": "Use default (On)",
"project.settings.autoAccept.default.off": "Use default (Off)",
"project.settings.autoAccept.on": "On",
"project.settings.autoAccept.off": "Off",
"project.settings.name.title": "Project name",
"project.settings.name.description": "The name shown for this project throughout OpenCode",
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
@@ -997,6 +1006,8 @@ export const dict = {
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will no longer exist.",
"settings.permissions.autoAccept.default.description":
"Approve permission requests without asking. Projects can override this in their settings.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
@@ -8,7 +8,7 @@ import { useComposerState } from "@/composer/persistence"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { formatServerError } from "@/runtime/server/errors"
import { useSettings } from "@/settings/model"
import { projectSettingsKey, useSettings } from "@/settings/model"
import { showToast } from "@/shell/notifications/toast"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
@@ -229,16 +229,38 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
)
}
const sessionProject = () => projectSettingsKey(serverSDK.scope, actions.session.data.info()?.projectID)
const autoAccepting = () => settings.permissions.autoApproves(sessionProject())
// From a session the toggle sets this project's choice, falling back to the default when they match.
const toggleAutoAccept = () => {
const active = !settings.permissions.autoApprove()
settings.permissions.setAutoApprove(active)
const project = sessionProject()
const active = !autoAccepting()
if (!project) settings.permissions.setAutoApproveDefault(active)
else
settings.permissions.setAutoApproveOverride(
project,
active === settings.permissions.autoApproveDefault() ? undefined : active,
)
if (!active) {
showToast({
title: language.t("toast.permissions.autoaccept.off.title"),
description: language.t("toast.permissions.autoaccept.off.description"),
})
return
}
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
title: project
? language.t("toast.permissions.autoaccept.project.title")
: language.t("toast.permissions.autoaccept.on.title"),
description: project
? language.t("toast.permissions.autoaccept.project.description")
: language.t("toast.permissions.autoaccept.on.description"),
})
}
@@ -429,9 +451,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: settings.permissions.autoApprove()
title: autoAccepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
: sessionProject()
? language.t("command.permissions.autoaccept.project")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: toggleAutoAccept,
@@ -2,7 +2,7 @@ import { createEffect, onCleanup } from "solid-js"
import type { PermissionRequest } from "@opencode/client/promise"
import type { Data } from "@opencode/client/solid"
import type { ServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { projectSettingsKey, useSettings } from "@/settings/model"
const respondedLimit = 1000
@@ -10,15 +10,19 @@ const retryLimit = 2
const retryDelayMs = 1000
// Auto-approves permission requests on one server connection whenever the
// app-level auto-approve setting is on. The setting lives in the client-local
// settings store, so it applies to every session, tab, and server at once.
// Auto-approves permission requests on one server connection for the projects
// the client-local settings cover: the app-wide default, unless a project overrides it.
export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data }) {
const enabled = useSettings().permissions.autoApprove
const permissions = useSettings().permissions
const enabled = () => permissions.autoApprovesOnServer(input.sdk.scope)
const covers = (projectID: string | undefined) =>
permissions.autoApproves(projectSettingsKey(input.sdk.scope, projectID))
const state = { disposed: false, generation: 0, responded: new Set<string>() }
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (enabled()) approve(event.data)
if (enabled()) consider(event.data)
})
onCleanup(() => {
@@ -68,7 +72,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
input.sdk.api.permission.request
.list({ location: { directory: location.directory } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => approve(request))
if (!state.disposed) pending.data.forEach((request) => consider(request))
return true
})
@@ -122,10 +126,24 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
}
}
function approve(permission: PermissionRequest, attempt = 0) {
// enabled() guards the retry timer path: the user may disable the setting
// A request from a session this client has not loaded needs that session's
// project before a per-project choice can apply to it.
function consider(permission: PermissionRequest) {
if (covers(undefined) || input.data.session.get(permission.sessionID)) return approve(permission)
void input.sdk.api.session
.get({ sessionID: permission.sessionID })
.then((session) => approve(permission, session.projectID))
.catch(() => undefined)
}
function approve(
permission: PermissionRequest,
projectID = input.data.session.get(permission.sessionID)?.projectID,
attempt = 0,
) {
// covers() guards the retry timer path: the user may disable the setting
// between a failed reply and its scheduled retry.
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
if (state.disposed || !covers(projectID) || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
@@ -136,7 +154,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
state.responded.delete(permission.id)
if (state.disposed || attempt >= retryLimit) return
setTimeout(() => approve(permission, attempt + 1), retryDelayMs * (attempt + 1))
setTimeout(() => approve(permission, projectID, attempt + 1), retryDelayMs * (attempt + 1))
})
}
+7 -3
View File
@@ -5,7 +5,7 @@ import { useParams } from "@solidjs/router"
import { showToast } from "@/shell/notifications/toast"
import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { projectSettingsKey, useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { createWebSearchRequest } from "./websearch"
@@ -69,9 +69,13 @@ export function createSessionRequestModel() {
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
if (settings.permissions.autoApprove()) return undefined
const id = params.id
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
const project = projectSettingsKey(serverSDK.scope, id ? data.session.get(id)?.projectID : undefined)
if (settings.permissions.autoApproves(project)) return
return sessionPermissionRequest(data.session.list(), data.session.permission.list, id)
})
const blocked = createMemo(() => {
@@ -27,12 +27,12 @@ const AutoApprovePermissionsSetting: Component = () => {
return (
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
description={language.t("settings.permissions.autoAccept.default.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={settings.permissions.autoApprove()}
onChange={(checked) => settings.permissions.setAutoApprove(checked)}
checked={settings.permissions.autoApproveDefault()}
onChange={(checked) => settings.permissions.setAutoApproveDefault(checked)}
/>
</div>
</SettingsRow>
+3 -3
View File
@@ -105,7 +105,7 @@ describe("settings schema", () => {
tabLayout: "horizontal",
},
keybinds: {},
permissions: { autoApprove: false },
permissions: { autoApprove: false, autoApproveProjects: {} },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
notifications: { agent: true, permissions: true, errors: false },
sounds: {
@@ -129,7 +129,7 @@ describe("settings schema", () => {
followUpBehavior: "invalid",
},
appearance: { fontSize: "large", mono: "Custom Mono", tabLayout: "vertical", showProjectName: true },
permissions: { autoApprove: true },
permissions: { autoApprove: true, autoApproveProjects: { on: true, off: false, bad: "yes" } },
workspaces: { defaultDestination: "new", lastUsed: { good: "workspace", bad: true } },
keybinds: { good: "ctrl+k", bad: 3 },
notifications: { agent: false, permissions: "yes", errors: true },
@@ -149,7 +149,7 @@ describe("settings schema", () => {
terminal: "",
tabLayout: "vertical",
})
expect(settings.permissions.autoApprove).toBe(true)
expect(settings.permissions).toEqual({ autoApprove: true, autoApproveProjects: { on: true, off: false } })
expect(settings.workspaces).toEqual({ defaultDestination: "new", lastUsed: { good: "workspace" } })
expect(settings.keybinds).toEqual({ good: "ctrl+k" })
expect(settings.notifications).toEqual({ agent: false, permissions: true, errors: true })
+42 -3
View File
@@ -23,6 +23,11 @@ export type NotificationSettings = Settings["notifications"]
export type SoundSettings = Settings["sounds"]
// Names a project for per-project settings: project IDs are only unique within one server.
export function projectSettingsKey(scope: ServerScope, projectID: string | undefined) {
return projectID ? ScopedKey.from(scope, projectID) : undefined
}
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
@@ -127,7 +132,12 @@ const appearanceSchema = Persistence.struct({
})
const permissionsSchema = Persistence.struct({
// The default for every project.
autoApprove: Schema.Boolean,
// Projects that override the default, keyed by server scope and project ID. Absent projects use the default.
autoApproveProjects: Persistence.record(
Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
})
const workspacesSchema = Persistence.struct({
@@ -274,7 +284,7 @@ export const defaultSettings: Settings = {
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
keybinds: {},
permissions: { autoApprove: false },
permissions: { autoApprove: false, autoApproveProjects: {} },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
notifications: { agent: true, permissions: true, errors: false },
sounds: {
@@ -314,6 +324,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
})
const autoApproveDefault = withFallback(
() => store.permissions?.autoApprove,
defaultSettings.permissions.autoApprove,
)
// A project's explicit choice, or undefined when it uses the default.
const autoApproveOverride = (project: ScopedKey | undefined) =>
project ? store.permissions?.autoApproveProjects?.[project] : undefined
return {
ready,
get current() {
@@ -396,10 +415,30 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
},
permissions: {
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) {
autoApproveDefault,
setAutoApproveDefault(value: boolean) {
setStore("permissions", "autoApprove", value)
},
autoApproveOverride,
// Undefined makes the project use the default again.
setAutoApproveOverride(project: ScopedKey, value: boolean | undefined) {
const projects = { ...store.permissions?.autoApproveProjects }
if (value === undefined) delete projects[project]
else projects[project] = value
setStore("permissions", "autoApproveProjects", reconcile(projects))
},
// Whether a project's requests are approved without asking.
autoApproves: (project: ScopedKey | undefined) => autoApproveOverride(project) ?? autoApproveDefault(),
// Whether any request on the server may be approved without asking.
autoApprovesOnServer(scope: ServerScope) {
if (autoApproveDefault()) return true
const prefix = ScopedKey.prefix(scope)
return Object.entries(store.permissions?.autoApproveProjects ?? {}).some(
([key, value]) => value && key.startsWith(prefix),
)
},
},
workspaces: {
defaultDestination: withFallback(
+8 -1
View File
@@ -39,7 +39,8 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
tab: "general",
label: "command.permissions.autoaccept.enable",
target: "settings-auto-accept-permissions",
keywords: "permissions approve allow auto accept",
description: "settings.permissions.autoAccept.default.description",
keywords: "permissions approve allow auto accept default",
},
{
tab: "general",
@@ -187,6 +188,12 @@ export const projectSettings: Entry<SettingsProjectTab>[] = [
{ tab: "general", label: "project.settings.name.title", target: "settings-project-name", keywords: "rename" },
{ tab: "general", label: "dialog.project.edit.icon", target: "settings-project-icon" },
{ tab: "general", label: "dialog.project.edit.color", target: "settings-project-color" },
{
tab: "general",
label: "command.permissions.autoaccept.enable",
target: "settings-project-auto-accept-permissions",
keywords: "permissions approve allow auto accept",
},
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
{ tab: "extensions", label: "settings.tab.extensions" },
{
@@ -5,13 +5,16 @@ import {
ProjectAvatar,
PROJECT_AVATAR_VARIANTS,
} from "@opencode/ui/project-avatar"
import { Select } from "@opencode/ui/select"
import { Textarea } from "@opencode/ui/textarea"
import { TextInput } from "@opencode/ui/text-input"
import { For, Show, type Component } from "solid-js"
import { For, Show, createMemo, type Component } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { LocalProject } from "@/shell/state/layout"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useServerCtx } from "@/runtime/server/runtime"
import { projectSettingsKey, useSettings } from "@/settings/model"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { ProjectOptions } from "./project-options"
import { SettingsList } from "@/settings/list"
@@ -156,6 +159,8 @@ export const SettingsProjectGeneral: Component<{
</SettingsRow>
</Show>
<AutoAcceptPermissionsSetting project={props.project} server={props.server} />
<div class="project-settings-startup">
<div class="project-settings-startup-copy">
<span class="project-settings-startup-title">{language.t("dialog.project.edit.worktree.startup")}</span>
@@ -184,6 +189,52 @@ export const SettingsProjectGeneral: Component<{
)
}
// A project uses the app-wide default unless it is explicitly turned on or off here.
const AutoAcceptPermissionsSetting: Component<{ project: LocalProject; server: ServerConnection.Any }> = (props) => {
const language = useLanguage()
const settings = useSettings()
const serverCtx = useServerCtx(() => props.server)
const key = createMemo(() => {
const scope = serverCtx()?.sdk.scope
return scope ? projectSettingsKey(scope, props.project.id) : undefined
})
const options = createMemo(() => [
{
value: undefined,
label: settings.permissions.autoApproveDefault()
? language.t("project.settings.autoAccept.default.on")
: language.t("project.settings.autoAccept.default.off"),
},
{ value: true, label: language.t("project.settings.autoAccept.on") },
{ value: false, label: language.t("project.settings.autoAccept.off") },
])
return (
<Show when={key()}>
{(project) => (
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("project.settings.autoAccept.description")}
>
<Select
data-action="settings-project-auto-accept-permissions"
options={options()}
current={options().find((option) => option.value === settings.permissions.autoApproveOverride(project()))}
value={(option) => String(option.value)}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.permissions.setAutoApproveOverride(project(), option.value)}
/>
</SettingsRow>
)}
</Show>
)
}
function inlineVariables(text: string) {
return text
.split(/(\$[A-Z][A-Z0-9_]*)/g)
@@ -2,7 +2,7 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
import { projectSettingsKey, useSettings } from "@/settings/model"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
@@ -30,11 +30,14 @@ export function useSessionTabAvatarState(
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
const project = projectSettingsKey(ctx.sdk.scope, ctx.data.session.get(sessionId())?.projectID)
if (settings.permissions.autoApproves(project)) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})