agent-ecosystem/test/main/services/team/RuntimePermission.test.ts

545 lines
15 KiB
TypeScript

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<typeof createRuntimePermissionRequestStore>;
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> = {}
): 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> = {}
): 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<OpenCodeNormalizedPermissionRequest[]> {
return this.pending;
}
async answerPermission(input: {
requestId: string;
sessionId: string;
decision: OpenCodePermissionDecision;
message?: string;
}): Promise<void> {
this.answers.push(input);
}
}
class FakeLaunchStateStore implements RuntimePermissionLaunchStateStore {
readonly members = new Map<string, RuntimePermissionLaunchMemberState>();
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<void> {
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) => {});
}