From d738d415743857de01b4d613e367726b699ad2e9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 15:03:16 +0300 Subject: [PATCH] feat: persist permission auto-accept on server (#2158) Move per-session permission auto-accept policy ownership from the UI to the OpenChamber server so enabled sessions continue running when clients disconnect or the server restarts. - persist explicit per-session policies in OpenChamber settings - inherit the nearest explicit policy across subagent session hierarchies - allow child sessions to opt out of an inherited parent policy - immediately accept matching global and directory-scoped pending requests - process future requests without requiring a connected UI client - reconcile pending permissions after startup and event-stream reconnects - deduplicate concurrent requests and retry transient reply failures - synchronize policy updates across connected clients - migrate existing browser-persisted policies to server storage - suppress auto-accepted permission cards before they enter UI state - show deduplicated permission toasts for inactive sessions - preserve foreground-only permission handling in VS Code - integrate directory-aware notification routing from main - add coverage for persistence, inheritance, retries, reconciliation, pending requests, client hydration, and inactive-session toasts --- AGENTS.md | 6 + packages/ui/src/apps/runtimeEndpointReset.ts | 2 + .../ui/src/stores/permissionStore.test.ts | 66 +++ packages/ui/src/stores/permissionStore.ts | 444 ++++-------------- packages/ui/src/sync/permission-toast.test.ts | 48 ++ packages/ui/src/sync/permission-toast.ts | 45 ++ packages/ui/src/sync/sync-context.tsx | 75 +-- packages/web/server/index.js | 17 +- .../server/lib/notifications/DOCUMENTATION.md | 2 +- .../web/server/lib/notifications/runtime.js | 11 +- .../web/server/lib/opencode/DOCUMENTATION.md | 1 + .../web/server/lib/opencode/core-routes.js | 1 + .../lib/opencode/feature-routes-runtime.js | 4 + .../server/lib/opencode/settings-helpers.js | 12 + .../lib/opencode/settings-helpers.test.js | 14 + .../permission-auto-accept/DOCUMENTATION.md | 34 ++ .../lib/permission-auto-accept/runtime.js | 263 +++++++++++ .../permission-auto-accept/runtime.test.js | 137 ++++++ 18 files changed, 795 insertions(+), 387 deletions(-) create mode 100644 packages/ui/src/stores/permissionStore.test.ts create mode 100644 packages/ui/src/sync/permission-toast.test.ts create mode 100644 packages/ui/src/sync/permission-toast.ts create mode 100644 packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md create mode 100644 packages/web/server/lib/permission-auto-accept/runtime.js create mode 100644 packages/web/server/lib/permission-auto-accept/runtime.test.js diff --git a/AGENTS.md b/AGENTS.md index 14da1901..82482d4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,12 @@ Notification message preparation utilities for system notifications, including t - Module docs: `packages/web/server/lib/notifications/DOCUMENTATION.md` +##### permission-auto-accept + +Persistent server-owned permission auto-accept policy, subagent inheritance, retries, and reconnect reconciliation. + +- Module docs: `packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md` + ##### scheduled-tasks Scheduled task persistence, execution, and event fanout for recurring sessions. diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index b4150cd2..a6a8cb00 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -6,6 +6,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useUIStore } from '@/stores/useUIStore'; +import { usePermissionStore } from '@/stores/permissionStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; @@ -43,6 +44,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD // Cross-project session list (mobile sessions sheet & co) belongs to the // previous instance — drop it so stale sessions can't linger after a switch. useGlobalSessionsStore.getState().resetForRuntimeSwitch(); + usePermissionStore.getState().reset(); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); resetStreamingState(); diff --git a/packages/ui/src/stores/permissionStore.test.ts b/packages/ui/src/stores/permissionStore.test.ts new file mode 100644 index 00000000..6aa18e3d --- /dev/null +++ b/packages/ui/src/stores/permissionStore.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +let fetchImpl: (input: string, init?: RequestInit) => Promise; +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: (input: string, init?: RequestInit) => fetchImpl(input, init), +})); +mock.module('@/sync/sync-refs', () => ({ getAllSyncSessions: () => [] })); +mock.module('@/sync/session-ui-store', () => ({ + useSessionUIStore: { getState: () => ({ getDirectoryForSession: () => '/project' }) }, +})); +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { getDirectory: () => '/fallback' }, +})); + +const { usePermissionStore } = await import('./permissionStore'); +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); + +describe('permission store server policy', () => { + beforeEach(() => { + usePermissionStore.getState().reset(); + fetchImpl = async () => json({ sessions: {} }); + }); + + test('hydrates the authoritative server snapshot', async () => { + fetchImpl = async () => json({ sessions: { root: true } }); + await usePermissionStore.getState().hydrate(); + expect(usePermissionStore.getState().autoAccept).toEqual({ root: true }); + }); + + test('preserves previous state when hydration fails', async () => { + usePermissionStore.setState({ autoAccept: { root: true }, loaded: true }); + fetchImpl = async () => json({}, 503); + await expect(usePermissionStore.getState().hydrate()).rejects.toThrow(); + expect(usePermissionStore.getState().autoAccept).toEqual({ root: true }); + }); + + test('updates local state only after server persistence succeeds', async () => { + fetchImpl = async () => json({}, 500); + await expect(usePermissionStore.getState().setSessionAutoAccept('root', true)).rejects.toThrow(); + expect(usePermissionStore.getState().autoAccept).toEqual({}); + }); + + test('sends the session directory for immediate pending reconciliation', async () => { + let body: unknown; + fetchImpl = async (_input, init) => { + body = JSON.parse(String(init?.body)); + return json({ sessions: { root: true } }); + }; + await usePermissionStore.getState().setSessionAutoAccept('root', true); + expect(body).toEqual({ enabled: true, directory: '/project' }); + }); + + test('migrates a legacy local policy when the server has no policy yet', async () => { + usePermissionStore.setState({ autoAccept: { root: true } }); + const requests: string[] = []; + fetchImpl = async (input) => { + requests.push(input); + return input.includes('/sessions/') + ? json({ sessions: { root: true } }) + : json({ sessions: {} }); + }; + await usePermissionStore.getState().hydrate(); + expect(requests).toEqual(['/api/permission-auto-accept', '/api/permission-auto-accept/sessions/root']); + expect(usePermissionStore.getState().autoAccept).toEqual({ root: true }); + }); +}); diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 6ac01416..628e9118 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,372 +1,120 @@ import { create } from "zustand"; -import { devtools, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk/v2/client"; -import { - autoRespondsPermission, - type PermissionAutoAcceptMap, -} from "./utils/permissionAutoAccept"; -import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; -import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; -import { opencodeClient } from "@/lib/opencode/client"; -import { respondToPermission } from "@/sync/session-actions"; -import { useSessionUIStore } from "@/sync/session-ui-store"; +import { autoRespondsPermission, type PermissionAutoAcceptMap } from "./utils/permissionAutoAccept"; +import { getAllSyncSessions } from "@/sync/sync-refs"; import { runtimeFetch } from "@/lib/runtime-fetch"; +import { isVSCodeRuntime } from "@/lib/desktop"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; +import { useSessionUIStore } from "@/sync/session-ui-store"; +import { opencodeClient } from "@/lib/opencode/client"; -interface PermissionState { +type PermissionPolicySnapshot = { + sessions: PermissionAutoAcceptMap; +}; + +interface PermissionStore { autoAccept: PermissionAutoAcceptMap; -} - -interface PermissionActions { + loaded: boolean; + saving: boolean; + hydrate: () => Promise; + applySnapshot: (snapshot: PermissionPolicySnapshot) => void; + reset: () => void; isSessionAutoAccepting: (sessionId: string) => boolean; setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise; } -type PermissionStore = PermissionState & PermissionActions; - -const coerceAutoAcceptValue = (value: unknown): boolean => { - if (typeof value === "boolean") { - return value; +const readSnapshot = async (response: Response): Promise => { + if (!response.ok) throw new Error(`Permission auto-accept request failed (${response.status})`); + const payload = await response.json() as Partial; + if (!payload.sessions || typeof payload.sessions !== "object") { + throw new Error("Invalid permission auto-accept response"); } - - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true") { - return true; - } - if (normalized === "false") { - return false; - } + const sessions: PermissionAutoAcceptMap = {}; + for (const [sessionId, enabled] of Object.entries(payload.sessions)) { + if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled; } - - if (typeof value === "number") { - return value === 1; - } - - return false; + return { sessions }; }; -const isLegacyDirectoryAutoAcceptKey = (key: string): boolean => key.endsWith("/*"); +const requestSnapshot = async (path: string, init?: RequestInit) => readSnapshot(await runtimeFetch(path, init)); -const extractSessionIdFromLegacyKey = (key: string): string | null => { - const trimmed = key.trim(); - if (!trimmed) { - return null; - } - const lastSlash = trimmed.lastIndexOf("/"); - if (lastSlash === -1 || lastSlash === trimmed.length - 1) { - return trimmed; - } - return trimmed.slice(lastSlash + 1); -}; +const isAutoAccepting = (autoAccept: PermissionAutoAcceptMap, sessions: Session[], sessionId: string) => + autoRespondsPermission({ autoAccept, sessions, sessionID: sessionId }); -const resolveSessionScope = (sessionID: string, sessions: Session[]): Set => { - const map = new Map(); - const children = new Map(); - for (const session of sessions) { - map.set(session.id, session); - if (session.parentID) { - const list = children.get(session.parentID); - if (list) { - list.push(session.id); - } else { - children.set(session.parentID, [session.id]); +export const usePermissionStore = create()(persist((set, get) => ({ + autoAccept: {}, + loaded: false, + saving: false, + + hydrate: async () => { + let snapshot = await requestSnapshot("/api/permission-auto-accept"); + const legacyEntries = Object.entries(get().autoAccept) + .filter(([sessionId, enabled]) => !sessionId.includes("/") && typeof enabled === "boolean"); + if (Object.keys(snapshot.sessions).length === 0 && legacyEntries.length > 0) { + for (const [sessionId, enabled] of legacyEntries) { + if (!sessionId || typeof enabled !== "boolean") continue; + snapshot = await requestSnapshot( + `/api/permission-auto-accept/sessions/${encodeURIComponent(sessionId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ); } } - } + set({ autoAccept: snapshot.sessions, loaded: true }); + }, - if (!map.has(sessionID)) { - return new Set([sessionID]); - } + reset: () => set({ autoAccept: {}, loaded: false, saving: false }), - const result = new Set(); - const seen = new Set(); - const queue = [sessionID]; - while (queue.length > 0) { - const current = queue.shift(); - if (!current || seen.has(current)) { - continue; + applySnapshot: (snapshot) => { + const sessions: PermissionAutoAcceptMap = {}; + for (const [sessionId, enabled] of Object.entries(snapshot.sessions ?? {})) { + if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled; } - seen.add(current); - result.add(current); - const nextChildren = children.get(current); - if (!nextChildren || nextChildren.length === 0) { - continue; + set({ autoAccept: sessions, loaded: true }); + }, + + isSessionAutoAccepting: (sessionId) => { + if (!sessionId) return false; + return isAutoAccepting(get().autoAccept, getAllSyncSessions(), sessionId); + }, + + 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; } - for (const child of nextChildren) { - if (!seen.has(child)) { - queue.push(child); - } - } - } - - return result; -}; - -const normalizeDirectoryCandidate = (value: unknown): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -}; - -const collectPendingFromSyncStores = (): Array<{ id: string; sessionID: string }> => { - try { - const stores = getSyncChildStores(); - const pending: Array<{ id: string; sessionID: string }> = []; - for (const store of stores.children.values()) { - const permissionMap = store.getState().permission ?? {}; - for (const [sessionId, entries] of Object.entries(permissionMap)) { - for (const permission of entries ?? []) { - if (!permission?.id) continue; - pending.push({ id: permission.id, sessionID: permission.sessionID || sessionId }); - } - } - } - return pending; - } catch { - return []; - } -}; - -const sessionBelongsToScope = async ( - sessionID: string, - rootSessionID: string, - knownSessions: Session[], - directories: string[], -): Promise => { - if (sessionID === rootSessionID) { - return true; - } - - const knownById = new Map(); - for (const session of knownSessions) { - knownById.set(session.id, session); - } - - const fetchedById = new Map(); - const fetchSession = async (id: string): Promise => { - const known = knownById.get(id) ?? fetchedById.get(id); - if (known) return known; - - for (const directory of directories) { - try { - const result = await opencodeClient.getScopedSdkClient(directory).session.get({ - sessionID: id, - directory, - }); - if (result.data) { - fetchedById.set(id, result.data); - return result.data; - } - } catch { - // Try the next known project directory. - } - } - + set({ saving: true }); try { - const result = await opencodeClient.getSdkClient().session.get({ sessionID: id }); - if (result.data) { - fetchedById.set(id, result.data); - return result.data; - } - } catch { - // Missing session metadata means we cannot safely inherit the parent setting. + const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId) + ?? opencodeClient.getDirectory() + ?? undefined; + const snapshot = await requestSnapshot( + `/api/permission-auto-accept/sessions/${encodeURIComponent(sessionId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled, directory }), + }, + ); + set({ autoAccept: snapshot.sessions, loaded: true }); + } finally { + set({ saving: false }); } + }, - return null; - }; - - const seen = new Set(); - let current: string | undefined = sessionID; - while (current && !seen.has(current)) { - if (current === rootSessionID) { - return true; - } - seen.add(current); - const session = await fetchSession(current); - current = session?.parentID ?? undefined; - } - - return false; -}; - -const autoRespondsPermissionBySession = ( - autoAccept: PermissionAutoAcceptMap, - sessions: Session[], - sessionID: string, -): boolean => { - return autoRespondsPermission({ - autoAccept, - sessionID, - sessions, - }); -}; - -const getStorage = () => createDeferredSafeJSONStorage(); - -export const usePermissionStore = create()( - devtools( - persist( - (set, get) => ({ - autoAccept: {}, - - isSessionAutoAccepting: (sessionId: string) => { - if (!sessionId) { - return false; - } - - const sessions = getAllSyncSessions(); - return autoRespondsPermissionBySession(get().autoAccept, sessions, sessionId); - }, - - setSessionAutoAccept: async (sessionId: string, enabled: boolean) => { - if (!sessionId) { - return; - } - - const sessions = getAllSyncSessions(); - - set((state) => { - const autoAccept = { ...state.autoAccept }; - autoAccept[sessionId] = enabled; - return { autoAccept }; - }); - - const sessionScope = resolveSessionScope(sessionId, sessions); - - // Mirror inherited state to the server so it can suppress - // permission notifications before the client auto-response - // round-trip. Send known descendants too; server-side - // ancestry lookup can lag OpenCode session indexing. - for (const scopedSessionId of sessionScope) { - void runtimeFetch('/api/notifications/auto-accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: scopedSessionId, enabled }), - }).catch(() => { /* best-effort */ }); - } - - if (!enabled) { - return; - } - - const sessionDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId); - const directories = new Set(); - const currentDirectory = normalizeDirectoryCandidate(opencodeClient.getDirectory()); - if (currentDirectory) { - directories.add(currentDirectory); - } - const mappedSessionDirectory = normalizeDirectoryCandidate(sessionDirectory); - if (mappedSessionDirectory) { - directories.add(mappedSessionDirectory); - } - for (const scopedSessionId of sessionScope) { - const mapped = normalizeDirectoryCandidate(useSessionUIStore.getState().getDirectoryForSession(scopedSessionId)); - if (mapped) { - directories.add(mapped); - } - } - - const directoryList = Array.from(directories); - const pendingFromStores = collectPendingFromSyncStores(); - // Best-effort: if listPendingPermissions throws (transient fetch failure), - // proceed with whatever sync-store snapshots gave us. The next SSE event - // or reconnect resync will auto-accept anything we missed. - const pendingFromApi = await opencodeClient - .listPendingPermissions({ directories: Array.from(directories) }) - .catch(() => []); - const mergedPending = new Map(); - - for (const permission of pendingFromStores) { - if (sessionScope.has(permission.sessionID)) { - mergedPending.set(permission.id, permission); - continue; - } - if (await sessionBelongsToScope(permission.sessionID, sessionId, sessions, directoryList)) { - mergedPending.set(permission.id, permission); - } - } - for (const permission of pendingFromApi) { - if (!permission?.id || !permission?.sessionID) { - continue; - } - if (!sessionScope.has(permission.sessionID)) { - const belongsToScope = await sessionBelongsToScope(permission.sessionID, sessionId, sessions, directoryList); - if (!belongsToScope) { - continue; - } - } - mergedPending.set(permission.id, { id: permission.id, sessionID: permission.sessionID }); - } - - await Promise.all( - Array.from(mergedPending.values()) - .map((permission) => respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)), - ); - }, - }), - { - name: "permission-store", - storage: getStorage(), - partialize: (state) => ({ autoAccept: state.autoAccept }), - merge: (persistedState, currentState) => { - const merged = { - ...currentState, - ...(persistedState as Partial), - }; - - const persisted = Object.entries(merged.autoAccept || {}); - const nextAutoAccept: PermissionAutoAcceptMap = {}; - - for (const [rawKey, rawEnabled] of persisted) { - if (rawKey.includes("/") || isLegacyDirectoryAutoAcceptKey(rawKey)) { - continue; - } - nextAutoAccept[rawKey] = coerceAutoAcceptValue(rawEnabled); - } - - for (const [rawKey, rawEnabled] of persisted) { - if (isLegacyDirectoryAutoAcceptKey(rawKey)) { - continue; - } - if (!rawKey.includes("/")) { - continue; - } - - const sessionId = extractSessionIdFromLegacyKey(rawKey); - if (!sessionId) { - continue; - } - if (Object.prototype.hasOwnProperty.call(nextAutoAccept, sessionId)) { - continue; - } - - const normalized = coerceAutoAcceptValue(rawEnabled); - const existing = nextAutoAccept[sessionId]; - nextAutoAccept[sessionId] = existing === true ? true : normalized; - } - - return { - ...merged, - autoAccept: nextAutoAccept, - }; - }, - onRehydrateStorage: () => (state) => { - if (!state) return; - // Re-broadcast auto-accept state to the server after - // rehydration so server-side notification suppression - // survives page reloads / server restarts. - for (const [sid, enabled] of Object.entries(state.autoAccept || {})) { - if (enabled === true) { - void runtimeFetch('/api/notifications/auto-accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sid, enabled: true }), - }).catch(() => { /* best-effort */ }); - } - } - }, - } - ), - { name: "permission-store" } - ) -); +}), { + name: "permission-store", + storage: createDeferredSafeJSONStorage(), + partialize: (state) => ({ autoAccept: state.autoAccept }), +})); diff --git a/packages/ui/src/sync/permission-toast.test.ts b/packages/ui/src/sync/permission-toast.test.ts new file mode 100644 index 00000000..0fae487f --- /dev/null +++ b/packages/ui/src/sync/permission-toast.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test'; +import type { PermissionRequest } from '@/types/permission'; +import { showPermissionNeededToast } from './permission-toast'; + +const permission = { + id: 'permission-1', + sessionID: 'inactive-session', + permission: 'bash', +} as PermissionRequest; + +describe('permission needed toast', () => { + test('shows for an inactive session and opens that session', () => { + const shown: Array<{ title: string; options: Parameters[0]['show']>[1] }> = []; + const opened: Array<[string, string]> = []; + const show: Parameters[0]['show'] = (title, options) => { shown.push({ title, options }); }; + const openSession = (sessionId: string, directory: string) => { opened.push([sessionId, directory]); }; + const pendingIds = new Set(); + + expect(showPermissionNeededToast({ + permission, + directory: '/project', + isViewed: false, + pendingIds, + show, + openSession, + })).toBe(true); + + expect(shown.length).toBe(1); + const { options } = shown[0]; + expect(options.id).toBe('permission-inactive-session:permission-1'); + expect(options.description).toBe('bash'); + options.action.onClick(); + expect(opened).toEqual([['inactive-session', '/project']]); + }); + + test('does not show for the viewed session or duplicate a pending toast', () => { + const shown: string[] = []; + const show: Parameters[0]['show'] = (title) => { shown.push(title); }; + const openSession: Parameters[0]['openSession'] = () => {}; + const pendingIds = new Set(); + const base = { permission, directory: '/project', pendingIds, show, openSession }; + + expect(showPermissionNeededToast({ ...base, isViewed: true })).toBe(false); + expect(showPermissionNeededToast({ ...base, isViewed: false })).toBe(true); + expect(showPermissionNeededToast({ ...base, isViewed: false })).toBe(false); + expect(shown.length).toBe(1); + }); +}); diff --git a/packages/ui/src/sync/permission-toast.ts b/packages/ui/src/sync/permission-toast.ts new file mode 100644 index 00000000..2ea45e55 --- /dev/null +++ b/packages/ui/src/sync/permission-toast.ts @@ -0,0 +1,45 @@ +import type { PermissionRequest } from '@/types/permission'; + +type PermissionToastOptions = { + permission: PermissionRequest; + directory: string; + isViewed: boolean; + pendingIds: Set; + show: (title: string, options: { + id: string; + description: string; + action: { label: string; onClick: () => void }; + }) => void; + openSession: (sessionId: string, directory: string) => void; +}; + +export const getPermissionToastKey = (sessionId?: string, requestId?: string) => { + if (!sessionId || !requestId) return null; + return `${sessionId}:${requestId}`; +}; + +export const showPermissionNeededToast = ({ + permission, + directory, + isViewed, + pendingIds, + show, + openSession, +}: PermissionToastOptions): boolean => { + const key = getPermissionToastKey(permission.sessionID, permission.id); + if (isViewed || !key || pendingIds.has(key)) return false; + + pendingIds.add(key); + const description = typeof permission.permission === 'string' && permission.permission.trim().length > 0 + ? permission.permission + : 'Agent needs your approval'; + show('Permission needed', { + id: `permission-${key}`, + description, + action: { + label: 'Open session', + onClick: () => openSession(permission.sessionID, directory), + }, + }); + return true; +}; diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 1e93d8fc..0ffd4420 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -42,6 +42,7 @@ 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" import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory" import { getRuntimeKey } from "@/lib/runtime-switch" import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" @@ -320,11 +321,6 @@ const getQuestionToastKey = (sessionID?: string, requestID?: string) => { return `${sessionID}:${requestID}` } -const getPermissionToastKey = (sessionID?: string, requestID?: string) => { - if (!sessionID || !requestID) return null - return `${sessionID}:${requestID}` -} - type UiNotificationPayload = { title?: unknown body?: unknown @@ -1135,7 +1131,9 @@ export async function resyncBlockingRequestsForDirectory( } const permissionStore = usePermissionStore.getState() - const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) + const autoAcceptingSessionIds = isVSCodeRuntime() + ? Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) + : [] if (autoAcceptingSessionIds.length > 0) { const acceptedIdsBySession = new Map>() @@ -1198,19 +1196,13 @@ export async function resyncBlockingRequestsForDirectory( if (isViewed) continue for (const permission of permissions) { if (knownIds.has(permission.id)) continue - const toastKey = getPermissionToastKey(sessionId, permission.id) - if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue - pendingPermissionToastIds.add(toastKey) - const description = typeof permission.permission === "string" && permission.permission.trim().length > 0 - ? permission.permission - : "Agent needs your approval" - toast.info("Permission needed", { - id: `permission-${toastKey}`, - description, - action: { - label: "Open session", - onClick: () => openSessionFromToast(sessionId, directory), - }, + showPermissionNeededToast({ + permission, + directory, + isViewed, + pendingIds: pendingPermissionToastIds, + show: (title, options) => toast.info(title, options), + openSession: openSessionFromToast, }) } } @@ -1336,6 +1328,19 @@ function handleEvent( childStores: ChildStoreManager, routingIndex: EventRoutingIndex, ) { + if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") { + const properties = (payload as unknown as { properties?: unknown }).properties + if (properties && typeof properties === "object") { + const snapshot = properties as { sessions?: unknown } + if (snapshot.sessions && typeof snapshot.sessions === "object") { + usePermissionStore.getState().applySnapshot({ + sessions: snapshot.sessions as Record, + }) + } + } + return + } + const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores) if (handleUiNotificationEvent(payload, directory)) { @@ -1421,26 +1426,21 @@ function handleEvent( const permissionStore = usePermissionStore.getState() if (permissionStore.isSessionAutoAccepting(permission.sessionID)) { updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) - void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) + if (isVSCodeRuntime()) { + void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) + } return } - const toastKey = getPermissionToastKey(permission.sessionID, permission.id) const isViewed = isViewedInCurrentSession(resolvedDirectory, permission.sessionID) - if (!isViewed && toastKey && !pendingPermissionToastIds.has(toastKey)) { - pendingPermissionToastIds.add(toastKey) - const description = typeof permission.permission === "string" && permission.permission.trim().length > 0 - ? permission.permission - : "Agent needs your approval" - toast.info("Permission needed", { - id: `permission-${toastKey}`, - description, - action: { - label: "Open session", - onClick: () => openSessionFromToast(permission.sessionID, resolvedDirectory), - }, - }) - } + showPermissionNeededToast({ + permission, + directory: resolvedDirectory, + isViewed, + pendingIds: pendingPermissionToastIds, + show: (title, options) => toast.info(title, options), + openSession: openSessionFromToast, + }) } if (payload.type === "permission.replied") { @@ -1732,6 +1732,11 @@ export function SyncProvider(props: { }, [childStores, routingIndex]) // Configure child store manager + useEffect(() => { + if (isVSCodeRuntime()) return + void usePermissionStore.getState().hydrate().catch(() => undefined) + }, [props.sdk]) + useEffect(() => { const bootingDirs = new Set() diff --git a/packages/web/server/index.js b/packages/web/server/index.js index fe16cfc3..e78075a7 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -85,6 +85,7 @@ import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js import { createPushRuntime } from './lib/notifications/push-runtime.js'; import { createApnsRuntime } from './lib/notifications/apns-runtime.js'; import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; +import { createPermissionAutoAcceptRuntime } from './lib/permission-auto-accept/runtime.js'; import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js'; @@ -716,7 +717,7 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({ }); const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); -const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args); +const setAutoAcceptSession = (sessionId, enabled) => permissionAutoAcceptRuntime.setSessionPolicy(sessionId, enabled); clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge(); const sessionAssistRuntime = createSessionAssistRuntime({ @@ -771,6 +772,19 @@ const globalMessageStreamHub = createGlobalMessageStreamHub({ upstreamStallTimeoutMs: getUpstreamStallTimeoutMs, }); +const permissionAutoAcceptRuntime = createPermissionAutoAcceptRuntime({ + globalEventHub: globalMessageStreamHub, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + persistSettings, + broadcastGlobalUiEvent, +}); +permissionAutoAcceptRuntime.start(); +notificationTriggerRuntime.setGetIsSessionAutoAccepting( + (sessionId, directory) => permissionAutoAcceptRuntime.isSessionAutoAccepting(sessionId, directory), +); + const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), buildOpenCodeUrl, @@ -1470,6 +1484,7 @@ async function main(options = {}) { scheduledTasksRuntime, getOpenChamberEventClients: () => uiOpenChamberEventClients, writeSseEvent, + permissionAutoAcceptRuntime, }); const previewProxyRuntime = createPreviewProxyRuntime({ diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index bf736a1e..4e03861e 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -45,7 +45,7 @@ This module provides notification message preparation utilities for the web serv - Returned API: - `maybeSendPushForTrigger(payload)` - Owns: - - completion/error/question/permission trigger routing + - completion/error/question/permission trigger routing; permission suppression consults the authoritative permission-auto-accept runtime - session parent cache for subtask suppression - template resolution and fallback behavior - native notification fanout and web push payload fanout diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js index e544e147..7bdafc35 100644 --- a/packages/web/server/lib/notifications/runtime.js +++ b/packages/web/server/lib/notifications/runtime.js @@ -15,6 +15,10 @@ export const createNotificationTriggerRuntime = (deps) => { buildOpenCodeUrl, getOpenCodeAuthHeaders, } = deps; + let getIsSessionAutoAccepting = deps.getIsSessionAutoAccepting; + const setGetIsSessionAutoAccepting = (resolver) => { + getIsSessionAutoAccepting = typeof resolver === 'function' ? resolver : undefined; + }; // App-icon badge for native push: the set of DISTINCT collapse-ids (the push // `tag`, e.g. `ready-` / `permission-`) we've sent since @@ -609,7 +613,8 @@ export const createNotificationTriggerRuntime = (deps) => { // Client may be in Permission Auto-Accept for this session (or any // ancestor). Skip the whole notification path — the client responds // directly and the user has opted out of approval prompts. - if (await isSessionAutoAccepting(sessionId, notificationDirectory)) { + if (await (getIsSessionAutoAccepting?.(sessionId, notificationDirectory) + ?? isSessionAutoAccepting(sessionId, notificationDirectory))) { if (requestKey) notifiedPermissionRequests.add(requestKey); return; } @@ -622,7 +627,8 @@ export const createNotificationTriggerRuntime = (deps) => { const timer = setTimeout(async () => { pushPermissionDebounceTimers.delete(sessionId); - if (await isSessionAutoAccepting(sessionId, notificationDirectory)) { + if (await (getIsSessionAutoAccepting?.(sessionId, notificationDirectory) + ?? isSessionAutoAccepting(sessionId, notificationDirectory))) { if (requestKey) notifiedPermissionRequests.add(requestKey); return; } @@ -742,6 +748,7 @@ export const createNotificationTriggerRuntime = (deps) => { maybeSendPushForTrigger, setAutoAcceptSession, setGetIsWindowFocused, + setGetIsSessionAutoAccepting, clearPendingPushBadge, sendGoalSettlePush, }; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 2e44890e..e30a3a11 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -166,6 +166,7 @@ This module provides OpenCode server integration utilities for the web server ru - `readSettingsFromDiskMigrated()` - `writeSettingsToDisk(settings)` - `persistSettings(changes)` + - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`. ## Public exports (settings-helpers.js) - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping. diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 69686978..6d212645 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -998,6 +998,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/opencode') || req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || + req.path.startsWith('/api/permission-auto-accept') || req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/small-model') || req.path.startsWith('/api/goals') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 2591808c..b76a39b2 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -6,6 +6,7 @@ import { registerGitHubRoutes } from '../github/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; import { registerSessionFoldersRoutes } from '../session-folders/routes.js'; +import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js'; import { registerConfigEntityRoutes } from './config-entity-routes.js'; import { registerSettingsUtilityRoutes } from './core-routes.js'; import { registerProjectIconRoutes } from './project-icon-routes.js'; @@ -98,6 +99,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { scheduledTasksRuntime, getOpenChamberEventClients, writeSseEvent, + permissionAutoAcceptRuntime, } = routeDependencies; registerSettingsUtilityRoutes(app, { @@ -106,6 +108,8 @@ export const createFeatureRoutesRuntime = (dependencies) => { clientReloadDelayMs, }); + registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime); + registerOpenCodeRoutes(app, { crypto, clientReloadDelayMs, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index ee9cd2f0..cf34c0bc 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -184,6 +184,18 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') { result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled; } + if (candidate.permissionAutoAccept && typeof candidate.permissionAutoAccept === 'object' && !Array.isArray(candidate.permissionAutoAccept)) { + const sessions = {}; + const sourceSessions = candidate.permissionAutoAccept.sessions; + if (sourceSessions && typeof sourceSessions === 'object' && !Array.isArray(sourceSessions)) { + for (const [sessionId, enabled] of Object.entries(sourceSessions)) { + if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled; + } + } + result.permissionAutoAccept = { + sessions, + }; + } if (typeof candidate.desktopUiPassword === 'string') { result.desktopUiPassword = candidate.desktopUiPassword.trim(); } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 7560f857..782618f2 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -111,6 +111,20 @@ describe('settings helpers', () => { }); }); + it('sanitizes the persisted permission auto-accept policy', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ + permissionAutoAccept: { + sessions: { root: true, child: false, invalid: 'true' }, + }, + })).toEqual({ + permissionAutoAccept: { + sessions: { root: true, child: false }, + }, + }); + }); + it('accepts desktopUiPassword as a persisted shared setting', () => { const helpers = createTestHelpers(); diff --git a/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md b/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md new file mode 100644 index 00000000..ad574a66 --- /dev/null +++ b/packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md @@ -0,0 +1,34 @@ +# Permission Auto-Accept + +## Purpose + +This module owns the authoritative permission auto-accept policy for web, desktop, and mobile runtimes. Policy is persisted in OpenChamber settings so permission handling survives UI disconnects and server restarts. + +## Policy + +`permissionAutoAccept.sessions` contains explicit per-session boolean policies. + +Policy inheritance uses the nearest explicit session value. A child `false` therefore overrides a parent `true`; descendants without an explicit value inherit from their nearest configured ancestor. + +## Runtime + +`createPermissionAutoAcceptRuntime` loads and serializes policy writes, subscribes to the global OpenCode event hub, caches session lineage, retries transient replies, and reconciles pending permissions after startup, reconnect, and policy enablement. Enabling Auto-Accept for a session immediately accepts matching pending requests and keeps handling future requests without requiring a connected UI. + +Unknown lineage and failed policy loads fail closed. A failed pending-permission fetch is distinct from an empty successful response and never clears policy state. + +## Routes + +- `GET /api/permission-auto-accept` +- `PUT /api/permission-auto-accept/sessions/:sessionId` + +These are normal authenticated OpenChamber runtime routes. They must not be added to browser URL-token allowlists. + +## UI ownership + +`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. + +## Tests + +`runtime.test.js` covers restart persistence, nearest explicit subagent inheritance, missing-lineage lookup, retry/deduplication, and reconnect reconciliation. diff --git a/packages/web/server/lib/permission-auto-accept/runtime.js b/packages/web/server/lib/permission-auto-accept/runtime.js new file mode 100644 index 00000000..e492688e --- /dev/null +++ b/packages/web/server/lib/permission-auto-accept/runtime.js @@ -0,0 +1,263 @@ +const SETTINGS_KEY = 'permissionAutoAccept'; +const RETRY_DELAYS_MS = [0, 250, 1000]; +const REQUEST_TIMEOUT_MS = 5000; +const SESSION_CACHE_LIMIT = 10000; + +const normalizePolicy = (value) => { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const sessions = {}; + const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions) + ? Object.entries(source.sessions) + : []; + for (const [sessionId, enabled] of entries) { + if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled; + } + return { sessions }; +}; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createPermissionAutoAcceptRuntime({ + globalEventHub, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + persistSettings, + broadcastGlobalUiEvent, + fetchImpl = fetch, + retryDelaysMs = RETRY_DELAYS_MS, + requestTimeoutMs = REQUEST_TIMEOUT_MS, +}) { + let policy = normalizePolicy(); + let loaded = false; + let loadPromise = null; + let writePromise = Promise.resolve(); + const sessions = new Map(); + const inFlight = new Map(); + const reconcilePromises = new Map(); + + const snapshot = () => ({ + sessions: { ...policy.sessions }, + }); + + const load = async () => { + if (loaded) return snapshot(); + if (!loadPromise) { + loadPromise = readSettingsFromDiskMigrated() + .then((settings) => { + policy = normalizePolicy(settings?.[SETTINGS_KEY]); + loaded = true; + return snapshot(); + }) + .finally(() => { loadPromise = null; }); + } + return loadPromise; + }; + + const persistUpdate = (update) => { + writePromise = writePromise.then(async () => { + const next = update(policy); + await persistSettings({ [SETTINGS_KEY]: next }); + policy = next; + loaded = true; + broadcastGlobalUiEvent?.({ + type: 'openchamber:permission-auto-accept.updated', + properties: snapshot(), + }); + return snapshot(); + }); + return writePromise; + }; + + const setSessionPolicy = async (sessionId, enabled, directory) => { + if (typeof sessionId !== 'string' || !sessionId.trim()) throw new TypeError('sessionId is required'); + if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean'); + await load(); + const result = await persistUpdate((current) => ({ + ...current, + sessions: { ...current.sessions, [sessionId.trim()]: enabled }, + })); + if (enabled) await reconcilePending({ directories: [directory] }); + return result; + }; + + const rememberSession = (info, directoryHint) => { + if (!info || typeof info.id !== 'string' || !info.id) return; + sessions.set(info.id, { + parentID: typeof info.parentID === 'string' && info.parentID ? info.parentID : null, + directory: typeof info.directory === 'string' && info.directory ? info.directory : directoryHint, + }); + if (sessions.size > SESSION_CACHE_LIMIT) { + sessions.delete(sessions.keys().next().value); + } + }; + + const request = async (path, { directory, method = 'GET', body } = {}) => { + const url = new URL(buildOpenCodeUrl(path, '')); + if (directory) url.searchParams.set('directory', directory); + const response = await fetchImpl(url, { + method, + headers: { + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...getOpenCodeAuthHeaders(), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(requestTimeoutMs), + }); + if (!response.ok) { + const error = new Error(`OpenCode request failed (${response.status})`); + error.status = response.status; + throw error; + } + return response.json().catch(() => null); + }; + + const getSession = async (sessionId, directory) => { + const cached = sessions.get(sessionId); + if (cached) return cached; + const info = await request(`/session/${encodeURIComponent(sessionId)}`, { directory }); + rememberSession(info?.data ?? info, directory); + return sessions.get(sessionId) ?? null; + }; + + const isSessionAutoAccepting = async (sessionId, directory) => { + await load(); + const seen = new Set(); + let current = sessionId; + let currentDirectory = directory; + while (current && !seen.has(current)) { + if (Object.hasOwn(policy.sessions, current)) return policy.sessions[current] === true; + seen.add(current); + let info; + try { + info = await getSession(current, currentDirectory); + } catch { + return false; + } + current = info?.parentID ?? null; + currentDirectory = info?.directory ?? currentDirectory; + } + return false; + }; + + const replyOnce = async (permission, directory) => { + if (!permission?.id || !permission?.sessionID) return false; + await load(); + if (!(await isSessionAutoAccepting(permission.sessionID, directory))) return false; + await request(`/permission/${encodeURIComponent(permission.id)}/reply`, { + directory, + method: 'POST', + body: { reply: 'once' }, + }); + return true; + }; + + const processPermission = (permission, directory) => { + if (!permission?.id) return Promise.resolve(false); + const key = permission.id; + const existing = inFlight.get(key); + if (existing) return existing; + const task = (async () => { + for (const delay of retryDelaysMs) { + if (delay > 0) await wait(delay); + try { + return await replyOnce(permission, directory); + } catch (error) { + if (error?.status === 404) return true; + } + } + return false; + })().finally(() => inFlight.delete(key)); + inFlight.set(key, task); + return task; + }; + + async function reconcilePending({ directories = [] } = {}) { + const normalizedDirectories = Array.from(new Set( + directories.filter((directory) => typeof directory === 'string' && directory.trim()).map((directory) => directory.trim()), + )); + const key = normalizedDirectories.length > 0 ? normalizedDirectories.join('\n') : 'all'; + const existing = reconcilePromises.get(key); + if (existing) return existing; + const task = (async () => { + await load(); + const scopes = [undefined, ...normalizedDirectories]; + const pendingById = new Map(); + for (const directory of scopes) { + let payload; + try { + payload = await request('/permission', { directory }); + } catch { + continue; + } + const pending = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : null; + if (!pending) continue; + for (const permission of pending) { + if (!permission?.id) continue; + pendingById.set(permission.id, { permission, directory: permission.directory ?? directory }); + } + } + await Promise.all(Array.from(pendingById.values()).map(({ permission, directory }) => + processPermission(permission, directory))); + })().finally(() => { reconcilePromises.delete(key); }); + reconcilePromises.set(key, task); + return task; + } + + const processEvent = (event) => { + const raw = event?.payload; + const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw; + const directory = typeof event?.directory === 'string' && event.directory !== 'global' ? event.directory : undefined; + if (payload?.type === 'session.created' || payload?.type === 'session.updated') { + rememberSession(payload.properties?.info, directory); + return; + } + if (payload?.type === 'permission.asked') { + void processPermission(payload.properties, directory); + } + }; + + const start = () => { + const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent); + const unsubscribeStatus = globalEventHub.subscribeStatus((status) => { + if (status?.type === 'connect') void reconcilePending(); + }); + void load().then(() => reconcilePending()).catch((error) => { + console.warn('[permission-auto-accept] failed to load policy:', error?.message ?? error); + }); + return () => { + unsubscribeEvent(); + unsubscribeStatus(); + }; + }; + + return { + snapshot, + load, + setSessionPolicy, + isSessionAutoAccepting, + processPermission, + reconcilePending, + start, + }; +} + +export function registerPermissionAutoAcceptRoutes(app, runtime) { + app.get('/api/permission-auto-accept', async (_req, res) => { + try { + res.json(await runtime.load()); + } catch (error) { + res.status(500).json({ error: error?.message ?? 'Failed to load permission auto-accept policy' }); + } + }); + + app.put('/api/permission-auto-accept/sessions/:sessionId', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory : undefined; + res.json(await runtime.setSessionPolicy(req.params.sessionId, req.body?.enabled, directory)); + } catch (error) { + res.status(error instanceof TypeError ? 400 : 500).json({ error: error?.message }); + } + }); +} diff --git a/packages/web/server/lib/permission-auto-accept/runtime.test.js b/packages/web/server/lib/permission-auto-accept/runtime.test.js new file mode 100644 index 00000000..f47bc0ad --- /dev/null +++ b/packages/web/server/lib/permission-auto-accept/runtime.test.js @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createPermissionAutoAcceptRuntime } from './runtime.js'; + +const createRuntime = ({ stored, fetchImpl, retryDelaysMs = [0] } = {}) => { + let settings = stored ?? { permissionAutoAccept: { sessions: {} } }; + let eventHandler; + let statusHandler; + const runtime = createPermissionAutoAcceptRuntime({ + globalEventHub: { + subscribeEvent(handler) { eventHandler = handler; return () => {}; }, + subscribeStatus(handler) { statusHandler = handler; return () => {}; }, + }, + buildOpenCodeUrl: (path) => `http://opencode.test${path}`, + getOpenCodeAuthHeaders: () => ({}), + readSettingsFromDiskMigrated: async () => settings, + persistSettings: async (changes) => { settings = { ...settings, ...changes }; }, + fetchImpl: fetchImpl ?? vi.fn(async () => new Response('[]')), + retryDelaysMs, + }); + runtime.start(); + return { + runtime, + getSettings: () => settings, + emit: (payload, directory = '/project') => eventHandler({ payload, directory }), + connect: () => statusHandler({ type: 'connect' }), + }; +}; + +const flush = async () => { + for (let index = 0; index < 20; index += 1) await Promise.resolve(); +}; + +describe('permission auto-accept runtime', () => { + it('persists explicit session policies across runtime restarts', async () => { + const first = createRuntime(); + await first.runtime.setSessionPolicy('root', true); + + const second = createRuntime({ stored: first.getSettings() }); + await expect(second.runtime.load()).resolves.toEqual({ + sessions: { root: true }, + }); + }); + + it('uses nearest explicit ancestor policy for subagents', async () => { + const { runtime, emit } = createRuntime({ + stored: { permissionAutoAccept: { sessions: { root: true, child: false } } }, + }); + emit({ type: 'session.created', properties: { info: { id: 'child', parentID: 'root' } } }); + emit({ type: 'session.created', properties: { info: { id: 'grandchild', parentID: 'child' } } }); + await expect(runtime.isSessionAutoAccepting('grandchild', '/project')).resolves.toBe(false); + await runtime.setSessionPolicy('child', true); + await expect(runtime.isSessionAutoAccepting('grandchild', '/project')).resolves.toBe(true); + }); + + it('fetches missing subagent lineage before replying', async () => { + const fetchImpl = vi.fn(async (url, init = {}) => { + const path = new URL(url).pathname; + if (path === '/permission') return new Response('[]'); + if (path === '/session/child') return Response.json({ id: 'child', parentID: 'root', directory: '/project' }); + if (init.method === 'POST') return Response.json({}); + return new Response('', { status: 404 }); + }); + const { runtime } = createRuntime({ + stored: { permissionAutoAccept: { sessions: { root: true } } }, + fetchImpl, + }); + await expect(runtime.processPermission({ id: 'perm', sessionID: 'child' }, '/project')).resolves.toBe(true); + expect(fetchImpl.mock.calls.some(([url, init]) => new URL(url).pathname === '/permission/perm/reply' && init.method === 'POST')).toBe(true); + }); + + it('retries a transient reply failure and deduplicates concurrent events', async () => { + let replyAttempts = 0; + const fetchImpl = vi.fn(async (url, init = {}) => { + const path = new URL(url).pathname; + if (path === '/permission') return new Response('[]'); + if (path === '/permission/perm/reply' && init.method === 'POST') { + replyAttempts += 1; + return replyAttempts === 1 ? new Response('', { status: 503 }) : Response.json({}); + } + return Response.json({ id: 'root' }); + }); + const { runtime } = createRuntime({ + stored: { permissionAutoAccept: { sessions: { root: true } } }, + fetchImpl, + retryDelaysMs: [0, 0], + }); + const permission = { id: 'perm', sessionID: 'root' }; + const first = runtime.processPermission(permission, '/project'); + const second = runtime.processPermission(permission, '/project'); + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(replyAttempts).toBe(2); + }); + + it('reconciles pending permissions after reconnect', async () => { + const fetchImpl = vi.fn(async (url, init = {}) => { + const path = new URL(url).pathname; + if (path === '/permission') return Response.json([{ id: 'pending', sessionID: 'root' }]); + if (path === '/permission/pending/reply' && init.method === 'POST') return Response.json({}); + return Response.json({ id: 'root' }); + }); + const { connect } = createRuntime({ + stored: { permissionAutoAccept: { sessions: { root: true } } }, + fetchImpl, + }); + connect(); + await flush(); + expect(fetchImpl.mock.calls.some(([url]) => new URL(url).pathname === '/permission/pending/reply')).toBe(true); + }); + + it('accepts existing pending permissions when a session policy is enabled', async () => { + const fetchImpl = vi.fn(async (url, init = {}) => { + const parsed = new URL(url); + const path = parsed.pathname; + if (path === '/permission') { + return parsed.searchParams.get('directory') === '/project' + ? Response.json([ + { id: 'root-pending', sessionID: 'root' }, + { id: 'other-pending', sessionID: 'other' }, + ]) + : Response.json([]); + } + if (path === '/permission/root-pending/reply' && init.method === 'POST') return Response.json({}); + if (path === '/session/other') return Response.json({ id: 'other' }); + return new Response('', { status: 404 }); + }); + const { runtime } = createRuntime({ fetchImpl }); + + await runtime.setSessionPolicy('root', true, '/project'); + + const replyPaths = fetchImpl.mock.calls + .filter(([, init]) => init?.method === 'POST') + .map(([url]) => new URL(url).pathname); + expect(replyPaths).toEqual(['/permission/root-pending/reply']); + expect(fetchImpl.mock.calls.some(([url]) => new URL(url).searchParams.get('directory') === '/project')).toBe(true); + expect(await runtime.load()).toEqual({ sessions: { root: true } }); + }); +});