fix(vscode): restore permission auto-accept parity
Add a VS Code-specific foreground permission responder while preserving the server-owned implementation for web, desktop, and mobile runtimes. Persist the authoritative VS Code policy in extension global state and expose matching GET/PUT bridge routes. Broadcast policy updates to the sidebar, session editor panels, and agent manager so every active webview observes the same explicit per-session policy. Resolve missing child-session lineage through OpenCode, honor nearest explicit ancestor overrides, deduplicate concurrent requests, retry transient replies, and reconcile pending permissions after enablement, bootstrap, and reconnect. Treat resolved requests as handled and route notification suppression through the same responder outcome. Keep post-toggle reconciliation failures non-fatal after policy persistence and fail closed when lineage or replies cannot be confirmed. Document that auto-accept intentionally cannot run while every OpenChamber webview is closed or suspended.
This commit is contained in:
@@ -89,16 +89,6 @@ export const usePermissionStore = create<PermissionStore>()(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<PermissionStore>()(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 });
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, Set<string>>()
|
||||
// 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<string, Set<string>>()
|
||||
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<string>()
|
||||
accepted.add(permission.id)
|
||||
acceptedIdsBySession.set(sessionId, accepted)
|
||||
} else if (outcome.state === "resolved") {
|
||||
const resolved = resolvedIdsBySession.get(sessionId) ?? new Set<string>()
|
||||
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<string>()
|
||||
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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, boolean>
|
||||
getSessions: () => ReadonlyMap<string, Session>
|
||||
getSession: (sessionId: string, directory?: string) => Promise<Session>
|
||||
listPendingPermissions: (directory?: string) => Promise<PermissionRequest[]>
|
||||
getPermissionState: (sessionId: string, requestId: string) => Promise<"ok" | "resolved" | "unknown">
|
||||
reply: (sessionId: string, requestId: string) => Promise<void>
|
||||
wait: (delayMs: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependencies) {
|
||||
const inFlight = new Map<string, Promise<boolean>>()
|
||||
const reconcileInFlight = new Map<string, Promise<void>>()
|
||||
const recentOutcomes = new Map<string, boolean>()
|
||||
|
||||
const isEnabled = async (sessionId: string, directory?: string) => {
|
||||
const policy = dependencies.getPolicy()
|
||||
const syncedSessions = dependencies.getSessions()
|
||||
const fetchedSessions = new Map<string, Session>()
|
||||
const seen = new Set<string>()
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>();
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const STORAGE_KEY = 'permissionAutoAccept';
|
||||
|
||||
type PolicyContext = {
|
||||
globalState: {
|
||||
get: (key: string) => unknown;
|
||||
update: (key: string, value: unknown) => PromiseLike<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PermissionAutoAcceptSnapshot = {
|
||||
sessions: Record<string, boolean>;
|
||||
};
|
||||
|
||||
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<string, boolean> = {};
|
||||
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<unknown>,
|
||||
) {
|
||||
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<unknown> },
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, unknown>): 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<string, boolean> });
|
||||
});
|
||||
|
||||
// Listen for active editor file changes from the extension
|
||||
onCommand('activeEditorFile', (payload) => {
|
||||
import('@/sync/input-store').then(({ useInputStore }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user