388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
collectOpenCodeRuntimeApprovalEntries,
|
|
openCodeApprovalToolInput,
|
|
openCodeApprovalToolName,
|
|
} from '../../../../src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider';
|
|
import {
|
|
RuntimeToolApprovalCoordinator,
|
|
type RuntimeToolApprovalEntry,
|
|
type RuntimeToolApprovalEvent,
|
|
} from '../../../../src/main/services/team/approvals/RuntimeToolApprovalCoordinator';
|
|
import {
|
|
DEFAULT_TOOL_APPROVAL_SETTINGS,
|
|
type ToolApprovalSettings,
|
|
} from '../../../../src/shared/types/team';
|
|
|
|
import type { TeamRuntimeMemberLaunchEvidence } from '../../../../src/main/services/team/runtime';
|
|
|
|
function settings(overrides: Partial<ToolApprovalSettings> = {}): ToolApprovalSettings {
|
|
return {
|
|
...DEFAULT_TOOL_APPROVAL_SETTINGS,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function approvalEntry(overrides: Partial<RuntimeToolApprovalEntry> = {}): RuntimeToolApprovalEntry {
|
|
const approval = overrides.approval ?? {
|
|
requestId: 'opencode:run-1:perm-1',
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
providerId: 'opencode' as const,
|
|
source: 'alice',
|
|
toolName: 'Bash',
|
|
toolInput: { command: 'npm test' },
|
|
receivedAt: '2026-05-22T10:00:00.000Z',
|
|
runtimePermission: {
|
|
providerId: 'opencode' as const,
|
|
laneId: 'primary',
|
|
memberName: 'alice',
|
|
providerRequestId: 'perm-1',
|
|
sessionId: 'ses-1',
|
|
},
|
|
};
|
|
return {
|
|
providerId: 'opencode',
|
|
approval,
|
|
providerRequestId: 'perm-1',
|
|
laneId: 'primary',
|
|
memberName: 'alice',
|
|
cwd: '/repo',
|
|
expectedMembers: [
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('RuntimeToolApprovalCoordinator', () => {
|
|
let currentSettings: ToolApprovalSettings;
|
|
let events: RuntimeToolApprovalEvent[];
|
|
let answers: { requestId: string; allow: boolean; message?: string }[];
|
|
let coordinator: RuntimeToolApprovalCoordinator;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
currentSettings = settings();
|
|
events = [];
|
|
answers = [];
|
|
coordinator = new RuntimeToolApprovalCoordinator({
|
|
getSettings: () => currentSettings,
|
|
answerApproval: async ({ entry, allow, message }) => {
|
|
answers.push({ requestId: entry.approval.requestId, allow, message });
|
|
},
|
|
emitApprovalEvent: (event) => {
|
|
events.push(event);
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
coordinator.dispose();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('deduplicates pending runtime approvals by app request id', () => {
|
|
const entry = approvalEntry();
|
|
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
|
|
|
|
expect(coordinator.size('team-a')).toBe(1);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ requestId: 'opencode:run-1:perm-1' });
|
|
});
|
|
|
|
it('auto-allows matching categories without emitting a manual prompt', async () => {
|
|
currentSettings = settings({ autoAllowSafeBash: true });
|
|
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(coordinator.size()).toBe(0);
|
|
expect(answers).toEqual([{ requestId: 'opencode:run-1:perm-1', allow: true }]);
|
|
expect(events).toEqual([
|
|
expect.objectContaining({
|
|
autoResolved: true,
|
|
reason: 'auto_allow_category',
|
|
requestId: 'opencode:run-1:perm-1',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('resolves timeout decisions through the provider answer callback', async () => {
|
|
currentSettings = settings({ timeoutAction: 'deny', timeoutSeconds: 5 });
|
|
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
expect(answers).toEqual([
|
|
{
|
|
requestId: 'opencode:run-1:perm-1',
|
|
allow: false,
|
|
message: 'Timed out - auto-denied by settings',
|
|
},
|
|
]);
|
|
expect(events.at(-1)).toMatchObject({
|
|
autoResolved: true,
|
|
reason: 'timeout_deny',
|
|
requestId: 'opencode:run-1:perm-1',
|
|
});
|
|
});
|
|
|
|
it('keeps timeout-resolved approvals pending when provider answer fails', async () => {
|
|
currentSettings = settings({ timeoutAction: 'deny', timeoutSeconds: 5 });
|
|
let failNextAnswer = true;
|
|
coordinator.dispose();
|
|
coordinator = new RuntimeToolApprovalCoordinator({
|
|
getSettings: () => currentSettings,
|
|
answerApproval: async ({ entry, allow, message }) => {
|
|
if (failNextAnswer) {
|
|
failNextAnswer = false;
|
|
throw new Error('bridge unavailable');
|
|
}
|
|
answers.push({ requestId: entry.approval.requestId, allow, message });
|
|
},
|
|
emitApprovalEvent: (event) => {
|
|
events.push(event);
|
|
},
|
|
});
|
|
|
|
const entry = approvalEntry();
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry);
|
|
expect(events.filter((event) => 'autoResolved' in event)).toEqual([]);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
expect(answers).toEqual([
|
|
{
|
|
requestId: 'opencode:run-1:perm-1',
|
|
allow: false,
|
|
message: 'Timed out - auto-denied by settings',
|
|
},
|
|
]);
|
|
expect(events.at(-1)).toMatchObject({
|
|
autoResolved: true,
|
|
reason: 'timeout_deny',
|
|
requestId: 'opencode:run-1:perm-1',
|
|
});
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined();
|
|
});
|
|
|
|
it('removes stale lane approvals when runtime state no longer reports them', () => {
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
|
|
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, []);
|
|
|
|
expect(coordinator.size()).toBe(0);
|
|
expect(events.at(-1)).toMatchObject({
|
|
autoResolved: true,
|
|
reason: 'runtime_resolved',
|
|
requestId: 'opencode:run-1:perm-1',
|
|
});
|
|
});
|
|
|
|
it('keeps other member approvals when runtime sync is scoped to one member', () => {
|
|
const alice = approvalEntry();
|
|
const bob = approvalEntry({
|
|
providerRequestId: 'perm-bob',
|
|
memberName: 'bob',
|
|
approval: {
|
|
requestId: 'opencode:run-1:perm-bob',
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
providerId: 'opencode',
|
|
source: 'bob',
|
|
toolName: 'Bash',
|
|
toolInput: { command: 'pnpm test' },
|
|
receivedAt: '2026-05-22T10:00:00.000Z',
|
|
runtimePermission: {
|
|
providerId: 'opencode',
|
|
laneId: 'primary',
|
|
memberName: 'bob',
|
|
providerRequestId: 'perm-bob',
|
|
sessionId: 'ses-bob',
|
|
},
|
|
},
|
|
});
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [alice, bob]);
|
|
|
|
coordinator.sync(
|
|
{ teamName: 'team-a', runId: 'run-1', laneId: 'primary', memberNames: ['alice'] },
|
|
[alice]
|
|
);
|
|
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-bob')).toBe(bob);
|
|
expect(coordinator.size('team-a')).toBe(2);
|
|
expect(
|
|
events.some(
|
|
(event) =>
|
|
'autoResolved' in event &&
|
|
event.requestId === 'opencode:run-1:perm-bob' &&
|
|
event.reason === 'runtime_resolved'
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects stale UI responses by run id', async () => {
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
|
|
|
|
await expect(
|
|
coordinator.respond('team-a', 'run-old', 'opencode:run-1:perm-1', true)
|
|
).rejects.toThrow('Stale approval: runId mismatch');
|
|
expect(answers).toEqual([]);
|
|
});
|
|
|
|
it('keeps manual approvals pending when provider answer fails so users can retry', async () => {
|
|
let failNextAnswer = true;
|
|
coordinator.dispose();
|
|
coordinator = new RuntimeToolApprovalCoordinator({
|
|
getSettings: () => currentSettings,
|
|
answerApproval: async ({ entry, allow, message }) => {
|
|
if (failNextAnswer) {
|
|
failNextAnswer = false;
|
|
throw new Error('bridge unavailable');
|
|
}
|
|
answers.push({ requestId: entry.approval.requestId, allow, message });
|
|
},
|
|
emitApprovalEvent: (event) => {
|
|
events.push(event);
|
|
},
|
|
});
|
|
|
|
const entry = approvalEntry();
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
|
|
|
|
await expect(
|
|
coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true)
|
|
).rejects.toThrow('bridge unavailable');
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry);
|
|
expect(coordinator.size('team-a')).toBe(1);
|
|
|
|
await expect(
|
|
coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true, 'retry')
|
|
).resolves.toBe(true);
|
|
expect(answers).toEqual([
|
|
{ requestId: 'opencode:run-1:perm-1', allow: true, message: 'retry' },
|
|
]);
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined();
|
|
});
|
|
|
|
it('leaves an in-flight approval tracked when a duplicate UI response arrives', async () => {
|
|
let releaseAnswer!: () => void;
|
|
let answerStarted!: () => void;
|
|
const answerStartedPromise = new Promise<void>((resolve) => {
|
|
answerStarted = resolve;
|
|
});
|
|
const releaseAnswerPromise = new Promise<void>((resolve) => {
|
|
releaseAnswer = resolve;
|
|
});
|
|
coordinator.dispose();
|
|
coordinator = new RuntimeToolApprovalCoordinator({
|
|
getSettings: () => currentSettings,
|
|
answerApproval: async ({ entry, allow, message }) => {
|
|
answerStarted();
|
|
await releaseAnswerPromise;
|
|
answers.push({ requestId: entry.approval.requestId, allow, message });
|
|
},
|
|
emitApprovalEvent: (event) => {
|
|
events.push(event);
|
|
},
|
|
});
|
|
|
|
const entry = approvalEntry();
|
|
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
|
|
|
|
const firstResponse = coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true);
|
|
await answerStartedPromise;
|
|
await expect(
|
|
coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', false)
|
|
).resolves.toBe(true);
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry);
|
|
|
|
releaseAnswer();
|
|
await expect(firstResponse).resolves.toBe(true);
|
|
expect(answers).toEqual([{ requestId: 'opencode:run-1:perm-1', allow: true }]);
|
|
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('OpenCodeRuntimeApprovalProvider', () => {
|
|
it('normalizes bridge pending permissions into provider-neutral approval entries', () => {
|
|
const member: TeamRuntimeMemberLaunchEvidence = {
|
|
memberName: 'bob',
|
|
providerId: 'opencode',
|
|
launchState: 'runtime_pending_permission',
|
|
agentToolAccepted: true,
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
pendingApprovals: [
|
|
{
|
|
providerId: 'opencode',
|
|
requestId: 'perm-1',
|
|
sessionId: 'ses-1',
|
|
tool: 'bash',
|
|
raw: { patterns: ['pnpm test'] },
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
};
|
|
|
|
const entries = collectOpenCodeRuntimeApprovalEntries({
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
laneId: 'primary',
|
|
cwd: '/repo',
|
|
members: { bob: member },
|
|
expectedMembers: [{ name: 'bob', providerId: 'opencode', cwd: '/repo' }],
|
|
nowIso: () => '2026-05-22T10:00:00.000Z',
|
|
});
|
|
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]?.approval).toMatchObject({
|
|
requestId: 'opencode:run-1:perm-1',
|
|
providerId: 'opencode',
|
|
source: 'bob',
|
|
toolName: 'Bash',
|
|
toolInput: {
|
|
provider: 'opencode',
|
|
providerRequestId: 'perm-1',
|
|
command: 'pnpm test',
|
|
},
|
|
runtimePermission: {
|
|
providerId: 'opencode',
|
|
laneId: 'primary',
|
|
memberName: 'bob',
|
|
providerRequestId: 'perm-1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('maps OpenCode permission display metadata without leaking protocol shape to UI', () => {
|
|
const approval = {
|
|
providerId: 'opencode' as const,
|
|
requestId: 'perm-2',
|
|
sessionId: 'ses-2',
|
|
kind: 'write',
|
|
title: 'Write file',
|
|
raw: { patterns: ['/repo/file.ts'] },
|
|
};
|
|
|
|
expect(openCodeApprovalToolName(approval)).toBe('Write');
|
|
expect(openCodeApprovalToolInput(approval)).toMatchObject({
|
|
provider: 'opencode',
|
|
providerRequestId: 'perm-2',
|
|
patterns: ['/repo/file.ts'],
|
|
title: 'Write file',
|
|
});
|
|
});
|
|
});
|