/
Alex_Ural
/
opencode
Обзор
Документация
Войти
/
Alex_Ural
/
opencode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
dev
packages/app/src/context/permission.tsx
470 строк
15 KB
Luke Parker
fix(app): retain permission state per server (#36873)
14 июл 2026, 18:26
Не верифицирован
14 июл 2026, 18:26
e7e7a97
Код
Авторство
О чём код?
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import { Persist, persisted } from "@/utils/persist" import type { ServerSDK } from "@/context/server-sdk" import type { ServerSync } from "./server-sync" import { useParams, useSearchParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { type DraftTab, useTabs } from "./tabs" import { useSettings } from "./settings" import { requireServerKey } from "@/utils/session-route" import type { ServerScope } from "@/utils/server-scope" import { acceptKey, directoryAcceptKey, isDirectoryAutoAccepting, autoRespondsPermission, sessionAutoAccept, } from "./permission-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 const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", gate: false, init: () => { const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() const [search] = useSearchParams<{ draftId?: string }>() const global = useGlobal() const server = useServer() const tabs = useTabs() const settings = useSettings() const owner = getOwner() const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>() const activeDraft = createMemo(() => { if (!search.draftId) return return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) }) const activeServer = createMemo(() => { if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey) return activeDraft()?.server ?? server.key }) const ensure = (key: ServerConnection.Key) => { const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) if (!conn) throw new Error(`Permission server not found: ${key}`) const ctx = global.ensureServerCtx(conn) const existing = states.get(ctx.sdk.scope) if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) { return existing.state } if (existing) { existing.dispose() states.delete(ctx.sdk.scope) } const root = createRoot( (dispose) => ({ key, dispose, state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }), }), owner ?? undefined, ) states.set(ctx.sdk.scope, root) return root.state } createEffect(() => { global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) }) createEffect(() => { const list = global.servers.list() const keys = new Set(list.map(ServerConnection.key)) states.forEach((value, scope) => { if (keys.has(value.key)) return value.dispose() states.delete(scope) const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope) if (replacement) ensure(ServerConnection.key(replacement)) }) }) onCleanup(() => states.forEach((value) => value.dispose())) let lastSelected: PermissionState | undefined const selected = () => { const key = activeServer() if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) { lastSelected = ensure(key) } if (lastSelected) return lastSelected return ensure(server.key) } const activeDirectory = createMemo(() => { const directory = decode64(params.dir) if (directory) return directory const draft = activeDraft() if (draft) return draft.directory if (!params.id) return if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return return selected().sync.session.lineage.peek(params.id)?.session.directory }) createEffect(() => { const directory = activeDirectory() if (!directory) return selected().enableConfiguredDirectory(directory) }) const permissionsEnabled = createMemo(() => { const directory = activeDirectory() if (!directory) return false return selected().permissionsEnabled(directory) }) return { ready: () => selected().ready(), ensureServerState: (key: ServerConnection.Key) => ensure(key).api, currentServerState: () => selected().api, respond(input: Parameters<PermissionRespondFn>[0]) { selected().respond(input) }, autoResponds(permission: PermissionRequest, directory?: string) { return selected().autoResponds(permission, directory) }, isAutoAccepting(sessionID: string, directory?: string) { return selected().isAutoAccepting(sessionID, directory) }, isAutoAcceptingDirectory(directory: string) { return selected().isAutoAcceptingDirectory(directory) }, toggleAutoAccept(sessionID: string, directory: string) { selected().toggleAutoAccept(sessionID, directory) }, toggleAutoAcceptDirectory(directory: string) { selected().toggleAutoAcceptDirectory(directory) }, enableAutoAccept(sessionID: string, directory: string) { selected().enableAutoAccept(sessionID, directory) }, disableAutoAccept(sessionID: string, directory?: string) { selected().disableAutoAccept(sessionID, directory) }, permissionsEnabled, isPermissionAllowAll(directory: string) { return selected().isPermissionAllowAll(directory) }, } }, }) type PermissionState = ReturnType<typeof createServerPermissionState> type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0] function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) { const [store, setStore, _, ready] = persisted( { ...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]), migrate(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return value const data = value as Record<string, unknown> if (data.autoAccept) return value return { ...data, autoAccept: typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) ? data.autoAcceptEdits : {}, } }, }, createStore({ autoAccept: {} as Record<string, boolean>, }), ) 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.client.permission.respond(request).catch(() => { responded.delete(request.permissionID) }) } function respondOnce(permission: PermissionRequest, directory?: string) { const now = Date.now() const hit = responded.has(permission.id) responded.delete(permission.id) responded.set(permission.id, now) pruneResponded(now) if (hit) return respond({ sessionID: permission.sessionID, permissionID: permission.id, response: "once", directory, }) } function sessions(directory?: string) { const info = Object.values(input.sync.session.data.info).filter((session) => !!session) if (!directory) return info return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session] } function isAutoAccepting(sessionID: string, directory?: string) { return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory) } function isAutoAcceptingDirectory(directory: string) { return isDirectoryAutoAccepting(store.autoAccept, directory) } function shouldAutoRespond(permission: PermissionRequest, directory?: string) { return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory) } function isPending(permission: PermissionRequest) { const pending = input.sync.session.data.permission[permission.sessionID] return pending === undefined || pending.some((item) => item.id === permission.id) } async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) { const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory) if (override !== undefined) return override if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) if (meta.disposed || !lineage) 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 handlePermission = (e: PermissionEvent) => { const event = e.details if (event?.type !== "permission.asked") return void respondPending(event.properties, e.name) } const unsubscribe = input.sdk.event.listen((event) => { if (ready()) { handlePermission(event) return } void ready.promise?.then(() => { if (meta.disposed) return handlePermission(event) }) }) onCleanup(() => { meta.disposed = true unsubscribe() }) function enableDirectory(directory: string) { if (meta.disposed) return const key = directoryAcceptKey(directory) setStore( produce((draft) => { draft.autoAccept[key] = true }), ) input.sdk.client.permission .list({ directory }) .then((x) => { if (meta.disposed) return if (!isAutoAcceptingDirectory(directory)) return for (const perm of x.data ?? []) { if (!perm?.id) continue void respondPending(perm, 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] }), ) input.sdk.client.permission .list({ directory }) .then((x) => { if (meta.disposed) return if (enableVersion.get(key) !== version) return if (!isAutoAccepting(sessionID, directory)) return for (const perm of x.data ?? []) { if (!perm?.id) continue void respondPending( perm, 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) }, } }