refactor(app): make auto-accept permissions an app-level setting (#44608)

This commit is contained in:
Luke Parker 2026-08-24 16:56:14 +10:00 committed by GitHub
parent e282066cf8
commit 46d1f1fed1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 315 additions and 685 deletions

View file

@ -13,9 +13,14 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
// Server A has no tab and is never visited: a pending request there proves
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
})
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@ -38,7 +43,17 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
},
])
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
@ -143,6 +158,99 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
})
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingA: MockPermission[] = []
const listFailures: Record<string, number> = {}
const sessionGets: string[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: pendingA },
listFailures,
sessionGets,
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
// This request is asked while the client is disconnected, so it is never
// delivered as an event and only a reconnect sweep can find it. The first
// listing after the reconnect fails, so only the bounded sweep retry can
// deliver the reply.
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
listFailures[serverA] = 1
const syncsBeforeReconnect = sessionGets.length
await transport.disconnect()
await transport.waitForConnection({ after: first.id })
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
// cached locations, since another client may have moved them meanwhile.
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
})
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// The request is only served from the per-session permission list, so it
// reaches the client through the store sync when the session view opens,
// never through a location sweep or an event.
await mockServers(page, permissionRequests, permissionResponses, {
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
@ -151,6 +259,19 @@ type PermissionResponse = {
body: unknown
}
type MockPermission = {
id: string
sessionID: string
action: string
resources: string[]
metadata: Record<string, unknown>
save: unknown[]
}
function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
@ -161,7 +282,23 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
type MockServerOptions = {
// Pending requests served from /api/permission/request, keyed by origin.
pending?: Record<string, MockPermission[]>
// Pending requests served from /api/session/:id/permission, keyed by session ID.
sessionPending?: Record<string, MockPermission[]>
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
}
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@ -178,8 +315,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
// The generated client requires exactly 204 for a successful reply.
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/provider")
return json(route, {
@ -197,7 +338,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
const failures = options.listFailures?.[url.origin] ?? 0
if (failures > 0) {
options.listFailures![url.origin] = failures - 1
return json(route, { name: "Internal" }, 500)
}
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
@ -219,9 +365,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === "/api/session/active")
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (currentSessionInfo) {
options.sessionGets?.push(currentSessionInfo.id)
return json(route, { data: currentSession(currentSessionInfo) })
}
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))

View file

@ -373,7 +373,6 @@ export function HomeSessionStatusController(props: {
}) {
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)

View file

@ -8,7 +8,6 @@ import { createComposerControls, createComposerModelSelection } from "@/composer
import { createComposerProjectControls } from "./project/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
@ -30,7 +29,6 @@ export function createNewSessionComposerAdapter(props: {
const data = useData()
const server = useServer()
const serverSDK = useServerSDK()
const permission = usePermission()
const tabs = useTabs()
const location = useWorkspaceLocation()
const language = useLanguage()
@ -86,9 +84,6 @@ export function createNewSessionComposerAdapter(props: {
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
}
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,

View file

@ -8,7 +8,7 @@ import { createServerSdkContext } from "./client"
import { createServerSyncContext } from "./sync"
import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
@ -144,7 +144,7 @@ function createServerController(
directory: "",
})
const sync = createServerSyncContext(sdk, data)
const permission = createServerPermissionState({ sdk, sync, data })
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
@ -187,7 +187,6 @@ function createServerController(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
permission,
notification,
}
}

View file

@ -4,9 +4,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePermission } from "@/session/requests/permission"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
@ -48,9 +46,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const permission = usePermission()
const prompt = useComposerState()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const settings = useSettings()
const terminal = useTerminal()
@ -99,11 +95,6 @@ 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 = actions.session.identity.params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
const exportSession = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@ -223,13 +214,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const toggleAutoAccept = () => {
const sessionID = actions.session.identity.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 = !settings.permissions.autoApprove()
settings.permissions.setAutoApprove(active)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
@ -454,7 +440,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
title: settings.permissions.autoApprove()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",

View file

@ -0,0 +1,136 @@
import { createEffect, onCleanup } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import type { ServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
const respondedLimit = 1000
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.
export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data }) {
const enabled = useSettings().permissions.autoApprove
const state = { disposed: false, generation: 0, responded: new Set<string>() }
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (enabled()) approve(event.data)
})
onCleanup(() => {
state.disposed = true
unsubscribe()
})
// The event stream does not replay requests asked while this client was
// disconnected, and requests may already be pending before the setting turns
// on, so sweep on every connect while the setting is on.
createEffect(() => {
if (!enabled() || input.sdk.connection.status() !== "connected") return
const generation = ++state.generation
void sweepWithRetry(generation, 0)
})
// Approves pending requests that reach the local store, which is how a
// previously unknown idle session's requests surface when its view opens
// and syncs them. Store changes cannot re-trigger the network sweep: it
// deliberately reads them after an await, outside Solid tracking.
createEffect(() => {
if (!enabled()) return
for (const session of input.data.session.list()) {
for (const request of input.data.session.permission.list(session.id) ?? []) approve(request)
}
})
// An incomplete sweep leaves pending requests hidden with no later trigger
// to recover them, so retry it a bounded number of times. A newer sweep
// supersedes scheduled retries.
async function sweepWithRetry(generation: number, attempt: number) {
const complete = await sweep()
if (complete || attempt >= retryLimit) return
setTimeout(() => {
if (state.disposed || !enabled() || generation !== state.generation) return
void sweepWithRetry(generation, attempt + 1)
}, retryDelayMs * (attempt + 1))
}
async function sweep() {
const inventory = await sweepLocations()
const listed = await Promise.all(
inventory.locations.map((location) =>
input.sdk.api.permission.request
.list({ location: { directory: location.directory, workspace: location.workspaceID } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => approve(request))
return true
})
.catch(() => false),
),
)
return inventory.complete && listed.every(Boolean)
}
// Active sessions are the primary inventory: session.active is server-wide,
// so it covers sessions no tab has loaded, and a request blocking a tool
// call always belongs to one (Permission.assert clears its entry when the
// awaiting fiber dies). Locally known sessions are swept too because the
// external session.permission.create API can park a request on an idle
// session. A detached request on a session this client never loaded is the
// one case that stays uncovered.
async function sweepLocations() {
const active = await input.sdk.api.session.active().catch(() => undefined)
const ids = Object.keys(active ?? {})
// Resync every active session rather than trusting cached info: another
// client may have moved one while this client was disconnected, and the
// cached location would list permissions from the old location. A failed
// resync falls back to the cached location and marks the sweep incomplete.
const synced = await Promise.all(
ids.map((id) => {
input.data.session.invalidate(id)
return input.data.session.sync(id).then(
() => true,
() => false,
)
}),
)
const locations = [
...ids.flatMap((id) => {
const location = input.data.session.get(id)?.location
return location ? [location] : []
}),
...input.data.session.list().map((session) => session.location),
]
return {
locations: [
...new Map(locations.map((item) => [`${item.directory}\u0000${item.workspaceID ?? ""}`, item])).values(),
],
complete: active !== undefined && synced.every(Boolean),
}
}
function approve(permission: PermissionRequest, attempt = 0) {
// enabled() 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
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
// of times. Later sweeps retry it after that.
state.responded.delete(permission.id)
if (state.disposed || attempt >= retryLimit) return
setTimeout(() => approve(permission, attempt + 1), retryDelayMs * (attempt + 1))
})
}
function remember(id: string) {
state.responded.add(id)
for (const oldest of state.responded) {
if (state.responded.size <= respondedLimit) break
state.responded.delete(oldest)
}
}
}

