484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
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 { normalizePermissionRequest } from "./global-sync/utils"
|
|
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 (input.sdk.protocolKind() !== "v1") return
|
|
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,
|
|
location: request.directory ? { directory: request.directory } : undefined,
|
|
})
|
|
.catch(() => {
|
|
responded.delete(request.permissionID)
|
|
})
|
|
}
|
|
|
|
const list = async (directory: string) => {
|
|
if ((await input.sdk.protocol) === "v1") {
|
|
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
|
}
|
|
return input.sdk.api.permission.request
|
|
.list({ location: { directory } })
|
|
.then((result) => result.data.map(normalizePermissionRequest))
|
|
}
|
|
|
|
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
|
|
}),
|
|
)
|
|
|
|
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)
|
|
},
|
|
}
|
|
}
|