feat(opencode): surface runtime diagnostics and approvals
This commit is contained in:
parent
5551eea482
commit
7b99a3713b
41 changed files with 3895 additions and 232 deletions
|
|
@ -147,6 +147,7 @@ import { clearAutoResumeService } from './services/team/AutoResumeService';
|
|||
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
|
||||
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import { OpenCodeBridgeDiagnosticsStore } from './services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
createOpenCodeBridgeCommandLedgerStore,
|
||||
|
|
@ -407,6 +408,9 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
});
|
||||
const mcpEntry = mcpLaunchSpec.args[0];
|
||||
if (mcpEntry) {
|
||||
for (const [key, value] of Object.entries(mcpLaunchSpec.env ?? {})) {
|
||||
targetEnv[key] = value;
|
||||
}
|
||||
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
|
||||
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
|
||||
|
|
@ -515,13 +519,16 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
}
|
||||
return nextEnv;
|
||||
};
|
||||
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
|
||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||
binaryPath,
|
||||
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
|
||||
env: bridgeEnv,
|
||||
envProvider: resolveBridgeCommandEnv,
|
||||
diagnostics: new OpenCodeBridgeDiagnosticsStore({
|
||||
directory: join(bridgeControlDir, 'diagnostics'),
|
||||
}),
|
||||
});
|
||||
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
|
||||
const clientIdentity = createOpenCodeBridgeClientIdentity({
|
||||
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0',
|
||||
gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null,
|
||||
|
|
@ -546,6 +553,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
});
|
||||
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
|
||||
stateChangingCommands,
|
||||
appVersion: clientIdentity.appVersion,
|
||||
});
|
||||
openCodeLifecycleBridge = readinessBridge;
|
||||
return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]);
|
||||
|
|
|
|||
|
|
@ -396,11 +396,21 @@ function createRuntimeStatusErrorProviderStatus(
|
|||
error: unknown
|
||||
): CliProviderStatus {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
const detailMessage =
|
||||
providerId === 'opencode' && (lower.includes('timed out') || lower.includes('timeout'))
|
||||
? [
|
||||
'OpenCode runtime status did not return before the desktop timeout.',
|
||||
'This means the Agent Teams runtime process did not produce provider-status JSON in time, not necessarily that OpenCode auth is missing.',
|
||||
'Likely causes include slow or hung OpenCode CLI startup, provider/model inventory, local OpenCode plugins, cache/profile corruption, stale bundled runtime, or Windows security software delaying child processes.',
|
||||
`Raw timeout detail: ${message}`,
|
||||
].join(' ')
|
||||
: message;
|
||||
return {
|
||||
...createDefaultProviderStatus(providerId),
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Provider status unavailable',
|
||||
detailMessage: message,
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ import {
|
|||
} from '@shared/utils/inboxNoise';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
} from '@shared/utils/openCodeWindowsAccessDenied';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
|
@ -228,11 +232,19 @@ import {
|
|||
normalizeMemberDiagnosticText,
|
||||
shouldUseGeminiStagedLaunch,
|
||||
} from './provisioning/TeamProvisioningPromptBuilders';
|
||||
|
||||
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
|
||||
export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec';
|
||||
export {
|
||||
buildAddMemberSpawnMessage,
|
||||
buildRestartMemberSpawnMessage,
|
||||
} from './provisioning/TeamProvisioningPromptBuilders';
|
||||
import { openCodeRuntimeApprovalProvider } from './approvals/OpenCodeRuntimeApprovalProvider';
|
||||
import {
|
||||
RuntimeToolApprovalCoordinator,
|
||||
type RuntimeToolApprovalEntry,
|
||||
} from './approvals/RuntimeToolApprovalCoordinator';
|
||||
import { isOpenCodeBridgeNoOutputDiagnostic } from './opencode/bridge/OpenCodeBridgeSupportDiagnostics';
|
||||
import {
|
||||
buildOpenCodePromptDeliveryAttemptId,
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
|
|
@ -406,10 +418,10 @@ import type {
|
|||
TeamRuntimeLaunchInput,
|
||||
TeamRuntimeLaunchResult,
|
||||
TeamRuntimeMemberLaunchEvidence,
|
||||
TeamRuntimeMemberSpec,
|
||||
TeamRuntimePrepareResult,
|
||||
TeamRuntimeStopInput,
|
||||
} from './runtime';
|
||||
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
|
||||
|
||||
type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & {
|
||||
sendMessageToMember(
|
||||
|
|
@ -608,6 +620,7 @@ import type {
|
|||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamProvisioningState,
|
||||
TeamProvisioningSupportDiagnostic,
|
||||
TeamRuntimeState,
|
||||
TeamTask,
|
||||
ToolActivityEventPayload,
|
||||
|
|
@ -962,9 +975,22 @@ function pushUniqueLine(lines: string[], line: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function pushUniqueSupportDiagnostics(
|
||||
diagnostics: TeamProvisioningSupportDiagnostic[],
|
||||
incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined
|
||||
): void {
|
||||
for (const diagnostic of incoming ?? []) {
|
||||
if (!diagnostics.some((existing) => existing.id === diagnostic.id)) {
|
||||
diagnostics.push({ ...diagnostic });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean {
|
||||
const lower = value.trim().toLowerCase();
|
||||
return (
|
||||
isOpenCodeBridgeNoOutputDiagnostic(value) ||
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic(value) ||
|
||||
lower.includes('opencode /experimental/tool') ||
|
||||
lower.includes('/experimental/tool') ||
|
||||
lower.includes('mcp_unavailable') ||
|
||||
|
|
@ -981,6 +1007,15 @@ function normalizeOpenCodePrepareDiagnostic(value: string, reason?: string): str
|
|||
return trimmed;
|
||||
}
|
||||
|
||||
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
|
||||
return 'OpenCode runtime check returned no output.';
|
||||
}
|
||||
|
||||
const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed);
|
||||
if (accessDeniedDiagnostic) {
|
||||
return accessDeniedDiagnostic;
|
||||
}
|
||||
|
||||
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
|
||||
return OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC;
|
||||
}
|
||||
|
|
@ -1028,6 +1063,11 @@ function isOpenCodeModelVerificationTimeoutDiagnostic(value: string | null | und
|
|||
function selectOpenCodeModelPreparePrimaryReason(
|
||||
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>
|
||||
): string {
|
||||
const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare);
|
||||
if (providerDiagnostic) {
|
||||
return providerDiagnostic;
|
||||
}
|
||||
|
||||
const candidates = [...prepare.diagnostics, prepare.reason]
|
||||
.map((entry) => entry?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
|
@ -1035,6 +1075,15 @@ function selectOpenCodeModelPreparePrimaryReason(
|
|||
return timeoutReason ?? candidates[0] ?? prepare.reason;
|
||||
}
|
||||
|
||||
function selectOpenCodePrepareProviderDiagnostic(
|
||||
prepare: Pick<TeamRuntimePrepareResult, 'diagnostics' | 'warnings'>
|
||||
): string | undefined {
|
||||
return [...prepare.diagnostics, ...prepare.warnings].find(
|
||||
(entry) =>
|
||||
isOpenCodeBridgeNoOutputDiagnostic(entry) || isOpenCodeWindowsAccessDeniedDiagnostic(entry)
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodeModelPrepareBusyDeferred(
|
||||
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>,
|
||||
primaryReason: string
|
||||
|
|
@ -4997,6 +5046,16 @@ export class TeamProvisioningService {
|
|||
| undefined = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private readonly runtimeAdapterTraceLinesByRunId = new Map<string, string[]>();
|
||||
private readonly runtimeAdapterTraceKeyByRunId = new Map<string, string>();
|
||||
private readonly runtimeToolApprovalCoordinator = new RuntimeToolApprovalCoordinator({
|
||||
getSettings: (teamName) => this.getToolApprovalSettings(teamName),
|
||||
answerApproval: ({ entry, allow, message }) =>
|
||||
this.answerRuntimeToolApproval(entry, allow, message),
|
||||
emitApprovalEvent: (event) => this.emitToolApprovalEvent(event),
|
||||
showApprovalNotification: (approval) =>
|
||||
this.maybeShowToolApprovalOsNotification(undefined, approval),
|
||||
dismissApprovalNotification: (requestId) => this.dismissApprovalNotification(requestId),
|
||||
logWarning: (message) => logger.warn(message),
|
||||
});
|
||||
private readonly runtimeAdapterRunByTeam = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -10423,6 +10482,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private deleteSecondaryRuntimeRun(teamName: string, laneId: string): void {
|
||||
this.clearOpenCodeRuntimeToolApprovals(teamName, { laneId, emitDismiss: true });
|
||||
const runs = this.secondaryRuntimeRunByTeam.get(teamName);
|
||||
if (!runs) {
|
||||
return;
|
||||
|
|
@ -10434,6 +10494,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private clearSecondaryRuntimeRuns(teamName: string): void {
|
||||
this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true });
|
||||
this.secondaryRuntimeRunByTeam.delete(teamName);
|
||||
}
|
||||
|
||||
|
|
@ -11253,6 +11314,7 @@ export class TeamProvisioningService {
|
|||
|
||||
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
|
||||
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
|
||||
this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true });
|
||||
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||
this.retainedClaudeLogsByTeam.delete(teamName);
|
||||
this.persistedTranscriptClaudeLogsCache.delete(teamName);
|
||||
|
|
@ -18260,6 +18322,7 @@ export class TeamProvisioningService {
|
|||
details: result.details ? [...result.details] : undefined,
|
||||
warnings: result.warnings ? [...result.warnings] : undefined,
|
||||
issues: result.issues?.map((issue) => ({ ...issue })),
|
||||
supportDiagnostics: result.supportDiagnostics?.map((diagnostic) => ({ ...diagnostic })),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -18304,6 +18367,7 @@ export class TeamProvisioningService {
|
|||
const details: string[] = [];
|
||||
const blockingMessages: string[] = [];
|
||||
const issues: TeamProvisioningPrepareIssue[] = [];
|
||||
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
|
||||
const selectedModelIds = Array.from(
|
||||
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
|
||||
);
|
||||
|
|
@ -18353,9 +18417,13 @@ export class TeamProvisioningService {
|
|||
normalizeOpenCodePrepareDiagnostic(warning, prepareReason)
|
||||
)
|
||||
);
|
||||
pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics);
|
||||
if (!prepare.ok) {
|
||||
const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare);
|
||||
blockingMessages.push(
|
||||
normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
|
||||
providerDiagnostic
|
||||
? normalizeOpenCodePrepareDiagnostic(providerDiagnostic, prepare.reason)
|
||||
: normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
|
|
@ -18371,6 +18439,7 @@ export class TeamProvisioningService {
|
|||
warnings.push(...openCodeModelPrepare.warnings);
|
||||
blockingMessages.push(...openCodeModelPrepare.blockingMessages);
|
||||
issues.push(...openCodeModelPrepare.issues);
|
||||
pushUniqueSupportDiagnostics(supportDiagnostics, openCodeModelPrepare.supportDiagnostics);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -18539,6 +18608,10 @@ export class TeamProvisioningService {
|
|||
: 'Some provider runtimes are not ready',
|
||||
warnings: failureWarnings.length > 0 ? failureWarnings : undefined,
|
||||
issues: issues.length > 0 ? issues : undefined,
|
||||
supportDiagnostics:
|
||||
supportDiagnostics.length > 0
|
||||
? supportDiagnostics.map((diagnostic) => ({ ...diagnostic }))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -18555,6 +18628,10 @@ export class TeamProvisioningService {
|
|||
: 'CLI is warmed up and ready to launch',
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
issues: issues.length > 0 ? issues : undefined,
|
||||
supportDiagnostics:
|
||||
supportDiagnostics.length > 0
|
||||
? supportDiagnostics.map((diagnostic) => ({ ...diagnostic }))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -18573,15 +18650,17 @@ export class TeamProvisioningService {
|
|||
warnings: string[];
|
||||
blockingMessages: string[];
|
||||
issues: TeamProvisioningPrepareIssue[];
|
||||
supportDiagnostics: TeamProvisioningSupportDiagnostic[];
|
||||
}> {
|
||||
const details: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const blockingMessages: string[] = [];
|
||||
const issues: TeamProvisioningPrepareIssue[] = [];
|
||||
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
return { details, warnings, blockingMessages, issues };
|
||||
return { details, warnings, blockingMessages, issues, supportDiagnostics };
|
||||
}
|
||||
|
||||
if (verificationMode === 'compatibility') {
|
||||
|
|
@ -18629,6 +18708,11 @@ export class TeamProvisioningService {
|
|||
reason: prepare.ok ? null : prepare.reason,
|
||||
diagnostics: prepare.diagnostics,
|
||||
warnings: prepare.warnings,
|
||||
supportDiagnostics: prepare.supportDiagnostics?.map((diagnostic) => ({
|
||||
id: diagnostic.id,
|
||||
kind: diagnostic.kind,
|
||||
title: diagnostic.title,
|
||||
})),
|
||||
});
|
||||
return prepare;
|
||||
} catch (error) {
|
||||
|
|
@ -18700,6 +18784,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const { modelId, prepare } = result;
|
||||
pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics);
|
||||
const prepareReason = prepare.ok ? undefined : prepare.reason;
|
||||
warnings.push(
|
||||
...prepare.warnings.map((warning) =>
|
||||
|
|
@ -18800,7 +18885,7 @@ export class TeamProvisioningService {
|
|||
blockingMessages,
|
||||
});
|
||||
|
||||
return { details, warnings, blockingMessages, issues };
|
||||
return { details, warnings, blockingMessages, issues, supportDiagnostics };
|
||||
}
|
||||
|
||||
private isProviderScopedOpenCodePrepareFailure(
|
||||
|
|
@ -18829,11 +18914,13 @@ export class TeamProvisioningService {
|
|||
warnings: string[];
|
||||
blockingMessages: string[];
|
||||
issues: TeamProvisioningPrepareIssue[];
|
||||
supportDiagnostics: TeamProvisioningSupportDiagnostic[];
|
||||
} | null> {
|
||||
const details: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const blockingMessages: string[] = [];
|
||||
const issues: TeamProvisioningPrepareIssue[] = [];
|
||||
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
|
||||
const startedAt = Date.now();
|
||||
|
||||
appendPreflightDebugLog('opencode_compatibility_batch_start', {
|
||||
|
|
@ -18879,11 +18966,20 @@ export class TeamProvisioningService {
|
|||
ok: sharedPrepare.ok,
|
||||
reason: sharedPrepare.ok ? null : sharedPrepare.reason,
|
||||
diagnostics: sharedPrepare.diagnostics,
|
||||
supportDiagnostics: sharedPrepare.supportDiagnostics?.map((diagnostic) => ({
|
||||
id: diagnostic.id,
|
||||
kind: diagnostic.kind,
|
||||
title: diagnostic.title,
|
||||
})),
|
||||
});
|
||||
|
||||
if (!sharedPrepare.ok) {
|
||||
pushUniqueSupportDiagnostics(supportDiagnostics, sharedPrepare.supportDiagnostics);
|
||||
const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(sharedPrepare);
|
||||
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
||||
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason,
|
||||
providerDiagnostic ??
|
||||
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ??
|
||||
sharedPrepare.reason,
|
||||
sharedPrepare.reason
|
||||
);
|
||||
if (primaryReason.trim().length > 0) {
|
||||
|
|
@ -18899,7 +18995,7 @@ export class TeamProvisioningService {
|
|||
code: sharedPrepare.reason,
|
||||
message: primaryReason.trim() || `OpenCode: ${sharedPrepare.reason}`,
|
||||
});
|
||||
return { details, warnings, blockingMessages, issues };
|
||||
return { details, warnings, blockingMessages, issues, supportDiagnostics };
|
||||
}
|
||||
|
||||
const latestReadiness =
|
||||
|
|
@ -18955,7 +19051,7 @@ export class TeamProvisioningService {
|
|||
details,
|
||||
});
|
||||
|
||||
return { details, warnings, blockingMessages, issues };
|
||||
return { details, warnings, blockingMessages, issues, supportDiagnostics };
|
||||
}
|
||||
|
||||
private resolveOpenCodeCompatibilityModel(
|
||||
|
|
@ -21517,6 +21613,19 @@ export class TeamProvisioningService {
|
|||
launchResult,
|
||||
launchInput
|
||||
);
|
||||
const requestTeamColor = 'color' in input.request ? input.request.color : undefined;
|
||||
const requestTeamDisplayName =
|
||||
'displayName' in input.request ? input.request.displayName : undefined;
|
||||
this.syncOpenCodeRuntimeToolApprovals({
|
||||
teamName: input.request.teamName,
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
cwd: launchCwd,
|
||||
members: result.members,
|
||||
expectedMembers: launchInput.expectedMembers,
|
||||
teamColor: requestTeamColor,
|
||||
teamDisplayName: requestTeamDisplayName,
|
||||
});
|
||||
const success = result.teamLaunchState === 'clean_success';
|
||||
const pending = result.teamLaunchState === 'partial_pending';
|
||||
const failed = result.teamLaunchState === 'partial_failure';
|
||||
|
|
@ -22716,6 +22825,11 @@ export class TeamProvisioningService {
|
|||
const teamName = runtimeProgress.teamName;
|
||||
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||
this.cancelledRuntimeAdapterRunIds.add(runId);
|
||||
this.clearOpenCodeRuntimeToolApprovals(teamName, {
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
emitDismiss: true,
|
||||
});
|
||||
this.runtimeAdapterRunByTeam.delete(teamName);
|
||||
this.aliveRunByTeam.delete(teamName);
|
||||
if (this.provisioningRunByTeam.get(teamName) === runId) {
|
||||
|
|
@ -28122,6 +28236,47 @@ export class TeamProvisioningService {
|
|||
this.emitMemberSpawnChange(run, lane.member.name);
|
||||
}
|
||||
|
||||
private async applyOpenCodeSecondaryPermissionAnswerResult(
|
||||
entry: RuntimeToolApprovalEntry,
|
||||
result: TeamRuntimeLaunchResult
|
||||
): Promise<void> {
|
||||
const trackedRunId = this.getTrackedRunId(entry.approval.teamName);
|
||||
const run = trackedRunId ? this.runs.get(trackedRunId) : null;
|
||||
if (!run) {
|
||||
throw new Error(`Run not found for team "${entry.approval.teamName}"`);
|
||||
}
|
||||
const lane = (run.mixedSecondaryLanes ?? []).find(
|
||||
(candidate) => candidate.laneId === entry.laneId
|
||||
);
|
||||
if (!lane) {
|
||||
throw new Error(
|
||||
`OpenCode secondary lane ${entry.laneId} was not found for team "${entry.approval.teamName}"`
|
||||
);
|
||||
}
|
||||
|
||||
const guarded = await this.guardCommittedOpenCodeSecondaryLaneEvidence({
|
||||
teamName: entry.approval.teamName,
|
||||
laneId: entry.laneId,
|
||||
memberName: entry.memberName,
|
||||
result,
|
||||
});
|
||||
lane.result = guarded;
|
||||
lane.warnings = [...guarded.warnings];
|
||||
lane.diagnostics = [...guarded.diagnostics];
|
||||
lane.state = 'finished';
|
||||
await this.publishMixedSecondaryLaneStatusChange(run, lane);
|
||||
this.syncOpenCodeRuntimeToolApprovals({
|
||||
teamName: entry.approval.teamName,
|
||||
runId: entry.approval.runId,
|
||||
laneId: entry.laneId,
|
||||
cwd: entry.cwd ?? '',
|
||||
members: guarded.members,
|
||||
expectedMembers: entry.expectedMembers ?? [],
|
||||
teamDisplayName: entry.approval.teamDisplayName,
|
||||
teamColor: entry.approval.teamColor,
|
||||
});
|
||||
}
|
||||
|
||||
private async guardCommittedOpenCodeSecondaryLaneEvidence(params: {
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
|
|
@ -28637,6 +28792,18 @@ export class TeamProvisioningService {
|
|||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
const laneExpectedMembers: TeamRuntimeMemberSpec[] = [
|
||||
{
|
||||
name: lane.member.name,
|
||||
role: lane.member.role,
|
||||
workflow: lane.member.workflow,
|
||||
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: 'opencode',
|
||||
model: lane.member.model,
|
||||
effort: lane.member.effort,
|
||||
cwd: laneCwd,
|
||||
},
|
||||
];
|
||||
const launchOpenCodeLane = () =>
|
||||
adapter.launch({
|
||||
runId: laneRunId,
|
||||
|
|
@ -28649,18 +28816,7 @@ export class TeamProvisioningService {
|
|||
effort: lane.member.effort,
|
||||
runtimeOnly: true,
|
||||
skipPermissions: run.request.skipPermissions !== false,
|
||||
expectedMembers: [
|
||||
{
|
||||
name: lane.member.name,
|
||||
role: lane.member.role,
|
||||
workflow: lane.member.workflow,
|
||||
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: 'opencode',
|
||||
model: lane.member.model,
|
||||
effort: lane.member.effort,
|
||||
cwd: laneCwd,
|
||||
},
|
||||
],
|
||||
expectedMembers: laneExpectedMembers,
|
||||
previousLaunchState,
|
||||
});
|
||||
let rawResult: TeamRuntimeLaunchResult;
|
||||
|
|
@ -28753,6 +28909,16 @@ export class TeamProvisioningService {
|
|||
)
|
||||
: resultWithTiming;
|
||||
lane.result = normalizedResult;
|
||||
this.syncOpenCodeRuntimeToolApprovals({
|
||||
teamName: run.teamName,
|
||||
runId: laneRunId,
|
||||
laneId: lane.laneId,
|
||||
cwd: laneCwd,
|
||||
members: normalizedResult.members,
|
||||
expectedMembers: laneExpectedMembers,
|
||||
teamColor: run.request.color,
|
||||
teamDisplayName: run.request.displayName,
|
||||
});
|
||||
lane.warnings = [...normalizedResult.warnings];
|
||||
const launchDiagnostics = appendDiagnosticOnce(
|
||||
[...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics],
|
||||
|
|
@ -31301,6 +31467,11 @@ export class TeamProvisioningService {
|
|||
startedAt: previousProgress?.startedAt ?? startedAt,
|
||||
updatedAt: startedAt,
|
||||
});
|
||||
this.clearOpenCodeRuntimeToolApprovals(teamName, {
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
emitDismiss: true,
|
||||
});
|
||||
this.runtimeAdapterRunByTeam.delete(teamName);
|
||||
this.aliveRunByTeam.delete(teamName);
|
||||
if (this.provisioningRunByTeam.get(teamName) === runId) {
|
||||
|
|
@ -32668,11 +32839,13 @@ export class TeamProvisioningService {
|
|||
|
||||
const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown';
|
||||
const toolInput = (request?.input ?? {}) as Record<string, unknown>;
|
||||
const providerId = toolInput.provider === 'codex' ? 'codex' : undefined;
|
||||
|
||||
const approval: ToolApprovalRequest = {
|
||||
requestId,
|
||||
runId: run.runId,
|
||||
teamName: run.teamName,
|
||||
...(providerId ? { providerId } : {}),
|
||||
source: 'lead',
|
||||
toolName,
|
||||
toolInput,
|
||||
|
|
@ -32776,13 +32949,35 @@ export class TeamProvisioningService {
|
|||
this.maybeShowToolApprovalOsNotification(run, approval);
|
||||
}
|
||||
|
||||
private syncOpenCodeRuntimeToolApprovals(input: {
|
||||
teamName: string;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
cwd: string;
|
||||
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
|
||||
expectedMembers: TeamRuntimeMemberSpec[];
|
||||
teamColor?: string;
|
||||
teamDisplayName?: string;
|
||||
}): void {
|
||||
const entries = openCodeRuntimeApprovalProvider.collectPendingApprovals(input);
|
||||
this.runtimeToolApprovalCoordinator.sync(
|
||||
{
|
||||
teamName: input.teamName,
|
||||
runId: input.runId,
|
||||
laneId: input.laneId,
|
||||
providerId: 'opencode',
|
||||
},
|
||||
entries
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a native OS notification for a pending tool approval when the app
|
||||
* is not in focus. On macOS, adds Allow/Deny action buttons that respond
|
||||
* directly from the notification without switching to the app.
|
||||
*/
|
||||
private maybeShowToolApprovalOsNotification(
|
||||
run: ProvisioningRun,
|
||||
run: ProvisioningRun | undefined,
|
||||
approval: ToolApprovalRequest
|
||||
): void {
|
||||
const win = this.mainWindowRef;
|
||||
|
|
@ -32795,13 +32990,16 @@ export class TeamProvisioningService {
|
|||
const snoozedUntil = config.notifications.snoozedUntil;
|
||||
if (snoozedUntil && Date.now() < snoozedUntil) return;
|
||||
|
||||
const { Notification: ElectronNotification } = require('electron') as typeof import('electron');
|
||||
if (!ElectronNotification.isSupported()) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Notification: ElectronNotification } = require('electron') as Partial<
|
||||
typeof import('electron')
|
||||
>;
|
||||
if (!ElectronNotification?.isSupported?.()) return;
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const teamLabel = run.request.displayName ?? run.teamName;
|
||||
const teamLabel = approval.teamDisplayName ?? run?.request.displayName ?? approval.teamName;
|
||||
const body = this.formatToolApprovalBody(approval.toolName, approval.toolInput);
|
||||
|
||||
// Actions (Allow/Deny buttons) supported on macOS and Windows.
|
||||
|
|
@ -32848,17 +33046,17 @@ export class TeamProvisioningService {
|
|||
cleanup();
|
||||
const allow = index === 0;
|
||||
logger.info(
|
||||
`[${run.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification`
|
||||
`[${approval.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification`
|
||||
);
|
||||
void this.respondToToolApproval(
|
||||
run.teamName,
|
||||
run.runId,
|
||||
approval.teamName,
|
||||
approval.runId,
|
||||
approval.requestId,
|
||||
allow,
|
||||
allow ? undefined : 'Denied via notification'
|
||||
).catch((err) => {
|
||||
logger.error(
|
||||
`[${run.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}`
|
||||
`[${approval.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -32876,6 +33074,16 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private clearOpenCodeRuntimeToolApprovals(
|
||||
teamName: string,
|
||||
options: { runId?: string; laneId?: string; emitDismiss?: boolean } = {}
|
||||
): void {
|
||||
this.runtimeToolApprovalCoordinator.clear(teamName, {
|
||||
...options,
|
||||
providerId: 'opencode',
|
||||
});
|
||||
}
|
||||
|
||||
private formatToolApprovalBody(toolName: string, toolInput: Record<string, unknown>): string {
|
||||
switch (toolName) {
|
||||
case 'AskUserQuestion':
|
||||
|
|
@ -33095,6 +33303,83 @@ export class TeamProvisioningService {
|
|||
this.inFlightResponses.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
this.runtimeToolApprovalCoordinator.reEvaluate();
|
||||
}
|
||||
|
||||
private async answerRuntimeToolApproval(
|
||||
entry: RuntimeToolApprovalEntry,
|
||||
allow: boolean,
|
||||
_message?: string
|
||||
): Promise<void> {
|
||||
if (entry.providerId !== 'opencode') {
|
||||
throw new Error(`Runtime approval provider is not supported: ${entry.providerId}`);
|
||||
}
|
||||
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||
if (!adapter?.answerRuntimePermission) {
|
||||
throw new Error('OpenCode runtime permission answer bridge is not available');
|
||||
}
|
||||
|
||||
const previousLaunchState = await this.launchStateStore.read(entry.approval.teamName);
|
||||
const result = await adapter.answerRuntimePermission({
|
||||
runId: entry.approval.runId,
|
||||
laneId: entry.laneId,
|
||||
teamName: entry.approval.teamName,
|
||||
cwd: entry.cwd ?? '',
|
||||
providerId: 'opencode',
|
||||
memberName: entry.memberName,
|
||||
requestId: entry.providerRequestId,
|
||||
decision: allow ? 'allow' : 'reject',
|
||||
expectedMembers: entry.expectedMembers ?? [],
|
||||
previousLaunchState,
|
||||
});
|
||||
|
||||
if (entry.laneId === 'primary') {
|
||||
const launchInput: TeamRuntimeLaunchInput = {
|
||||
runId: entry.approval.runId,
|
||||
laneId: entry.laneId,
|
||||
teamName: entry.approval.teamName,
|
||||
cwd: entry.cwd ?? '',
|
||||
providerId: 'opencode',
|
||||
skipPermissions: false,
|
||||
expectedMembers: entry.expectedMembers ?? [],
|
||||
previousLaunchState,
|
||||
};
|
||||
const { result: committed } = await this.persistOpenCodeRuntimeAdapterLaunchResult(
|
||||
result,
|
||||
launchInput
|
||||
);
|
||||
if (committed.teamLaunchState === 'partial_failure') {
|
||||
this.runtimeAdapterRunByTeam.delete(entry.approval.teamName);
|
||||
} else {
|
||||
this.runtimeAdapterRunByTeam.set(entry.approval.teamName, {
|
||||
runId: entry.approval.runId,
|
||||
providerId: 'opencode',
|
||||
cwd: entry.cwd,
|
||||
members: committed.members,
|
||||
});
|
||||
this.aliveRunByTeam.set(entry.approval.teamName, entry.approval.runId);
|
||||
}
|
||||
this.syncOpenCodeRuntimeToolApprovals({
|
||||
teamName: entry.approval.teamName,
|
||||
runId: entry.approval.runId,
|
||||
laneId: entry.laneId,
|
||||
cwd: entry.cwd ?? '',
|
||||
members: committed.members,
|
||||
expectedMembers: entry.expectedMembers ?? [],
|
||||
teamDisplayName: entry.approval.teamDisplayName,
|
||||
teamColor: entry.approval.teamColor,
|
||||
});
|
||||
} else {
|
||||
await this.applyOpenCodeSecondaryPermissionAnswerResult(entry, result);
|
||||
}
|
||||
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'process',
|
||||
teamName: entry.approval.teamName,
|
||||
runId: entry.approval.runId,
|
||||
detail: allow ? 'permission-allowed' : 'permission-denied',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33108,6 +33393,17 @@ export class TeamProvisioningService {
|
|||
allow: boolean,
|
||||
message?: string
|
||||
): Promise<void> {
|
||||
const handledByRuntime = await this.runtimeToolApprovalCoordinator.respond(
|
||||
teamName,
|
||||
runId,
|
||||
requestId,
|
||||
allow,
|
||||
message
|
||||
);
|
||||
if (handledByRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look in both provisioning and alive runs — control_requests arrive during provisioning too
|
||||
const currentRunId = this.getTrackedRunId(teamName);
|
||||
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
|
||||
|
|
@ -33122,8 +33418,8 @@ export class TeamProvisioningService {
|
|||
// to handle the race where timeout already responded and deleted the approval
|
||||
this.clearApprovalTimeout(requestId);
|
||||
if (!this.tryClaimResponse(requestId)) {
|
||||
// Timeout already responded — silently exit, UI cleanup via autoResolved event
|
||||
run.pendingApprovals.delete(requestId);
|
||||
// Another response is already being written; leave the pending approval tracked
|
||||
// until that write succeeds or fails.
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -33148,15 +33444,22 @@ export class TeamProvisioningService {
|
|||
approval.toolName,
|
||||
approval.toolInput
|
||||
);
|
||||
} finally {
|
||||
run.pendingApprovals.delete(requestId);
|
||||
this.inFlightResponses.delete(requestId);
|
||||
run.pendingApprovals.delete(requestId);
|
||||
this.dismissApprovalNotification(requestId);
|
||||
} catch (error) {
|
||||
this.inFlightResponses.delete(requestId);
|
||||
if (run.pendingApprovals.has(requestId)) {
|
||||
this.startApprovalTimeout(run, requestId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run.child?.stdin?.writable) {
|
||||
this.inFlightResponses.delete(requestId);
|
||||
this.startApprovalTimeout(run, requestId);
|
||||
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
||||
}
|
||||
|
||||
|
|
@ -33222,11 +33525,16 @@ export class TeamProvisioningService {
|
|||
}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
run.pendingApprovals.delete(requestId);
|
||||
} catch (error) {
|
||||
this.inFlightResponses.delete(requestId);
|
||||
this.dismissApprovalNotification(requestId);
|
||||
if (run.pendingApprovals.has(requestId)) {
|
||||
this.startApprovalTimeout(run, requestId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
run.pendingApprovals.delete(requestId);
|
||||
this.inFlightResponses.delete(requestId);
|
||||
this.dismissApprovalNotification(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35740,7 +36048,6 @@ export class TeamProvisioningService {
|
|||
const nextMembers: Record<string, unknown>[] = [];
|
||||
for (const m of membersRaw) {
|
||||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
|
||||
if (!name) continue;
|
||||
if (isLeadMember(m) || name === 'user') {
|
||||
nextMembers.push(m);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import type {
|
||||
TeamRuntimeMemberLaunchEvidence,
|
||||
TeamRuntimeMemberSpec,
|
||||
TeamRuntimePendingApproval,
|
||||
} from '../runtime/TeamRuntimeAdapter';
|
||||
import type {
|
||||
RuntimeApprovalLaunchPolicy,
|
||||
RuntimeApprovalProviderPort,
|
||||
RuntimeToolApprovalAnswerInput,
|
||||
RuntimeToolApprovalEntry,
|
||||
} from './RuntimeToolApprovalCoordinator';
|
||||
import type { ToolApprovalRequest } from '@shared/types/team';
|
||||
|
||||
interface CollectOpenCodeRuntimeApprovalsInput {
|
||||
teamName: string;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
cwd: string;
|
||||
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
|
||||
expectedMembers: TeamRuntimeMemberSpec[];
|
||||
teamColor?: string;
|
||||
teamDisplayName?: string;
|
||||
nowIso?: () => string;
|
||||
}
|
||||
|
||||
export class OpenCodeRuntimeApprovalProvider implements RuntimeApprovalProviderPort<
|
||||
{ toolApprovalMode?: 'auto' | 'manual' },
|
||||
CollectOpenCodeRuntimeApprovalsInput
|
||||
> {
|
||||
readonly providerId = 'opencode' as const;
|
||||
|
||||
buildLaunchPolicy(
|
||||
skipPermissions: boolean,
|
||||
_context: { toolApprovalMode?: 'auto' | 'manual' } = {}
|
||||
): RuntimeApprovalLaunchPolicy {
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
mode: skipPermissions ? 'auto' : 'manual',
|
||||
config: {
|
||||
permission: skipPermissions ? 'allow' : 'ask',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
collectPendingApprovals(input: CollectOpenCodeRuntimeApprovalsInput): RuntimeToolApprovalEntry[] {
|
||||
return collectOpenCodeRuntimeApprovalEntries(input);
|
||||
}
|
||||
|
||||
async answerApproval(_input: RuntimeToolApprovalAnswerInput): Promise<void> {
|
||||
throw new Error('OpenCode approval answers are handled by the runtime adapter bridge.');
|
||||
}
|
||||
|
||||
assertManualSupported(): void {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export const openCodeRuntimeApprovalProvider = new OpenCodeRuntimeApprovalProvider();
|
||||
|
||||
export function collectOpenCodeRuntimeApprovalEntries(
|
||||
input: CollectOpenCodeRuntimeApprovalsInput
|
||||
): RuntimeToolApprovalEntry[] {
|
||||
const entries: RuntimeToolApprovalEntry[] = [];
|
||||
const nowIso = input.nowIso ?? (() => new Date().toISOString());
|
||||
for (const [memberName, member] of Object.entries(input.members)) {
|
||||
for (const approval of collectOpenCodeRuntimePendingApprovals(member)) {
|
||||
const providerRequestId = approval.requestId.trim();
|
||||
if (!providerRequestId) {
|
||||
continue;
|
||||
}
|
||||
const requestId = buildOpenCodeRuntimeApprovalRequestId(input.runId, providerRequestId);
|
||||
const toolName = openCodeApprovalToolName(approval);
|
||||
const toolInput = openCodeApprovalToolInput(approval);
|
||||
const uiRequest: ToolApprovalRequest = {
|
||||
requestId,
|
||||
runId: input.runId,
|
||||
teamName: input.teamName,
|
||||
providerId: 'opencode',
|
||||
source: memberName,
|
||||
toolName,
|
||||
toolInput,
|
||||
receivedAt: nowIso(),
|
||||
teamColor: input.teamColor,
|
||||
teamDisplayName: input.teamDisplayName,
|
||||
runtimePermission: {
|
||||
providerId: 'opencode',
|
||||
laneId: input.laneId,
|
||||
memberName,
|
||||
providerRequestId,
|
||||
sessionId: approval.sessionId ?? member.sessionId ?? null,
|
||||
},
|
||||
};
|
||||
entries.push({
|
||||
providerId: 'opencode',
|
||||
approval: uiRequest,
|
||||
providerRequestId,
|
||||
laneId: input.laneId,
|
||||
memberName,
|
||||
cwd: input.cwd,
|
||||
expectedMembers: input.expectedMembers,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function collectOpenCodeRuntimePendingApprovals(
|
||||
member: TeamRuntimeMemberLaunchEvidence
|
||||
): TeamRuntimePendingApproval[] {
|
||||
const approvals = [...(member.pendingApprovals ?? []), ...(member.pendingPermissions ?? [])];
|
||||
const byRequestId = new Map<string, TeamRuntimePendingApproval>();
|
||||
for (const approval of approvals) {
|
||||
const requestId = approval.requestId.trim();
|
||||
if (!requestId || approval.providerId !== 'opencode' || byRequestId.has(requestId)) {
|
||||
continue;
|
||||
}
|
||||
byRequestId.set(requestId, { ...approval, requestId });
|
||||
}
|
||||
for (const requestId of member.pendingPermissionRequestIds ?? []) {
|
||||
const trimmed = requestId.trim();
|
||||
if (!trimmed || byRequestId.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
byRequestId.set(trimmed, {
|
||||
providerId: 'opencode',
|
||||
requestId: trimmed,
|
||||
sessionId: member.sessionId ?? null,
|
||||
tool: null,
|
||||
title: null,
|
||||
kind: null,
|
||||
});
|
||||
}
|
||||
return Array.from(byRequestId.values());
|
||||
}
|
||||
|
||||
export function buildOpenCodeRuntimeApprovalRequestId(
|
||||
runId: string,
|
||||
providerRequestId: string
|
||||
): string {
|
||||
return `opencode:${runId}:${providerRequestId}`;
|
||||
}
|
||||
|
||||
export function openCodeApprovalToolName(approval: TeamRuntimePendingApproval): string {
|
||||
const rawTool = approval.tool?.trim() || approval.kind?.trim() || approval.title?.trim();
|
||||
const normalized = rawTool?.toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'bash':
|
||||
case 'shell':
|
||||
case 'terminal':
|
||||
return 'Bash';
|
||||
case 'edit':
|
||||
return 'Edit';
|
||||
case 'write':
|
||||
return 'Write';
|
||||
case 'read':
|
||||
return 'Read';
|
||||
default:
|
||||
return rawTool || 'OpenCodeTool';
|
||||
}
|
||||
}
|
||||
|
||||
export function openCodeApprovalToolInput(
|
||||
approval: TeamRuntimePendingApproval
|
||||
): Record<string, unknown> {
|
||||
const raw: Record<string, unknown> =
|
||||
approval.raw && typeof approval.raw === 'object' ? approval.raw : {};
|
||||
const patterns = Array.isArray(raw.patterns)
|
||||
? raw.patterns.filter((value): value is string => typeof value === 'string')
|
||||
: undefined;
|
||||
const firstPattern = patterns?.[0];
|
||||
const title = approval.title?.trim();
|
||||
const input: Record<string, unknown> = {
|
||||
providerRequestId: approval.requestId,
|
||||
provider: 'opencode',
|
||||
...(approval.sessionId ? { sessionId: approval.sessionId } : {}),
|
||||
...(approval.tool ? { tool: approval.tool } : {}),
|
||||
...(approval.kind ? { kind: approval.kind } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(patterns?.length ? { patterns } : {}),
|
||||
};
|
||||
if (openCodeApprovalToolName(approval) === 'Bash' && firstPattern) {
|
||||
input.command = firstPattern;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||
|
||||
import type {
|
||||
TeamRuntimeApprovalProviderId,
|
||||
TeamRuntimeMemberSpec,
|
||||
} from '../runtime/TeamRuntimeAdapter';
|
||||
import type {
|
||||
ToolApprovalAutoResolved,
|
||||
ToolApprovalDismiss,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalSettings,
|
||||
} from '@shared/types/team';
|
||||
|
||||
export type RuntimeApprovalProviderId = TeamRuntimeApprovalProviderId;
|
||||
|
||||
export type RuntimeApprovalDecision = 'allow' | 'deny';
|
||||
|
||||
export interface RuntimeApprovalLaunchPolicy {
|
||||
providerId: RuntimeApprovalProviderId;
|
||||
mode: 'auto' | 'manual';
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuntimeApprovalProviderPort<TContext = unknown, TRuntimeState = unknown> {
|
||||
readonly providerId: RuntimeApprovalProviderId;
|
||||
buildLaunchPolicy(skipPermissions: boolean, context: TContext): RuntimeApprovalLaunchPolicy;
|
||||
collectPendingApprovals(runtimeState: TRuntimeState): RuntimeToolApprovalEntry[];
|
||||
answerApproval(input: RuntimeToolApprovalAnswerInput): Promise<void>;
|
||||
assertManualSupported(context: TContext): void;
|
||||
}
|
||||
|
||||
export interface RuntimeToolApprovalEntry {
|
||||
providerId: RuntimeApprovalProviderId;
|
||||
approval: ToolApprovalRequest;
|
||||
providerRequestId: string;
|
||||
laneId: string;
|
||||
memberName: string;
|
||||
cwd?: string;
|
||||
expectedMembers?: TeamRuntimeMemberSpec[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuntimeToolApprovalAnswerInput {
|
||||
entry: RuntimeToolApprovalEntry;
|
||||
allow: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type RuntimeToolApprovalEvent =
|
||||
| ToolApprovalRequest
|
||||
| ToolApprovalDismiss
|
||||
| ToolApprovalAutoResolved;
|
||||
|
||||
export interface RuntimeToolApprovalCoordinatorDeps {
|
||||
getSettings(teamName: string): ToolApprovalSettings;
|
||||
answerApproval(input: RuntimeToolApprovalAnswerInput): Promise<void>;
|
||||
emitApprovalEvent(event: RuntimeToolApprovalEvent): void;
|
||||
showApprovalNotification?(approval: ToolApprovalRequest): void;
|
||||
dismissApprovalNotification?(requestId: string): void;
|
||||
logWarning?(message: string): void;
|
||||
}
|
||||
|
||||
export interface RuntimeToolApprovalSyncScope {
|
||||
teamName: string;
|
||||
runId: string;
|
||||
laneId?: string;
|
||||
providerId?: RuntimeApprovalProviderId;
|
||||
}
|
||||
|
||||
export interface RuntimeToolApprovalClearOptions {
|
||||
runId?: string;
|
||||
laneId?: string;
|
||||
providerId?: RuntimeApprovalProviderId;
|
||||
emitDismiss?: boolean;
|
||||
}
|
||||
|
||||
export function mapAppApprovalDecisionToProviderDecision(
|
||||
decision: RuntimeApprovalDecision
|
||||
): 'allow' | 'reject' {
|
||||
return decision === 'allow' ? 'allow' : 'reject';
|
||||
}
|
||||
|
||||
export class RuntimeToolApprovalCoordinator {
|
||||
private readonly approvalsByTeam = new Map<string, Map<string, RuntimeToolApprovalEntry>>();
|
||||
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private readonly inFlightResponses = new Set<string>();
|
||||
|
||||
constructor(private readonly deps: RuntimeToolApprovalCoordinatorDeps) {}
|
||||
|
||||
sync(scope: RuntimeToolApprovalSyncScope, entries: RuntimeToolApprovalEntry[]): void {
|
||||
const observedRequestIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
observedRequestIds.add(entry.approval.requestId);
|
||||
this.register(entry);
|
||||
}
|
||||
|
||||
const approvals = this.approvalsByTeam.get(scope.teamName);
|
||||
if (!approvals) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [requestId, entry] of approvals) {
|
||||
if (!this.matchesScope(entry, scope)) {
|
||||
continue;
|
||||
}
|
||||
if (observedRequestIds.has(requestId)) {
|
||||
continue;
|
||||
}
|
||||
this.removeEntry(entry);
|
||||
this.deps.emitApprovalEvent({
|
||||
autoResolved: true,
|
||||
requestId,
|
||||
runId: entry.approval.runId,
|
||||
teamName: entry.approval.teamName,
|
||||
reason: 'runtime_resolved',
|
||||
} as ToolApprovalAutoResolved);
|
||||
}
|
||||
}
|
||||
|
||||
register(entry: RuntimeToolApprovalEntry): void {
|
||||
const requestId = entry.approval.requestId;
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
const approvals = this.getTeamApprovals(entry.approval.teamName);
|
||||
if (approvals.has(requestId) || this.inFlightResponses.has(requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoResult = shouldAutoAllow(
|
||||
this.deps.getSettings(entry.approval.teamName),
|
||||
entry.approval.toolName,
|
||||
entry.approval.toolInput
|
||||
);
|
||||
if (autoResult.autoAllow) {
|
||||
void this.answerUntracked(entry, true, undefined, 'auto_allow_category');
|
||||
return;
|
||||
}
|
||||
|
||||
approvals.set(requestId, entry);
|
||||
this.deps.emitApprovalEvent(entry.approval);
|
||||
this.startTimeout(entry);
|
||||
this.deps.showApprovalNotification?.(entry.approval);
|
||||
}
|
||||
|
||||
async respond(
|
||||
teamName: string,
|
||||
runId: string,
|
||||
requestId: string,
|
||||
allow: boolean,
|
||||
message?: string
|
||||
): Promise<boolean> {
|
||||
const entry = this.approvalsByTeam.get(teamName)?.get(requestId);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (entry.approval.runId !== runId) {
|
||||
throw new Error(
|
||||
`Stale approval: runId mismatch (expected ${entry.approval.runId}, got ${runId})`
|
||||
);
|
||||
}
|
||||
|
||||
this.clearTimer(requestId);
|
||||
if (!this.tryClaimResponse(requestId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deps.answerApproval({ entry, allow, message });
|
||||
} catch (error) {
|
||||
this.inFlightResponses.delete(requestId);
|
||||
if (this.get(entry.approval.teamName, requestId) === entry) {
|
||||
this.startTimeout(entry);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.removeEntry(entry);
|
||||
this.inFlightResponses.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(teamName: string, options: RuntimeToolApprovalClearOptions = {}): number {
|
||||
const approvals = this.approvalsByTeam.get(teamName);
|
||||
if (!approvals) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
const removedRunIds = new Set<string>();
|
||||
for (const entry of Array.from(approvals.values())) {
|
||||
if (!this.matchesClearOptions(entry, options)) {
|
||||
continue;
|
||||
}
|
||||
this.removeEntry(entry);
|
||||
removed += 1;
|
||||
removedRunIds.add(entry.approval.runId);
|
||||
}
|
||||
|
||||
if (removed > 0 && options.emitDismiss) {
|
||||
for (const runId of removedRunIds) {
|
||||
this.deps.emitApprovalEvent({ dismissed: true, teamName, runId });
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
reEvaluate(): void {
|
||||
for (const approvals of Array.from(this.approvalsByTeam.values())) {
|
||||
for (const entry of Array.from(approvals.values())) {
|
||||
const requestId = entry.approval.requestId;
|
||||
const settings = this.deps.getSettings(entry.approval.teamName);
|
||||
const autoResult = shouldAutoAllow(
|
||||
settings,
|
||||
entry.approval.toolName,
|
||||
entry.approval.toolInput
|
||||
);
|
||||
if (autoResult.autoAllow) {
|
||||
this.clearTimer(requestId);
|
||||
void this.answerTracked(entry, true, undefined, 'auto_allow_category');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (settings.timeoutAction === 'wait') {
|
||||
this.clearTimer(requestId);
|
||||
} else if (!this.timers.has(requestId)) {
|
||||
this.startTimeout(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(teamName: string, requestId: string): RuntimeToolApprovalEntry | undefined {
|
||||
return this.approvalsByTeam.get(teamName)?.get(requestId);
|
||||
}
|
||||
|
||||
size(teamName?: string): number {
|
||||
if (teamName) {
|
||||
return this.approvalsByTeam.get(teamName)?.size ?? 0;
|
||||
}
|
||||
let total = 0;
|
||||
for (const approvals of this.approvalsByTeam.values()) {
|
||||
total += approvals.size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const requestId of Array.from(this.timers.keys())) {
|
||||
this.clearTimer(requestId);
|
||||
}
|
||||
this.approvalsByTeam.clear();
|
||||
this.inFlightResponses.clear();
|
||||
}
|
||||
|
||||
private startTimeout(entry: RuntimeToolApprovalEntry): void {
|
||||
const { timeoutAction, timeoutSeconds } = this.deps.getSettings(entry.approval.teamName);
|
||||
if (timeoutAction === 'wait') {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = entry.approval.requestId;
|
||||
if (this.timers.has(requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(requestId);
|
||||
const current = this.get(entry.approval.teamName, requestId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const currentAction = this.deps.getSettings(entry.approval.teamName).timeoutAction;
|
||||
if (currentAction === 'wait') {
|
||||
return;
|
||||
}
|
||||
const allow = currentAction === 'allow';
|
||||
void this.answerTracked(
|
||||
current,
|
||||
allow,
|
||||
allow ? undefined : 'Timed out - auto-denied by settings',
|
||||
allow ? 'timeout_allow' : 'timeout_deny'
|
||||
);
|
||||
}, timeoutSeconds * 1000);
|
||||
timer.unref?.();
|
||||
this.timers.set(requestId, timer);
|
||||
}
|
||||
|
||||
private async answerTracked(
|
||||
entry: RuntimeToolApprovalEntry,
|
||||
allow: boolean,
|
||||
message: string | undefined,
|
||||
reason: ToolApprovalAutoResolved['reason']
|
||||
): Promise<void> {
|
||||
const requestId = entry.approval.requestId;
|
||||
if (!this.tryClaimResponse(requestId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.deps.answerApproval({ entry, allow, message });
|
||||
this.removeEntry(entry);
|
||||
this.deps.emitApprovalEvent({
|
||||
autoResolved: true,
|
||||
requestId,
|
||||
runId: entry.approval.runId,
|
||||
teamName: entry.approval.teamName,
|
||||
reason,
|
||||
} as ToolApprovalAutoResolved);
|
||||
} catch (error) {
|
||||
this.deps.logWarning?.(
|
||||
`[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
if (this.get(entry.approval.teamName, requestId) === entry) {
|
||||
this.startTimeout(entry);
|
||||
}
|
||||
} finally {
|
||||
this.inFlightResponses.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async answerUntracked(
|
||||
entry: RuntimeToolApprovalEntry,
|
||||
allow: boolean,
|
||||
message: string | undefined,
|
||||
reason: ToolApprovalAutoResolved['reason']
|
||||
): Promise<void> {
|
||||
const requestId = entry.approval.requestId;
|
||||
if (!this.tryClaimResponse(requestId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.deps.answerApproval({ entry, allow, message });
|
||||
this.deps.emitApprovalEvent({
|
||||
autoResolved: true,
|
||||
requestId,
|
||||
runId: entry.approval.runId,
|
||||
teamName: entry.approval.teamName,
|
||||
reason,
|
||||
} as ToolApprovalAutoResolved);
|
||||
} catch (error) {
|
||||
this.deps.logWarning?.(
|
||||
`[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
this.inFlightResponses.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private removeEntry(entry: RuntimeToolApprovalEntry): void {
|
||||
const requestId = entry.approval.requestId;
|
||||
this.clearTimer(requestId);
|
||||
this.inFlightResponses.delete(requestId);
|
||||
this.deps.dismissApprovalNotification?.(requestId);
|
||||
const approvals = this.approvalsByTeam.get(entry.approval.teamName);
|
||||
if (!approvals) {
|
||||
return;
|
||||
}
|
||||
approvals.delete(requestId);
|
||||
if (approvals.size === 0) {
|
||||
this.approvalsByTeam.delete(entry.approval.teamName);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(requestId: string): void {
|
||||
const timer = this.timers.get(requestId);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
this.timers.delete(requestId);
|
||||
}
|
||||
|
||||
private tryClaimResponse(requestId: string): boolean {
|
||||
if (this.inFlightResponses.has(requestId)) {
|
||||
return false;
|
||||
}
|
||||
this.inFlightResponses.add(requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private getTeamApprovals(teamName: string): Map<string, RuntimeToolApprovalEntry> {
|
||||
const existing = this.approvalsByTeam.get(teamName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const approvals = new Map<string, RuntimeToolApprovalEntry>();
|
||||
this.approvalsByTeam.set(teamName, approvals);
|
||||
return approvals;
|
||||
}
|
||||
|
||||
private matchesScope(
|
||||
entry: RuntimeToolApprovalEntry,
|
||||
scope: RuntimeToolApprovalSyncScope
|
||||
): boolean {
|
||||
if (entry.approval.teamName !== scope.teamName) {
|
||||
return false;
|
||||
}
|
||||
if (entry.approval.runId !== scope.runId) {
|
||||
return false;
|
||||
}
|
||||
if (scope.laneId && entry.laneId !== scope.laneId) {
|
||||
return false;
|
||||
}
|
||||
if (scope.providerId && entry.providerId !== scope.providerId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private matchesClearOptions(
|
||||
entry: RuntimeToolApprovalEntry,
|
||||
options: RuntimeToolApprovalClearOptions
|
||||
): boolean {
|
||||
if (options.runId && entry.approval.runId !== options.runId) {
|
||||
return false;
|
||||
}
|
||||
if (options.laneId && entry.laneId !== options.laneId) {
|
||||
return false;
|
||||
}
|
||||
if (options.providerId && entry.providerId !== options.providerId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner {
|
|||
run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult>;
|
||||
}
|
||||
|
||||
interface OpenCodeBridgeOutputReadResult {
|
||||
content: string;
|
||||
outputSource: 'stdout' | 'file' | 'none';
|
||||
stdoutBytes: number;
|
||||
outputFileBytes: number | null;
|
||||
outputReadError: string | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeBridgeDiagnosticsSink {
|
||||
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
|
||||
}
|
||||
|
|
@ -185,7 +193,16 @@ export class OpenCodeBridgeCommandClient {
|
|||
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
|
||||
env: await this.resolveEnv(),
|
||||
});
|
||||
const stdout = await this.readBridgeOutput(processResult.stdout, outputPath);
|
||||
const bridgeOutput = await this.readBridgeOutput(processResult.stdout, outputPath);
|
||||
const processDetails = {
|
||||
exitCode: processResult.exitCode,
|
||||
timedOut: processResult.timedOut,
|
||||
stdoutBytes: bridgeOutput.stdoutBytes,
|
||||
stderrBytes: byteLength(processResult.stderr),
|
||||
outputSource: bridgeOutput.outputSource,
|
||||
outputFileBytes: bridgeOutput.outputFileBytes,
|
||||
outputReadError: bridgeOutput.outputReadError,
|
||||
};
|
||||
|
||||
if (processResult.timedOut) {
|
||||
return this.contractFailure(
|
||||
|
|
@ -196,6 +213,7 @@ export class OpenCodeBridgeCommandClient {
|
|||
{
|
||||
stderr: redactBridgeDiagnosticText(processResult.stderr),
|
||||
attempts: attempt,
|
||||
...processDetails,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -207,14 +225,14 @@ export class OpenCodeBridgeCommandClient {
|
|||
'OpenCode bridge command failed',
|
||||
true,
|
||||
{
|
||||
exitCode: processResult.exitCode,
|
||||
stderr: redactBridgeDiagnosticText(processResult.stderr),
|
||||
attempts: attempt,
|
||||
...processDetails,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseSingleBridgeJsonResult<TData>(stdout);
|
||||
const parsed = parseSingleBridgeJsonResult<TData>(bridgeOutput.content);
|
||||
if (!parsed.ok) {
|
||||
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
|
||||
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
|
||||
|
|
@ -222,9 +240,10 @@ export class OpenCodeBridgeCommandClient {
|
|||
}
|
||||
|
||||
return this.contractFailure(envelope, 'contract_violation', parsed.error, false, {
|
||||
stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)),
|
||||
stdoutPreview: redactBridgeDiagnosticText(bridgeOutput.content.slice(0, 2_000)),
|
||||
stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)),
|
||||
attempts: attempt,
|
||||
...processDetails,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +251,7 @@ export class OpenCodeBridgeCommandClient {
|
|||
if (!validation.ok) {
|
||||
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {
|
||||
attempts: attempt,
|
||||
...processDetails,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -253,14 +273,38 @@ export class OpenCodeBridgeCommandClient {
|
|||
}
|
||||
}
|
||||
|
||||
private async readBridgeOutput(stdout: string, outputPath: string): Promise<string> {
|
||||
private async readBridgeOutput(
|
||||
stdout: string,
|
||||
outputPath: string
|
||||
): Promise<OpenCodeBridgeOutputReadResult> {
|
||||
const stdoutBytes = byteLength(stdout);
|
||||
if (stdout.trim().length > 0) {
|
||||
return stdout;
|
||||
return {
|
||||
content: stdout,
|
||||
outputSource: 'stdout',
|
||||
stdoutBytes,
|
||||
outputFileBytes: null,
|
||||
outputReadError: null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await fs.readFile(outputPath, 'utf8');
|
||||
} catch {
|
||||
return stdout;
|
||||
const output = await fs.readFile(outputPath, 'utf8');
|
||||
const outputFileBytes = byteLength(output);
|
||||
return {
|
||||
content: output,
|
||||
outputSource: output.trim().length > 0 ? 'file' : 'none',
|
||||
stdoutBytes,
|
||||
outputFileBytes,
|
||||
outputReadError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: stdout,
|
||||
outputSource: 'none',
|
||||
stdoutBytes,
|
||||
outputFileBytes: 0,
|
||||
outputReadError: getBridgeOutputReadError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +335,13 @@ export class OpenCodeBridgeCommandClient {
|
|||
details: Record<string, unknown>
|
||||
): Promise<OpenCodeBridgeFailure> {
|
||||
const completedAt = this.clock().toISOString();
|
||||
const diagnosticDetails = {
|
||||
command: envelope.command,
|
||||
requestId: envelope.requestId,
|
||||
cwd: redactBridgeDiagnosticText(envelope.cwd),
|
||||
binaryPath: redactBridgeDiagnosticText(this.binaryPath),
|
||||
...details,
|
||||
};
|
||||
const diagnostic: OpenCodeBridgeDiagnosticEvent = {
|
||||
id: this.diagnosticIdFactory(),
|
||||
type:
|
||||
|
|
@ -301,11 +352,11 @@ export class OpenCodeBridgeCommandClient {
|
|||
runId: extractRunId(envelope.body) ?? undefined,
|
||||
severity: retryable ? 'warning' : 'error',
|
||||
message,
|
||||
data: details,
|
||||
data: diagnosticDetails,
|
||||
createdAt: completedAt,
|
||||
};
|
||||
|
||||
await this.diagnostics?.append(diagnostic);
|
||||
await this.diagnostics?.append(diagnostic).catch(() => undefined);
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -318,7 +369,7 @@ export class OpenCodeBridgeCommandClient {
|
|||
kind,
|
||||
message,
|
||||
retryable,
|
||||
details,
|
||||
details: diagnosticDetails,
|
||||
},
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
|
|
@ -356,3 +407,17 @@ function bufferToString(value: string | Buffer | undefined): string {
|
|||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return Buffer.byteLength(value, 'utf8');
|
||||
}
|
||||
|
||||
function getBridgeOutputReadError(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && code.trim()) {
|
||||
return code.trim();
|
||||
}
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export interface OpenCodeLaunchTeamCommandBody {
|
|||
teamName: string;
|
||||
projectPath: string;
|
||||
selectedModel: string;
|
||||
skipPermissions?: boolean;
|
||||
members: OpenCodeTeamLaunchMemberCommandSpec[];
|
||||
leadPrompt: string;
|
||||
expectedCapabilitySnapshotId: string | null;
|
||||
|
|
@ -71,6 +72,15 @@ export interface OpenCodeLaunchTeamCommandBody {
|
|||
capabilitySnapshotRecoveryAttemptId?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimePermissionCommandData {
|
||||
requestId: string;
|
||||
sessionId: string | null;
|
||||
tool: string | null;
|
||||
title: string | null;
|
||||
kind: string | null;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamMemberLaunchCommandData {
|
||||
sessionId: string;
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState;
|
||||
|
|
@ -78,6 +88,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
|
|||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
pendingPermissions?: OpenCodeRuntimePermissionCommandData[];
|
||||
diagnostics?: string[];
|
||||
model: string;
|
||||
runtimePid?: number;
|
||||
|
|
@ -132,6 +143,30 @@ export interface OpenCodeStopTeamCommandData {
|
|||
runtimeStoreManifestHighWatermark?: number | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeAnswerPermissionCommandBody {
|
||||
runId: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
projectPath: string;
|
||||
memberName?: string;
|
||||
requestId: string;
|
||||
decision: 'allow' | 'always' | 'reject';
|
||||
expectedCapabilitySnapshotId?: string | null;
|
||||
manifestHighWatermark?: number | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeListRuntimePermissionsCommandBody {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeListRuntimePermissionsCommandData {
|
||||
permissions: OpenCodeRuntimePermissionCommandData[];
|
||||
}
|
||||
|
||||
export interface OpenCodeCleanupHostsCommandBody {
|
||||
reason: 'startup' | 'shutdown' | 'manual' | string;
|
||||
mode?: 'stale' | 'force';
|
||||
|
|
@ -590,6 +625,7 @@ export function assertBridgeResultCanMutateState<TData>(
|
|||
command: OpenCodeBridgeCommandName;
|
||||
runId: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
allowCapabilitySnapshotRecovery?: boolean;
|
||||
}
|
||||
): asserts result is OpenCodeBridgeSuccess<TData> {
|
||||
if (!result.ok) {
|
||||
|
|
@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState<TData>(
|
|||
|
||||
if (
|
||||
expected.capabilitySnapshotId !== null &&
|
||||
result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId
|
||||
result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId &&
|
||||
!(
|
||||
expected.allowCapabilitySnapshotRecovery === true &&
|
||||
hasOpenCodeBridgeDataDiagnosticCode(result.data, 'opencode_capability_snapshot_recovery')
|
||||
)
|
||||
) {
|
||||
throw new Error('OpenCode bridge capability snapshot mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
function hasOpenCodeBridgeDataDiagnosticCode(value: unknown, code: string): boolean {
|
||||
if (!isRecord(value) || !Array.isArray(value.diagnostics)) {
|
||||
return false;
|
||||
}
|
||||
return value.diagnostics.some((diagnostic) => {
|
||||
if (!isRecord(diagnostic)) {
|
||||
return false;
|
||||
}
|
||||
return diagnostic.code === code;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateOpenCodeBridgeHandshake(input: {
|
||||
handshake: OpenCodeBridgeHandshake;
|
||||
expectedClient: OpenCodeBridgePeerIdentity;
|
||||
|
|
@ -744,6 +796,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
|
|||
manifest: RuntimeStoreManifestEvidence;
|
||||
idempotencyKey: string;
|
||||
enforceManifestHighWatermark?: boolean;
|
||||
allowCapabilitySnapshotRecovery?: boolean;
|
||||
}): asserts input is {
|
||||
result: OpenCodeBridgeSuccess<unknown>;
|
||||
requestId: string;
|
||||
|
|
@ -758,6 +811,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
|
|||
command: input.command,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.capabilitySnapshotId,
|
||||
allowCapabilitySnapshotRecovery: input.allowCapabilitySnapshotRecovery,
|
||||
});
|
||||
|
||||
const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient';
|
||||
|
||||
import type { OpenCodeBridgeDiagnosticsSink } from './OpenCodeBridgeCommandClient';
|
||||
import type { OpenCodeBridgeDiagnosticEvent } from './OpenCodeBridgeCommandContract';
|
||||
|
||||
const DEFAULT_MAX_EVENTS_BYTES = 3 * 1024 * 1024;
|
||||
const MAX_STRING_CHARS = 4_000;
|
||||
|
||||
export interface OpenCodeBridgeDiagnosticsStoreOptions {
|
||||
directory: string;
|
||||
maxEventsBytes?: number;
|
||||
}
|
||||
|
||||
export class OpenCodeBridgeDiagnosticsStore implements OpenCodeBridgeDiagnosticsSink {
|
||||
private readonly directory: string;
|
||||
private readonly maxEventsBytes: number;
|
||||
|
||||
constructor(options: OpenCodeBridgeDiagnosticsStoreOptions) {
|
||||
this.directory = options.directory;
|
||||
this.maxEventsBytes = options.maxEventsBytes ?? DEFAULT_MAX_EVENTS_BYTES;
|
||||
}
|
||||
|
||||
async append(event: OpenCodeBridgeDiagnosticEvent): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
const sanitized = sanitizeDiagnosticEvent(event);
|
||||
await fs.writeFile(
|
||||
path.join(this.directory, 'latest.json'),
|
||||
`${JSON.stringify(sanitized, null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
await this.rotateEventsIfNeeded();
|
||||
await fs.appendFile(
|
||||
path.join(this.directory, 'events.ndjson'),
|
||||
`${JSON.stringify(sanitized)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
} catch {
|
||||
// Best-effort diagnostics must never block provider preflight or launch.
|
||||
}
|
||||
}
|
||||
|
||||
private async rotateEventsIfNeeded(): Promise<void> {
|
||||
const eventsPath = path.join(this.directory, 'events.ndjson');
|
||||
const stat = await fs.stat(eventsPath).catch(() => null);
|
||||
if (!stat || stat.size <= this.maxEventsBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(eventsPath, 'utf8').catch(() => '');
|
||||
const keepBytes = Math.max(0, Math.floor(this.maxEventsBytes / 2));
|
||||
const tailLines = selectNdjsonTailLines(content, keepBytes);
|
||||
await fs.writeFile(
|
||||
eventsPath,
|
||||
`${JSON.stringify({
|
||||
type: 'opencode_bridge_diagnostics_truncated',
|
||||
providerId: 'opencode',
|
||||
severity: 'info',
|
||||
message: 'truncated previous bridge diagnostics',
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n${tailLines.length > 0 ? `${tailLines.join('\n')}\n` : ''}`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNdjsonTailLines(content: string, maxBytes: number): string[] {
|
||||
if (maxBytes <= 0) {
|
||||
return [];
|
||||
}
|
||||
const selected: string[] = [];
|
||||
let totalBytes = 0;
|
||||
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
const nextBytes = Buffer.byteLength(`${line}\n`, 'utf8');
|
||||
if (selected.length > 0 && totalBytes + nextBytes > maxBytes) {
|
||||
break;
|
||||
}
|
||||
if (selected.length === 0 || totalBytes + nextBytes <= maxBytes) {
|
||||
selected.unshift(line);
|
||||
totalBytes += nextBytes;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sanitizeDiagnosticEvent(
|
||||
event: OpenCodeBridgeDiagnosticEvent
|
||||
): OpenCodeBridgeDiagnosticEvent {
|
||||
return {
|
||||
...event,
|
||||
message: sanitizeString(event.message),
|
||||
...(event.teamName ? { teamName: sanitizeString(event.teamName) } : {}),
|
||||
...(event.runId ? { runId: sanitizeString(event.runId) } : {}),
|
||||
...(event.data ? { data: sanitizeRecord(event.data) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeRecord(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, sanitizeRecordEntry(key, entry)])
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeRecordEntry(key: string, entry: unknown): unknown {
|
||||
const normalized = key.toLowerCase();
|
||||
if (
|
||||
normalized === 'stdin' ||
|
||||
normalized === 'stdout' ||
|
||||
normalized === 'stderr' ||
|
||||
normalized === 'input'
|
||||
) {
|
||||
return '[omitted]';
|
||||
}
|
||||
return sanitizeValue(entry);
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return sanitizeString(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeValue(entry));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return sanitizeRecord(value as Record<string, unknown>);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeString(value: string): string {
|
||||
const redacted = redactBridgeDiagnosticText(value);
|
||||
return redacted.length > MAX_STRING_CHARS
|
||||
? `${redacted.slice(0, MAX_STRING_CHARS)}...[truncated]`
|
||||
: redacted;
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import * as os from 'os';
|
||||
|
||||
import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient';
|
||||
|
||||
import type { OpenCodeBridgeFailure } from './OpenCodeBridgeCommandContract';
|
||||
import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team';
|
||||
|
||||
const NO_OUTPUT_TITLE = 'OpenCode runtime check returned no output';
|
||||
const NO_OUTPUT_SUMMARY = 'OpenCode readiness bridge exited without returning diagnostic JSON.';
|
||||
|
||||
export function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
|
||||
const lower = value?.trim().toLowerCase() ?? '';
|
||||
return (
|
||||
lower.includes('opencode runtime check returned no output') ||
|
||||
lower.includes('bridge stdout was empty') ||
|
||||
lower.includes('opencode_bridge_contract_violation') ||
|
||||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOpenCodeBridgeSupportDiagnostic(input: {
|
||||
result: OpenCodeBridgeFailure;
|
||||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
appVersion?: string | null;
|
||||
}): TeamProvisioningSupportDiagnostic | null {
|
||||
const event =
|
||||
input.result.diagnostics.find((diagnostic) =>
|
||||
isOpenCodeBridgeNoOutputDiagnostic(`${diagnostic.type}: ${diagnostic.message}`)
|
||||
) ?? input.result.diagnostics[0];
|
||||
const visibleError = `OpenCode readiness bridge failed: ${input.result.error.kind}: ${input.result.error.message}`;
|
||||
const eventText = event ? `${event.type}: ${event.message}` : '';
|
||||
if (
|
||||
!isOpenCodeBridgeNoOutputDiagnostic(visibleError) &&
|
||||
!isOpenCodeBridgeNoOutputDiagnostic(eventText)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = {
|
||||
...(event?.data ?? {}),
|
||||
...(input.result.error.details ?? {}),
|
||||
};
|
||||
const createdAt = event?.createdAt ?? input.result.completedAt;
|
||||
const copyText = buildOpenCodeBridgeSupportCopyText({
|
||||
createdAt,
|
||||
severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'),
|
||||
visibleError,
|
||||
details,
|
||||
result: input.result,
|
||||
projectPath: input.projectPath,
|
||||
selectedModel: input.selectedModel,
|
||||
appVersion: input.appVersion ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
id: event?.id ?? `opencode-bridge-support-${input.result.requestId}`,
|
||||
providerId: 'opencode',
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'),
|
||||
title: NO_OUTPUT_TITLE,
|
||||
summary: NO_OUTPUT_SUMMARY,
|
||||
copyText,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodeBridgeSupportCopyText(input: {
|
||||
createdAt: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
visibleError: string;
|
||||
details: Record<string, unknown>;
|
||||
result: OpenCodeBridgeFailure;
|
||||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
appVersion: string | null;
|
||||
}): string {
|
||||
const command = formatDiagnosticValue(input.details.command, input.result.command);
|
||||
const requestId = formatDiagnosticValue(input.details.requestId, input.result.requestId);
|
||||
const stderrPreview = formatPreview(input.details.stderrPreview);
|
||||
|
||||
return [
|
||||
'Agent Teams OpenCode diagnostics',
|
||||
`Time: ${input.createdAt}`,
|
||||
'Provider: opencode',
|
||||
`Severity: ${input.severity}`,
|
||||
`Title: ${NO_OUTPUT_TITLE}`,
|
||||
`Summary: ${NO_OUTPUT_SUMMARY}`,
|
||||
'',
|
||||
'Visible error:',
|
||||
redactBridgeDiagnosticText(input.visibleError),
|
||||
'',
|
||||
'Bridge command:',
|
||||
`command: ${command}`,
|
||||
`requestId: ${requestId}`,
|
||||
`attempts: ${formatDiagnosticValue(input.details.attempts)}`,
|
||||
`exitCode: ${formatDiagnosticValue(input.details.exitCode)}`,
|
||||
`timedOut: ${formatDiagnosticValue(input.details.timedOut)}`,
|
||||
`stdoutBytes: ${formatDiagnosticValue(input.details.stdoutBytes)}`,
|
||||
`stderrBytes: ${formatDiagnosticValue(input.details.stderrBytes)}`,
|
||||
`outputSource: ${formatDiagnosticValue(input.details.outputSource)}`,
|
||||
`outputFileBytes: ${formatDiagnosticValue(input.details.outputFileBytes)}`,
|
||||
`outputReadError: ${formatDiagnosticValue(input.details.outputReadError)}`,
|
||||
'',
|
||||
'Environment:',
|
||||
`platform: ${process.platform}`,
|
||||
`arch: ${process.arch}`,
|
||||
`appVersion: ${formatDiagnosticValue(input.appVersion)}`,
|
||||
`projectPath: ${redactDiagnosticPath(input.projectPath)}`,
|
||||
`selectedModel: ${formatDiagnosticValue(input.selectedModel)}`,
|
||||
'',
|
||||
'stderrPreview:',
|
||||
stderrPreview,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): string {
|
||||
const resolved = value ?? fallback;
|
||||
if (resolved === null || resolved === undefined || resolved === '') {
|
||||
return '(none)';
|
||||
}
|
||||
if (typeof resolved === 'string') {
|
||||
return redactBridgeDiagnosticText(resolved);
|
||||
}
|
||||
if (typeof resolved === 'number' || typeof resolved === 'boolean') {
|
||||
return String(resolved);
|
||||
}
|
||||
return redactBridgeDiagnosticText(JSON.stringify(resolved));
|
||||
}
|
||||
|
||||
function formatPreview(value: unknown): string {
|
||||
const formatted = formatDiagnosticValue(value);
|
||||
return formatted === '(none)' ? '(empty)' : formatted;
|
||||
}
|
||||
|
||||
function redactDiagnosticPath(value: string): string {
|
||||
const home = os.homedir();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '(none)';
|
||||
}
|
||||
if (home && trimmed.startsWith(home)) {
|
||||
return `~${trimmed.slice(home.length)}`;
|
||||
}
|
||||
return redactBridgeDiagnosticText(trimmed);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
stableHash,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import { buildOpenCodeBridgeSupportDiagnostic } from './OpenCodeBridgeSupportDiagnostics';
|
||||
|
||||
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||
import type {
|
||||
|
|
@ -11,6 +12,7 @@ import type {
|
|||
OpenCodeTeamLaunchReadinessState,
|
||||
} from '../readiness/OpenCodeTeamLaunchReadiness';
|
||||
import type {
|
||||
OpenCodeAnswerPermissionCommandBody,
|
||||
OpenCodeBackfillTaskLedgerCommandBody,
|
||||
OpenCodeBackfillTaskLedgerCommandData,
|
||||
OpenCodeBridgeCommandName,
|
||||
|
|
@ -24,6 +26,8 @@ import type {
|
|||
OpenCodeCommandStatusCommandData,
|
||||
OpenCodeLaunchTeamCommandBody,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeListRuntimePermissionsCommandBody,
|
||||
OpenCodeListRuntimePermissionsCommandData,
|
||||
OpenCodeObserveMessageDeliveryCommandBody,
|
||||
OpenCodeObserveMessageDeliveryCommandData,
|
||||
OpenCodeReconcileTeamCommandBody,
|
||||
|
|
@ -62,6 +66,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
observeTimeoutMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
cleanupTimeoutMs?: number;
|
||||
appVersion?: string;
|
||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +85,7 @@ const DEFAULT_SEND_TIMEOUT_MS = 45_000;
|
|||
const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
|
||||
const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000;
|
||||
const OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE =
|
||||
|
|
@ -118,6 +124,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
}
|
||||
|
||||
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
|
||||
const supportDiagnostic = buildOpenCodeBridgeSupportDiagnostic({
|
||||
result,
|
||||
projectPath: input.projectPath,
|
||||
selectedModel: input.selectedModel,
|
||||
appVersion: this.options.appVersion ?? null,
|
||||
});
|
||||
return blockedReadiness({
|
||||
state: mapBridgeFailureToReadinessState(result.error.kind),
|
||||
modelId: input.selectedModel,
|
||||
|
|
@ -126,6 +138,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
...result.diagnostics.map(formatDiagnosticEvent),
|
||||
],
|
||||
missing: [result.error.message],
|
||||
supportDiagnostics: supportDiagnostic ? [supportDiagnostic] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +217,40 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
};
|
||||
}
|
||||
|
||||
async answerOpenCodeRuntimePermission(
|
||||
input: OpenCodeAnswerPermissionCommandBody
|
||||
): Promise<OpenCodeLaunchTeamCommandData> {
|
||||
const result = await this.executeStateChangingCommand<
|
||||
OpenCodeAnswerPermissionCommandBody,
|
||||
OpenCodeLaunchTeamCommandData
|
||||
>('opencode.answerPermission', input, {
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
|
||||
cwd: input.projectPath,
|
||||
timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
|
||||
});
|
||||
return result.ok ? result.data : blockedLaunchData(input.runId, result);
|
||||
}
|
||||
|
||||
async listOpenCodeRuntimePermissions(
|
||||
input: OpenCodeListRuntimePermissionsCommandBody
|
||||
): Promise<OpenCodeListRuntimePermissionsCommandData> {
|
||||
const cwd = input.projectPath ?? process.cwd();
|
||||
const result = await this.bridge.execute<
|
||||
OpenCodeListRuntimePermissionsCommandBody,
|
||||
OpenCodeListRuntimePermissionsCommandData
|
||||
>('opencode.listRuntimePermissions', input, {
|
||||
cwd,
|
||||
timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
|
||||
});
|
||||
if (result.ok) {
|
||||
return result.data;
|
||||
}
|
||||
return { permissions: [] };
|
||||
}
|
||||
|
||||
async cleanupOpenCodeHosts(
|
||||
input: OpenCodeCleanupHostsCommandBody
|
||||
): Promise<OpenCodeCleanupHostsCommandData> {
|
||||
|
|
@ -565,7 +612,11 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
|
||||
type OpenCodeStateChangingTeamCommandName = Extract<
|
||||
OpenCodeBridgeCommandName,
|
||||
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage'
|
||||
| 'opencode.launchTeam'
|
||||
| 'opencode.reconcileTeam'
|
||||
| 'opencode.stopTeam'
|
||||
| 'opencode.sendMessage'
|
||||
| 'opencode.answerPermission'
|
||||
>;
|
||||
|
||||
function blockedLaunchData(
|
||||
|
|
@ -600,6 +651,7 @@ function blockedReadiness(input: {
|
|||
modelId: string | null;
|
||||
diagnostics: string[];
|
||||
missing: string[];
|
||||
supportDiagnostics?: OpenCodeTeamLaunchReadiness['supportDiagnostics'];
|
||||
}): OpenCodeTeamLaunchReadiness {
|
||||
return {
|
||||
state: input.state,
|
||||
|
|
@ -617,6 +669,9 @@ function blockedReadiness(input: {
|
|||
supportLevel: null,
|
||||
missing: dedupe(input.missing),
|
||||
diagnostics: dedupe(input.diagnostics),
|
||||
...(input.supportDiagnostics?.length
|
||||
? { supportDiagnostics: [...input.supportDiagnostics] }
|
||||
: {}),
|
||||
evidence: {
|
||||
capabilitiesReady: false,
|
||||
mcpToolProofRoute: null,
|
||||
|
|
|
|||
|
|
@ -245,6 +245,10 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
manifest,
|
||||
idempotencyKey,
|
||||
enforceManifestHighWatermark,
|
||||
allowCapabilitySnapshotRecovery: isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
|
||||
input.command,
|
||||
input.body
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
await this.ledger.markFailed({
|
||||
|
|
@ -354,6 +358,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: unknown
|
||||
): boolean {
|
||||
if (command !== 'opencode.launchTeam' || !isRecord(body)) {
|
||||
return false;
|
||||
}
|
||||
const recoveryAttemptId = body.capabilitySnapshotRecoveryAttemptId;
|
||||
return typeof recoveryAttemptId === 'string' && recoveryAttemptId.trim().length > 0;
|
||||
}
|
||||
|
||||
function requiresOpenCodeDeliveryAcceptanceContract(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: unknown
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
||||
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
||||
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
||||
import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team';
|
||||
|
||||
export type OpenCodeTeamLaunchReadinessState =
|
||||
| 'ready'
|
||||
|
|
@ -57,6 +58,7 @@ export interface OpenCodeTeamLaunchReadiness {
|
|||
supportLevel: OpenCodeSupportLevel | null;
|
||||
missing: string[];
|
||||
diagnostics: string[];
|
||||
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
|
||||
evidence: {
|
||||
capabilitiesReady: boolean;
|
||||
mcpToolProofRoute: OpenCodeMcpToolProof['route'];
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type {
|
||||
OpenCodeAnswerPermissionCommandBody,
|
||||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeLaunchTeamCommandBody,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeObserveMessageDeliveryCommandBody,
|
||||
OpenCodeObserveMessageDeliveryCommandData,
|
||||
OpenCodeReconcileTeamCommandBody,
|
||||
OpenCodeRuntimePermissionCommandData,
|
||||
OpenCodeSendMessageCommandBody,
|
||||
OpenCodeSendMessageCommandData,
|
||||
OpenCodeStopTeamCommandBody,
|
||||
|
|
@ -20,6 +22,8 @@ import type {
|
|||
TeamRuntimeLaunchResult,
|
||||
TeamRuntimeMemberLaunchEvidence,
|
||||
TeamRuntimeMemberStopEvidence,
|
||||
TeamRuntimePendingPermission,
|
||||
TeamRuntimePermissionAnswerInput,
|
||||
TeamRuntimePrepareResult,
|
||||
TeamRuntimeReconcileInput,
|
||||
TeamRuntimeReconcileResult,
|
||||
|
|
@ -52,6 +56,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
observeOpenCodeTeamMessageDelivery?(
|
||||
input: OpenCodeObserveMessageDeliveryCommandBody
|
||||
): Promise<OpenCodeObserveMessageDeliveryCommandData>;
|
||||
answerOpenCodeRuntimePermission?(
|
||||
input: OpenCodeAnswerPermissionCommandBody
|
||||
): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeMessageInput {
|
||||
|
|
@ -199,6 +206,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
retryable: isRetryableReadinessState(readiness.state),
|
||||
diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing),
|
||||
warnings: [],
|
||||
...(readiness.supportDiagnostics?.length
|
||||
? { supportDiagnostics: [...readiness.supportDiagnostics] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +218,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
modelId: readiness.modelId,
|
||||
diagnostics: readiness.diagnostics,
|
||||
warnings: [],
|
||||
...(readiness.supportDiagnostics?.length
|
||||
? { supportDiagnostics: [...readiness.supportDiagnostics] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +303,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
teamName: input.teamName,
|
||||
projectPath: input.cwd,
|
||||
selectedModel: model,
|
||||
skipPermissions: input.skipPermissions,
|
||||
members: input.expectedMembers.map((member) => ({
|
||||
name: member.name,
|
||||
role: member.role?.trim() || member.workflow?.trim() || 'teammate',
|
||||
|
|
@ -547,6 +561,42 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
async answerRuntimePermission(
|
||||
input: TeamRuntimePermissionAnswerInput
|
||||
): Promise<TeamRuntimeLaunchResult> {
|
||||
if (!this.bridge.answerOpenCodeRuntimePermission) {
|
||||
throw new Error('OpenCode permission answer bridge is not registered.');
|
||||
}
|
||||
|
||||
const data = await this.bridge.answerOpenCodeRuntimePermission({
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: input.cwd,
|
||||
memberName: input.memberName,
|
||||
requestId: input.requestId,
|
||||
decision: input.decision,
|
||||
expectedCapabilitySnapshotId: null,
|
||||
manifestHighWatermark: null,
|
||||
});
|
||||
|
||||
return mapOpenCodeLaunchDataToRuntimeResult(
|
||||
{
|
||||
runId: input.runId,
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
cwd: input.cwd,
|
||||
providerId: this.providerId,
|
||||
skipPermissions: false,
|
||||
expectedMembers: input.expectedMembers,
|
||||
previousLaunchState: input.previousLaunchState,
|
||||
},
|
||||
data,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
|
||||
if (this.bridge.stopOpenCodeTeam) {
|
||||
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
|
||||
|
|
@ -701,6 +751,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
bridgeMember?.model,
|
||||
bridgeMember?.runtimePid,
|
||||
bridgeMember?.pendingPermissionRequestIds,
|
||||
bridgeMember?.pendingPermissions,
|
||||
bridgeMember != null,
|
||||
memberDiagnostics,
|
||||
input.runId,
|
||||
|
|
@ -798,6 +849,33 @@ function normalizeAppManagedBootstrapCandidate(
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeOpenCodeRuntimePendingPermissions(
|
||||
permissions: OpenCodeRuntimePermissionCommandData[] | undefined
|
||||
): TeamRuntimePendingPermission[] | undefined {
|
||||
if (!permissions?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: TeamRuntimePendingPermission[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const permission of permissions) {
|
||||
const requestId = permission.requestId?.trim();
|
||||
if (!requestId || seen.has(requestId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(requestId);
|
||||
normalized.push({
|
||||
providerId: 'opencode',
|
||||
requestId,
|
||||
sessionId: permission.sessionId ?? null,
|
||||
tool: permission.tool ?? null,
|
||||
title: permission.title ?? null,
|
||||
kind: permission.kind ?? null,
|
||||
...(permission.raw ? { raw: permission.raw } : {}),
|
||||
});
|
||||
}
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function mapBridgeMemberToRuntimeEvidence(
|
||||
memberName: string,
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState,
|
||||
|
|
@ -805,6 +883,7 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
model: string | undefined,
|
||||
runtimePid: number | undefined,
|
||||
pendingPermissionRequestIds: string[] | undefined,
|
||||
pendingPermissions: OpenCodeRuntimePermissionCommandData[] | undefined,
|
||||
runtimeMaterialized: boolean,
|
||||
diagnostics: string[],
|
||||
runId: string,
|
||||
|
|
@ -863,6 +942,7 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
|
||||
? 'warning'
|
||||
: undefined;
|
||||
const normalizedPendingApprovals = normalizeOpenCodeRuntimePendingPermissions(pendingPermissions);
|
||||
return {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -887,6 +967,8 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0
|
||||
? [...new Set(pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
pendingApprovals: normalizedPendingApprovals,
|
||||
pendingPermissions: normalizedPendingApprovals,
|
||||
sessionId,
|
||||
...(appManagedCandidatePresent
|
||||
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamLaunchAggregateState,
|
||||
TeamProvisioningSupportDiagnostic,
|
||||
} from '@shared/types';
|
||||
|
||||
export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const;
|
||||
|
|
@ -28,6 +29,33 @@ export interface TeamRuntimeMemberSpec {
|
|||
cwd: string;
|
||||
}
|
||||
|
||||
export type TeamRuntimeApprovalProviderId = 'anthropic' | 'opencode' | 'codex';
|
||||
|
||||
export interface TeamRuntimePendingApproval {
|
||||
providerId: Extract<TeamRuntimeApprovalProviderId, 'opencode' | 'codex'>;
|
||||
requestId: string;
|
||||
sessionId?: string | null;
|
||||
tool?: string | null;
|
||||
title?: string | null;
|
||||
kind?: string | null;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TeamRuntimePendingPermission = TeamRuntimePendingApproval;
|
||||
|
||||
export interface TeamRuntimePermissionAnswerInput {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
cwd: string;
|
||||
providerId: Extract<TeamRuntimeApprovalProviderId, 'opencode' | 'codex'>;
|
||||
memberName: string;
|
||||
requestId: string;
|
||||
decision: 'allow' | 'reject';
|
||||
expectedMembers: TeamRuntimeMemberSpec[];
|
||||
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
||||
}
|
||||
|
||||
export interface TeamRuntimeLaunchInput {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
|
|
@ -58,6 +86,7 @@ export interface TeamRuntimePrepareSuccess {
|
|||
modelId: string | null;
|
||||
diagnostics: string[];
|
||||
warnings: string[];
|
||||
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
|
||||
}
|
||||
|
||||
export interface TeamRuntimePrepareFailure {
|
||||
|
|
@ -67,6 +96,7 @@ export interface TeamRuntimePrepareFailure {
|
|||
diagnostics: string[];
|
||||
warnings: string[];
|
||||
retryable: boolean;
|
||||
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
|
||||
}
|
||||
|
||||
export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure;
|
||||
|
|
@ -82,6 +112,8 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
hardFailure: boolean;
|
||||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
pendingApprovals?: TeamRuntimePendingApproval[];
|
||||
pendingPermissions?: TeamRuntimePendingApproval[];
|
||||
sessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
|
|
@ -171,6 +203,9 @@ export interface TeamLaunchRuntimeAdapter {
|
|||
launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult>;
|
||||
reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult>;
|
||||
stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult>;
|
||||
answerRuntimePermission?(
|
||||
input: TeamRuntimePermissionAnswerInput
|
||||
): Promise<TeamRuntimeLaunchResult>;
|
||||
}
|
||||
|
||||
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ export type {
|
|||
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
|
||||
export type {
|
||||
TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeApprovalProviderId,
|
||||
TeamRuntimeLaunchInput,
|
||||
TeamRuntimeLaunchResult,
|
||||
TeamRuntimeMemberLaunchEvidence,
|
||||
TeamRuntimeMemberSpec,
|
||||
TeamRuntimeMemberStopEvidence,
|
||||
TeamRuntimePendingApproval,
|
||||
TeamRuntimePendingPermission,
|
||||
TeamRuntimePrepareFailure,
|
||||
TeamRuntimePrepareResult,
|
||||
TeamRuntimePrepareSuccess,
|
||||
|
|
|
|||
|
|
@ -1082,6 +1082,7 @@ export const CreateTeamDialog = ({
|
|||
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.cachedSnapshot.details,
|
||||
supportDiagnostics: undefined,
|
||||
});
|
||||
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
|
||||
}
|
||||
|
|
@ -1150,6 +1151,7 @@ export const CreateTeamDialog = ({
|
|||
status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
supportDiagnostics: undefined,
|
||||
}
|
||||
);
|
||||
commitChecks(nextChecks);
|
||||
|
|
@ -1181,6 +1183,7 @@ export const CreateTeamDialog = ({
|
|||
status: prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: prepResult.details,
|
||||
supportDiagnostics: prepResult.supportDiagnostics,
|
||||
});
|
||||
commitChecks(nextChecks);
|
||||
applyPrepareOutcome(nextChecks, loadingMessage);
|
||||
|
|
@ -1194,6 +1197,7 @@ export const CreateTeamDialog = ({
|
|||
status: 'failed',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: [failureMessage],
|
||||
supportDiagnostics: undefined,
|
||||
});
|
||||
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
|
||||
commitChecks(nextChecks);
|
||||
|
|
|
|||
|
|
@ -1674,6 +1674,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.cachedSnapshot.details,
|
||||
supportDiagnostics: undefined,
|
||||
});
|
||||
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
|
||||
}
|
||||
|
|
@ -1722,6 +1723,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
supportDiagnostics: undefined,
|
||||
});
|
||||
commitChecks(nextChecks);
|
||||
applyPrepareOutcome(nextChecks, loadingMessage);
|
||||
|
|
@ -1752,6 +1754,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
status: prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: prepResult.details,
|
||||
supportDiagnostics: prepResult.supportDiagnostics,
|
||||
});
|
||||
commitChecks(nextChecks);
|
||||
applyPrepareOutcome(nextChecks, loadingMessage);
|
||||
|
|
@ -1765,6 +1768,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
status: 'failed',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: [failureMessage],
|
||||
supportDiagnostics: undefined,
|
||||
});
|
||||
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
|
||||
commitChecks(nextChecks);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getProvisioningFailureHint } from './ProvisioningProviderStatusList';
|
||||
|
||||
describe('getProvisioningFailureHint', () => {
|
||||
it('returns the OpenCode Windows permissions hint for OpenCode access-denied details', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint(null, [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps non-OpenCode access-denied details on the generic CLI hint', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint(null, [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
status: 'failed',
|
||||
details: ['EACCES: permission denied'],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat a mixed-provider generic access-denied message as OpenCode-specific', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint('EACCES: permission denied', [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'ready',
|
||||
details: [],
|
||||
},
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
status: 'failed',
|
||||
details: ['EACCES: permission denied'],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prefer OpenCode access-denied notes over another provider failure', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint(null, [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'notes',
|
||||
details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE],
|
||||
},
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
status: 'failed',
|
||||
details: ['EACCES: permission denied'],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a normalized OpenCode access-denied message for a failed mixed-provider check', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [],
|
||||
},
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
status: 'ready',
|
||||
details: [],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a raw OpenCode access-denied message when no other provider failed', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint('EPERM: operation not permitted', [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [],
|
||||
},
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
status: 'ready',
|
||||
details: [],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the OpenCode Windows permissions hint for a single OpenCode access-denied message', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint('EPERM: operation not permitted', [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps existing OpenCode runtime missing and MCP hints unchanged', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint(null, [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [
|
||||
'OpenCode runtime binary is not installed or not reachable by launch preflight.',
|
||||
],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
|
||||
);
|
||||
|
||||
expect(
|
||||
getProvisioningFailureHint(null, [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: ['OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,9 +2,17 @@ import React from 'react';
|
|||
|
||||
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { AlertTriangle, CheckCircle2, Loader2, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
|
||||
} from '@shared/utils/openCodeWindowsAccessDenied';
|
||||
import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
import type {
|
||||
CliProviderStatus,
|
||||
TeamProviderId,
|
||||
TeamProvisioningSupportDiagnostic,
|
||||
} from '@shared/types';
|
||||
|
||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
|
||||
|
|
@ -14,6 +22,7 @@ export interface ProvisioningProviderCheck {
|
|||
status: ProvisioningProviderCheckStatus;
|
||||
backendSummary?: string | null;
|
||||
details: string[];
|
||||
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
|
||||
}
|
||||
|
||||
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
|
||||
|
|
@ -151,6 +160,8 @@ export function getProvisioningProviderProgressMessage(
|
|||
type ProvisioningDetailSummary =
|
||||
| 'CLI binary missing'
|
||||
| 'OpenCode runtime missing'
|
||||
| 'OpenCode Windows access blocked'
|
||||
| 'OpenCode runtime check returned no output'
|
||||
| 'OpenCode app MCP unreachable'
|
||||
| 'Working directory missing'
|
||||
| 'CLI binary could not be started'
|
||||
|
|
@ -174,6 +185,16 @@ function isSelectedModelDetail(lower: string): boolean {
|
|||
return lower.includes('selected model');
|
||||
}
|
||||
|
||||
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
|
||||
const lower = value?.trim().toLowerCase() ?? '';
|
||||
return (
|
||||
lower.includes('opencode runtime check returned no output') ||
|
||||
lower.includes('bridge stdout was empty') ||
|
||||
lower.includes('opencode_bridge_contract_violation') ||
|
||||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
|
||||
);
|
||||
}
|
||||
|
||||
function isFormattedModelDetail(lower: string): boolean {
|
||||
return (
|
||||
lower.includes(' - checking...') ||
|
||||
|
|
@ -219,10 +240,17 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
|
|||
|
||||
function summarizeDetail(
|
||||
detail: string,
|
||||
status: ProvisioningProviderCheckStatus
|
||||
status: ProvisioningProviderCheckStatus,
|
||||
providerId?: TeamProviderId
|
||||
): ProvisioningDetailSummary | null {
|
||||
const lower = detail.toLowerCase();
|
||||
|
||||
if (providerId === 'opencode' && isOpenCodeWindowsAccessDeniedDiagnostic(detail)) {
|
||||
return 'OpenCode Windows access blocked';
|
||||
}
|
||||
if (providerId === 'opencode' && isOpenCodeBridgeNoOutputDiagnostic(detail)) {
|
||||
return 'OpenCode runtime check returned no output';
|
||||
}
|
||||
if (lower.includes('spawn ') && lower.includes(' enoent')) {
|
||||
return 'CLI binary missing';
|
||||
}
|
||||
|
|
@ -449,13 +477,15 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
|||
}
|
||||
|
||||
const summarizedDetails = publicDetails
|
||||
.map((detail) => summarizeDetail(detail, check.status))
|
||||
.map((detail) => summarizeDetail(detail, check.status, check.providerId))
|
||||
.filter((detail): detail is ProvisioningDetailSummary => Boolean(detail));
|
||||
|
||||
const summary =
|
||||
check.status === 'failed'
|
||||
? (summarizedDetails.find(
|
||||
(detail) =>
|
||||
detail === 'OpenCode Windows access blocked' ||
|
||||
detail === 'OpenCode runtime check returned no output' ||
|
||||
detail === 'OpenCode app MCP unreachable' ||
|
||||
detail === 'OpenCode runtime missing' ||
|
||||
detail === 'Selected model unavailable' ||
|
||||
|
|
@ -472,9 +502,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
|||
|
||||
function getDetailTone(
|
||||
detail: string,
|
||||
status: ProvisioningProviderCheckStatus
|
||||
status: ProvisioningProviderCheckStatus,
|
||||
providerId?: TeamProviderId
|
||||
): 'success' | 'failure' | 'checking' | 'neutral' {
|
||||
const summary = summarizeDetail(detail, status);
|
||||
const summary = summarizeDetail(detail, status, providerId);
|
||||
if (
|
||||
summary === 'Selected model verified' ||
|
||||
summary === 'Selected model available' ||
|
||||
|
|
@ -493,6 +524,8 @@ function getDetailTone(
|
|||
summary === 'Selected model check failed' ||
|
||||
summary === 'CLI binary missing' ||
|
||||
summary === 'OpenCode runtime missing' ||
|
||||
summary === 'OpenCode Windows access blocked' ||
|
||||
summary === 'OpenCode runtime check returned no output' ||
|
||||
summary === 'OpenCode app MCP unreachable' ||
|
||||
summary === 'Working directory missing' ||
|
||||
summary === 'CLI binary could not be started' ||
|
||||
|
|
@ -510,8 +543,12 @@ function getDetailTone(
|
|||
return 'neutral';
|
||||
}
|
||||
|
||||
function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string {
|
||||
switch (getDetailTone(detail, status)) {
|
||||
function getDetailColorClass(
|
||||
detail: string,
|
||||
status: ProvisioningProviderCheckStatus,
|
||||
providerId?: TeamProviderId
|
||||
): string {
|
||||
switch (getDetailTone(detail, status, providerId)) {
|
||||
case 'success':
|
||||
return 'text-emerald-400';
|
||||
case 'failure':
|
||||
|
|
@ -551,14 +588,14 @@ export function getPrimaryProvisioningFailureDetail(
|
|||
|
||||
const publicDetails = getPublicProvisioningDetails(check.details);
|
||||
const preferredFailure = publicDetails.find(
|
||||
(detail) => getDetailTone(detail, check.status) === 'failure'
|
||||
(detail) => getDetailTone(detail, check.status, check.providerId) === 'failure'
|
||||
);
|
||||
if (preferredFailure) {
|
||||
return preferredFailure;
|
||||
}
|
||||
|
||||
const nonSuccessDetail = publicDetails.find(
|
||||
(detail) => getDetailTone(detail, check.status) !== 'success'
|
||||
(detail) => getDetailTone(detail, check.status, check.providerId) !== 'success'
|
||||
);
|
||||
if (nonSuccessDetail) {
|
||||
return nonSuccessDetail;
|
||||
|
|
@ -715,6 +752,16 @@ function getProvisioningProviderSettingsActionLabel(
|
|||
: null;
|
||||
}
|
||||
|
||||
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
|
||||
if (check.providerId !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
const payloads = (check.supportDiagnostics ?? [])
|
||||
.map((diagnostic) => diagnostic.copyText.trim())
|
||||
.filter(Boolean);
|
||||
return payloads.length > 0 ? payloads.join('\n\n---\n\n') : null;
|
||||
}
|
||||
|
||||
export const ProvisioningProviderStatusList = ({
|
||||
checks,
|
||||
className = '',
|
||||
|
|
@ -726,10 +773,29 @@ export const ProvisioningProviderStatusList = ({
|
|||
suppressDetailsMatching?: string | null;
|
||||
onOpenProviderSettings?: (providerId: TeamProviderId) => void;
|
||||
}): React.JSX.Element | null => {
|
||||
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState<string | null>(null);
|
||||
|
||||
if (checks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copySupportDiagnostics = async (copyKey: string, payload: string): Promise<void> => {
|
||||
try {
|
||||
const writeText = globalThis.navigator?.clipboard?.writeText;
|
||||
if (typeof writeText !== 'function') {
|
||||
setCopiedDiagnosticsKey(null);
|
||||
return;
|
||||
}
|
||||
await writeText.call(globalThis.navigator.clipboard, payload);
|
||||
setCopiedDiagnosticsKey(copyKey);
|
||||
globalThis.setTimeout(() => {
|
||||
setCopiedDiagnosticsKey((currentKey) => (currentKey === copyKey ? null : currentKey));
|
||||
}, 1500);
|
||||
} catch {
|
||||
setCopiedDiagnosticsKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-1 pl-5 ${className}`.trim()}>
|
||||
{checks.map((check) => {
|
||||
|
|
@ -740,6 +806,12 @@ export const ProvisioningProviderStatusList = ({
|
|||
const settingsActionLabel = onOpenProviderSettings
|
||||
? getProvisioningProviderSettingsActionLabel(check)
|
||||
: null;
|
||||
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
|
||||
const supportDiagnosticsKey =
|
||||
supportDiagnosticsPayload && check.supportDiagnostics?.[0]
|
||||
? `${check.providerId}:${check.supportDiagnostics[0].id}`
|
||||
: check.providerId;
|
||||
const copiedDiagnostics = copiedDiagnosticsKey === supportDiagnosticsKey;
|
||||
|
||||
return (
|
||||
<div key={check.providerId}>
|
||||
|
|
@ -758,7 +830,11 @@ export const ProvisioningProviderStatusList = ({
|
|||
{visibleDetails.map((detail, index) => (
|
||||
<p
|
||||
key={`${check.providerId}:${index}:${detail}`}
|
||||
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
|
||||
className={`text-[10px] ${getDetailColorClass(
|
||||
detail,
|
||||
check.status,
|
||||
check.providerId
|
||||
)}`}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
|
|
@ -781,6 +857,24 @@ export const ProvisioningProviderStatusList = ({
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{supportDiagnosticsPayload ? (
|
||||
<div className="mt-1 pl-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
onClick={() =>
|
||||
void copySupportDiagnostics(supportDiagnosticsKey, supportDiagnosticsPayload)
|
||||
}
|
||||
>
|
||||
{copiedDiagnostics ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedDiagnostics ? 'Copied' : 'Copy diagnostics'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -792,6 +886,34 @@ export function getProvisioningFailureHint(
|
|||
message: string | null | undefined,
|
||||
checks: ProvisioningProviderCheck[]
|
||||
): string {
|
||||
const failedOpenCodeChecks = checks.filter(
|
||||
(check) => check.providerId === 'opencode' && check.status === 'failed'
|
||||
);
|
||||
const hasFailedNonOpenCodeCheck = checks.some(
|
||||
(check) => check.providerId !== 'opencode' && check.status === 'failed'
|
||||
);
|
||||
const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) =>
|
||||
check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic)
|
||||
);
|
||||
const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) =>
|
||||
check.details.some(isOpenCodeBridgeNoOutputDiagnostic)
|
||||
);
|
||||
const normalizedMessage = message?.trim() ?? '';
|
||||
const hasOpenCodeAccessDeniedMessage =
|
||||
failedOpenCodeChecks.length > 0 &&
|
||||
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
|
||||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
|
||||
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
|
||||
return 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
|
||||
}
|
||||
const hasOpenCodeBridgeNoOutputMessage =
|
||||
failedOpenCodeChecks.length > 0 &&
|
||||
!hasFailedNonOpenCodeCheck &&
|
||||
isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage);
|
||||
if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) {
|
||||
return 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
|
||||
}
|
||||
|
||||
const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runProviderPrepareDiagnostics } from './providerPrepareDiagnostics';
|
||||
|
||||
import type { TeamProvisioningPrepareResult } from '@shared/types';
|
||||
|
||||
describe('runProviderPrepareDiagnostics', () => {
|
||||
it('normalizes OpenCode access-denied provider failures', async () => {
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: 'C:\\Program Files\\locked-project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: [],
|
||||
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
|
||||
ready: false,
|
||||
message: 'OpenCode bridge failed: EPERM: operation not permitted, mkdir C:\\Program Files',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
});
|
||||
|
||||
it('keeps non-OpenCode access-denied provider failures generic', async () => {
|
||||
const detail = 'EACCES: permission denied, open C:\\work\\repo';
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: 'C:\\work\\repo',
|
||||
providerId: 'anthropic',
|
||||
selectedModelIds: [],
|
||||
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
|
||||
ready: false,
|
||||
message: detail,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.details).toEqual([detail]);
|
||||
});
|
||||
|
||||
it('normalizes OpenCode access-denied runtime note details', async () => {
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: 'C:\\Program Files\\locked-project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: [],
|
||||
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
|
||||
ready: true,
|
||||
message: '',
|
||||
warnings: ['EACCES: permission denied, open C:\\Program Files\\locked-project'],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('notes');
|
||||
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
});
|
||||
|
||||
it('treats model-scoped OpenCode access-denied details as provider failures', async () => {
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: 'C:\\Program Files\\locked-project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/big-pickle'],
|
||||
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
|
||||
ready: false,
|
||||
message: 'Selected model opencode/big-pickle is unavailable.',
|
||||
details: [
|
||||
'Selected model opencode/big-pickle is unavailable. EPERM: operation not permitted',
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
expect(result.modelResultsById).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import {
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
} from '@shared/utils/openCodeWindowsAccessDenied';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
|
||||
import type {
|
||||
|
|
@ -6,6 +10,7 @@ import type {
|
|||
TeamProvisioningModelCheckRequest,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningSupportDiagnostic,
|
||||
} from '@shared/types';
|
||||
|
||||
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
|
||||
|
|
@ -45,6 +50,7 @@ export interface ProviderPrepareDiagnosticsResult {
|
|||
details: string[];
|
||||
warnings: string[];
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
|
||||
}
|
||||
|
||||
type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number];
|
||||
|
|
@ -93,6 +99,45 @@ function uniquePrepareLines(lines: (string | null | undefined)[]): string[] {
|
|||
return uniqueLines;
|
||||
}
|
||||
|
||||
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
|
||||
const lower = value?.trim().toLowerCase() ?? '';
|
||||
return (
|
||||
lower.includes('opencode runtime check returned no output') ||
|
||||
lower.includes('bridge stdout was empty') ||
|
||||
lower.includes('opencode_bridge_contract_violation') ||
|
||||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
|
||||
);
|
||||
}
|
||||
|
||||
function cloneSupportDiagnostics(
|
||||
diagnostics: readonly TeamProvisioningSupportDiagnostic[] | undefined
|
||||
): TeamProvisioningSupportDiagnostic[] {
|
||||
return (diagnostics ?? []).map((diagnostic) => ({ ...diagnostic }));
|
||||
}
|
||||
|
||||
function mergeSupportDiagnostics(
|
||||
target: TeamProvisioningSupportDiagnostic[],
|
||||
incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined
|
||||
): void {
|
||||
for (const diagnostic of incoming ?? []) {
|
||||
if (!target.some((existing) => existing.id === diagnostic.id)) {
|
||||
target.push({ ...diagnostic });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withSupportDiagnostics(
|
||||
result: ProviderPrepareDiagnosticsResult,
|
||||
supportDiagnostics: readonly TeamProvisioningSupportDiagnostic[]
|
||||
): ProviderPrepareDiagnosticsResult {
|
||||
return supportDiagnostics.length > 0
|
||||
? {
|
||||
...result,
|
||||
supportDiagnostics: cloneSupportDiagnostics(supportDiagnostics),
|
||||
}
|
||||
: result;
|
||||
}
|
||||
|
||||
function getModelLabel(providerId: TeamProviderId, modelId: string): string {
|
||||
if (isDefaultProviderModelSelection(modelId)) {
|
||||
return 'Default';
|
||||
|
|
@ -287,8 +332,12 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
|
|||
}
|
||||
|
||||
return (
|
||||
isOpenCodeBridgeNoOutputDiagnostic(reason) ||
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic(reason) ||
|
||||
lower.includes('opencode /experimental/tool') ||
|
||||
lower.includes('/experimental/tool') ||
|
||||
lower.includes('opencode_bridge_contract_violation') ||
|
||||
lower.includes('bridge stdout was empty') ||
|
||||
lower.includes('mcp_unavailable') ||
|
||||
lower.includes('unable to connect') ||
|
||||
lower.includes('runtime store') ||
|
||||
|
|
@ -339,7 +388,9 @@ function isAdvisoryOpenCodeDeepVerificationIssue(
|
|||
lower.includes('/experimental/tool') ||
|
||||
lower.includes('runtime store') ||
|
||||
lower.includes('opencode cli') ||
|
||||
lower.includes('opencode runtime binary');
|
||||
lower.includes('opencode runtime binary') ||
|
||||
isOpenCodeBridgeNoOutputDiagnostic(lower) ||
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic(lower);
|
||||
if (hasHardRuntimeMarker) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -476,27 +527,48 @@ function buildModelVerificationDeferredLine(
|
|||
: `${label} - verification deferred`;
|
||||
}
|
||||
|
||||
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
|
||||
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]);
|
||||
function createRuntimeDetailLines(
|
||||
result: TeamProvisioningPrepareResult,
|
||||
providerId: TeamProviderId
|
||||
): string[] {
|
||||
return uniquePrepareLines(
|
||||
[...(result.details ?? []), ...(result.warnings ?? [])]
|
||||
.map((detail) => normalizeRuntimeDetailLine(detail, providerId))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] {
|
||||
function createRuntimeWarningLines(
|
||||
result: TeamProvisioningPrepareResult,
|
||||
providerId: TeamProviderId
|
||||
): string[] {
|
||||
return uniquePrepareLines(
|
||||
(result.warnings ?? [])
|
||||
.map((warning) => normalizeRuntimeFailureDetailLine(warning))
|
||||
.map((warning) => normalizeRuntimeFailureDetailLine(warning, undefined, providerId))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeFailureDetailLine(
|
||||
detail: string | null | undefined,
|
||||
code?: string | null
|
||||
code?: string | null,
|
||||
providerId?: TeamProviderId
|
||||
): string | null {
|
||||
const trimmed = detail?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (providerId === 'opencode') {
|
||||
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
|
||||
return 'OpenCode runtime check returned no output.';
|
||||
}
|
||||
const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed);
|
||||
if (accessDeniedDiagnostic) {
|
||||
return accessDeniedDiagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
|
||||
return 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
|
||||
}
|
||||
|
|
@ -518,13 +590,33 @@ function normalizeRuntimeFailureDetailLine(
|
|||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeRuntimeDetailLine(
|
||||
detail: string | null | undefined,
|
||||
providerId: TeamProviderId
|
||||
): string | null {
|
||||
const trimmed = detail?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (providerId !== 'opencode') {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
|
||||
return 'OpenCode runtime check returned no output.';
|
||||
}
|
||||
return normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed) ?? trimmed;
|
||||
}
|
||||
|
||||
function createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines: readonly string[],
|
||||
message: string | null | undefined
|
||||
message: string | null | undefined,
|
||||
providerId: TeamProviderId
|
||||
): string[] {
|
||||
return uniquePrepareLines(
|
||||
[...runtimeDetailLines, message]
|
||||
.map((detail) => normalizeRuntimeFailureDetailLine(detail))
|
||||
.map((detail) => normalizeRuntimeFailureDetailLine(detail, undefined, providerId))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
|
@ -957,6 +1049,7 @@ export async function runProviderPrepareDiagnostics({
|
|||
);
|
||||
const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0;
|
||||
const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model)));
|
||||
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
|
||||
if (orderedModelIds.length === 0) {
|
||||
const runtimeResult = await prepareProvisioning(
|
||||
cwd,
|
||||
|
|
@ -965,24 +1058,35 @@ export async function runProviderPrepareDiagnostics({
|
|||
undefined,
|
||||
limitContext
|
||||
);
|
||||
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
|
||||
const runtimeWarnings = createRuntimeWarningLines(runtimeResult);
|
||||
mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
|
||||
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
|
||||
const runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
|
||||
|
||||
if (!runtimeResult.ready) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
runtimeResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: runtimeDetailLines,
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: runtimeDetailLines,
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
|
||||
const reusableModelResultsById = cachedModelResultsById ?? {};
|
||||
|
|
@ -1047,16 +1151,24 @@ export async function runProviderPrepareDiagnostics({
|
|||
undefined,
|
||||
limitContext
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
|
||||
runtimeWarnings = createRuntimeWarningLines(runtimeResult);
|
||||
mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
|
||||
runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
|
||||
runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
|
||||
|
||||
if (!runtimeResult.ready) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
runtimeResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const recordTerminalModelResult = (
|
||||
|
|
@ -1090,10 +1202,11 @@ export async function runProviderPrepareDiagnostics({
|
|||
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
|
||||
: [])
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
|
||||
mergeSupportDiagnostics(supportDiagnostics, compatibilityResult.supportDiagnostics);
|
||||
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
|
||||
runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
|
||||
|
|
@ -1116,17 +1229,21 @@ export async function runProviderPrepareDiagnostics({
|
|||
const structuredProviderScopedFailure =
|
||||
structuredProviderScopedIssue?.message.trim() ?? null;
|
||||
if (structuredProviderScopedFailure || providerScopedFailure) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
||||
structuredProviderScopedIssue?.code
|
||||
) ?? 'OpenCode failed',
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
||||
structuredProviderScopedIssue?.code,
|
||||
providerId
|
||||
) ?? 'OpenCode failed',
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
if (
|
||||
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
|
||||
|
|
@ -1141,15 +1258,19 @@ export async function runProviderPrepareDiagnostics({
|
|||
(uncachedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
compatibilityResult.message
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
compatibilityResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
|
||||
runtimeDetailLines = [];
|
||||
|
|
@ -1204,19 +1325,22 @@ export async function runProviderPrepareDiagnostics({
|
|||
)
|
||||
);
|
||||
|
||||
return {
|
||||
status: hasFailure
|
||||
? 'failed'
|
||||
: hasNotes || dedupedWarnings.length > 0
|
||||
? 'notes'
|
||||
: 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: hasFailure
|
||||
? 'failed'
|
||||
: hasNotes || dedupedWarnings.length > 0
|
||||
? 'notes'
|
||||
: 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -1231,10 +1355,11 @@ export async function runProviderPrepareDiagnostics({
|
|||
? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)]
|
||||
: [])
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
|
||||
mergeSupportDiagnostics(supportDiagnostics, batchedModelResult.supportDiagnostics);
|
||||
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter(
|
||||
runtimeWarnings = createRuntimeWarningLines(batchedModelResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
|
||||
);
|
||||
|
||||
|
|
@ -1278,17 +1403,21 @@ export async function runProviderPrepareDiagnostics({
|
|||
}
|
||||
handledAdvisoryDeepFailure = true;
|
||||
} else {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
failureReason,
|
||||
structuredProviderScopedIssue?.code
|
||||
) ?? failureReason,
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
failureReason,
|
||||
structuredProviderScopedIssue?.code,
|
||||
providerId
|
||||
) ?? failureReason,
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
|
|
@ -1323,15 +1452,19 @@ export async function runProviderPrepareDiagnostics({
|
|||
(compatibilityPassedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
batchedModelResult.message
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
batchedModelResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
if (
|
||||
!handledAdvisoryDeepFailure &&
|
||||
|
|
@ -1385,10 +1518,10 @@ export async function runProviderPrepareDiagnostics({
|
|||
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
|
||||
: [])
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
|
||||
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
|
||||
runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
|
||||
|
|
@ -1410,7 +1543,8 @@ export async function runProviderPrepareDiagnostics({
|
|||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
compatibilityResult.message
|
||||
compatibilityResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
|
|
@ -1445,10 +1579,10 @@ export async function runProviderPrepareDiagnostics({
|
|||
limitContext,
|
||||
'deep'
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(deepResult).filter(
|
||||
runtimeDetailLines = createRuntimeDetailLines(deepResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = createRuntimeWarningLines(deepResult).filter(
|
||||
runtimeWarnings = createRuntimeWarningLines(deepResult, providerId).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
if (
|
||||
|
|
@ -1502,13 +1636,16 @@ export async function runProviderPrepareDiagnostics({
|
|||
)
|
||||
);
|
||||
|
||||
return {
|
||||
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1500,12 +1500,24 @@ export interface TeamProvisioningPrepareIssue {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export interface TeamProvisioningSupportDiagnostic {
|
||||
id: string;
|
||||
providerId: TeamProviderId;
|
||||
kind: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
title: string;
|
||||
summary: string;
|
||||
copyText: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamProvisioningPrepareResult {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
warnings?: string[];
|
||||
issues?: TeamProvisioningPrepareIssue[];
|
||||
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
|
||||
}
|
||||
|
||||
export interface TeamProvisioningProgress {
|
||||
|
|
@ -1771,6 +1783,8 @@ export interface ToolApprovalRequest {
|
|||
/** Run ID — prevents stale approvals after stop→launch race. */
|
||||
runId: string;
|
||||
teamName: string;
|
||||
/** Runtime/provider that owns the approval, when it is not the Anthropic CLI control protocol. */
|
||||
providerId?: TeamProviderId;
|
||||
/** Which process sent this (e.g. 'lead'). */
|
||||
source: string;
|
||||
/** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */
|
||||
|
|
@ -1783,6 +1797,14 @@ export interface ToolApprovalRequest {
|
|||
teamColor?: string;
|
||||
/** Team display name (from config or create request). */
|
||||
teamDisplayName?: string;
|
||||
/** Provider runtime permission metadata used to answer non-Anthropic approval APIs. */
|
||||
runtimePermission?: {
|
||||
providerId: 'anthropic' | 'opencode' | 'codex';
|
||||
laneId: string;
|
||||
memberName: string;
|
||||
providerRequestId: string;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
/** Permission suggestions from teammate runtime (only for teammate permission_request).
|
||||
* FACT: Populated by Claude Code runtime, contains instructions to add permission rules.
|
||||
*/
|
||||
|
|
@ -1837,7 +1859,7 @@ export interface ToolApprovalAutoResolved {
|
|||
requestId: string;
|
||||
runId: string;
|
||||
teamName: string;
|
||||
reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny';
|
||||
reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny' | 'runtime_resolved';
|
||||
}
|
||||
|
||||
/** Union of approval events pushed from main to renderer. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
|
||||
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
|
||||
} from '../openCodeWindowsAccessDenied';
|
||||
|
||||
describe('OpenCode Windows access-denied diagnostics', () => {
|
||||
it.each([
|
||||
'EPERM: operation not permitted, mkdir C:\\Program Files\\project',
|
||||
'EACCES: permission denied, open C:\\work\\repo',
|
||||
'Access is denied.',
|
||||
'permission denied while opening OpenCode runtime file',
|
||||
'operation not permitted while starting OpenCode',
|
||||
])('detects %s', (message) => {
|
||||
expect(isOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(true);
|
||||
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(
|
||||
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE
|
||||
);
|
||||
});
|
||||
|
||||
it('does not match unrelated OpenCode diagnostics', () => {
|
||||
expect(isOpenCodeWindowsAccessDeniedDiagnostic('OpenCode app MCP is unreachable')).toBe(false);
|
||||
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic('OpenCode CLI not found')).toBeNull();
|
||||
});
|
||||
});
|
||||
24
src/shared/utils/openCodeWindowsAccessDenied.ts
Normal file
24
src/shared/utils/openCodeWindowsAccessDenied.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export const OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE =
|
||||
'Windows blocked OpenCode from accessing project or runtime files. Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
|
||||
|
||||
const OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN =
|
||||
/\b(?:EPERM|EACCES)\b|access is denied|permission denied|operation not permitted/i;
|
||||
|
||||
export function isOpenCodeWindowsAccessDeniedDiagnostic(value: string | null | undefined): boolean {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
trimmed === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
|
||||
OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeOpenCodeWindowsAccessDeniedDiagnostic(
|
||||
value: string | null | undefined
|
||||
): string | null {
|
||||
return isOpenCodeWindowsAccessDeniedDiagnostic(value)
|
||||
? OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE
|
||||
: null;
|
||||
}
|
||||
|
|
@ -1698,8 +1698,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
plugins: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason:
|
||||
'Plugins are not currently guaranteed for codex-native sessions in the multimodel runtime.',
|
||||
reason: 'Plugin support is not yet guaranteed for this agent.',
|
||||
},
|
||||
mcp: {
|
||||
status: 'unsupported',
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
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 {
|
||||
OpenCodeBridgeCommandClient,
|
||||
redactBridgeDiagnosticText,
|
||||
resolveOpenCodeBridgeProcessCwd,
|
||||
type OpenCodeBridgeDiagnosticsSink,
|
||||
type OpenCodeBridgeProcessRunInput,
|
||||
type OpenCodeBridgeProcessRunResult,
|
||||
type OpenCodeBridgeProcessRunner,
|
||||
type OpenCodeBridgeProcessRunResult,
|
||||
redactBridgeDiagnosticText,
|
||||
resolveOpenCodeBridgeProcessCwd,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
|
||||
import type {
|
||||
OpenCodeBridgeDiagnosticEvent,
|
||||
OpenCodeBridgeSuccess,
|
||||
|
|
@ -188,6 +188,34 @@ describe('OpenCodeBridgeCommandClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps bridge failures best-effort when the diagnostics sink fails', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
diagnostics.append.mockRejectedValueOnce(new Error('disk full'));
|
||||
const client = createClient();
|
||||
|
||||
await expect(
|
||||
client.execute(
|
||||
'opencode.launchTeam',
|
||||
{ runId: 'run-1' },
|
||||
{
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'contract_violation',
|
||||
message: 'Bridge stdout was empty',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('turns non-zero process exit into provider_error without parsing stdout', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: `${JSON.stringify(bridgeSuccess())}\n`,
|
||||
|
|
@ -258,6 +286,50 @@ describe('OpenCodeBridgeCommandClient', () => {
|
|||
expect(runner.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps empty readiness stdout diagnostics after the retry is exhausted', async () => {
|
||||
runner.nextResults = [
|
||||
{
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
},
|
||||
{
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
},
|
||||
];
|
||||
const client = createClient();
|
||||
|
||||
const result = await client.execute(
|
||||
'opencode.readiness',
|
||||
{ projectPath: '/tmp/project' },
|
||||
{
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'contract_violation',
|
||||
message: 'Bridge stdout was empty',
|
||||
details: {
|
||||
attempts: 2,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
outputSource: 'none',
|
||||
outputFileBytes: 0,
|
||||
outputReadError: 'ENOENT',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not retry empty stdout for state-changing bridge commands', async () => {
|
||||
runner.nextResults = [
|
||||
{
|
||||
|
|
@ -289,9 +361,30 @@ describe('OpenCodeBridgeCommandClient', () => {
|
|||
error: {
|
||||
kind: 'contract_violation',
|
||||
message: 'Bridge stdout was empty',
|
||||
details: {
|
||||
command: 'opencode.launchTeam',
|
||||
requestId: 'req-1',
|
||||
attempts: 1,
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
outputSource: 'none',
|
||||
outputFileBytes: 0,
|
||||
outputReadError: 'ENOENT',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(diagnostics.append).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'opencode_bridge_contract_violation',
|
||||
data: expect.objectContaining({
|
||||
attempts: 1,
|
||||
outputReadError: 'ENOENT',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects bridge result envelope mismatches before caller can mutate state', async () => {
|
||||
|
|
|
|||
|
|
@ -9,15 +9,15 @@ import {
|
|||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
parseSingleBridgeJsonResult,
|
||||
stableHash,
|
||||
validateBridgeResultEnvelope,
|
||||
validateOpenCodeBridgeHandshake,
|
||||
type OpenCodeBridgeCommandEnvelope,
|
||||
type OpenCodeBridgeHandshake,
|
||||
type OpenCodeBridgePeerIdentity,
|
||||
type OpenCodeBridgeRuntimeSnapshot,
|
||||
type OpenCodeBridgeSuccess,
|
||||
parseSingleBridgeJsonResult,
|
||||
stableHash,
|
||||
validateBridgeResultEnvelope,
|
||||
validateOpenCodeBridgeHandshake,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
describe('OpenCodeBridgeCommandContract', () => {
|
||||
|
|
@ -135,6 +135,51 @@ describe('OpenCodeBridgeCommandContract', () => {
|
|||
).toThrow('OpenCode bridge capability snapshot mismatch');
|
||||
});
|
||||
|
||||
it('allows state mutation for a launch capability recovery result', () => {
|
||||
const result = bridgeSuccess({
|
||||
runtime: { capabilitySnapshotId: 'new-snapshot' },
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'opencode_capability_snapshot_recovery',
|
||||
severity: 'warning',
|
||||
message: 'Accepted fresh OpenCode capability snapshot after app recovery attempt.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertBridgeResultCanMutateState(result, {
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'old-snapshot',
|
||||
allowCapabilitySnapshotRecovery: true,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not allow capability recovery without orchestrator recovery evidence', () => {
|
||||
const result = bridgeSuccess({
|
||||
runtime: { capabilitySnapshotId: 'new-snapshot' },
|
||||
data: { runId: 'run-1' },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertBridgeResultCanMutateState(result, {
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'old-snapshot',
|
||||
allowCapabilitySnapshotRecovery: true,
|
||||
})
|
||||
).toThrow('OpenCode bridge capability snapshot mismatch');
|
||||
});
|
||||
|
||||
it('allows state mutation when caller has no capability snapshot evidence to compare', () => {
|
||||
const result = bridgeSuccess({
|
||||
runtime: { capabilitySnapshotId: 'runtime-snapshot' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { OpenCodeBridgeDiagnosticsStore } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
describe('OpenCodeBridgeDiagnosticsStore', () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-diagnostics-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('persists capped redacted bridge diagnostic metadata', async () => {
|
||||
const store = new OpenCodeBridgeDiagnosticsStore({
|
||||
directory: tempDir,
|
||||
maxEventsBytes: 512,
|
||||
});
|
||||
|
||||
await store.append({
|
||||
id: 'diag-1',
|
||||
type: 'opencode_bridge_contract_violation',
|
||||
providerId: 'opencode',
|
||||
severity: 'error',
|
||||
message: 'Bridge stdout was empty',
|
||||
data: {
|
||||
stderrPreview: 'token=secret Authorization: Bearer live-token',
|
||||
stdout: 'raw stdout should not be stored',
|
||||
inputPreview: 'x'.repeat(5_000),
|
||||
},
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const latest = await fs.readFile(path.join(tempDir, 'latest.json'), 'utf8');
|
||||
const events = await fs.readFile(path.join(tempDir, 'events.ndjson'), 'utf8');
|
||||
|
||||
expect(latest).toContain('token=[redacted]');
|
||||
expect(latest).toContain('Authorization: Bearer [redacted]');
|
||||
expect(latest).toContain('[truncated]');
|
||||
expect(events).toContain('opencode_bridge_contract_violation');
|
||||
expect(latest).not.toContain('secret');
|
||||
expect(events).not.toContain('live-token');
|
||||
expect(latest).toContain('"stdout": "[omitted]"');
|
||||
expect(latest).not.toContain('raw stdout should not be stored');
|
||||
});
|
||||
|
||||
it('rotates events as complete ndjson lines', async () => {
|
||||
const store = new OpenCodeBridgeDiagnosticsStore({
|
||||
directory: tempDir,
|
||||
maxEventsBytes: 120,
|
||||
});
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await store.append({
|
||||
id: `diag-${index}`,
|
||||
type: 'opencode_bridge_contract_violation',
|
||||
providerId: 'opencode',
|
||||
severity: 'error',
|
||||
message: `Bridge stdout was empty ${index}`,
|
||||
data: { index },
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
});
|
||||
}
|
||||
|
||||
const lines = (await fs.readFile(path.join(tempDir, 'events.ndjson'), 'utf8'))
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
|
||||
expect(lines.some((line) => line.includes('opencode_bridge_diagnostics_truncated'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(() => lines.map((line) => JSON.parse(line))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -77,6 +77,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
|
||||
...mcpLaunchSpec.env,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
|
|
@ -189,6 +190,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
|
||||
...mcpLaunchSpec.env,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ import {
|
|||
REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
import type {
|
||||
OpenCodeBridgeFailureKind,
|
||||
OpenCodeBridgeCommandName,
|
||||
OpenCodeBridgeFailureKind,
|
||||
OpenCodeBridgeResult,
|
||||
OpenCodeBridgeSuccess,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeSendMessageCommandData,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
|
||||
describe('OpenCodeReadinessBridge', () => {
|
||||
it('executes the read-only opencode.readiness command and returns readiness data', async () => {
|
||||
|
|
@ -86,6 +86,70 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds copyable support diagnostics for bridge no-output contract failures', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure(
|
||||
'contract_violation',
|
||||
'Bridge stdout was empty',
|
||||
[
|
||||
{
|
||||
id: 'diag-empty-stdout',
|
||||
type: 'opencode_bridge_contract_violation',
|
||||
providerId: 'opencode',
|
||||
severity: 'error',
|
||||
message: 'Bridge stdout was empty',
|
||||
data: {
|
||||
command: 'opencode.readiness',
|
||||
requestId: 'req-1',
|
||||
attempts: 2,
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 27,
|
||||
outputSource: 'none',
|
||||
outputFileBytes: 0,
|
||||
outputReadError: 'ENOENT',
|
||||
stderrPreview: 'token=secret',
|
||||
},
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
{
|
||||
attempts: 2,
|
||||
outputReadError: 'ENOENT',
|
||||
}
|
||||
)
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
appVersion: '1.3.0-test',
|
||||
});
|
||||
|
||||
const result = await bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: 'D:\\project\\03_codex',
|
||||
selectedModel: 'qwen3.6-2b',
|
||||
requireExecutionProbe: false,
|
||||
});
|
||||
|
||||
expect(result.supportDiagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'diag-empty-stdout',
|
||||
providerId: 'opencode',
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: 'error',
|
||||
title: 'OpenCode runtime check returned no output',
|
||||
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
|
||||
}),
|
||||
]);
|
||||
expect(result.supportDiagnostics?.[0]?.copyText).toContain(
|
||||
'Agent Teams OpenCode diagnostics'
|
||||
);
|
||||
expect(result.supportDiagnostics?.[0]?.copyText).toContain('outputReadError: ENOENT');
|
||||
expect(result.supportDiagnostics?.[0]?.copyText).toContain('appVersion: 1.3.0-test');
|
||||
expect(result.supportDiagnostics?.[0]?.copyText).toContain('selectedModel: qwen3.6-2b');
|
||||
expect(result.supportDiagnostics?.[0]?.copyText).toContain('token=[redacted]');
|
||||
expect(result.supportDiagnostics?.[0]?.copyText).not.toContain('token=secret');
|
||||
});
|
||||
|
||||
it('executes host cleanup through the direct bridge command', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeCommandSuccess({
|
||||
|
|
@ -961,6 +1025,70 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
);
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes OpenCode permission answers through the guarded command service', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||
);
|
||||
const stateChangingExecute = vi.fn();
|
||||
const stateChangingCommands = {
|
||||
async execute<TBody, TData>(input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
body: TBody;
|
||||
teamName: string;
|
||||
laneId?: string | null;
|
||||
runId: string | null;
|
||||
}): Promise<OpenCodeBridgeResult<TData>> {
|
||||
stateChangingExecute(input);
|
||||
return bridgeCommandSuccess<OpenCodeLaunchTeamCommandData>({
|
||||
command: input.command,
|
||||
requestId: 'guarded-permission-req-1',
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
members: {},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
}) as unknown as OpenCodeBridgeResult<TData>;
|
||||
},
|
||||
};
|
||||
const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands });
|
||||
|
||||
await expect(
|
||||
bridge.answerOpenCodeRuntimePermission({
|
||||
runId: 'run-1',
|
||||
laneId: 'primary',
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
projectPath: '/repo',
|
||||
memberName: 'alice',
|
||||
requestId: 'perm-1',
|
||||
decision: 'allow',
|
||||
expectedCapabilitySnapshotId: null,
|
||||
manifestHighWatermark: null,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
});
|
||||
|
||||
expect(stateChangingExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'opencode.answerPermission',
|
||||
teamName: 'team-a',
|
||||
laneId: 'primary',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: null,
|
||||
cwd: '/repo',
|
||||
body: expect.objectContaining({
|
||||
requestId: 'perm-1',
|
||||
decision: 'allow',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeExecutor(
|
||||
|
|
@ -1014,7 +1142,8 @@ function bridgeSuccess(
|
|||
function bridgeFailure(
|
||||
kind: OpenCodeBridgeFailureKind,
|
||||
message: string,
|
||||
diagnostics: OpenCodeBridgeResult<unknown>['diagnostics']
|
||||
diagnostics: OpenCodeBridgeResult<unknown>['diagnostics'],
|
||||
details?: Record<string, unknown>
|
||||
): OpenCodeBridgeResult<unknown> {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -1027,6 +1156,7 @@ function bridgeFailure(
|
|||
kind,
|
||||
message,
|
||||
retryable: true,
|
||||
...(details ? { details } : {}),
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
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 {
|
||||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
type OpenCodeBridgeCommandName,
|
||||
type OpenCodeBridgeHandshake,
|
||||
type OpenCodeBridgePeerIdentity,
|
||||
|
|
@ -18,13 +17,13 @@ import {
|
|||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
createOpenCodeBridgeCommandLedgerStore,
|
||||
type OpenCodeBridgeCommandLedger,
|
||||
type OpenCodeBridgeCommandLeaseStore,
|
||||
type OpenCodeBridgeCommandLedger,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
|
||||
import {
|
||||
OpenCodeStateChangingBridgeCommandService,
|
||||
type OpenCodeBridgeCommandExecutor,
|
||||
type OpenCodeBridgeHandshakePort,
|
||||
OpenCodeStateChangingBridgeCommandService,
|
||||
type OpenCodeStateChangingBridgeDiagnosticsSink,
|
||||
type RuntimeStoreManifestReader,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
|
|
@ -381,6 +380,48 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
|
|||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('commits a launch result when recovery accepted a newer capability snapshot', async () => {
|
||||
bridge.resultFactory = ({ body, command, options }) =>
|
||||
bridgeSuccess({
|
||||
requestId: options.requestId,
|
||||
command,
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/usr/local/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'cap-2',
|
||||
},
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: body.preconditions.idempotencyKey,
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'opencode_capability_snapshot_recovery',
|
||||
severity: 'warning',
|
||||
message: 'Accepted fresh OpenCode capability snapshot after app recovery attempt.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
const result = await service.execute({
|
||||
...buildLaunchInput(),
|
||||
body: {
|
||||
prompt: 'launch',
|
||||
capabilitySnapshotRecoveryAttemptId: 'opencode-capability-recovery-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey;
|
||||
await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
});
|
||||
});
|
||||
|
||||
function createService(
|
||||
overrides: {
|
||||
leaseAcquireTimeoutMs?: number;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data'),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
|
||||
...mcpLaunchSpec.env,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
|
|
@ -227,6 +228,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
|
||||
...mcpLaunchSpec.env,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
skipPermissions: true,
|
||||
expectedCapabilitySnapshotId: null,
|
||||
})
|
||||
);
|
||||
|
|
@ -376,6 +377,32 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('passes manual tool approval intent with a fresh capability precondition', async () => {
|
||||
const launchOpenCodeTeam = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
||||
>(() => Promise.resolve(successfulOpenCodeLaunchData()));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter({
|
||||
checkOpenCodeTeamLaunchReadiness: vi.fn(async () =>
|
||||
readiness({ state: 'ready', launchAllowed: true })
|
||||
),
|
||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-manual')),
|
||||
launchOpenCodeTeam,
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapter.launch(launchInput({ skipPermissions: false }))
|
||||
).resolves.toMatchObject({
|
||||
teamLaunchState: 'clean_success',
|
||||
});
|
||||
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skipPermissions: false,
|
||||
expectedCapabilitySnapshotId: 'cap-manual',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('launches model-less Default selections with the readiness-resolved model', async () => {
|
||||
const launchOpenCodeTeam = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
||||
|
|
@ -1357,6 +1384,23 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
sessionId: 'oc-session-1',
|
||||
launchState: 'permission_blocked',
|
||||
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
|
||||
pendingPermissions: [
|
||||
{
|
||||
requestId: 'perm-1',
|
||||
sessionId: 'oc-session-1',
|
||||
tool: 'bash',
|
||||
title: 'Run git status',
|
||||
kind: 'tool',
|
||||
raw: {
|
||||
requestID: 'perm-1',
|
||||
sessionID: 'oc-session-1',
|
||||
tool: 'bash',
|
||||
title: 'Run git status',
|
||||
kind: 'tool',
|
||||
patterns: ['git status'],
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: ['waiting for permission approval'],
|
||||
runtimePid: 123,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
|
|
@ -1393,6 +1437,15 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
providerId: 'opencode',
|
||||
launchState: 'runtime_pending_permission',
|
||||
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
|
||||
pendingPermissions: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
requestId: 'perm-1',
|
||||
sessionId: 'oc-session-1',
|
||||
tool: 'bash',
|
||||
title: 'Run git status',
|
||||
},
|
||||
],
|
||||
runtimeAlive: false,
|
||||
agentToolAccepted: true,
|
||||
livenessKind: 'permission_blocked',
|
||||
|
|
@ -1410,6 +1463,85 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('answers OpenCode runtime permissions through the bridge and remaps the lane state', async () => {
|
||||
const answerOpenCodeRuntimePermission = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['answerOpenCodeRuntimePermission']>
|
||||
>(async () => ({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
members: {
|
||||
alice: {
|
||||
sessionId: 'oc-session-1',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimePid: 123,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [
|
||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
answerOpenCodeRuntimePermission,
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.answerRuntimePermission({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
laneId: 'primary',
|
||||
cwd: '/repo',
|
||||
providerId: 'opencode',
|
||||
memberName: 'alice',
|
||||
requestId: 'perm-1',
|
||||
decision: 'allow',
|
||||
expectedMembers: launchInput().expectedMembers,
|
||||
previousLaunchState: null,
|
||||
});
|
||||
|
||||
expect(answerOpenCodeRuntimePermission).toHaveBeenCalledWith({
|
||||
runId: 'run-1',
|
||||
laneId: 'primary',
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
projectPath: '/repo',
|
||||
memberName: 'alice',
|
||||
requestId: 'perm-1',
|
||||
decision: 'allow',
|
||||
expectedCapabilitySnapshotId: null,
|
||||
manifestHighWatermark: null,
|
||||
});
|
||||
expect(result.teamLaunchState).toBe('clean_success');
|
||||
expect(result.members.alice?.launchState).toBe('confirmed_alive');
|
||||
});
|
||||
|
||||
it('fails runtime permission answers when the OpenCode answer bridge is unavailable', async () => {
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapter.answerRuntimePermission({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
laneId: 'primary',
|
||||
cwd: '/repo',
|
||||
providerId: 'opencode',
|
||||
memberName: 'alice',
|
||||
requestId: 'perm-1',
|
||||
decision: 'allow',
|
||||
expectedMembers: launchInput().expectedMembers,
|
||||
previousLaunchState: null,
|
||||
})
|
||||
).rejects.toThrow('OpenCode permission answer bridge is not registered.');
|
||||
});
|
||||
|
||||
it('does not mark created bridge members without runtimePid as runtimeAlive', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(
|
||||
async () =>
|
||||
|
|
@ -1706,7 +1838,7 @@ function launchInput(overrides: Partial<TeamRuntimeLaunchInput> = {}): TeamRunti
|
|||
cwd: '/repo',
|
||||
providerId: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
skipPermissions: false,
|
||||
skipPermissions: true,
|
||||
expectedMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
|
|
|
|||
346
test/main/services/team/RuntimeToolApprovalCoordinator.test.ts
Normal file
346
test/main/services/team/RuntimeToolApprovalCoordinator.test.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
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('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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,48 +1,13 @@
|
|||
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 type {
|
||||
WorkspaceTrustCoordinator,
|
||||
WorkspaceTrustExecutionPlan,
|
||||
} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator';
|
||||
import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver';
|
||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import {
|
||||
getMixedLaunchFallbackRecoveryError,
|
||||
TeamProvisioningService,
|
||||
} from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import type {
|
||||
OpenCodeTeamRuntimeMessageInput,
|
||||
OpenCodeTeamRuntimeMessageResult,
|
||||
} from '../../../../src/main/services/team/runtime';
|
||||
import {
|
||||
TeamRuntimeAdapterRegistry,
|
||||
type TeamLaunchRuntimeAdapter,
|
||||
type TeamRuntimeLaunchInput,
|
||||
type TeamRuntimeMemberLaunchEvidence,
|
||||
type TeamRuntimeMemberSpec,
|
||||
type TeamRuntimeLaunchResult,
|
||||
type TeamRuntimePrepareResult,
|
||||
type TeamRuntimeReconcileInput,
|
||||
type TeamRuntimeReconcileResult,
|
||||
type TeamRuntimeStopInput,
|
||||
type TeamRuntimeStopResult,
|
||||
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
|
||||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getProjectsBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||
import { agentTeamsMcpHttpServer } from '../../../../src/main/services/team/AgentTeamsMcpHttpServer';
|
||||
import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver';
|
||||
import {
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
getOpenCodeRuntimeLaneIndexPath,
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
|
|
@ -54,8 +19,49 @@ import {
|
|||
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
|
||||
RuntimeStoreBatchWriter,
|
||||
} from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
||||
import {
|
||||
type TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeAdapterRegistry,
|
||||
type TeamRuntimeLaunchInput,
|
||||
type TeamRuntimeLaunchResult,
|
||||
type TeamRuntimeMemberLaunchEvidence,
|
||||
type TeamRuntimeMemberSpec,
|
||||
type TeamRuntimePermissionAnswerInput,
|
||||
type TeamRuntimePrepareResult,
|
||||
type TeamRuntimeReconcileInput,
|
||||
type TeamRuntimeReconcileResult,
|
||||
type TeamRuntimeStopInput,
|
||||
type TeamRuntimeStopResult,
|
||||
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
|
||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||
import {
|
||||
getMixedLaunchFallbackRecoveryError,
|
||||
TeamProvisioningService,
|
||||
} from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getProjectsBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import type { InboxMessage, TaskRef, TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
import type {
|
||||
WorkspaceTrustCoordinator,
|
||||
WorkspaceTrustExecutionPlan,
|
||||
} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator';
|
||||
import type {
|
||||
OpenCodeTeamRuntimeMessageInput,
|
||||
OpenCodeTeamRuntimeMessageResult,
|
||||
} from '../../../../src/main/services/team/runtime';
|
||||
import type {
|
||||
InboxMessage,
|
||||
TaskRef,
|
||||
TeamProvisioningProgress,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalRequest,
|
||||
} from '../../../../src/shared/types';
|
||||
|
||||
const LAUNCH_MATRIX_SAFE_E2E_TIMEOUT_MS = 60_000;
|
||||
const WORKSPACE_TRUST_TEST_ENV_NAMES = [
|
||||
|
|
@ -375,6 +381,73 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(statuses.summary?.pendingCount).toBe(1);
|
||||
});
|
||||
|
||||
it('routes OpenCode runtime approval UI responses back through the adapter', async () => {
|
||||
const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending');
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
const approvalEvents: ToolApprovalEvent[] = [];
|
||||
svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event));
|
||||
|
||||
const launch = await svc.createTeam(
|
||||
{
|
||||
teamName: 'approve-opencode-safe-e2e',
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/big-pickle',
|
||||
skipPermissions: false,
|
||||
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
|
||||
const approval = approvalEvents.find(
|
||||
(event): event is ToolApprovalRequest =>
|
||||
!('dismissed' in event) && !('autoResolved' in event)
|
||||
);
|
||||
expect(approval).toMatchObject({
|
||||
runId: launch.runId,
|
||||
teamName: 'approve-opencode-safe-e2e',
|
||||
providerId: 'opencode',
|
||||
source: 'alice',
|
||||
toolName: 'Bash',
|
||||
runtimePermission: {
|
||||
providerId: 'opencode',
|
||||
laneId: 'primary',
|
||||
memberName: 'alice',
|
||||
providerRequestId: 'perm-alice',
|
||||
},
|
||||
});
|
||||
expect(approval?.toolInput).toMatchObject({
|
||||
provider: 'opencode',
|
||||
providerRequestId: 'perm-alice',
|
||||
command: 'git status',
|
||||
});
|
||||
|
||||
await svc.respondToToolApproval(
|
||||
'approve-opencode-safe-e2e',
|
||||
launch.runId!,
|
||||
approval!.requestId,
|
||||
true
|
||||
);
|
||||
|
||||
expect(adapter.permissionAnswerInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: launch.runId,
|
||||
teamName: 'approve-opencode-safe-e2e',
|
||||
laneId: 'primary',
|
||||
memberName: 'alice',
|
||||
requestId: 'perm-alice',
|
||||
decision: 'allow',
|
||||
}),
|
||||
]);
|
||||
const statuses = await svc.getMemberSpawnStatuses('approve-opencode-safe-e2e');
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
});
|
||||
expect(statuses.statuses.alice?.pendingPermissionRequestIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks createTeam at workspace trust preflight before spawn and preserves existing launch state', async () => {
|
||||
forceWorkspaceTrustPreflightEnv();
|
||||
process.env.CLAUDE_CLI_PATH = await writeFakeClaudeCli(tempDir);
|
||||
|
|
@ -18293,6 +18366,7 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
readonly providerId = 'opencode' as const;
|
||||
readonly launchInputs: TeamRuntimeLaunchInput[] = [];
|
||||
readonly messageInputs: OpenCodeTeamRuntimeMessageInput[] = [];
|
||||
readonly permissionAnswerInputs: TeamRuntimePermissionAnswerInput[] = [];
|
||||
readonly reconcileInputs: TeamRuntimeReconcileInput[] = [];
|
||||
readonly stopInputs: TeamRuntimeStopInput[] = [];
|
||||
|
||||
|
|
@ -18359,6 +18433,30 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
async answerRuntimePermission(
|
||||
input: TeamRuntimePermissionAnswerInput
|
||||
): Promise<TeamRuntimeLaunchResult> {
|
||||
this.permissionAnswerInputs.push(input);
|
||||
this.memberOutcomes = {
|
||||
...this.memberOutcomes,
|
||||
[input.memberName]: input.decision === 'allow' ? 'confirmed' : 'failed',
|
||||
};
|
||||
return {
|
||||
runId: input.runId,
|
||||
teamName: input.teamName,
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: this.aggregateLaunchState(input.expectedMembers),
|
||||
members: Object.fromEntries(
|
||||
input.expectedMembers.map((member, index) => [
|
||||
member.name,
|
||||
this.buildMemberEvidence(member, index),
|
||||
])
|
||||
),
|
||||
warnings: [],
|
||||
diagnostics: ['fake OpenCode permission answer'],
|
||||
};
|
||||
}
|
||||
|
||||
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
|
||||
this.reconcileInputs.push(input);
|
||||
const members = Object.fromEntries(
|
||||
|
|
@ -18437,6 +18535,26 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
hardFailure: failed,
|
||||
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
|
||||
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
|
||||
pendingPermissions: permissionPending
|
||||
? [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
requestId: `perm-${member.name}`,
|
||||
sessionId: `session-${member.name}`,
|
||||
tool: 'bash',
|
||||
title: `Run git status for ${member.name}`,
|
||||
kind: 'tool',
|
||||
raw: {
|
||||
requestID: `perm-${member.name}`,
|
||||
sessionID: `session-${member.name}`,
|
||||
tool: 'bash',
|
||||
title: `Run git status for ${member.name}`,
|
||||
kind: 'tool',
|
||||
patterns: ['git status'],
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
sessionId: failed ? undefined : `session-${member.name}`,
|
||||
runtimePid: failed ? undefined : 10_000 + index,
|
||||
livenessKind,
|
||||
|
|
|
|||
|
|
@ -17786,6 +17786,189 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('tags Codex app-server control_request approvals and replies through control_response', async () => {
|
||||
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
|
||||
cb?.();
|
||||
return true;
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
const events: unknown[] = [];
|
||||
svc.setToolApprovalEventEmitter((event) => events.push(event));
|
||||
svc.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
} as never);
|
||||
|
||||
const run = {
|
||||
teamName: 'codex-manual-team',
|
||||
runId: 'run-codex-manual',
|
||||
request: { color: '#2563eb', displayName: 'Codex Manual Team' },
|
||||
child: { stdin: { writable: true, write } },
|
||||
pendingApprovals: new Map(),
|
||||
};
|
||||
const internals = svc as unknown as {
|
||||
runs: Map<string, unknown>;
|
||||
aliveRunByTeam: Map<string, string>;
|
||||
handleControlRequest(run: unknown, msg: Record<string, unknown>): void;
|
||||
};
|
||||
internals.runs.set(run.runId, run);
|
||||
internals.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
internals.handleControlRequest(run, {
|
||||
request_id: 'codex-approval-1',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
input: {
|
||||
provider: 'codex',
|
||||
providerRequestId: 'codex:item/commandExecution/requestApproval:item-1',
|
||||
command: 'printf ok',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
requestId: 'codex-approval-1',
|
||||
runId: run.runId,
|
||||
teamName: run.teamName,
|
||||
providerId: 'codex',
|
||||
toolName: 'Bash',
|
||||
toolInput: {
|
||||
provider: 'codex',
|
||||
command: 'printf ok',
|
||||
},
|
||||
});
|
||||
|
||||
await svc.respondToToolApproval(run.teamName, run.runId, 'codex-approval-1', true);
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
const firstWrite = write.mock.calls[0]?.[0];
|
||||
expect(typeof firstWrite).toBe('string');
|
||||
const payload = JSON.parse(firstWrite as string) as Record<string, unknown>;
|
||||
expect(payload).toMatchObject({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'codex-approval-1',
|
||||
response: { behavior: 'allow', updatedInput: {} },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps control_request approvals pending when control_response write fails so retry works', async () => {
|
||||
let failNextWrite = true;
|
||||
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
|
||||
if (failNextWrite) {
|
||||
failNextWrite = false;
|
||||
cb?.(new Error('broken pipe'));
|
||||
return false;
|
||||
}
|
||||
cb?.();
|
||||
return true;
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
} as never);
|
||||
|
||||
const run = {
|
||||
teamName: 'anthropic-manual-team',
|
||||
runId: 'run-anthropic-manual',
|
||||
request: { color: '#7c3aed', displayName: 'Anthropic Manual Team' },
|
||||
child: { stdin: { writable: true, write } },
|
||||
pendingApprovals: new Map(),
|
||||
};
|
||||
const internals = svc as unknown as {
|
||||
runs: Map<string, unknown>;
|
||||
aliveRunByTeam: Map<string, string>;
|
||||
handleControlRequest(run: unknown, msg: Record<string, unknown>): void;
|
||||
};
|
||||
internals.runs.set(run.runId, run);
|
||||
internals.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
internals.handleControlRequest(run, {
|
||||
request_id: 'anthropic-approval-retry',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
input: { command: 'printf ok' },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.respondToToolApproval(run.teamName, run.runId, 'anthropic-approval-retry', true)
|
||||
).rejects.toThrow('broken pipe');
|
||||
expect(
|
||||
vi.mocked(console.error).mock.calls.some((args) => args.join(' ').includes('broken pipe'))
|
||||
).toBe(true);
|
||||
vi.mocked(console.error).mockClear();
|
||||
expect(run.pendingApprovals.has('anthropic-approval-retry')).toBe(true);
|
||||
|
||||
await expect(
|
||||
svc.respondToToolApproval(run.teamName, run.runId, 'anthropic-approval-retry', true)
|
||||
).resolves.toBeUndefined();
|
||||
expect(write).toHaveBeenCalledTimes(2);
|
||||
expect(run.pendingApprovals.has('anthropic-approval-retry')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves control_request approvals tracked while a duplicate UI response is in flight', async () => {
|
||||
let releaseWrite: ((error?: Error | null) => void) | undefined;
|
||||
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
|
||||
releaseWrite = cb;
|
||||
return true;
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
} as never);
|
||||
|
||||
const run = {
|
||||
teamName: 'codex-duplicate-response-team',
|
||||
runId: 'run-codex-duplicate-response',
|
||||
request: { color: '#2563eb', displayName: 'Codex Duplicate Response Team' },
|
||||
child: { stdin: { writable: true, write } },
|
||||
pendingApprovals: new Map(),
|
||||
};
|
||||
const internals = svc as unknown as {
|
||||
runs: Map<string, unknown>;
|
||||
aliveRunByTeam: Map<string, string>;
|
||||
handleControlRequest(run: unknown, msg: Record<string, unknown>): void;
|
||||
};
|
||||
internals.runs.set(run.runId, run);
|
||||
internals.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
internals.handleControlRequest(run, {
|
||||
request_id: 'codex-approval-duplicate',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
input: {
|
||||
provider: 'codex',
|
||||
providerRequestId: 'codex:item/commandExecution/requestApproval:item-2',
|
||||
command: 'printf ok',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstResponse = svc.respondToToolApproval(
|
||||
run.teamName,
|
||||
run.runId,
|
||||
'codex-approval-duplicate',
|
||||
true
|
||||
);
|
||||
await expect(
|
||||
svc.respondToToolApproval(run.teamName, run.runId, 'codex-approval-duplicate', false)
|
||||
).resolves.toBeUndefined();
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
expect(run.pendingApprovals.has('codex-approval-duplicate')).toBe(true);
|
||||
|
||||
releaseWrite?.();
|
||||
await expect(firstResponse).resolves.toBeUndefined();
|
||||
expect(run.pendingApprovals.has('codex-approval-duplicate')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps AskUserQuestion answers in teammate fallback control responses', async () => {
|
||||
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
|
||||
cb?.();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
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 {
|
||||
type TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeAdapterRegistry,
|
||||
} from '../../../../src/main/services/team/runtime';
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
|
||||
let tempRoot: string;
|
||||
|
||||
describe('TeamProvisioningService OpenCode support diagnostics', () => {
|
||||
beforeEach(async () => {
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-provisioning-opencode-support-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps bridge no-output selected-model failures provider-scoped with support diagnostics', async () => {
|
||||
const supportDiagnostic = {
|
||||
id: 'diag-empty-stdout',
|
||||
providerId: 'opencode' as const,
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: 'error' as const,
|
||||
title: 'OpenCode runtime check returned no output',
|
||||
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
|
||||
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
};
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'unknown_error',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
|
||||
],
|
||||
warnings: [],
|
||||
supportDiagnostics: [supportDiagnostic],
|
||||
}));
|
||||
const adapter: TeamLaunchRuntimeAdapter = {
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const service = new TeamProvisioningService();
|
||||
service.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
const result = await service.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/qwen3.6-2b'],
|
||||
modelVerificationMode: 'deep',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toBe('OpenCode runtime check returned no output.');
|
||||
expect(result.details).toEqual(['OpenCode runtime check returned no output.']);
|
||||
expect(result.supportDiagnostics).toEqual([supportDiagnostic]);
|
||||
expect(result.issues).toEqual([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'blocking',
|
||||
code: 'unknown_error',
|
||||
message: 'OpenCode runtime check returned no output.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses bridge no-output diagnostics as the model-less prepare failure message', async () => {
|
||||
const supportDiagnostic = {
|
||||
id: 'diag-empty-stdout',
|
||||
providerId: 'opencode' as const,
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: 'error' as const,
|
||||
title: 'OpenCode runtime check returned no output',
|
||||
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
|
||||
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
};
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'unknown_error',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
|
||||
],
|
||||
warnings: [],
|
||||
supportDiagnostics: [supportDiagnostic],
|
||||
}));
|
||||
const adapter: TeamLaunchRuntimeAdapter = {
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const service = new TeamProvisioningService();
|
||||
service.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
const result = await service.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toBe('OpenCode runtime check returned no output.');
|
||||
expect(result.details).toEqual(['OpenCode runtime check returned no output.']);
|
||||
expect(result.supportDiagnostics).toEqual([supportDiagnostic]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -97,7 +98,10 @@ vi.mock('@main/utils/childProcess', () => ({
|
|||
|
||||
import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
|
||||
import {
|
||||
type TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeAdapterRegistry,
|
||||
} from '@main/services/team/runtime';
|
||||
import {
|
||||
buildDirectTmuxRestartEnvAssignments,
|
||||
TeamProvisioningService,
|
||||
|
|
@ -886,15 +890,14 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
diagnostics: [],
|
||||
warnings: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const adapter: TeamLaunchRuntimeAdapter = {
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const registry = new TeamRuntimeAdapterRegistry([adapter]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
|
|
@ -931,6 +934,78 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses OpenCode access-denied warnings as the model-less prepare failure message', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'unknown_error',
|
||||
retryable: false,
|
||||
diagnostics: [],
|
||||
warnings: ['EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project'],
|
||||
}));
|
||||
const adapter: TeamLaunchRuntimeAdapter = {
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const registry = new TeamRuntimeAdapterRegistry([adapter]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toBe(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE);
|
||||
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
});
|
||||
|
||||
it('keeps OpenCode access-denied selected-model failures provider-scoped', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'unknown_error',
|
||||
retryable: false,
|
||||
diagnostics: ['EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project'],
|
||||
warnings: [],
|
||||
}));
|
||||
const adapter: TeamLaunchRuntimeAdapter = {
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const registry = new TeamRuntimeAdapterRegistry([adapter]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/big-pickle'],
|
||||
modelVerificationMode: 'deep',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toBe(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE);
|
||||
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
|
||||
expect(result.issues).toEqual([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'blocking',
|
||||
code: 'unknown_error',
|
||||
message: OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => {
|
||||
const prepareGate: { release?: () => void } = {};
|
||||
const prepare = vi.fn(
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export async function createOpenCodeLiveHarness(input: {
|
|||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
||||
CLAUDE_TEAM_CONTROL_URL: controlApi.baseUrl,
|
||||
...mcpLaunchSpec.env,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
|
|
|
|||
|
|
@ -155,6 +155,156 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('gives a concrete hint for OpenCode bridge no-output failures', () => {
|
||||
expect(
|
||||
getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
backendSummary: null,
|
||||
details: [
|
||||
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
|
||||
],
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Copy diagnostics for OpenCode support diagnostics and copies the prepared payload', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProviderStatusList, {
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
backendSummary: 'OpenCode CLI',
|
||||
details: ['OpenCode runtime check returned no output.'],
|
||||
supportDiagnostics: [
|
||||
{
|
||||
id: 'diag-empty-stdout',
|
||||
providerId: 'opencode',
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: 'error',
|
||||
title: 'OpenCode runtime check returned no output',
|
||||
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
|
||||
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
status: 'failed',
|
||||
details: ['Codex failed'],
|
||||
supportDiagnostics: [
|
||||
{
|
||||
id: 'diag-codex',
|
||||
providerId: 'codex',
|
||||
kind: 'codex_debug',
|
||||
severity: 'error',
|
||||
title: 'Codex debug',
|
||||
summary: 'Codex debug summary',
|
||||
copyText: 'should not render',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(
|
||||
'OpenCode (OpenCode CLI): OpenCode runtime check returned no output'
|
||||
);
|
||||
expect(host.textContent).toContain('Copy diagnostics');
|
||||
const buttons = Array.from(host.querySelectorAll('button'));
|
||||
expect(buttons).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
buttons[0]?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT'
|
||||
);
|
||||
expect(host.textContent).toContain('Copied');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show copied when the Clipboard API is unavailable', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProviderStatusList, {
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: ['OpenCode runtime check returned no output.'],
|
||||
supportDiagnostics: [
|
||||
{
|
||||
id: 'diag-empty-stdout',
|
||||
providerId: 'opencode',
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: 'error',
|
||||
title: 'OpenCode runtime check returned no output',
|
||||
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
|
||||
copyText: 'Agent Teams OpenCode diagnostics',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('button');
|
||||
expect(button?.textContent).toContain('Copy diagnostics');
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Copy diagnostics');
|
||||
expect(host.textContent).not.toContain('Copied');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('picks the first real failure detail instead of a verified line', () => {
|
||||
expect(
|
||||
getPrimaryProvisioningFailureDetail([
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
|
||||
|
||||
|
|
@ -72,4 +71,47 @@ describe('runProviderPrepareDiagnostics OpenCode runtime failures', () => {
|
|||
]);
|
||||
expect(result.modelResultsById).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves support diagnostics for OpenCode bridge no-output provider failures', async () => {
|
||||
const supportDiagnostic = {
|
||||
id: 'diag-empty-stdout',
|
||||
providerId: 'opencode' as const,
|
||||
kind: 'opencode_bridge_no_output',
|
||||
severity: 'error' as const,
|
||||
title: 'OpenCode runtime check returned no output',
|
||||
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
|
||||
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
};
|
||||
const prepareProvisioning = vi.fn<PrepareProvisioningFn>().mockResolvedValue({
|
||||
ready: false,
|
||||
message: 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
|
||||
details: [
|
||||
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'blocking',
|
||||
code: 'unknown_error',
|
||||
message:
|
||||
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
|
||||
},
|
||||
],
|
||||
supportDiagnostics: [supportDiagnostic],
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/Users/tester/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/qwen3.6-2b'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.details).toEqual(['OpenCode runtime check returned no output.']);
|
||||
expect(result.modelResultsById).toEqual({});
|
||||
expect(result.supportDiagnostics).toEqual([supportDiagnostic]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue