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:
committed by
GitHub
parent
3d90eddcaf
commit
d738d41574
@@ -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 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user