diff --git a/src/main/index.ts b/src/main/index.ts index e5bdb94b..3ece25ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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)]); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index a01eef47..72aecb3f 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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, }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3173c969..e45985c8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 ): 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 +): string | undefined { + return [...prepare.diagnostics, ...prepare.warnings].find( + (entry) => + isOpenCodeBridgeNoOutputDiagnostic(entry) || isOpenCodeWindowsAccessDeniedDiagnostic(entry) + ); +} + function isOpenCodeModelPrepareBusyDeferred( prepare: Extract, primaryReason: string @@ -4997,6 +5046,16 @@ export class TeamProvisioningService { | undefined = new Map>(); private readonly runtimeAdapterTraceLinesByRunId = new Map(); private readonly runtimeAdapterTraceKeyByRunId = new Map(); + 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 { + 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; + 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; + 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 { 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 { + 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 { + 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[] = []; 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); diff --git a/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts b/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts new file mode 100644 index 00000000..002cb22b --- /dev/null +++ b/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts @@ -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; + 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 { + 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(); + 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 { + const raw: Record = + 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 = { + 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; +} diff --git a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts new file mode 100644 index 00000000..eb921f39 --- /dev/null +++ b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts @@ -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; +} + +export interface RuntimeApprovalProviderPort { + readonly providerId: RuntimeApprovalProviderId; + buildLaunchPolicy(skipPermissions: boolean, context: TContext): RuntimeApprovalLaunchPolicy; + collectPendingApprovals(runtimeState: TRuntimeState): RuntimeToolApprovalEntry[]; + answerApproval(input: RuntimeToolApprovalAnswerInput): Promise; + assertManualSupported(context: TContext): void; +} + +export interface RuntimeToolApprovalEntry { + providerId: RuntimeApprovalProviderId; + approval: ToolApprovalRequest; + providerRequestId: string; + laneId: string; + memberName: string; + cwd?: string; + expectedMembers?: TeamRuntimeMemberSpec[]; + metadata?: Record; +} + +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; + 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>(); + private readonly timers = new Map>(); + private readonly inFlightResponses = new Set(); + + constructor(private readonly deps: RuntimeToolApprovalCoordinatorDeps) {} + + sync(scope: RuntimeToolApprovalSyncScope, entries: RuntimeToolApprovalEntry[]): void { + const observedRequestIds = new Set(); + 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 { + 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(); + 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 { + 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 { + 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 { + const existing = this.approvalsByTeam.get(teamName); + if (existing) { + return existing; + } + const approvals = new Map(); + 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; + } +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index acab846a..ab6ea9f6 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner { run(input: OpenCodeBridgeProcessRunInput): Promise; } +interface OpenCodeBridgeOutputReadResult { + content: string; + outputSource: 'stdout' | 'file' | 'none'; + stdoutBytes: number; + outputFileBytes: number | null; + outputReadError: string | null; +} + export interface OpenCodeBridgeDiagnosticsSink { append(event: OpenCodeBridgeDiagnosticEvent): Promise; } @@ -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(stdout); + const parsed = parseSingleBridgeJsonResult(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 { + private async readBridgeOutput( + stdout: string, + outputPath: string + ): Promise { + 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 ): Promise { 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); +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 0d0c50e2..7c9c36c5 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -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; +} + 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( command: OpenCodeBridgeCommandName; runId: string | null; capabilitySnapshotId: string | null; + allowCapabilitySnapshotRecovery?: boolean; } ): asserts result is OpenCodeBridgeSuccess { if (!result.ok) { @@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState( 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; 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); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts new file mode 100644 index 00000000..58c7f210 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts @@ -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 { + 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 { + 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): Record { + 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); + } + 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; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts new file mode 100644 index 00000000..2278b098 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts @@ -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; + 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); +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 07588dd8..b8e11c19 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -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; } @@ -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 { + 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 { + 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 { @@ -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, diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts index 850898bf..a6fc5517 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -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 { 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 diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index dd6277ef..ffcb2661 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -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']; diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index de4a649a..8fcbf273 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -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; + answerOpenCodeRuntimePermission?( + input: OpenCodeAnswerPermissionCommandBody + ): Promise; } 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 { + 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 { 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(); + 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 } diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 808e7ce8..22a2d909 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -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; + requestId: string; + sessionId?: string | null; + tool?: string | null; + title?: string | null; + kind?: string | null; + raw?: Record; +} + +export type TeamRuntimePendingPermission = TeamRuntimePendingApproval; + +export interface TeamRuntimePermissionAnswerInput { + runId: string; + teamName: string; + laneId?: string; + cwd: string; + providerId: Extract; + 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; reconcile(input: TeamRuntimeReconcileInput): Promise; stop(input: TeamRuntimeStopInput): Promise; + answerRuntimePermission?( + input: TeamRuntimePermissionAnswerInput + ): Promise; } export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index bd6de5b5..bbf41d8f 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -6,11 +6,14 @@ export type { export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; export type { TeamLaunchRuntimeAdapter, + TeamRuntimeApprovalProviderId, TeamRuntimeLaunchInput, TeamRuntimeLaunchResult, TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberSpec, TeamRuntimeMemberStopEvidence, + TeamRuntimePendingApproval, + TeamRuntimePendingPermission, TeamRuntimePrepareFailure, TeamRuntimePrepareResult, TeamRuntimePrepareSuccess, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 808ce29f..b57abe01 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index be8a75e7..4b693263 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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); diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts new file mode 100644 index 00000000..b6d36814 --- /dev/null +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -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.' + ); + }); +}); diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 84eb0958..dcda6c15 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -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(null); + if (checks.length === 0) { return null; } + const copySupportDiagnostics = async (copyKey: string, payload: string): Promise => { + 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 (
{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 (
@@ -758,7 +830,11 @@ export const ProvisioningProviderStatusList = ({ {visibleDetails.map((detail, index) => (

{detail}

@@ -781,6 +857,24 @@ export const ProvisioningProviderStatusList = ({
) : null} + {supportDiagnosticsPayload ? ( +
+ +
+ ) : null}
); })} @@ -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(); diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts new file mode 100644 index 00000000..45592518 --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + 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({}); + }); +}); diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index 55cd595e..7a44f1c2 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -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; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; } type TeamProvisioningPrepareIssue = NonNullable[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 + ); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d6b96e11..8a96ae5d 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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. */ diff --git a/src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts b/src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts new file mode 100644 index 00000000..b9f37496 --- /dev/null +++ b/src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts @@ -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(); + }); +}); diff --git a/src/shared/utils/openCodeWindowsAccessDenied.ts b/src/shared/utils/openCodeWindowsAccessDenied.ts new file mode 100644 index 00000000..e5d6a38c --- /dev/null +++ b/src/shared/utils/openCodeWindowsAccessDenied.ts @@ -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; +} diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index b6e2fef5..9f747bb2 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -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', diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index e7b13f6c..e8ef5c10 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -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 () => { diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index f5f56267..c8a7b2c6 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -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' }, diff --git a/test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts b/test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts new file mode 100644 index 00000000..ce213140 --- /dev/null +++ b/test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts @@ -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(); + }); +}); diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index e411fced..9fd1b633 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -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), diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index dae44327..61a24612 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -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(input: { + command: OpenCodeBridgeCommandName; + body: TBody; + teamName: string; + laneId?: string | null; + runId: string | null; + }): Promise> { + stateChangingExecute(input); + return bridgeCommandSuccess({ + command: input.command, + requestId: 'guarded-permission-req-1', + data: { + runId: 'run-1', + teamLaunchState: 'ready', + members: {}, + warnings: [], + diagnostics: [], + }, + }) as unknown as OpenCodeBridgeResult; + }, + }; + 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['diagnostics'] + diagnostics: OpenCodeBridgeResult['diagnostics'], + details?: Record ): OpenCodeBridgeResult { return { ok: false, @@ -1027,6 +1156,7 @@ function bridgeFailure( kind, message, retryable: true, + ...(details ? { details } : {}), }, diagnostics, }; diff --git a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts index c61e1f88..437ad3d1 100644 --- a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts +++ b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts @@ -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; diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 57ed3cce..294bc809 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -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), diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 8250b9ff..5aa07b88 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -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 + >(() => 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 @@ -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 + >(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 = {}): TeamRunti cwd: '/repo', providerId: 'opencode', model: 'openai/gpt-5.4-mini', - skipPermissions: false, + skipPermissions: true, expectedMembers: [ { name: 'alice', diff --git a/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts b/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts new file mode 100644 index 00000000..9f6f5107 --- /dev/null +++ b/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts @@ -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 { + return { + ...DEFAULT_TOOL_APPROVAL_SETTINGS, + ...overrides, + }; +} + +function approvalEntry(overrides: Partial = {}): 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((resolve) => { + answerStarted = resolve; + }); + const releaseAnswerPromise = new Promise((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', + }); + }); +}); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 5df4b0d6..4aea6c8a 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -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 { + 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 { 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, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 64201867..54528feb 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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; + aliveRunByTeam: Map; + handleControlRequest(run: unknown, msg: Record): 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; + 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; + aliveRunByTeam: Map; + handleControlRequest(run: unknown, msg: Record): 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; + aliveRunByTeam: Map; + handleControlRequest(run: unknown, msg: Record): 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?.(); diff --git a/test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts b/test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts new file mode 100644 index 00000000..a83e2357 --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts @@ -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]); + }); +}); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index de377a28..29107b6e 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -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( diff --git a/test/main/services/team/openCodeLiveTestHarness.ts b/test/main/services/team/openCodeLiveTestHarness.ts index fa41c87c..c25da789 100644 --- a/test/main/services/team/openCodeLiveTestHarness.ts +++ b/test/main/services/team/openCodeLiveTestHarness.ts @@ -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), diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 06605c17..87cf896a 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -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([ diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts index 1650a774..ea33d7c1 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts @@ -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().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]); + }); });