fix(app): retain permission state per server (#36873)

This commit is contained in:
Luke Parker
2026-07-14 15:26:58 +00:00
committed by GitHub
parent f58b8cb67b
commit e7e7a97648
10 changed files with 664 additions and 280 deletions
@@ -1,30 +1,19 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a" const directoryA = "C:/server-a"
const directoryB = "/home/server-b" const directoryB = "/home/server-b"
const sessionB = { const sessionA = session("ses_server_a", directoryA, "Server A session")
id: "ses_server_b", const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
slug: "ses_server_b", const sessionB = session("ses_server_b", directoryB, "Server B session")
projectID: "project-server-b",
directory: directoryB,
title: "Server B session",
version: "dev",
time: { created: 1, updated: 1 },
}
test("session settings use the remote server context", async ({ page }) => { test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = [] const permissionRequests: string[] = []
await mockServers(page, permissionRequests) await mockServers(page, permissionRequests)
await page.addInitScript( await configureServers(page)
({ serverB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
},
{ serverB },
)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
@@ -53,21 +42,152 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
}) })
async function mockServers(page: Page, permissionRequests: string[]) { test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
})
await mockServers(page, permissionRequests, permissionResponses)
await configureServers(page, [
{ type: "session", server: serverA, sessionId: sessionA.id },
{ type: "session", server: serverB, sessionId: sessionB.id },
])
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-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("directory") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a",
type: "permission.asked",
properties: {
id: "permission-background-a",
sessionID: sessionA.id,
permission: "bash",
patterns: ["git status"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
])
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a-child",
type: "permission.asked",
properties: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
permission: "bash",
patterns: ["git diff"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
{
origin: serverA,
directory: directoryA,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { response: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
sessionID: string
permissionID: string
body: unknown
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
},
{ serverB, tabs },
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback() if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory") const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
permissionResponses.push({
origin: url.origin,
directory: requestDirectory ?? undefined,
sessionID: response[1]!,
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, remote ? [sessionB] : []) if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === `/session/${sessionB.id}` && remote) return json(route, sessionB) const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${sessionB.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (url.pathname === "/permission") { if (url.pathname === "/permission") {
permissionRequests.push(url.toString()) permissionRequests.push(url.toString())
@@ -101,6 +221,18 @@ async function mockServers(page: Page, permissionRequests: string[]) {
}) })
} }
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
function provider(id: string) { function provider(id: string) {
const name = id === "server-b" ? "Server B" : "Server A" const name = id === "server-b" ? "Server B" : "Server A"
return { return {
+1 -1
View File
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(input, init) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
const record = { const record = {
+6 -16
View File
@@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
} }
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // server via ServerKey, then provide the server-scoped shell for that server.
// Notification/Models + the visual Layout) for that server.
function SelectedServerProviders(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
@@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<DraftServerScopedProviders directory={directory}> <ModelsProvider directory={directory}>
<SDKProvider directory={directory}> <SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders> <DraftProviders>
@@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders> </DraftProviders>
</DirectoryDataProvider> </DirectoryDataProvider>
</SDKProvider> </SDKProvider>
</DraftServerScopedProviders> </ModelsProvider>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</Show> </Show>
@@ -309,24 +308,21 @@ function DesktopCommands() {
// Server-scoped providers shared by the legacy shell and the top-level new shell. // Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element serverScoped?: JSX.Element
}> }>
function ServerScopedProviders(props: ServerScopedShellProps) { function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
{props.serverScoped} {props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) { function LegacyServerScopedShell(props: ServerScopedShellProps) {
return ( return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}> <ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout> <LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders> </ServerScopedProviders>
) )
@@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
) )
} }
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider. // The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) { function DraftProviders(props: ParentProps) {
@@ -559,6 +547,7 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<PermissionProvider>
<NotificationProvider> <NotificationProvider>
<ServerShell> <ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
@@ -566,6 +555,7 @@ export function AppInterface(props: {
</Show> </Show>
</ServerShell> </ServerShell>
</NotificationProvider> </NotificationProvider>
</PermissionProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@@ -27,6 +27,8 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = { const prompt = {
@@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
return { return {
session: { session: {
create: async () => { create: async () => {
await createSessionGate
createdSessions.push(directory) createdSessions.push(directory)
return { return {
data: { data: {
@@ -122,13 +125,14 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/permission", () => ({ mock.module("@/context/permission", () => {
usePermission: () => ({ const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ sessionID, directory }) enabledAutoAccept.push({ server, sessionID, directory })
}, },
}), })
})) return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
})
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }), useServer: () => ({ key: "server-key" }),
@@ -251,6 +255,8 @@ beforeEach(() => {
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
@@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("keeps auto-accept bound to the submission server", async () => {
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
release()
await result
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
}) })
test("promotes drafts using the selected project's server", async () => { test("promotes drafts using the selected project's server", async () => {
@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation() input.resetHistoryNavigation()
const projectDirectory = sdk().directory const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
session = created session = created
await startTransition(() => { await startTransition(() => {
if (!session) return if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, { local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name, agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id }, model: { providerID: currentModel.provider.id, modelID: currentModel.id },
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) => const session = (input: { id: string; parentID?: string }) =>
({ ({
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
} }
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(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", () => { test("session-level override takes precedence over directory-level", () => {
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(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", () => { describe("isDirectoryAutoAccepting", () => {
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) { function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory) const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined return autoAccept[key] ?? autoAccept[sessionID]
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
} }
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) { export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
permission: { sessionID: string }, permission: { sessionID: string },
directory?: string, directory?: string,
) { ) {
const value = sessionLineage(session, permission.sessionID) 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)) .map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined) .find((item): item is boolean => item !== undefined)
return value ?? false
} }
+247 -57
View File
@@ -1,17 +1,24 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk" import type { ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
import { useParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64" 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 { import {
acceptKey, acceptKey,
directoryAcceptKey, directoryAcceptKey,
isDirectoryAutoAccepting, isDirectoryAutoAccepting,
autoRespondsPermission, autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond" } from "./permission-auto-respond"
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
@@ -47,21 +54,142 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: (props: { directory?: Accessor<string | undefined> }) => { init: () => {
const params = useParams() const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const serverSDK = useServerSDK() const [search] = useSearchParams<{ draftId?: string }>()
const serverSync = useServerSync() 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 permissionsEnabled = createMemo(() => { const activeDraft = createMemo(() => {
const directory = props.directory?.() ?? decode64(params.dir) if (!search.draftId) return
if (!directory) return false return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
}) })
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( const [store, setStore, _, ready] = persisted(
{ {
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]), ...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
migrate(value) { migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value if (!value || typeof value !== "object" || Array.isArray(value)) return value
@@ -82,29 +210,24 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}), }),
) )
// When config has permission: "allow", auto-enable directory-level auto-accept function enableConfiguredDirectory(directory: string) {
createEffect(() => { if (meta.disposed || !ready()) return
if (!ready()) return const [childStore] = input.sync.child(directory)
const directory = props.directory?.() ?? decode64(params.dir) if (childStore.config.permission !== "allow") return
if (!directory) return
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory) const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) { if (store.autoAccept[key] !== undefined) return
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.autoAccept[key] = true draft.autoAccept[key] = true
}), }),
) )
} }
}
})
const MAX_RESPONDED = 1000 const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>() const responded = new Map<string, number>()
const enableVersion = new Map<string, number>() const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) { function pruneResponded(now: number) {
for (const [id, ts] of responded) { for (const [id, ts] of responded) {
@@ -118,11 +241,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
} }
} }
const respond: PermissionRespondFn = (input) => { const respond: PermissionRespondFn = (request) => {
serverSDK() if (meta.disposed) return
.client.permission.respond(input) input.sdk.client.permission.respond(request).catch(() => {
.catch(() => { responded.delete(request.permissionID)
responded.delete(input.permissionID)
}) })
} }
@@ -141,9 +263,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}) })
} }
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) { function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
} }
function isAutoAcceptingDirectory(directory: string) { function isAutoAcceptingDirectory(directory: string) {
@@ -151,8 +278,32 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
} }
function shouldAutoRespond(permission: PermissionRequest, directory?: string) { function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
return autoRespondsPermission(store.autoAccept, session, 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) { function bumpEnableVersion(sessionID: string, directory?: string) {
@@ -162,18 +313,29 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return next return next
} }
const unsubscribe = serverSDK().event.listen((e) => { const handlePermission = (e: PermissionEvent) => {
const event = e.details const event = e.details
if (event?.type !== "permission.asked") return if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name)
}
const perm = event.properties const unsubscribe = input.sdk.event.listen((event) => {
if (!shouldAutoRespond(perm, e.name)) return if (ready()) {
handlePermission(event)
respondOnce(perm, e.name) return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
}) })
onCleanup(unsubscribe)
function enableDirectory(directory: string) { function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory) const key = directoryAcceptKey(directory)
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -181,20 +343,21 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}), }),
) )
serverSDK() input.sdk.client.permission
.client.permission.list({ directory }) .list({ directory })
.then((x) => { .then((x) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) { for (const perm of x.data ?? []) {
if (!perm?.id) continue if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
respondOnce(perm, directory)
} }
}) })
.catch(() => undefined) .catch(() => undefined)
} }
function disableDirectory(directory: string) { function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory) const key = directoryAcceptKey(directory)
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -204,6 +367,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
} }
function enable(sessionID: string, directory: string) { function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory) const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory) const version = bumpEnableVersion(sessionID, directory)
setStore( setStore(
@@ -213,21 +377,26 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}), }),
) )
serverSDK() input.sdk.client.permission
.client.permission.list({ directory }) .list({ directory })
.then((x) => { .then((x) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) { for (const perm of x.data ?? []) {
if (!perm?.id) continue if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue void respondPending(
respondOnce(perm, directory) perm,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
} }
}) })
.catch(() => undefined) .catch(() => undefined)
} }
function disable(sessionID: string, directory?: string) { function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory) bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore( setStore(
@@ -239,15 +408,23 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
) )
} }
return { const api = {
ready, ready: () => !meta.disposed && ready(),
respond, respond,
autoResponds(permission: PermissionRequest, directory?: string) { autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory) return shouldAutoRespond(permission, directory)
}, },
isAutoAccepting, isAutoAccepting(sessionID: string, directory?: string) {
isAutoAcceptingDirectory, 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) { toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) { if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory) disable(sessionID, directory)
return return
@@ -256,6 +433,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
enable(sessionID, directory) enable(sessionID, directory)
}, },
toggleAutoAcceptDirectory(directory: string) { toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) { if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory) disableDirectory(directory)
return return
@@ -263,18 +441,30 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
enableDirectory(directory) enableDirectory(directory)
}, },
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory) enable(sessionID, directory)
}, },
disableAutoAccept(sessionID: string, directory?: string) { disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory) disable(sessionID, directory)
}, },
permissionsEnabled,
isPermissionAllowAll(directory: string) { isPermissionAllowAll(directory: string) {
const [childStore] = serverSync().child(directory) if (meta.disposed) return false
const perm = childStore.config.permission const [childStore] = input.sync.child(directory)
return typeof perm === "string" && perm === "allow" 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)
}, },
}) }
}
@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
const global = useGlobal() const global = useGlobal()
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
const permissionState = createMemo(() => permission.ensureServerState(server()))
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => { const sync = createMemo(() => {
const conn = connection() const conn = connection()
@@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
if (!serverSync) return false if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false }) const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory()) return !permissionState().autoResponds(item, directory())
}) })
}) })
const hasQuestions = createMemo(() => { const hasQuestions = createMemo(() => {
+3 -4
View File
@@ -47,7 +47,6 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider, usePrompt } from "@/context/prompt" import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
@@ -159,7 +158,7 @@ export function TargetSessionRouteContent() {
const serverSync = useServerSync() const serverSync = useServerSync()
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory) const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
return ( return (
// Settings must keep the complete target-server context and remain registered // Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary. // when session content falls back to the route error boundary.
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}> <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetSessionSettingsCommand /> <TargetSessionSettingsCommand />
@@ -294,10 +293,10 @@ function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) { ) {
return ( return (
<PermissionProvider directory={props.directory}> <>
<MarkSessionNotificationsViewed sessionID={props.sessionID} /> <MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider> </>
) )
} }