diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 9f1cd9c9..34db0838 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -89,16 +89,6 @@ export const usePermissionStore = create()(persist((set, get) = setSessionAutoAccept: async (sessionId, enabled) => { if (!sessionId) return; - if (isVSCodeRuntime()) { - const response = await runtimeFetch("/api/notifications/auto-accept", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId, enabled }), - }); - if (!response.ok) throw new Error(`Permission auto-accept request failed (${response.status})`); - set((state) => ({ autoAccept: { ...state.autoAccept, [sessionId]: enabled }, loaded: true })); - return; - } set({ saving: true }); try { const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId) @@ -113,6 +103,10 @@ export const usePermissionStore = create()(persist((set, get) = }, ); set({ autoAccept: snapshot.sessions, loaded: true }); + if (isVSCodeRuntime() && enabled) { + const { reconcileVSCodePendingPermissions } = await import("@/sync/vscode-permission-auto-accept"); + void reconcileVSCodePendingPermissions(directory).catch(() => undefined); + } } finally { set({ saving: false }); } diff --git a/packages/ui/src/stores/permissionStore.vscode.test.ts b/packages/ui/src/stores/permissionStore.vscode.test.ts new file mode 100644 index 00000000..04db33a3 --- /dev/null +++ b/packages/ui/src/stores/permissionStore.vscode.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" + +let reconcileDirectory: string | undefined +let reconcileShouldFail = false + +mock.module("@/lib/runtime-fetch", () => ({ + runtimeFetch: async (_path: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { enabled?: boolean } + return new Response(JSON.stringify({ sessions: { root: body.enabled === true } }), { status: 200 }) + }, +})) +mock.module("@/lib/desktop", () => ({ isVSCodeRuntime: () => true })) +mock.module("@/sync/sync-refs", () => ({ getAllSyncSessionMap: () => new Map() })) +mock.module("@/sync/session-ui-store", () => ({ + useSessionUIStore: { getState: () => ({ getDirectoryForSession: () => "/repo" }) }, +})) +mock.module("@/lib/opencode/client", () => ({ + opencodeClient: { getDirectory: () => "/fallback" }, +})) +mock.module("@/sync/vscode-permission-auto-accept", () => ({ + reconcileVSCodePendingPermissions: async (directory?: string) => { + reconcileDirectory = directory + if (reconcileShouldFail) throw new Error("offline") + }, +})) + +const { usePermissionStore } = await import("./permissionStore") + +describe("permission store VS Code policy", () => { + beforeEach(() => { + reconcileDirectory = undefined + reconcileShouldFail = false + usePermissionStore.getState().reset() + }) + + test("reconciles existing pending requests after enabling auto-accept", async () => { + await usePermissionStore.getState().setSessionAutoAccept("root", true) + await Promise.resolve() + + expect(usePermissionStore.getState().autoAccept).toEqual({ root: true }) + expect(reconcileDirectory).toBe("/repo") + }) + + test("does not reconcile when disabling auto-accept", async () => { + await usePermissionStore.getState().setSessionAutoAccept("root", false) + + expect(reconcileDirectory).toBe(undefined) + }) + + test("keeps a persisted toggle successful when pending reconciliation fails", async () => { + reconcileShouldFail = true + + await usePermissionStore.getState().setSessionAutoAccept("root", true) + expect(usePermissionStore.getState().autoAccept).toEqual({ root: true }) + }) +}) diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 47a01834..c478ce39 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -84,6 +84,8 @@ Cross-directory selectors subscribe to the narrow child-store field they aggrega 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. 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 `useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by: diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 0e5a301b..2aeff26c 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -30,6 +30,7 @@ import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds } from "./reconnect-recovery" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" +import { processVSCodePermissionAutoAccept } from "./vscode-permission-auto-accept" import { useConfigStore } from "@/stores/useConfigStore" import { useTodosPersistStore } from "@/stores/useTodosPersistStore" import { toast } from "@/components/ui" @@ -39,7 +40,6 @@ import type { State } from "./types" import type { SessionStatus } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@/types/permission" import type { QuestionRequest } from "@/types/question" -import * as sessionActions from "./session-actions" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { openSessionFromToast } from "./session-navigation" import { getPermissionToastKey, showPermissionNeededToast } from "./permission-toast" @@ -1159,61 +1159,21 @@ export async function resyncBlockingRequestsForDirectory( grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) } - const permissionStore = usePermissionStore.getState() - const autoAcceptingSessionIds = isVSCodeRuntime() - ? Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) - : [] - - if (autoAcceptingSessionIds.length > 0) { + if (isVSCodeRuntime()) { const acceptedIdsBySession = new Map>() - // Track server-confirmed resolved permissions separately so we can - // remove them from `grouped` below — the V1 listPendingPermissions - // snapshot can still contain entries the server has already answered, - // and leaving them in place produces a spurious "Permission needed" - // toast for a permission the user has already resolved. - const resolvedIdsBySession = new Map>() - await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) => - (grouped[sessionId] ?? []).map(async (permission) => { - try { - // Verify the permission is still pending before auto-accepting. - // - state: "ok" → still pending, safe to auto-accept - // - state: "resolved" → server returned 404, drop from grouped - // - state: "unknown" → network error / pre-1.17.12 server, - // keep in grouped for the user to act on - // - // On a pre-v1.17.12 server without the V2 endpoint, every call - // returns "unknown". This permanently disables auto-accept - // (acknowledged scope tradeoff — project requires SDK 1.17.12) - // but does not falsely report permissions as resolved. - const outcome = await opencodeClient.fetchPermission( - permission.sessionID, - permission.id, - ) - if (outcome.state === "ok") { - await sessionActions.respondToPermission(permission.sessionID, permission.id, "once") - const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() - accepted.add(permission.id) - acceptedIdsBySession.set(sessionId, accepted) - } else if (outcome.state === "resolved") { - const resolved = resolvedIdsBySession.get(sessionId) ?? new Set() - resolved.add(permission.id) - resolvedIdsBySession.set(sessionId, resolved) - } - // state: "unknown" → keep the permission in grouped; user can - // answer manually. - } catch { - // Keep failed auto-accept permissions in UI state so the user can act. - } + await Promise.all(Object.entries(grouped).flatMap(([sessionId, permissions]) => + permissions.map(async (permission) => { + if (!(await processVSCodePermissionAutoAccept(permission, directory))) return + const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() + accepted.add(permission.id) + acceptedIdsBySession.set(sessionId, accepted) }), )) - for (const sessionId of autoAcceptingSessionIds) { + for (const sessionId of Object.keys(grouped)) { const acceptedIds = acceptedIdsBySession.get(sessionId) - const resolvedIds = resolvedIdsBySession.get(sessionId) - if (!acceptedIds && !resolvedIds) continue - const drop = (id: string) => - acceptedIds?.has(id) || resolvedIds?.has(id) || false - const remaining = (grouped[sessionId] ?? []).filter((permission) => !drop(permission.id)) + if (!acceptedIds) continue + const remaining = (grouped[sessionId] ?? []).filter((permission) => !acceptedIds.has(permission.id)) if (remaining.length > 0) grouped[sessionId] = remaining else delete grouped[sessionId] } @@ -1356,6 +1316,7 @@ function handleEvent( payload: Event, childStores: ChildStoreManager, routingIndex: EventRoutingIndex, + skipVSCodeAutoAccept = false, ) { if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") { const properties = (payload as unknown as { properties?: unknown }).properties @@ -1452,12 +1413,15 @@ function handleEvent( if (payload.type === "permission.asked") { const permission = payload.properties as PermissionRequest - const permissionStore = usePermissionStore.getState() - if (permissionStore.isSessionAutoAccepting(permission.sessionID)) { + if (isVSCodeRuntime() && !skipVSCodeAutoAccept) { + updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) + void processVSCodePermissionAutoAccept(permission, resolvedDirectory).then((accepted) => { + if (!accepted) handleEvent(rawDirectory, payload, childStores, routingIndex, true) + }) + return + } + if (!isVSCodeRuntime() && usePermissionStore.getState().isSessionAutoAccepting(permission.sessionID)) { updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) - if (isVSCodeRuntime()) { - void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) - } return } @@ -1762,7 +1726,6 @@ export function SyncProvider(props: { // Configure child store manager useEffect(() => { - if (isVSCodeRuntime()) return void usePermissionStore.getState().hydrate().catch(() => undefined) }, [props.sdk]) diff --git a/packages/ui/src/sync/vscode-permission-auto-accept.test.ts b/packages/ui/src/sync/vscode-permission-auto-accept.test.ts new file mode 100644 index 00000000..d25278b6 --- /dev/null +++ b/packages/ui/src/sync/vscode-permission-auto-accept.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, mock, test } from "bun:test" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { createVSCodePermissionAutoAcceptRuntime } from "./vscode-permission-auto-accept" + +const permission = { id: "perm-1", sessionID: "child" } as PermissionRequest +const session = (id: string, parentID?: string) => ({ id, parentID }) as Session + +describe("VS Code permission auto-accept runtime", () => { + test("loads missing child lineage and inherits the nearest enabled policy", async () => { + let replyCalls = 0 + let getSessionCalls = 0 + const reply = mock(async () => { replyCalls += 1 }) + const getSession = mock(async (id: string) => { + getSessionCalls += 1 + return session(id, id === "child" ? "root" : undefined) + }) + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ root: true }), + getSessions: () => new Map(), + getSession, + listPendingPermissions: async () => [], + getPermissionState: async () => "ok", + reply, + wait: async () => undefined, + }) + + expect(await runtime.processPermission(permission, "/repo")).toBe(true) + expect(getSessionCalls).toBe(1) + expect(replyCalls).toBe(1) + }) + + test("honors an explicit child disable over an enabled parent", async () => { + let replyCalls = 0 + const reply = mock(async () => { replyCalls += 1 }) + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ root: true, child: false }), + getSessions: () => new Map([["child", session("child", "root")]]), + getSession: async () => session("root"), + listPendingPermissions: async () => [], + getPermissionState: async () => "ok", + reply, + wait: async () => undefined, + }) + + expect(await runtime.processPermission(permission)).toBe(false) + expect(replyCalls).toBe(0) + }) + + test("fails closed when lineage cannot be loaded", async () => { + let replyCalls = 0 + const reply = mock(async () => { replyCalls += 1 }) + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ root: true }), + getSessions: () => new Map(), + getSession: async () => { throw new Error("offline") }, + listPendingPermissions: async () => [], + getPermissionState: async () => "ok", + reply, + wait: async () => undefined, + }) + + expect(await runtime.processPermission(permission)).toBe(false) + expect(replyCalls).toBe(0) + }) + + test("deduplicates concurrent events and retries failed replies", async () => { + let attempts = 0 + const reply = mock(async () => { + attempts += 1 + if (attempts < 2) throw new Error("transient") + }) + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ child: true }), + getSessions: () => new Map(), + getSession: async () => session("child"), + listPendingPermissions: async () => [], + getPermissionState: async () => "ok", + reply, + wait: async () => undefined, + }) + + const first = runtime.processPermission(permission) + const second = runtime.processPermission(permission) + expect(await first).toBe(true) + expect(await second).toBe(true) + expect(attempts).toBe(2) + }) + + test("reconciles existing pending permissions immediately after enablement", async () => { + const replied: string[] = [] + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ root: true, disabled: false }), + getSessions: () => new Map([ + ["child", session("child", "root")], + ["disabled", session("disabled", "root")], + ]), + getSession: async (id) => session(id), + listPendingPermissions: async () => [ + permission, + { ...permission, id: "perm-disabled", sessionID: "disabled" }, + ], + getPermissionState: async () => "ok", + reply: async (_sessionId, requestId) => { replied.push(requestId) }, + wait: async () => undefined, + }) + + await runtime.reconcilePending("/repo") + + expect(replied).toEqual(["perm-1"]) + }) + + test("treats an already resolved permission as handled without replying", async () => { + let replyCalls = 0 + const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => ({ child: true }), + getSessions: () => new Map(), + getSession: async () => session("child"), + listPendingPermissions: async () => [], + getPermissionState: async () => "resolved", + reply: async () => { replyCalls += 1 }, + wait: async () => undefined, + }) + + expect(await runtime.processPermission({ ...permission, id: "resolved" })).toBe(true) + 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 new file mode 100644 index 00000000..9097ebeb --- /dev/null +++ b/packages/ui/src/sync/vscode-permission-auto-accept.ts @@ -0,0 +1,114 @@ +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 * as sessionActions from "./session-actions" + +const RETRY_DELAYS_MS = [0, 250, 1000] + +type Dependencies = { + getPolicy: () => Record + getSessions: () => ReadonlyMap + getSession: (sessionId: string, directory?: string) => Promise + listPendingPermissions: (directory?: string) => Promise + getPermissionState: (sessionId: string, requestId: string) => Promise<"ok" | "resolved" | "unknown"> + reply: (sessionId: string, requestId: string) => Promise + wait: (delayMs: number) => Promise +} + +export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependencies) { + const inFlight = new Map>() + const reconcileInFlight = new Map>() + const recentOutcomes = new Map() + + const isEnabled = async (sessionId: string, directory?: string) => { + const policy = dependencies.getPolicy() + const syncedSessions = dependencies.getSessions() + const fetchedSessions = new Map() + const seen = new Set() + let current: string | undefined = sessionId + let currentDirectory = directory + + while (current && !seen.has(current)) { + if (Object.prototype.hasOwnProperty.call(policy, current)) return policy[current] === true + seen.add(current) + + let session: Session | undefined = syncedSessions.get(current) ?? fetchedSessions.get(current) + if (!session) { + try { + session = await dependencies.getSession(current, currentDirectory) + fetchedSessions.set(session.id, session) + } catch { + return false + } + } + current = session.parentID + currentDirectory = session.directory || currentDirectory + } + return false + } + + const processPermission = (permission: PermissionRequest, directory?: string) => { + const recent = recentOutcomes.get(permission.id) + if (recent !== undefined) return Promise.resolve(recent) + const existing = inFlight.get(permission.id) + if (existing) return existing + + 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 + + for (const delay of RETRY_DELAYS_MS) { + if (delay > 0) await dependencies.wait(delay) + try { + await dependencies.reply(permission.sessionID, permission.id) + return true + } catch { + // A failed reply stays visible after the bounded retries. + } + } + return false + })().then((accepted) => { + if (accepted) { + recentOutcomes.set(permission.id, true) + setTimeout(() => recentOutcomes.delete(permission.id), 5000) + } + return accepted + }).finally(() => inFlight.delete(permission.id)) + + inFlight.set(permission.id, task) + return task + } + + const reconcilePending = (directory?: string) => { + const key = directory?.trim() || "all" + 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))) + }) + .finally(() => reconcileInFlight.delete(key)) + + reconcileInFlight.set(key, task) + return task + } + + return { processPermission, reconcilePending } +} + +const runtime = createVSCodePermissionAutoAcceptRuntime({ + getPolicy: () => usePermissionStore.getState().autoAccept, + getSessions: getAllSyncSessionMap, + getSession: (sessionId, directory) => opencodeClient.getSession(sessionId, directory), + listPendingPermissions: (directory) => opencodeClient.listPendingPermissions({ directories: [directory] }), + getPermissionState: async (sessionId, requestId) => (await opencodeClient.fetchPermission(sessionId, requestId)).state, + reply: (sessionId, requestId) => sessionActions.respondToPermission(sessionId, requestId, "once"), + wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), +}) + +export const processVSCodePermissionAutoAccept = runtime.processPermission +export const reconcileVSCodePendingPermissions = runtime.reconcilePending diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 1da28c2b..13f46991 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -140,6 +140,14 @@ export class AgentManagerPanelProvider { }); } + public notifyPermissionAutoAcceptSynced(snapshot: unknown): void { + this._panel?.webview.postMessage({ + type: 'command', + command: 'permissionAutoAcceptSynced', + payload: snapshot, + }); + } + public notifyWindowFocusChanged(focused: boolean): void { if (!this._panel) { return; diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index f300f0c2..038a9e4c 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -318,6 +318,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }); } + public notifyPermissionAutoAcceptSynced(snapshot: unknown): void { + this._view?.webview.postMessage({ + type: 'command', + command: 'permissionAutoAcceptSynced', + payload: snapshot, + }); + } + /** * Ask the webview to run the full OpenCode reload flow (overlay + managed * restart via the bridge + config/data refresh) — the same flow used after an diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 9f8dd3dd..b550f1e8 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -52,6 +52,10 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`). - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). +- `bridge-permission-auto-accept-runtime.ts` + - Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract. + - Broadcasts policy snapshots to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime. + ## Extension guideline When adding new bridge route families: diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 8686ad71..0553dbd3 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -199,6 +199,16 @@ export class SessionEditorPanelProvider { } } + public notifyPermissionAutoAcceptSynced(snapshot: unknown): void { + for (const entry of this._panels.values()) { + entry.panel.webview.postMessage({ + type: 'command', + command: 'permissionAutoAcceptSynced', + payload: snapshot, + }); + } + } + public notifyWindowFocusChanged(focused: boolean): void { for (const entry of this._panels.values()) { entry.panel.webview.postMessage({ diff --git a/packages/vscode/src/bridge-permission-auto-accept-runtime.test.ts b/packages/vscode/src/bridge-permission-auto-accept-runtime.test.ts new file mode 100644 index 00000000..b48de738 --- /dev/null +++ b/packages/vscode/src/bridge-permission-auto-accept-runtime.test.ts @@ -0,0 +1,48 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime'; + +const createContext = () => { + const values = new Map(); + return { + globalState: { + get: (key: string) => values.get(key), + update: async (key: string, value: unknown) => { values.set(key, value); }, + }, + }; +}; + +describe('VS Code permission auto-accept policy bridge', () => { + test('persists policy and broadcasts the authoritative snapshot', async () => { + const context = createContext(); + const broadcasts: unknown[] = []; + const dependencies = { broadcast: async (snapshot: unknown) => { broadcasts.push(snapshot); } }; + const response = await handlePermissionAutoAcceptBridgeMessage({ + id: '1', + type: 'api:permission-auto-accept:set', + payload: { sessionId: 'root', enabled: true }, + }, context, dependencies); + + assert.equal(response?.success, true); + assert.deepEqual(response?.data, { sessions: { root: true } }); + assert.deepEqual(broadcasts, [{ sessions: { root: true } }]); + + const reloaded = await handlePermissionAutoAcceptBridgeMessage({ + id: '2', + type: 'api:permission-auto-accept:get', + }, context, dependencies); + assert.deepEqual(reloaded?.data, { sessions: { root: true } }); + }); + + test('rejects malformed policy writes', async () => { + const broadcasts: unknown[] = []; + const response = await handlePermissionAutoAcceptBridgeMessage({ + id: '1', + type: 'api:permission-auto-accept:set', + payload: { sessionId: 'root', enabled: 'yes' }, + }, createContext(), { broadcast: async (snapshot) => { broadcasts.push(snapshot); } }); + + assert.equal(response?.success, false); + assert.deepEqual(broadcasts, []); + }); +}); diff --git a/packages/vscode/src/bridge-permission-auto-accept-runtime.ts b/packages/vscode/src/bridge-permission-auto-accept-runtime.ts new file mode 100644 index 00000000..6f34e456 --- /dev/null +++ b/packages/vscode/src/bridge-permission-auto-accept-runtime.ts @@ -0,0 +1,76 @@ +const STORAGE_KEY = 'permissionAutoAccept'; + +type PolicyContext = { + globalState: { + get: (key: string) => unknown; + update: (key: string, value: unknown) => PromiseLike; + }; +}; + +export type PermissionAutoAcceptSnapshot = { + sessions: Record; +}; + +const normalizeSnapshot = (value: unknown): PermissionAutoAcceptSnapshot => { + const source = value && typeof value === 'object' && !Array.isArray(value) + ? value as { sessions?: unknown } + : {}; + const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions) + ? Object.entries(source.sessions) + : []; + const sessions: Record = {}; + for (const [sessionId, enabled] of entries) { + if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled; + } + return { sessions }; +}; + +const readPermissionAutoAcceptPolicy = (context: PolicyContext) => + normalizeSnapshot(context.globalState.get(STORAGE_KEY)); + +async function setPermissionAutoAcceptPolicy( + context: PolicyContext, + sessionId: string, + enabled: boolean, + broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike, +) { + const current = readPermissionAutoAcceptPolicy(context); + const snapshot = { + sessions: { ...current.sessions, [sessionId]: enabled }, + }; + await context.globalState.update(STORAGE_KEY, snapshot); + await broadcast(snapshot); + return snapshot; +} + +export async function handlePermissionAutoAcceptBridgeMessage( + message: { id: string; type: string; payload?: unknown }, + context?: PolicyContext, + dependencies?: { broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike }, +) { + if (message.type !== 'api:permission-auto-accept:get' && message.type !== 'api:permission-auto-accept:set') { + return null; + } + if (!context) return { id: message.id, type: message.type, success: false, error: 'Extension context is unavailable' }; + + if (message.type === 'api:permission-auto-accept:get') { + return { id: message.id, type: message.type, success: true, data: readPermissionAutoAcceptPolicy(context) }; + } + + const payload = message.payload && typeof message.payload === 'object' + ? message.payload as { sessionId?: unknown; enabled?: unknown } + : {}; + const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''; + if (!sessionId) return { id: message.id, type: message.type, success: false, error: 'sessionId is required' }; + if (typeof payload.enabled !== 'boolean') { + return { id: message.id, type: message.type, success: false, error: 'enabled must be a boolean' }; + } + + const snapshot = await setPermissionAutoAcceptPolicy( + context, + sessionId, + payload.enabled, + dependencies?.broadcast ?? (() => Promise.resolve()), + ); + return { id: message.id, type: message.type, success: true, data: snapshot }; +} diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index c1ea3e0f..d5829de3 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -558,15 +558,6 @@ export async function handleSystemBridgeMessage( } } - case 'api:notifications/auto-accept': { - const request = (payload || {}) as { sessionId?: unknown; enabled?: unknown }; - const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : ''; - if (!sessionId) { - return { id, type, success: false, error: 'sessionId is required' }; - } - return { id, type, success: true, data: { success: true } }; - } - default: return null; } diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 7a9906b0..300f6973 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -6,6 +6,7 @@ import { handleFsBridgeMessage } from './bridge-fs-runtime'; import { handleConfigBridgeMessage } from './bridge-config-runtime'; import { handleSystemBridgeMessage } from './bridge-system-runtime'; import { handleProxyBridgeMessage } from './bridge-proxy-runtime'; +import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime'; import { fetchOpenCodeSkillsFromApi, persistSettings, @@ -63,6 +64,18 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const { id, type, payload } = message; try { + const permissionAutoAcceptResponse = await handlePermissionAutoAcceptBridgeMessage( + { id, type, payload }, + ctx?.context, + { + broadcast: (snapshot) => vscode.commands.executeCommand( + 'openchamber.internal.permissionAutoAcceptSynced', + snapshot, + ), + }, + ); + if (permissionAutoAcceptResponse) return permissionAutoAcceptResponse; + const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload }); if (standardGitResponse) { return standardGitResponse; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 8fbdec8c..b7a39255 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -200,6 +200,14 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.internal.permissionAutoAcceptSynced', (snapshot: unknown) => { + chatViewProvider?.notifyPermissionAutoAcceptSynced(snapshot); + sessionEditorProvider?.notifyPermissionAutoAcceptSynced(snapshot); + agentManagerProvider?.notifyPermissionAutoAcceptSynced(snapshot); + }) + ); + context.subscriptions.push( vscode.window.onDidChangeWindowState((state) => { chatViewProvider?.notifyWindowFocusChanged(state.focused); diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 5412fb4f..5e5351bb 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -13,6 +13,9 @@ import { } from '@openchamber/ui/lib/theme/vscode/adapter'; import { getBootstrapMessages, readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n'; import type { VSCodeActiveEditorFile } from '@/sync/input-store'; +import { usePermissionStore } from '@openchamber/ui/stores/permissionStore'; +import { processVSCodePermissionAutoAccept } from '@openchamber/ui/sync/vscode-permission-auto-accept'; +import type { PermissionRequest } from '@opencode-ai/sdk/v2/client'; type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected'; type PanelType = 'chat' | 'agentManager'; @@ -408,15 +411,24 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R }); } - if (normalizedPathname === '/api/notifications/auto-accept' && method === 'POST') { + if (normalizedPathname === '/api/permission-auto-accept' && method === 'GET') { + const snapshot = await sendBridgeMessage('api:permission-auto-accept:get'); + return new Response(JSON.stringify(snapshot), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const permissionPolicyMatch = normalizedPathname.match(/^\/api\/permission-auto-accept\/sessions\/([^/]+)$/); + if (permissionPolicyMatch && method === 'PUT') { const bodyText = await extractBodyText(url, init, method); - const body = bodyText - ? JSON.parse(bodyText) as { sessionId?: unknown; enabled?: unknown } - : {}; - const result = await sendBridgeMessage<{ success?: boolean }>('api:notifications/auto-accept', body) - .catch(() => ({ success: false })); - return new Response(JSON.stringify(result), { - status: result?.success === false ? 400 : 200, + const body = bodyText ? JSON.parse(bodyText) as { enabled?: unknown } : {}; + const snapshot = await sendBridgeMessage('api:permission-auto-accept:set', { + sessionId: decodeURIComponent(permissionPolicyMatch[1]), + enabled: body.enabled, + }); + return new Response(JSON.stringify(snapshot), { + status: 200, headers: { 'Content-Type': 'application/json' }, }); } @@ -1657,7 +1669,7 @@ const getNotificationDirectory = (payload: Record): string | nu }; window.addEventListener('openchamber:vscode-notification-event', (event) => { - const detail = (event as CustomEvent<{ payload?: unknown }>).detail; + const detail = (event as CustomEvent<{ directory?: string; payload?: unknown }>).detail; const payload = detail?.payload; if (!payload || typeof payload !== 'object') { return; @@ -1674,8 +1686,7 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => { Promise.all([ import('@/stores/useUIStore'), - import('@/stores/permissionStore'), - ]).then(async ([{ useUIStore }, { usePermissionStore }]) => { + ]).then(async ([{ useUIStore }]) => { await ensureNotificationSettingsSynced(); const settings = useUIStore.getState(); if (!settings.nativeNotificationsEnabled) { @@ -1763,7 +1774,14 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => { if (type === 'permission.asked') { if (!settings.notifyOnQuestion) return; - if (usePermissionStore.getState().isSessionAutoAccepting(sessionId)) return; + const requestId = getPayloadString(properties.id); + if (requestId) { + const accepted = await processVSCodePermissionAutoAccept( + properties as unknown as PermissionRequest, + detail?.directory, + ); + if (accepted) return; + } const permission = getPayloadString(properties.permission); const sessionTitle = getPayloadString(properties.sessionTitle); const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval'; @@ -1788,6 +1806,13 @@ onCommand('settingsSynced', () => { }); }); +onCommand('permissionAutoAcceptSynced', (payload) => { + if (!payload || typeof payload !== 'object') return; + const sessions = (payload as { sessions?: unknown }).sessions; + if (!sessions || typeof sessions !== 'object') return; + usePermissionStore.getState().applySnapshot({ sessions: sessions as Record }); +}); + // Listen for active editor file changes from the extension onCommand('activeEditorFile', (payload) => { import('@/sync/input-store').then(({ useInputStore }) => { diff --git a/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md b/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md index ad574a66..16384463 100644 --- a/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md +++ b/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md @@ -27,7 +27,7 @@ These are normal authenticated OpenChamber runtime routes. They must not be adde `packages/ui/src/stores/permissionStore.ts` is a projection of server policy and does not persist an independent policy. The server is the sole responder and the UI renders pending requests until the authoritative `permission.replied` event arrives. -VS Code retains its foreground-only implementation because it does not run the web server runtime. +VS Code retains its foreground-only responder because it does not run the web server runtime. Its extension host persists and broadcasts the authoritative policy across webviews, while the active UI handles live events plus startup, reconnect, and enablement reconciliation. With all OpenChamber webviews closed or suspended, permissions are not auto-accepted; this is an intentional VS Code limitation. ## Tests