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.
This commit is contained in:
@@ -1183,12 +1183,13 @@ class OpencodeService {
|
||||
async fetchPermission(
|
||||
sessionID: string,
|
||||
requestID: string,
|
||||
directory?: string,
|
||||
): Promise<FetchPermissionResult> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1137,12 +1137,17 @@ export async function respondToPermission(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
response: "once" | "always" | "reject",
|
||||
directoryOverride?: string,
|
||||
): Promise<void> {
|
||||
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 } : {}),
|
||||
|
||||
@@ -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<string, Set<string>>()
|
||||
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<string>()
|
||||
accepted.add(permission.id)
|
||||
acceptedIdsBySession.set(sessionId, accepted)
|
||||
|
||||
@@ -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<string | undefined> = []
|
||||
const replyDirectories: Array<string | undefined> = []
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, boolean>
|
||||
getSessions: () => ReadonlyMap<string, Session>
|
||||
getSession: (sessionId: string, directory?: string) => Promise<Session>
|
||||
getKnownPendingPermissions?: (directory?: string) => PermissionRequest[]
|
||||
listPendingPermissions: (directory?: string) => Promise<PermissionRequest[]>
|
||||
getPermissionState: (sessionId: string, requestId: string) => Promise<"ok" | "resolved" | "unknown">
|
||||
reply: (sessionId: string, requestId: string) => Promise<void>
|
||||
getPermissionState: (sessionId: string, requestId: string, directory?: string) => Promise<"ok" | "resolved" | "unknown">
|
||||
reply: (sessionId: string, requestId: string, directory?: string) => Promise<void>
|
||||
wait: (delayMs: number) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user