Bladeren bron

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

Luke Parker 1 maand geleden
bovenliggende
commit
e7e7a97648

+ 152 - 20
packages/app/e2e/regression/remote-session-settings.spec.ts

@@ -1,30 +1,19 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 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 serverB = "http://127.0.0.1:4097"
 const directoryA = "C:/server-a"
 const directoryB = "/home/server-b"
-const sessionB = {
-  id: "ses_server_b",
-  slug: "ses_server_b",
-  projectID: "project-server-b",
-  directory: directoryB,
-  title: "Server B session",
-  version: "dev",
-  time: { created: 1, updated: 1 },
-}
+const sessionA = session("ses_server_a", directoryA, "Server A session")
+const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
+const sessionB = session("ses_server_b", directoryB, "Server B session")
 
 test("session settings use the remote server context", async ({ page }) => {
   const permissionRequests: string[] = []
   await mockServers(page, permissionRequests)
-  await page.addInitScript(
-    ({ serverB }) => {
-      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
-      localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
-    },
-    { serverB },
-  )
+  await configureServers(page)
 
   await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
   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)
 })
 
-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) => {
     const url = new URL(route.request().url())
     if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
     const remote = url.origin === serverB
     const directory = remote ? directoryB : directoryA
+    const sessions = remote ? [sessionB] : [sessionA, childSessionA]
     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 (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
     if (url.pathname === "/global/health") return json(route, { healthy: true })
     if (url.pathname === "/session/status") return json(route, {})
-    if (url.pathname === "/session") return json(route, remote ? [sessionB] : [])
-    if (url.pathname === `/session/${sessionB.id}` && remote) return json(route, sessionB)
+    if (url.pathname === "/session") return json(route, sessions)
+    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 (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 (url.pathname === "/permission") {
       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) {
   const name = id === "server-b" ? "Server B" : "Server A"
   return {

+ 1 - 1
packages/app/e2e/utils/sse-transport.ts

@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
         const request = new Request(input, init)
         const url = new URL(request.url)
         if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
-          return originalFetch(input, init)
+          return originalFetch(request)
 
         const id = ++nextConnectionID
         const record = {

+ 17 - 27
packages/app/src/app.tsx

@@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
 }
 
 // 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/
-// Notification/Models + the visual Layout) for that server.
+// server via ServerKey, then provide the server-scoped shell for that server.
 function SelectedServerProviders(props: ParentProps) {
   return (
     <ServerKey>
@@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
     <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
       <ServerSDKProvider server={conn}>
         <ServerSyncProvider server={conn}>
-          <DraftServerScopedProviders directory={directory}>
+          <ModelsProvider directory={directory}>
             <SDKProvider directory={directory}>
               <DirectoryDataProvider directory={directory} server={serverKey}>
                 <DraftProviders>
@@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
                 </DraftProviders>
               </DirectoryDataProvider>
             </SDKProvider>
-          </DraftServerScopedProviders>
+          </ModelsProvider>
         </ServerSyncProvider>
       </ServerSDKProvider>
     </Show>
@@ -309,24 +308,21 @@ function DesktopCommands() {
 // Server-scoped providers shared by the legacy shell and the top-level new shell.
 type ServerScopedShellProps = ParentProps<{
   directory?: () => string | undefined
-  sessionID?: () => string | undefined
   serverScoped?: JSX.Element
 }>
 
 function ServerScopedProviders(props: ServerScopedShellProps) {
   return (
-    <PermissionProvider directory={props.directory}>
-      <LayoutProvider>
-        {props.serverScoped}
-        <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
-      </LayoutProvider>
-    </PermissionProvider>
+    <LayoutProvider>
+      {props.serverScoped}
+      <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
+    </LayoutProvider>
   )
 }
 
 function LegacyServerScopedShell(props: ServerScopedShellProps) {
   return (
-    <ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
+    <ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
       <LegacyLayout>{props.children}</LegacyLayout>
     </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.
 // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
 function DraftProviders(props: ParentProps) {
@@ -559,13 +547,15 @@ export function AppInterface(props: {
                 component={props.router ?? Router}
                 root={(routerProps) => (
                   <TabsProvider>
-                    <NotificationProvider>
-                      <ServerShell>
-                        <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
-                          <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
-                        </Show>
-                      </ServerShell>
-                    </NotificationProvider>
+                    <PermissionProvider>
+                      <NotificationProvider>
+                        <ServerShell>
+                          <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
+                            <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
+                          </Show>
+                        </ServerShell>
+                      </NotificationProvider>
+                    </PermissionProvider>
                   </TabsProvider>
                 )}
               >

+ 46 - 7
packages/app/src/components/prompt-input/submit.test.ts

@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
 
 const createdClients: string[] = []
 const createdSessions: string[] = []
-const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
+const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
 const optimistic: Array<{
   directory?: string
   sessionID?: string
@@ -27,6 +27,8 @@ let params: { id?: string } = {}
 let search: { draftId?: string } = {}
 let selected = "/repo/worktree-a"
 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 prompt = {
@@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
   return {
     session: {
       create: async () => {
+        await createSessionGate
         createdSessions.push(directory)
         return {
           data: {
@@ -122,13 +125,14 @@ beforeAll(async () => {
     }),
   }))
 
-  mock.module("@/context/permission", () => ({
-    usePermission: () => ({
+  mock.module("@/context/permission", () => {
+    const state = (server: string) => ({
       enableAutoAccept(sessionID: string, directory: string) {
-        enabledAutoAccept.push({ sessionID, directory })
+        enabledAutoAccept.push({ server, sessionID, directory })
       },
-    }),
-  }))
+    })
+    return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
+  })
 
   mock.module("@/context/server", () => ({
     useServer: () => ({ key: "server-key" }),
@@ -251,6 +255,8 @@ beforeEach(() => {
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
   variant = undefined
+  permissionServer = "server-a"
+  createSessionGate = undefined
   for (const key of Object.keys(storedSessions)) delete storedSessions[key]
 })
 
@@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
 
     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 () => {

+ 2 - 1
packages/app/src/components/prompt-input/submit.ts

@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     input.resetHistoryNavigation()
 
     const projectDirectory = sdk().directory
+    const permissionState = permission.currentServerState()
     const isNewSession = !params.id
     const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         session = created
         await startTransition(() => {
           if (!session) return
-          if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
+          if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
           local.session.promote(sessionDirectory, session.id, {
             agent: currentAgent.name,
             model: { providerID: currentModel.provider.id, modelID: currentModel.id },

+ 24 - 1
packages/app/src/context/permission-auto-respond.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
 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 }) =>
   ({
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
     }
 
     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", () => {
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
 
     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", () => {

+ 13 - 4
packages/app/src/context/permission-auto-respond.ts

@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
 
 function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
   const key = acceptKey(sessionID, directory)
-  const directoryKey = directory ? directoryAcceptKey(directory) : undefined
-  return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
+  return autoAccept[key] ?? autoAccept[sessionID]
 }
 
 export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
   permission: { sessionID: 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))
     .find((item): item is boolean => item !== undefined)
-  return value ?? false
 }

+ 390 - 200
packages/app/src/context/permission.tsx

@@ -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 { createSimpleContext } from "@opencode-ai/ui/context"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
 import { Persist, persisted } from "@/utils/persist"
-import { useServerSDK } from "@/context/server-sdk"
-import { useServerSync } from "./server-sync"
-import { useParams } from "@solidjs/router"
+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: {
@@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
 export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
   name: "Permission",
   gate: false,
-  init: (props: { directory?: Accessor<string | undefined> }) => {
-    const params = useParams()
-    const serverSDK = useServerSDK()
-    const serverSync = useServerSync()
+  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 permissionsEnabled = createMemo(() => {
-      const directory = props.directory?.() ?? decode64(params.dir)
-      if (!directory) return false
-      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 [store, setStore, _, ready] = persisted(
-      {
-        ...Persist.serverGlobal(serverSDK().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>,
-      }),
-    )
+    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
+    }
 
-    // When config has permission: "allow", auto-enable directory-level auto-accept
     createEffect(() => {
-      if (!ready()) return
-      const directory = props.directory?.() ?? decode64(params.dir)
-      if (!directory) return
-      const [childStore] = serverSync().child(directory)
-      const perm = childStore.config.permission
-      if (typeof perm === "string" && perm === "allow") {
-        const key = directoryAcceptKey(directory)
-        if (store.autoAccept[key] === undefined) {
-          setStore(
-            produce((draft) => {
-              draft.autoAccept[key] = true
-            }),
-          )
-        }
-      }
+      global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
     })
 
-    const MAX_RESPONDED = 1000
-    const RESPONDED_TTL_MS = 60 * 60 * 1000
-    const responded = new Map<string, number>()
-    const enableVersion = new Map<string, number>()
+    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))
+      })
+    })
 
-    function pruneResponded(now: number) {
-      for (const [id, ts] of responded) {
-        if (now - ts < RESPONDED_TTL_MS) break
-        responded.delete(id)
-      }
+    onCleanup(() => states.forEach((value) => value.dispose()))
 
-      for (const id of responded.keys()) {
-        if (responded.size <= MAX_RESPONDED) break
-        responded.delete(id)
+    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
+    })
 
-    const respond: PermissionRespondFn = (input) => {
-      serverSDK()
-        .client.permission.respond(input)
-        .catch(() => {
-          responded.delete(input.permissionID)
-        })
-    }
+    createEffect(() => {
+      const directory = activeDirectory()
+      if (!directory) return
+      selected().enableConfiguredDirectory(directory)
+    })
 
-    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,
-      })
-    }
+    const permissionsEnabled = createMemo(() => {
+      const directory = activeDirectory()
+      if (!directory) return false
+      return selected().permissionsEnabled(directory)
+    })
 
-    function isAutoAccepting(sessionID: string, directory?: string) {
-      const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
-      return autoRespondsPermission(store.autoAccept, session, { sessionID }, 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)
+      },
     }
+  },
+})
 
-    function isAutoAcceptingDirectory(directory: string) {
-      return isDirectoryAutoAccepting(store.autoAccept, 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
+      }),
+    )
+  }
 
-    function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
-      const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
-      return autoRespondsPermission(store.autoAccept, session, permission, directory)
-    }
+  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 bumpEnableVersion(sessionID: string, directory?: string) {
-      const key = acceptKey(sessionID, directory)
-      const next = (enableVersion.get(key) ?? 0) + 1
-      enableVersion.set(key, next)
-      return next
+  function pruneResponded(now: number) {
+    for (const [id, ts] of responded) {
+      if (now - ts < RESPONDED_TTL_MS) break
+      responded.delete(id)
     }
 
-    const unsubscribe = serverSDK().event.listen((e) => {
-      const event = e.details
-      if (event?.type !== "permission.asked") return
+    for (const id of responded.keys()) {
+      if (responded.size <= MAX_RESPONDED) break
+      responded.delete(id)
+    }
+  }
 
-      const perm = event.properties
-      if (!shouldAutoRespond(perm, e.name)) return
+  const respond: PermissionRespondFn = (request) => {
+    if (meta.disposed) return
+    input.sdk.client.permission.respond(request).catch(() => {
+      responded.delete(request.permissionID)
+    })
+  }
 
-      respondOnce(perm, e.name)
+  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,
     })
-    onCleanup(unsubscribe)
+  }
 
-    function enableDirectory(directory: string) {
-      const key = directoryAcceptKey(directory)
-      setStore(
-        produce((draft) => {
-          draft.autoAccept[key] = true
-        }),
-      )
+  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]
+  }
 
-      serverSDK()
-        .client.permission.list({ directory })
-        .then((x) => {
-          if (!isAutoAcceptingDirectory(directory)) return
-          for (const perm of x.data ?? []) {
-            if (!perm?.id) continue
-            if (!shouldAutoRespond(perm, directory)) continue
-            respondOnce(perm, directory)
-          }
-        })
-        .catch(() => undefined)
-    }
+  function isAutoAccepting(sessionID: string, directory?: string) {
+    return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
+  }
 
-    function disableDirectory(directory: string) {
-      const key = directoryAcceptKey(directory)
-      setStore(
-        produce((draft) => {
-          draft.autoAccept[key] = false
-        }),
-      )
-    }
+  function isAutoAcceptingDirectory(directory: string) {
+    return isDirectoryAutoAccepting(store.autoAccept, directory)
+  }
 
-    function enable(sessionID: string, directory: string) {
-      const key = acceptKey(sessionID, directory)
-      const version = bumpEnableVersion(sessionID, directory)
-      setStore(
-        produce((draft) => {
-          draft.autoAccept[key] = true
-          delete draft.autoAccept[sessionID]
-        }),
-      )
+  function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
+    return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
+  }
 
-      serverSDK()
-        .client.permission.list({ directory })
-        .then((x) => {
-          if (enableVersion.get(key) !== version) return
-          if (!isAutoAccepting(sessionID, directory)) return
-          for (const perm of x.data ?? []) {
-            if (!perm?.id) continue
-            if (!shouldAutoRespond(perm, directory)) continue
-            respondOnce(perm, directory)
-          }
-        })
-        .catch(() => undefined)
-    }
+  function isPending(permission: PermissionRequest) {
+    const pending = input.sync.session.data.permission[permission.sessionID]
+    return pending === undefined || pending.some((item) => item.id === permission.id)
+  }
 
-    function disable(sessionID: string, directory?: string) {
-      bumpEnableVersion(sessionID, directory)
-      const key = directory ? acceptKey(sessionID, directory) : sessionID
-      setStore(
-        produce((draft) => {
-          draft.autoAccept[key] = false
-          if (!directory) return
-          delete draft.autoAccept[sessionID]
-        }),
-      )
+  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
+      }),
+    )
 
-    return {
-      ready,
-      respond,
-      autoResponds(permission: PermissionRequest, directory?: string) {
-        return shouldAutoRespond(permission, directory)
-      },
-      isAutoAccepting,
-      isAutoAcceptingDirectory,
-      toggleAutoAccept(sessionID: string, directory: string) {
-        if (isAutoAccepting(sessionID, directory)) {
-          disable(sessionID, directory)
-          return
+    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)
+  }
 
-        enable(sessionID, directory)
-      },
-      toggleAutoAcceptDirectory(directory: string) {
-        if (isAutoAcceptingDirectory(directory)) {
-          disableDirectory(directory)
-          return
+  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),
+          )
         }
-        enableDirectory(directory)
-      },
-      enableAutoAccept(sessionID: string, directory: string) {
-        if (isAutoAccepting(sessionID, directory)) return
-        enable(sessionID, directory)
-      },
-      disableAutoAccept(sessionID: string, directory?: string) {
+      })
+      .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)
-      },
-      permissionsEnabled,
-      isPermissionAllowAll(directory: string) {
-        const [childStore] = serverSync().child(directory)
-        const perm = childStore.config.permission
-        return typeof perm === "string" && perm === "allow"
-      },
-    }
-  },
-})
+        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)
+    },
+  }
+}

