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
138 lines
6.0 KiB
JavaScript
138 lines
6.0 KiB
JavaScript
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 } });
|
|
});
|
|
});
|