From f1e8e03c31a88b534c68f2f25ba32abda3db3c6d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 1 Aug 2026 03:14:59 +0300 Subject: [PATCH] fix(vscode): restore permission auto-accept replies Route live VS Code permission requests directly to directory-scoped permission replies instead of blocking on the refresh-only state preflight. When auto-accept is enabled after prompts are already visible, reply to the authoritative local requests first, then reconcile and deduplicate the server pending list. Keep state verification for reconnect and refresh flows so stale resolved requests are not resurrected. Add regression coverage for live events, existing prompts, directory routing, retry behavior, stale reconciliation, and network failures. --- packages/ui/src/lib/opencode/client.ts | 9 +- packages/ui/src/sync/DOCUMENTATION.md | 2 +- packages/ui/src/sync/session-actions.test.ts | 12 +++ packages/ui/src/sync/session-actions.ts | 9 +- packages/ui/src/sync/sync-context.tsx | 7 +- .../vscode-permission-auto-accept.test.ts | 94 ++++++++++++++++++- .../src/sync/vscode-permission-auto-accept.ts | 53 ++++++++--- 7 files changed, 161 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index ce89831e..3e8ab753 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1183,12 +1183,13 @@ class OpencodeService { async fetchPermission( sessionID: string, requestID: string, + directory?: string, ): Promise { try { - // The V2 path is session-scoped and does not require a `directory` - // parameter. The client-scoped directory (set via setDirectory) is - // honored by the underlying SDK client when the call is routed. - const response = await this.client.v2.session.permission.get({ + // The V2 endpoint does not accept a directory parameter. Callers that + // reconcile a known project must therefore select its scoped SDK client. + const client = directory ? this.getScopedSdkClient(directory) : this.client; + const response = await client.v2.session.permission.get({ sessionID, requestID, }); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 41425e96..a48479ba 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -132,7 +132,7 @@ The active-session watchdog in `sync-context.tsx` (per-directory status polls an Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call. -VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Enabling the policy and reconnect/bootstrap both reconcile pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned. +VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Once policy is enabled, a live `permission.asked` event sends the directory-scoped `permission.reply` immediately and does not block on a permission-state preflight request. Enabling the policy treats permission cards already present in the directory store the same way and replies immediately, then reconciles the server's pending list with a state preflight so stale already-resolved requests are not replied to or resurrected. Reconnect/bootstrap also uses the preflight while reconciling pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned. ### Mutation responsibility diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index cf60e16a..cfd4f9f0 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -926,6 +926,18 @@ describe("respondToPermission passes directory", () => { expect(replyCalls[0].params.reply).toBe("reject") expect(replyCalls[0].params.directory).toBe("/fallback/dir") }) + + test("uses an explicit event directory before incomplete local routing state", async () => { + const childStores = createChildStores([]) + + const { setActionRefs, respondToPermission } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/stale/current") + + await respondToPermission("unknown-session", "perm-event", "once", "/event/project") + + expect(scopedClientDirectories).toContain("/event/project") + expect(replyCalls[0].params.directory).toBe("/event/project") + }) }) describe("revertToMessage passes session directory", () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index d9aff83c..c25ecb1e 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1137,12 +1137,17 @@ export async function respondToPermission( sessionId: string, requestId: string, response: "once" | "always" | "reject", + directoryOverride?: string, ): Promise { await waitForConnectionOrThrow() - const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId) + const directory = directoryOverride + || resolveDirectoryForBlockingRequest("permission", sessionId, requestId) || getSessionDirectory(sessionId) || dir() - const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ + const client = directoryOverride + ? opencodeClient.getScopedSdkClient(directoryOverride) + : getRequestReplyClient("permission", sessionId, requestId) + const result = await client.permission.reply({ requestID: requestId, reply: response, ...(directory ? { directory } : {}), diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index a413c105..9e1da2b4 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -40,7 +40,10 @@ import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" -import { processVSCodePermissionAutoAccept } from "./vscode-permission-auto-accept" +import { + processVSCodePermissionAutoAccept, + processVSCodeReconciledPermissionAutoAccept, +} from "./vscode-permission-auto-accept" import { useConfigStore } from "@/stores/useConfigStore" import { useTodosPersistStore } from "@/stores/useTodosPersistStore" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" @@ -1247,7 +1250,7 @@ export async function resyncBlockingRequestsForDirectory( const acceptedIdsBySession = new Map>() await Promise.all(Object.entries(grouped).flatMap(([sessionId, permissions]) => permissions.map(async (permission) => { - if (!(await processVSCodePermissionAutoAccept(permission, directory))) return + if (!(await processVSCodeReconciledPermissionAutoAccept(permission, directory))) return const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() accepted.add(permission.id) acceptedIdsBySession.set(sessionId, accepted) diff --git a/packages/ui/src/sync/vscode-permission-auto-accept.test.ts b/packages/ui/src/sync/vscode-permission-auto-accept.test.ts index d25278b6..d44a6afe 100644 --- a/packages/ui/src/sync/vscode-permission-auto-accept.test.ts +++ b/packages/ui/src/sync/vscode-permission-auto-accept.test.ts @@ -86,6 +86,27 @@ describe("VS Code permission auto-accept runtime", () => { expect(attempts).toBe(2) }) + test("routes the state check and reply through the permission event directory", async () => { + const stateDirectories: Array = [] + const replyDirectories: Array = [] + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ child: true }), + getSessions: () => new Map(), + getSession: async () => session("child"), + listPendingPermissions: async () => [], + getPermissionState: async (_sessionId, _requestId, directory) => { + stateDirectories.push(directory) + return "ok" + }, + reply: async (_sessionId, _requestId, directory) => { replyDirectories.push(directory) }, + wait: async () => undefined, + }) + + expect(await runtime.processPermission(permission, "/permission/project")).toBe(true) + expect(stateDirectories).toEqual(["/permission/project"]) + expect(replyDirectories).toEqual(["/permission/project"]) + }) + test("reconciles existing pending permissions immediately after enablement", async () => { const replied: string[] = [] const runtime = createVSCodePermissionAutoAcceptRuntime({ @@ -109,19 +130,88 @@ describe("VS Code permission auto-accept runtime", () => { expect(replied).toEqual(["perm-1"]) }) - test("treats an already resolved permission as handled without replying", async () => { + test("accepts visible pending permissions before a network reconciliation failure", async () => { + const replied: string[] = [] + let stateChecks = 0 + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ child: true }), + getSessions: () => new Map(), + getSession: async () => session("child"), + getKnownPendingPermissions: () => [permission], + listPendingPermissions: async () => { throw new Error("offline") }, + getPermissionState: async () => { + stateChecks += 1 + return "ok" + }, + reply: async (_sessionId, requestId) => { replied.push(requestId) }, + wait: async () => undefined, + }) + + await expect(runtime.reconcilePending("/repo")).rejects.toThrow("offline") + expect(stateChecks).toBe(0) + expect(replied).toEqual(["perm-1"]) + }) + + test("deduplicates visible and network pending permissions", async () => { let replyCalls = 0 + let stateChecks = 0 + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ child: true }), + getSessions: () => new Map(), + getSession: async () => session("child"), + getKnownPendingPermissions: () => [permission], + listPendingPermissions: async () => [permission], + getPermissionState: async () => { + stateChecks += 1 + return "ok" + }, + reply: async () => { replyCalls += 1 }, + wait: async () => undefined, + }) + + await runtime.reconcilePending("/repo") + expect(stateChecks).toBe(0) + expect(replyCalls).toBe(1) + }) + + test("sends a live-event reply immediately without a permission-state preflight", async () => { + let stateChecks = 0 + let replyStarted = false const runtime = createVSCodePermissionAutoAcceptRuntime({ getPolicy: () => ({ child: true }), getSessions: () => new Map(), getSession: async () => session("child"), listPendingPermissions: async () => [], + getPermissionState: async () => { + stateChecks += 1 + return "ok" + }, + reply: async () => { replyStarted = true }, + wait: async () => undefined, + }) + + expect(await runtime.processPermission( + { ...permission, id: "immediate" }, + "/repo", + { verifyPending: false }, + )).toBe(true) + expect(stateChecks).toBe(0) + expect(replyStarted).toBe(true) + }) + + test("keeps the permission-state preflight for refresh reconciliation", async () => { + let replyCalls = 0 + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ child: true }), + getSessions: () => new Map(), + getSession: async () => session("child"), + listPendingPermissions: async () => [{ ...permission, id: "resolved" }], getPermissionState: async () => "resolved", reply: async () => { replyCalls += 1 }, wait: async () => undefined, }) - expect(await runtime.processPermission({ ...permission, id: "resolved" })).toBe(true) + await runtime.reconcilePending("/repo") expect(replyCalls).toBe(0) }) }) diff --git a/packages/ui/src/sync/vscode-permission-auto-accept.ts b/packages/ui/src/sync/vscode-permission-auto-accept.ts index 9097ebeb..a7aa230b 100644 --- a/packages/ui/src/sync/vscode-permission-auto-accept.ts +++ b/packages/ui/src/sync/vscode-permission-auto-accept.ts @@ -1,7 +1,7 @@ import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" -import { getAllSyncSessionMap } from "./sync-refs" +import { getAllSyncSessionMap, getDirectoryState } from "./sync-refs" import * as sessionActions from "./session-actions" const RETRY_DELAYS_MS = [0, 250, 1000] @@ -10,9 +10,10 @@ type Dependencies = { getPolicy: () => Record getSessions: () => ReadonlyMap getSession: (sessionId: string, directory?: string) => Promise + getKnownPendingPermissions?: (directory?: string) => PermissionRequest[] listPendingPermissions: (directory?: string) => Promise - getPermissionState: (sessionId: string, requestId: string) => Promise<"ok" | "resolved" | "unknown"> - reply: (sessionId: string, requestId: string) => Promise + getPermissionState: (sessionId: string, requestId: string, directory?: string) => Promise<"ok" | "resolved" | "unknown"> + reply: (sessionId: string, requestId: string, directory?: string) => Promise wait: (delayMs: number) => Promise } @@ -48,7 +49,11 @@ export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependenci return false } - const processPermission = (permission: PermissionRequest, directory?: string) => { + const processPermission = ( + permission: PermissionRequest, + directory?: string, + options?: { verifyPending?: boolean }, + ) => { const recent = recentOutcomes.get(permission.id) if (recent !== undefined) return Promise.resolve(recent) const existing = inFlight.get(permission.id) @@ -57,13 +62,15 @@ export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependenci const task = (async () => { if (!(await isEnabled(permission.sessionID, directory))) return false - const permissionState = await dependencies.getPermissionState(permission.sessionID, permission.id) - if (permissionState === "resolved") return true + if (options?.verifyPending !== false) { + const permissionState = await dependencies.getPermissionState(permission.sessionID, permission.id, directory) + if (permissionState === "resolved") return true + } for (const delay of RETRY_DELAYS_MS) { if (delay > 0) await dependencies.wait(delay) try { - await dependencies.reply(permission.sessionID, permission.id) + await dependencies.reply(permission.sessionID, permission.id, directory) return true } catch { // A failed reply stays visible after the bounded retries. @@ -87,10 +94,23 @@ export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependenci const existing = reconcileInFlight.get(key) if (existing) return existing - const task = dependencies.listPendingPermissions(directory) - .then(async (permissions) => { - await Promise.all(permissions.map((permission) => processPermission(permission, directory))) - }) + const task = (async () => { + const processed = new Set() + const processAll = async (permissions: PermissionRequest[], verifyPending: boolean) => { + const pending = permissions.filter((permission) => { + if (!permission?.id || processed.has(permission.id)) return false + processed.add(permission.id) + return true + }) + await Promise.all(pending.map((permission) => processPermission(permission, directory, { verifyPending }))) + } + + // A permission.asked event is already authoritative local state. Process + // those visible cards before the network reconciliation so enabling the + // toggle works even when permission.list is unavailable or stale. + await processAll(dependencies.getKnownPendingPermissions?.(directory) ?? [], false) + await processAll(await dependencies.listPendingPermissions(directory), true) + })() .finally(() => reconcileInFlight.delete(key)) reconcileInFlight.set(key, task) @@ -104,11 +124,16 @@ const runtime = createVSCodePermissionAutoAcceptRuntime({ getPolicy: () => usePermissionStore.getState().autoAccept, getSessions: getAllSyncSessionMap, getSession: (sessionId, directory) => opencodeClient.getSession(sessionId, directory), + getKnownPendingPermissions: (directory) => Object.values(getDirectoryState(directory)?.permission ?? {}).flat(), listPendingPermissions: (directory) => opencodeClient.listPendingPermissions({ directories: [directory] }), - getPermissionState: async (sessionId, requestId) => (await opencodeClient.fetchPermission(sessionId, requestId)).state, - reply: (sessionId, requestId) => sessionActions.respondToPermission(sessionId, requestId, "once"), + getPermissionState: async (sessionId, requestId, directory) => (await opencodeClient.fetchPermission(sessionId, requestId, directory)).state, + reply: (sessionId, requestId, directory) => sessionActions.respondToPermission(sessionId, requestId, "once", directory), wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), }) -export const processVSCodePermissionAutoAccept = runtime.processPermission +export const processVSCodePermissionAutoAccept = ( + permission: PermissionRequest, + directory?: string, +) => runtime.processPermission(permission, directory, { verifyPending: false }) +export const processVSCodeReconciledPermissionAutoAccept = runtime.processPermission export const reconcileVSCodePendingPermissions = runtime.reconcilePending