import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createOpenCodePermissionAppRequestId, createRuntimePermissionRequestStore, normalizeOpenCodePermissionRequest, RuntimePermissionAnswerService, RuntimePermissionReconciler, type OpenCodeNormalizedPermissionRequest, type OpenCodePermissionClientPort, type OpenCodePermissionDecision, type RuntimePermissionDiagnosticEvent, type RuntimePermissionDiagnosticsSink, type RuntimePermissionLaunchMemberState, type RuntimePermissionLaunchStateStore, type RuntimePermissionRequestRecord, } from '../../../../src/main/services/team/opencode/permissions/RuntimePermission'; describe('normalizeOpenCodePermissionRequest', () => { it('normalizes v1.14 permission payload', () => { const normalized = normalizeOpenCodePermissionRequest({ id: 'perm_1', sessionID: 'ses_1', permission: 'bash', patterns: ['npm test'], metadata: { reason: 'test command' }, always: ['npm test'], tool: { messageID: 'msg_1', callID: 'call_1', name: 'bash_tool' }, }); expect(normalized).toMatchObject({ requestId: 'perm_1', sessionId: 'ses_1', permission: 'bash', toolName: 'bash_tool', toolCallId: 'call_1', messageId: 'msg_1', rawShape: 'v1.14', title: 'OpenCode wants bash permission for npm test', description: 'Patterns: npm test\nAlways candidates: npm test\nReason: test command', }); }); it('normalizes legacy local permission payload', () => { const normalized = normalizeOpenCodePermissionRequest({ requestID: 'perm_legacy', sessionID: 'ses_legacy', tool: 'bash', title: 'Run command', kind: 'command', }); expect(normalized).toMatchObject({ requestId: 'perm_legacy', sessionId: 'ses_legacy', toolName: 'bash', title: 'Run command', description: 'command', rawShape: 'legacy', }); }); it('returns null for invalid payload without ids', () => { expect(normalizeOpenCodePermissionRequest({ permission: 'bash' })).toBeNull(); }); }); describe('RuntimePermissionRequestStore and services', () => { let tempDir: string; let now: Date; let store: ReturnType; let client: FakePermissionClient; let launchState: FakeLaunchStateStore; let diagnostics: FakeDiagnosticsSink; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-permissions-')); now = new Date('2026-04-21T12:00:00.000Z'); store = createRuntimePermissionRequestStore({ filePath: path.join(tempDir, 'permissions.json'), clock: () => now, }); client = new FakePermissionClient(); launchState = new FakeLaunchStateStore('run-1'); diagnostics = new FakeDiagnosticsSink(); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); it('rejects approval for stale run', async () => { await store.upsertPending(permissionRecord({ runId: 'run-current' })); const service = answerService(); await expect( service.answer({ appRequestId: 'opencode:run-current:perm_1', runId: 'run-old', decision: 'once', }) ).resolves.toMatchObject({ ok: false, diagnostics: ['Stale runId rejected'], }); expect(client.answers).toEqual([]); expect(diagnostics.append).toHaveBeenCalledWith( expect.objectContaining({ type: 'opencode_permission_stale_answer_rejected', severity: 'warning', }) ); }); it('handles duplicate answer click as idempotent UI action', async () => { await store.upsertPending(permissionRecord()); const service = answerService(); await expect( service.answer({ appRequestId: 'opencode:run-1:perm_1', runId: 'run-1', decision: 'once', }) ).resolves.toMatchObject({ ok: true, diagnostics: [], }); await expect( service.answer({ appRequestId: 'opencode:run-1:perm_1', runId: 'run-1', decision: 'once', }) ).resolves.toMatchObject({ ok: true, diagnostics: ['Permission already answered'], }); expect(client.answers).toEqual([ { requestId: 'perm_1', sessionId: 'ses_1', decision: 'once', message: undefined, }, ]); await expect(store.get('opencode:run-1:perm_1')).resolves.toMatchObject({ state: 'answered', decision: 'once', answerOrigin: 'user_click', answeredAt: '2026-04-21T12:00:00.000Z', }); }); it('projects reject side effects for same-session pending permissions', async () => { const firstId = createOpenCodePermissionAppRequestId('run-1', 'perm_1'); const secondId = createOpenCodePermissionAppRequestId('run-1', 'perm_2'); await store.upsertPending(permissionRecord({ appRequestId: firstId, providerRequestId: 'perm_1' })); await store.upsertPending( permissionRecord({ appRequestId: secondId, providerRequestId: 'perm_2', patterns: ['npm run lint'], }) ); launchState.members.set('alice', { launchState: 'runtime_pending_permission', bootstrapConfirmed: false, pendingPermissionRequestIds: [firstId, secondId], }); await expect( answerService().answer({ appRequestId: firstId, runId: 'run-1', decision: 'reject', }) ).resolves.toMatchObject({ ok: true, diagnostics: [], }); expect(client.answers).toEqual([ { requestId: 'perm_1', sessionId: 'ses_1', decision: 'reject', message: undefined, }, ]); await expect(store.get(firstId)).resolves.toMatchObject({ state: 'answered', decision: 'reject', answerOrigin: 'user_click', }); await expect(store.get(secondId)).resolves.toMatchObject({ state: 'answered', decision: 'reject', answerOrigin: 'provider_side_effect_projection', }); expect(launchState.members.get('alice')).toMatchObject({ launchState: 'runtime_pending_bootstrap', pendingPermissionRequestIds: [], }); }); it('keeps confirmed_alive after answering the last pending permission', async () => { await store.upsertPending(permissionRecord()); launchState.members.set('alice', { launchState: 'confirmed_alive', bootstrapConfirmed: true, pendingPermissionRequestIds: ['opencode:run-1:perm_1'], }); await expect( answerService().answer({ appRequestId: 'opencode:run-1:perm_1', runId: 'run-1', decision: 'once', }) ).resolves.toMatchObject({ ok: true, diagnostics: [], }); expect(launchState.members.get('alice')).toMatchObject({ launchState: 'confirmed_alive', pendingPermissionRequestIds: [], }); }); it('projects always only for exact pattern matches and reopens wrong projections on provider poll', async () => { const firstId = createOpenCodePermissionAppRequestId('run-1', 'perm_1'); const secondId = createOpenCodePermissionAppRequestId('run-1', 'perm_2'); const thirdId = createOpenCodePermissionAppRequestId('run-1', 'perm_3'); await store.upsertPending( permissionRecord({ appRequestId: firstId, providerRequestId: 'perm_1', patterns: ['npm test'], alwaysPatterns: ['npm test'], }) ); await store.upsertPending( permissionRecord({ appRequestId: secondId, providerRequestId: 'perm_2', patterns: ['npm test'], }) ); await store.upsertPending( permissionRecord({ appRequestId: thirdId, providerRequestId: 'perm_3', patterns: ['npm run lint'], }) ); launchState.members.set('alice', { launchState: 'runtime_pending_permission', bootstrapConfirmed: false, pendingPermissionRequestIds: [firstId, secondId, thirdId], }); await expect( answerService().answer({ appRequestId: firstId, runId: 'run-1', decision: 'always', }) ).resolves.toMatchObject({ ok: true, diagnostics: [], }); await expect(store.get(firstId)).resolves.toMatchObject({ state: 'answered', decision: 'always', answerOrigin: 'user_click', }); await expect(store.get(secondId)).resolves.toMatchObject({ state: 'answered', decision: 'always', answerOrigin: 'provider_side_effect_projection', }); await expect(store.get(thirdId)).resolves.toMatchObject({ state: 'pending', decision: null, }); expect(launchState.members.get('alice')).toMatchObject({ pendingPermissionRequestIds: [thirdId], }); client.pending = [ normalizedPermission({ requestId: 'perm_2', sessionId: 'ses_1', patterns: ['npm test'], }), normalizedPermission({ requestId: 'perm_3', sessionId: 'ses_1', patterns: ['npm run lint'], }), ]; const reconciler = new RuntimePermissionReconciler( client, store, launchState, diagnostics, () => now ); await reconciler.reconcile({ runId: 'run-1', teamName: 'team-a', sessionsByOpenCodeId: new Map([ ['ses_1', { runId: 'run-1', memberName: 'alice', runtimeSessionId: 'ses_1' }], ]), }); await expect(store.get(secondId)).resolves.toMatchObject({ state: 'pending', decision: null, answerOrigin: null, }); expect(launchState.members.get('alice')).toMatchObject({ pendingPermissionRequestIds: [secondId, thirdId], }); }); it('does not confirm member alive while permission is pending before bootstrap', async () => { client.pending = [ normalizedPermission({ requestId: 'perm_1', sessionId: 'ses_1', toolName: 'bash', patterns: ['npm test'], alwaysPatterns: ['npm test'], }), ]; const reconciler = new RuntimePermissionReconciler( client, store, launchState, diagnostics, () => now ); await reconciler.reconcile({ runId: 'run-1', teamName: 'team-a', sessionsByOpenCodeId: new Map([ ['ses_1', { runId: 'run-1', memberName: 'alice', runtimeSessionId: 'ses_1' }], ]), }); expect(await store.listPendingForTeam('team-a')).toEqual([ expect.objectContaining({ appRequestId: 'opencode:run-1:perm_1', state: 'pending', memberName: 'alice', patterns: ['npm test'], alwaysPatterns: ['npm test'], }), ]); expect(launchState.members.get('alice')).toMatchObject({ launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['opencode:run-1:perm_1'], }); }); it('expires local pending requests that disappeared from provider', async () => { await store.upsertPending(permissionRecord()); await launchState.updateMember('team-a', 'alice', (member) => ({ ...member, launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['opencode:run-1:perm_1'], })); client.pending = []; const reconciler = new RuntimePermissionReconciler( client, store, launchState, diagnostics, () => now ); await reconciler.reconcile({ runId: 'run-1', teamName: 'team-a', sessionsByOpenCodeId: new Map(), }); await expect(store.get('opencode:run-1:perm_1')).resolves.toMatchObject({ state: 'provider_missing', lastError: 'Provider no longer lists this permission request', }); expect(launchState.members.get('alice')).toMatchObject({ launchState: 'runtime_pending_bootstrap', pendingPermissionRequestIds: [], }); expect(diagnostics.append).toHaveBeenCalledWith( expect.objectContaining({ type: 'opencode_permission_requests_expired', data: { expiredCount: 1 }, }) ); }); it('quarantines invalid permission store data', async () => { const filePath = path.join(tempDir, 'invalid-permissions.json'); await fs.writeFile( filePath, JSON.stringify({ schemaVersion: 1, updatedAt: '2026-04-21T12:00:00.000Z', data: [{ appRequestId: '' }], }), 'utf8' ); const invalidStore = createRuntimePermissionRequestStore({ filePath, clock: () => now, }); await expect(invalidStore.list()).rejects.toMatchObject({ reason: 'invalid_data', }); }); function answerService(): RuntimePermissionAnswerService { return new RuntimePermissionAnswerService( store, launchState, client, diagnostics, () => now ); } }); function permissionRecord( overrides: Partial = {} ): RuntimePermissionRequestRecord { return { appRequestId: createOpenCodePermissionAppRequestId(overrides.runId ?? 'run-1', 'perm_1'), providerRequestId: 'perm_1', runId: 'run-1', teamName: 'team-a', memberName: 'alice', providerId: 'opencode', runtimeSessionId: 'ses_1', permission: 'bash', patterns: [], alwaysPatterns: [], toolName: 'bash', title: 'Run command', description: null, state: 'pending', rawShape: 'v1.14', requestedAt: '2026-04-21T12:00:00.000Z', updatedAt: '2026-04-21T12:00:00.000Z', expiresAt: '2026-04-21T12:15:00.000Z', answeredAt: null, decision: null, answerOrigin: null, lastError: null, ...overrides, }; } function normalizedPermission( overrides: Partial = {} ): OpenCodeNormalizedPermissionRequest { return { requestId: 'perm_1', sessionId: 'ses_1', permission: 'bash', patterns: [], alwaysPatterns: [], toolName: 'bash', toolCallId: null, messageId: null, title: 'Run command', description: null, metadata: {}, rawShape: 'v1.14', raw: {}, ...overrides, }; } class FakePermissionClient implements OpenCodePermissionClientPort { pending: OpenCodeNormalizedPermissionRequest[] = []; answers: Array<{ requestId: string; sessionId: string; decision: OpenCodePermissionDecision; message?: string; }> = []; async listPendingPermissions(): Promise { return this.pending; } async answerPermission(input: { requestId: string; sessionId: string; decision: OpenCodePermissionDecision; message?: string; }): Promise { this.answers.push(input); } } class FakeLaunchStateStore implements RuntimePermissionLaunchStateStore { readonly members = new Map(); constructor(public runId: string | null) {} async read(): Promise<{ runId: string | null }> { return { runId: this.runId }; } async updateMember( _teamName: string, memberName: string, updater: (member: RuntimePermissionLaunchMemberState) => RuntimePermissionLaunchMemberState ): Promise { const current = this.members.get(memberName) ?? { launchState: 'runtime_pending_bootstrap', bootstrapConfirmed: false, pendingPermissionRequestIds: [], }; this.members.set(memberName, updater(current)); } } class FakeDiagnosticsSink implements RuntimePermissionDiagnosticsSink { readonly append = vi.fn(async (_event: RuntimePermissionDiagnosticEvent) => {}); }