View file

@ -1,129 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, relocateAutoAccept, sessionAutoAccept } from "./auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
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("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)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
})
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)
})
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
})
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)
})
})
test("relocates bare session settings when the directory becomes known", () => {
const directory = "/tmp/project"
expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({
[`${base64Encode(directory)}/root`]: true,
})
expect(
relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory),
).toEqual({ [`${base64Encode(directory)}/root`]: false })
})

View file

@ -1,79 +0,0 @@
import { base64Encode } from "@opencode-ai/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) {
return autoAccept[acceptKey(sessionID, directory)]
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
}
export function relocateAutoAccept(
autoAccept: Record<string, boolean>,
sessions: readonly { id: string }[],
directory: string,
) {
const moves = sessions.flatMap((session) => {
const value = autoAccept[session.id]
if (value === undefined) return []
return [{ source: session.id, target: acceptKey(session.id, directory), value }]
})
if (moves.length === 0) return autoAccept
const next = { ...autoAccept }
for (const move of moves) {
if (next[move.target] === undefined) next[move.target] = move.value
delete next[move.source]
}
return next
}
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 = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
}

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 { usePermission } from "@/session/requests/permission"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { useData } from "@/runtime/server/current"
@ -16,7 +16,7 @@ export function createSessionRequestModel() {
const serverSDK = useServerSDK()
const data = useData()
const language = useLanguage()
const permission = usePermission()
const settings = useSettings()
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
@ -32,9 +32,8 @@ export function createSessionRequestModel() {
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory)
})
if (settings.permissions.autoApprove()) return undefined
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
})
const blocked = createMemo(() => {

View file

@ -1,3 +0,0 @@
import { useServer } from "@/runtime/server/current"
export const usePermission = () => useServer().ctx.permission

View file

@ -1,348 +0,0 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { ServerSDK } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import type { Data } from "@opencode-ai/client/solid"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/runtime/persistence/base64"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { requireServerKey } from "@/shell/routes/session"
import { ServerScope } from "@/runtime/server/scope"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
relocateAutoAccept,
sessionAutoAccept,
} from "./auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
directory?: string
}) => void
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
return Object.values(config).some(isNonAllowRule)
}
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission"),
...(input.sdk.scope === ServerScope.local ? { previousKey: "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>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
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>()
const meta = { disposed: false }
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 = (request) => {
if (meta.disposed) return
input.sdk.api.permission
.reply({
sessionID: request.sessionID,
requestID: request.permissionID,
reply: request.response,
})
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
}
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",
directory,
})
}
function sessions(_directory?: string) {
return input.data.session.list()
}
function autoAccept(directory?: string) {
if (!directory) return store.autoAccept
const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory)
if (next !== store.autoAccept) setStore("autoAccept", reconcile(next))
return next
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.data.session.permission.list(permission.sessionID)
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory)
if (override !== undefined) return override
const loaded = new Set<string>()
while (!loaded.has(input.data.session.root(permission.sessionID))) {
const root = input.data.session.root(permission.sessionID)
loaded.add(root)
if (input.data.session.get(root)) break
await input.data.session.sync(root).catch(() => undefined)
}
if (meta.disposed || !input.data.session.get(permission.sessionID)) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(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 = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const permission of permissions) {
void respondPending(
permission,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}

View file

@ -1,4 +1,3 @@
import { useParams } from "@solidjs/router"
import { onCleanup } from "solid-js"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
@ -6,7 +5,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
let dead = false
@ -16,10 +14,9 @@ export function useSettingsDialog(defaultValue?: string) {
return () => {
const current = ++run
const sessionID = params.id
void import("@/settings/shell").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
})
}
}

