2026-07-12 15:03:16 +03:00
|
|
|
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 },
|
2026-07-21 20:52:20 +03:00
|
|
|
revision: 1,
|
2026-07-12 15:03:16 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
it('increments the authoritative policy revision', async () => {
|
|
|
|
|
const { runtime, getSettings } = createRuntime();
|
|
|
|
|
|
|
|
|
|
await expect(runtime.setSessionPolicy('root', true)).resolves.toMatchObject({ revision: 1 });
|
|
|
|
|
await expect(runtime.setSessionPolicy('child', false)).resolves.toMatchObject({ revision: 2 });
|
|
|
|
|
expect(getSettings().permissionAutoAccept.revision).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 15:03:16 +03:00
|
|
|
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);
|
2026-07-21 20:52:20 +03:00
|
|
|
expect(await runtime.load()).toEqual({ sessions: { root: true }, revision: 1 });
|
2026-07-12 15:03:16 +03:00
|
|
|
});
|
|
|
|
|
});
|