mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 16:12:12 +00:00
refactor(app): make auto-accept permissions an app-level setting (#44608)
This commit is contained in:
parent
e282066cf8
commit
46d1f1fed1
19 changed files with 315 additions and 685 deletions
|
|
@ -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`))
|
||||
|
|
|
|||
|
|
@ -373,7 +373,6 @@ export function HomeSessionStatusController(props: {
|
|||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.record.session.location.directory,
|
||||
() => props.record.session.id,
|
||||
() => true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
136
packages/app/src/session/requests/auto-approve.ts
Normal file
136
packages/app/src/session/requests/auto-approve.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
export const usePermission = () => useServer().ctx.permission
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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} />)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export function SessionTabAvatar(props: {
|
|||
}) {
|
||||
const state = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.directory,
|
||||
() => props.sessionId,
|
||||
)
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue