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
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 15:03:16 +03:00
committed by GitHub
parent 3d90eddcaf
commit d738d41574
18 changed files with 795 additions and 387 deletions
@@ -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.
@@ -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') ||
@@ -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,
@@ -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();
}
@@ -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();