View file

@ -1,7 +1,6 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/session/requests/permission"
import {
monoDefault,
monoFontFamily,
@ -17,43 +16,11 @@ import {
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController, type ShellOption } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerCtx } from "@/runtime/server/runtime"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createPermissionScopeController(
server: Accessor<ServerConnection.Any | undefined>,
sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => {
const s = server()
const id = sessionID()
if (!s || !id) return undefined
return serverCtx()?.data.session.get(id)?.location.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission()?.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission()?.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
const [shells] = createResource(
@ -172,7 +139,6 @@ export function createSoundSettingsController() {
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>

View file

@ -13,11 +13,9 @@ import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
} from "./controllers"
import "@/settings/settings.css"
@ -47,8 +45,9 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const AutoApprovePermissionsSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
return (
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
@ -56,9 +55,8 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
checked={settings.permissions.autoApprove()}
onChange={(checked) => settings.permissions.setAutoApprove(checked)}
/>
</div>
</SettingsRow>
@ -262,7 +260,6 @@ const LanguageSetting = () => {
}
export const SettingsGeneral: Component<{
sessionID?: string
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
@ -270,10 +267,6 @@ export const SettingsGeneral: Component<{
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(
() => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop")
@ -297,7 +290,7 @@ export const SettingsGeneral: Component<{
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<AutoApprovePermissionsSetting />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />

View file

@ -23,7 +23,6 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import "@/settings/settings.css"
export const DialogSettings: Component<{
sessionID?: string
defaultValue?: string
}> = (props) => {
const language = useLanguage()
@ -68,7 +67,7 @@ export const DialogSettings: Component<{
})
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
@ -144,7 +143,7 @@ export const DialogSettings: Component<{
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} sessionID={props.sessionID} />
<SettingsGeneral server={server()} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />

View file

@ -2,14 +2,15 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
) {
const global = useGlobal()
const settings = useSettings()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
@ -23,12 +24,10 @@ export function useSessionTabAvatarState(
})
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data

View file

@ -15,7 +15,6 @@ export function SessionTabAvatar(props: {
}) {
const state = useSessionTabAvatarState(
() => props.server,
() => props.directory,
() => props.sessionId,
)
return (

View file

@ -42,7 +42,6 @@ export default defineMain({
{ find: /^@\/workspaces\/location$/, replacement: path.resolve(mocks, "app/context/location.ts") },
{ find: /^@\/composer\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") },
{ find: /^@\/shell\/commands\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") },
{ find: /^@\/session\/requests\/permission$/, replacement: path.resolve(mocks, "app/context/permission.ts") },
{ find: /^@\/runtime\/platform\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
{ find: /^@\/runtime\/server\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
{ find: /^@\/runtime\/server\/sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") },

View file

@ -1,27 +0,0 @@
const accepted = new Set<string>()
function key(sessionID: string, directory?: string) {
return `${directory ?? ""}:${sessionID}`
}
export function usePermission() {
return {
autoResponds() {
return false
},
isAutoAccepting(sessionID: string, directory?: string) {
return accepted.has(key(sessionID, directory))
},
isAutoAcceptingDirectory() {
return false
},
toggleAutoAccept(sessionID: string, directory?: string) {
const next = key(sessionID, directory)
if (accepted.has(next)) {
accepted.delete(next)
return
}
accepted.add(next)
},
}
}