+ 2 - 1
packages/app/src/pages/layout/project-avatar-state.ts

@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
   const global = useGlobal()
   const notification = useNotification()
   const permission = usePermission()
+  const permissionState = createMemo(() => permission.ensureServerState(server()))
   const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
   const sync = createMemo(() => {
     const conn = connection()
@@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
     if (!serverSync) return false
     const [store] = serverSync.child(directory(), { bootstrap: false })
     return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
-      return !permission.autoResponds(item, directory())
+      return !permissionState().autoResponds(item, directory())
     })
   })
   const hasQuestions = createMemo(() => {

+ 3 - 4
packages/app/src/pages/session.tsx

@@ -47,7 +47,6 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { ModelsProvider } from "@/context/models"
 import { useNotification } from "@/context/notification"
-import { PermissionProvider } from "@/context/permission"
 import { PromptProvider, usePrompt } from "@/context/prompt"
 import { usePlatform } from "@/context/platform"
 import { SDKProvider, useSDK } from "@/context/sdk"
@@ -159,7 +158,7 @@ export function TargetSessionRouteContent() {
   const serverSync = useServerSync()
   const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
   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.
     <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
       <TargetSessionSettingsCommand />
@@ -294,10 +293,10 @@ function TargetServerScopedProviders(
   props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
 ) {
   return (
-    <PermissionProvider directory={props.directory}>
+    <>
       <MarkSessionNotificationsViewed sessionID={props.sessionID} />
       <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
-    </PermissionProvider>
+    </>
   )
 }