diff --git a/src/features/runtime-provider-management/contracts/types.ts b/src/features/runtime-provider-management/contracts/types.ts index d0e13302..f98f6e52 100644 --- a/src/features/runtime-provider-management/contracts/types.ts +++ b/src/features/runtime-provider-management/contracts/types.ts @@ -193,6 +193,7 @@ export type RuntimeProviderManagementErrorCodeDto = | 'unsupported-runtime' | 'unsupported-action' | 'runtime-missing' + | 'runtime-misconfigured' | 'runtime-unhealthy' | 'provider-missing' | 'auth-required' @@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto = | 'model-test-failed' | 'unsupported-auth-method'; +export interface RuntimeProviderManagementErrorDiagnosticsDto { + errorCode?: RuntimeProviderManagementErrorCodeDto | null; + summary: string | null; + likelyCause: string | null; + binaryPath: string | null; + command: string | null; + projectPath: string | null; + exitCode: number | null; + stderrPreview: string | null; + stdoutPreview: string | null; + hints: readonly string[]; +} + export interface RuntimeProviderManagementErrorDto { code: RuntimeProviderManagementErrorCodeDto; message: string; recoverable: boolean; + diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null; } export interface RuntimeProviderManagementViewResponse { diff --git a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts index 0dbd5713..10c27974 100644 --- a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts +++ b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts @@ -16,6 +16,7 @@ import type { RuntimeProviderManagementConnectApiKeyInput, RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, + RuntimeProviderManagementErrorDto, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, @@ -32,6 +33,85 @@ import type { import type { IpcMain } from 'electron'; const logger = createLogger('Feature:RuntimeProviderManagement:IPC'); +const RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT = 1_600; +const ESCAPE_CHARACTER = String.fromCharCode(27); +const BELL_CHARACTER = String.fromCharCode(7); +const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g'); +const OSC_ESCAPE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`, + 'g' +); + +function truncateRuntimeProviderIpcErrorDetail(message: string): string { + if (message.length <= RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT) { + return message; + } + return `${message.slice(0, RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT).trimEnd()}...`; +} + +function sanitizeRuntimeProviderIpcErrorMessage(message: string): string { + const sanitized = message + .replace(OSC_ESCAPE_PATTERN, '') + .replace(ANSI_ESCAPE_PATTERN, '') + .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted') + .replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted') + .replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted') + .replace( + /\b([A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|[_-]key)["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, + '$1...redacted' + ) + .replace(/\b(key["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted') + .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted') + .trim(); + return truncateRuntimeProviderIpcErrorDetail(sanitized); +} + +function getRuntimeProviderIpcErrorMessage(error: unknown, fallback: string): string { + if (typeof error === 'string') { + return sanitizeRuntimeProviderIpcErrorMessage(error) || fallback; + } + if (!(error instanceof Error) || !error.message.trim()) { + return fallback; + } + return sanitizeRuntimeProviderIpcErrorMessage(error.message) || fallback; +} + +function getRuntimeProviderIpcConnectLogDetail(error: unknown): string { + if (error instanceof Error) { + return sanitizeRuntimeProviderIpcErrorMessage(error.message) || error.name || 'Error'; + } + if (typeof error === 'string') { + return sanitizeRuntimeProviderIpcErrorMessage(error) || 'Non-Error throw'; + } + return 'Non-Error throw'; +} + +function createUnexpectedRuntimeProviderIpcError( + code: RuntimeProviderManagementErrorDto['code'], + message: string +): RuntimeProviderManagementErrorDto { + return { + code, + message, + recoverable: true, + diagnostics: { + errorCode: code, + summary: message, + likelyCause: + 'The desktop app runtime provider management handler failed before it returned a normal response.', + binaryPath: null, + command: null, + projectPath: null, + exitCode: null, + stderrPreview: message, + stdoutPreview: null, + hints: [ + 'Retry the action once after refreshing provider settings.', + 'If it repeats, copy diagnostics and attach the app logs from the same session.', + ], + }, + }; +} export function registerRuntimeProviderManagementIpc( ipcMain: IpcMain, @@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadView(input); } catch (error) { - logger.error('Failed to load runtime provider management view', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load providers'); + logger.error('Failed to load runtime provider management view', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load providers', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadProviderDirectory(input); } catch (error) { - logger.error('Failed to load runtime provider directory', error); + const message = getRuntimeProviderIpcErrorMessage( + error, + 'Failed to load provider directory' + ); + logger.error('Failed to load runtime provider directory', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load provider directory', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadSetupForm(input); } catch (error) { - logger.error('Failed to load runtime provider setup form', error); + const message = getRuntimeProviderIpcErrorMessage( + error, + 'Failed to load provider setup form' + ); + logger.error('Failed to load runtime provider setup form', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load provider setup form', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.connectProvider(input); } catch (error) { + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider'); logger.error( 'Failed to connect runtime provider', - error instanceof Error ? error.name : error + getRuntimeProviderIpcConnectLogDetail(error) ); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'auth-failed', - message: 'Failed to connect provider', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('auth-failed', message), }; } } @@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.connectWithApiKey(input); } catch (error) { + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider'); logger.error( 'Failed to connect runtime provider', - error instanceof Error ? error.name : error + getRuntimeProviderIpcConnectLogDetail(error) ); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'auth-failed', - message: 'Failed to connect provider', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('auth-failed', message), }; } } @@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.forgetCredential(input); } catch (error) { - logger.error('Failed to forget runtime provider credential', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to forget provider'); + logger.error('Failed to forget runtime provider credential', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'unsupported-action', - message: error instanceof Error ? error.message : 'Failed to forget provider', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message), }; } } @@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadModels(input); } catch (error) { - logger.error('Failed to load runtime provider models', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load provider models'); + logger.error('Failed to load runtime provider models', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load provider models', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.testModel(input); } catch (error) { - logger.error('Failed to test runtime provider model', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to test model'); + logger.error('Failed to test runtime provider model', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'model-test-failed', - message: error instanceof Error ? error.message : 'Failed to test model', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message), }; } } @@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.setDefaultModel(input); } catch (error) { - logger.error('Failed to set runtime provider default model', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to set default model'); + logger.error('Failed to set runtime provider default model', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'model-test-failed', - message: error instanceof Error ? error.message : 'Failed to set default model', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message), }; } } diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 9f3327f0..a2942160 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; @@ -28,6 +31,33 @@ import type { ChildProcessWithoutNullStreams } from 'child_process'; const COMMAND_TIMEOUT_MS = 45_000; const PROBE_COMMAND_TIMEOUT_MS = 90_000; const COMMAND_ERROR_DETAIL_LIMIT = 1_600; +const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200; +const ESCAPE_CHARACTER = String.fromCharCode(27); +const BELL_CHARACTER = String.fromCharCode(7); +const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g'); +const OSC_ESCAPE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`, + 'g' +); +const OPENCODE_BINARY_BASENAMES = new Set([ + 'opencode', + 'opencode.exe', + 'opencode.cmd', + 'opencode.ps1', +]); +const RUNTIME_PROVIDER_ERROR_CODES = new Set([ + 'unsupported-runtime', + 'unsupported-action', + 'runtime-missing', + 'runtime-misconfigured', + 'runtime-unhealthy', + 'provider-missing', + 'auth-required', + 'auth-failed', + 'model-missing', + 'model-test-failed', + 'unsupported-auth-method', +]); type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementViewResponse @@ -37,10 +67,32 @@ type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementModelsResponse | RuntimeProviderManagementModelTestResponse; +interface RuntimeProviderCommandContext { + binaryPath: string; + args: readonly string[]; + projectPath: string | null; +} + +interface RuntimeProviderCommandFailure { + message: string; + diagnostics?: RuntimeProviderManagementErrorDto['diagnostics']; +} + +class RuntimeProviderCommandOutputError extends Error { + readonly diagnostics: RuntimeProviderManagementErrorDto['diagnostics']; + + constructor(failure: RuntimeProviderCommandFailure) { + super(failure.message); + this.name = 'RuntimeProviderCommandOutputError'; + this.diagnostics = failure.diagnostics ?? null; + } +} + function errorResponse( runtimeId: RuntimeProviderManagementRuntimeId, message: string, - code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy' + code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy', + diagnostics: RuntimeProviderManagementErrorDto['diagnostics'] = null ): T { return { schemaVersion: 1, @@ -49,25 +101,667 @@ function errorResponse( code, message, recoverable: true, + diagnostics: withRuntimeProviderErrorCode(code, diagnostics), }, } as T; } -function extractJsonObject(raw: string): T { - const start = raw.indexOf('{'); - const end = raw.lastIndexOf('}'); - if (start < 0 || end < start) { - throw new Error('CLI did not return a JSON object'); - } - return JSON.parse(raw.slice(start, end + 1)) as T; +function commandFailureResponse( + runtimeId: RuntimeProviderManagementRuntimeId, + failure: RuntimeProviderCommandFailure, + code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy' +): T { + return errorResponse(runtimeId, failure.message, code, failure.diagnostics ?? null); } -function tryExtractJsonObject(raw: string | null): T | null { +function sanitizeRuntimeProviderResponse( + response: T +): T { + const sanitizedResponse = sanitizeRuntimeProviderOutputValue(response) as T; + if (sanitizedResponse.error === null) { + const responseWithoutNullError = { ...sanitizedResponse }; + delete (responseWithoutNullError as { error?: unknown }).error; + return responseWithoutNullError as T; + } + if (!sanitizedResponse.error) { + return sanitizedResponse; + } + + return { + ...sanitizedResponse, + error: sanitizeRuntimeProviderError(sanitizedResponse.error), + }; +} + +function sanitizeRuntimeProviderError(error: unknown): RuntimeProviderManagementErrorDto { + if (!isRecord(error)) { + return { + code: 'runtime-unhealthy', + message: 'Runtime provider management command failed', + recoverable: true, + diagnostics: null, + }; + } + const rawCode = error.code; + const code = + typeof rawCode === 'string' && + RUNTIME_PROVIDER_ERROR_CODES.has(rawCode as RuntimeProviderManagementErrorDto['code']) + ? (rawCode as RuntimeProviderManagementErrorDto['code']) + : 'runtime-unhealthy'; + const diagnostics = sanitizeRuntimeProviderDiagnostics(error.diagnostics); + return { + code, + message: + sanitizeNullableRuntimeProviderText(error.message) ?? + 'Runtime provider management command failed', + recoverable: typeof error.recoverable === 'boolean' ? error.recoverable : true, + diagnostics: withRuntimeProviderErrorCode(code, diagnostics), + }; +} + +function withRuntimeProviderErrorCode( + errorCode: RuntimeProviderManagementErrorDto['code'], + diagnostics: RuntimeProviderManagementErrorDto['diagnostics'] +): RuntimeProviderManagementErrorDto['diagnostics'] { + return diagnostics ? { ...diagnostics, errorCode } : null; +} + +function sanitizeRuntimeProviderOutputValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeRuntimeProviderText(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeRuntimeProviderOutputValue); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, sanitizeRuntimeProviderOutputValue(entry)]) + ); +} + +function sanitizeRuntimeProviderDiagnostics( + diagnostics: unknown +): RuntimeProviderManagementErrorDto['diagnostics'] { + if (!isRecord(diagnostics)) { + return null; + } + return { + errorCode: + typeof diagnostics.errorCode === 'string' && + RUNTIME_PROVIDER_ERROR_CODES.has( + diagnostics.errorCode as RuntimeProviderManagementErrorDto['code'] + ) + ? (diagnostics.errorCode as RuntimeProviderManagementErrorDto['code']) + : null, + summary: sanitizeNullableRuntimeProviderText(diagnostics.summary), + likelyCause: sanitizeNullableRuntimeProviderText(diagnostics.likelyCause), + binaryPath: sanitizeNullableRuntimeProviderText(diagnostics.binaryPath), + command: sanitizeNullableRuntimeProviderText(diagnostics.command), + projectPath: sanitizeNullableRuntimeProviderText(diagnostics.projectPath), + exitCode: typeof diagnostics.exitCode === 'number' ? diagnostics.exitCode : null, + stderrPreview: sanitizeNullableRuntimeProviderText(diagnostics.stderrPreview), + stdoutPreview: sanitizeNullableRuntimeProviderText(diagnostics.stdoutPreview), + hints: Array.isArray(diagnostics.hints) + ? diagnostics.hints + .filter((hint): hint is string => typeof hint === 'string') + .map(sanitizeRuntimeProviderText) + : [], + }; +} + +function sanitizeNullableRuntimeProviderText(value: unknown): string | null { + return typeof value === 'string' ? sanitizeRuntimeProviderText(value) : null; +} + +function extractJsonObject(raw: string): T { + const start = raw.indexOf('{'); + if (start < 0) { + throw new Error('CLI did not return a JSON object'); + } + + for (let index = start; index >= 0 && index < raw.length; index = raw.indexOf('{', index + 1)) { + const end = findJsonObjectEnd(raw, index); + if (end === null) { + continue; + } + try { + const candidate = JSON.parse(raw.slice(index, end + 1)) as T; + if (isRuntimeProviderResponseCandidate(candidate)) { + return candidate; + } + } catch { + // Keep scanning. CLI output can contain brace-looking logs before the JSON response. + } + } + + throw new Error('CLI did not return a JSON object'); +} + +function findJsonObjectEnd(raw: string, start: number): number | null { + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < raw.length; index += 1) { + const char = raw[index]; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + if (char === '{') { + depth += 1; + continue; + } + if (char !== '}') { + continue; + } + + depth -= 1; + if (depth === 0) { + return index; + } + if (depth < 0) { + return null; + } + } + + return null; +} + +function isRuntimeProviderResponseCandidate(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return ( + typeof value.schemaVersion === 'number' && + typeof value.runtimeId === 'string' && + hasRuntimeProviderResponsePayload(value) + ); +} + +function hasRuntimeProviderResponsePayload(record: Record): boolean { + if (isRecord(record.error)) { + return isRuntimeProviderErrorPayload(record.error); + } + if ('view' in record) { + return isRuntimeProviderViewPayload(record.view); + } + if ('directory' in record) { + return isRuntimeProviderDirectoryPayload(record.directory); + } + if ('provider' in record) { + return isRuntimeProviderProviderPayload(record.provider); + } + if ('setupForm' in record) { + return isRuntimeProviderSetupFormPayload(record.setupForm); + } + if ('models' in record) { + return isRuntimeProviderModelsPayload(record.models); + } + if ('result' in record) { + return isRuntimeProviderModelTestResultPayload(record.result); + } + return false; +} + +function isRuntimeProviderErrorPayload(value: unknown): boolean { + return ( + isRecord(value) && + (typeof value.code === 'string' || + typeof value.message === 'string' || + typeof value.recoverable === 'boolean' || + 'diagnostics' in value) + ); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasArrayField( + record: Record, + key: K +): record is Record & Record { + return Array.isArray(record[key]); +} + +function isRuntimeProviderViewPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'providers') && + hasArrayField(value, 'diagnostics') && + value.providers.every(isRuntimeProviderProviderPayload) + ); +} + +function isRuntimeProviderProviderPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'actions') && + hasArrayField(value, 'authMethods') && + hasArrayField(value, 'ownership') + ); +} + +function isRuntimeProviderDirectoryPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'entries') && + hasArrayField(value, 'diagnostics') && + value.entries.every(isRuntimeProviderDirectoryEntryPayload) + ); +} + +function isRuntimeProviderDirectoryEntryPayload(value: unknown): boolean { + return ( + isRuntimeProviderProviderPayload(value) && isRecord(value) && hasArrayField(value, 'sources') + ); +} + +function isRuntimeProviderSetupFormPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'prompts') && + value.prompts.every(isRuntimeProviderSetupPromptPayload) + ); +} + +function isRuntimeProviderSetupPromptPayload(value: unknown): boolean { + return isRecord(value) && hasArrayField(value, 'options'); +} + +function isRuntimeProviderModelsPayload(value: unknown): boolean { + return isRecord(value) && hasArrayField(value, 'models') && hasArrayField(value, 'diagnostics'); +} + +function isRuntimeProviderModelTestResultPayload(value: unknown): boolean { + return isRecord(value) && hasArrayField(value, 'diagnostics'); +} + +function stripTerminalFormatting(value: string): string { + return value.replace(OSC_ESCAPE_PATTERN, '').replace(ANSI_ESCAPE_PATTERN, ''); +} + +function sanitizeRuntimeProviderText(value: string): string { + return redactSensitiveText(stripTerminalFormatting(value)); +} + +function redactSensitiveText(value: string): string { + return value + .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted') + .replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted') + .replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted') + .replace( + /\b([A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|[_-]key)["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, + '$1...redacted' + ) + .replace(/\b(key["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted') + .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted'); +} + +function formatCommandForDisplay(context: RuntimeProviderCommandContext): string { + return [context.binaryPath, ...context.args].map(formatCommandPartForDisplay).join(' '); +} + +function formatCommandPartForDisplay(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function getOutputPreview(value: string | null): string | null { + const normalized = sanitizeRuntimeProviderText(value ?? '').trim(); + if (!normalized) { + return null; + } + return truncateCommandErrorDetail( + normalized.length > COMMAND_OUTPUT_PREVIEW_LIMIT + ? `${normalized.slice(0, COMMAND_OUTPUT_PREVIEW_LIMIT).trimEnd()}...` + : normalized + ); +} + +function sanitizeCommandErrorMessage(value: string): string { + return truncateCommandErrorDetail(sanitizeRuntimeProviderText(value.trim())); +} + +function outputLooksLikeOpenCodeCliHelp(value: string | null): boolean { + const normalized = stripTerminalFormatting(value ?? '').toLowerCase(); + return ( + normalized.includes('opencode providers') || + normalized.includes('opencode models') || + (normalized.includes('commands:') && normalized.includes('opencode')) + ); +} + +function binaryLooksLikeOpenCode(binaryPath: string): boolean { + return getBinaryBasenameCandidates(binaryPath).some((basename) => + OPENCODE_BINARY_BASENAMES.has(basename) + ); +} + +function getBinaryBasenameCandidates(binaryPath: string): string[] { + const basenames = new Set([path.basename(binaryPath).toLowerCase()]); + try { + basenames.add(path.basename(fs.realpathSync.native(binaryPath)).toLowerCase()); + } catch { + // Nonexistent mocked paths are handled by the literal basename above. + } + return [...basenames]; +} + +function formatNonJsonCliOutputError(input: { + context: RuntimeProviderCommandContext; + stdout?: string | null; + stderr?: string | null; + exitCode?: number | null; +}): RuntimeProviderCommandFailure { + const stdoutPreview = getOutputPreview(input.stdout ?? null); + const stderrPreview = getOutputPreview(input.stderr ?? null); + const likelyWrongBinary = + binaryLooksLikeOpenCode(input.context.binaryPath) || + outputLooksLikeOpenCodeCliHelp(input.stdout ?? null) || + outputLooksLikeOpenCodeCliHelp(input.stderr ?? null); + const likelyCause = likelyWrongBinary + ? 'The app is launching the OpenCode CLI itself instead of the Agent Teams runtime (claude-multimodel).' + : 'The runtime command printed logs, help text, or a crash message instead of JSON.'; + const hints = likelyWrongBinary + ? [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'Those environment variables must not point to opencode.', + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.', + ] + : [ + 'Open stderr preview first. It usually contains the real crash or missing dependency.', + 'Run the shown command from the same project path to reproduce the runtime output.', + ]; + const lines = [ + 'OpenCode provider settings could not read the runtime response.', + 'Expected a JSON object from the Agent Teams runtime provider command.', + `Resolved runtime binary: ${input.context.binaryPath}`, + `Command: ${formatCommandForDisplay(input.context)}`, + ]; + + if (input.context.projectPath) { + lines.push(`Project path: ${input.context.projectPath}`); + } + if (input.exitCode !== undefined) { + lines.push(`Exit code: ${String(input.exitCode ?? 'unknown')}`); + } + + if (likelyWrongBinary) { + lines.push(`Likely cause: ${likelyCause}`, ...hints); + } else { + lines.push(`Likely cause: ${likelyCause}`); + } + + if (stderrPreview) { + lines.push('stderr preview:', stderrPreview); + } + if (stdoutPreview) { + lines.push('stdout preview:', stdoutPreview); + } + if (!stderrPreview && !stdoutPreview) { + lines.push('No stdout or stderr was captured from the runtime command.'); + } + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings could not read the runtime response.', + likelyCause, + binaryPath: input.context.binaryPath, + command: formatCommandForDisplay(input.context), + projectPath: input.context.projectPath, + exitCode: input.exitCode ?? null, + stderrPreview, + stdoutPreview, + hints, + }, + }; +} + +function formatWrongRuntimeBinaryError( + context: RuntimeProviderCommandContext +): RuntimeProviderCommandFailure { + const likelyCause = 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.'; + const hints = [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'Those environment variables must not point to opencode.', + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.', + ]; + const lines = [ + 'OpenCode provider settings are using the wrong runtime binary.', + `Resolved runtime binary: ${context.binaryPath}`, + `Command that was blocked: ${formatCommandForDisplay(context)}`, + ]; + + if (context.projectPath) { + lines.push(`Project path: ${context.projectPath}`); + } + + lines.push(`Likely cause: ${likelyCause}`, ...hints); + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause, + binaryPath: context.binaryPath, + command: formatCommandForDisplay(context), + projectPath: context.projectPath, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints, + }, + }; +} + +function formatCommandExecutionError(input: { + context: RuntimeProviderCommandContext; + errorMessage: string; +}): RuntimeProviderCommandFailure { + const sanitizedError = sanitizeCommandErrorMessage(input.errorMessage); + const likelyCause = 'The runtime command failed before it returned JSON output.'; + const hints = [ + 'Check whether the resolved runtime binary exists and is executable.', + 'Run the shown command from the same project path to reproduce the failure.', + ]; + const lines = [ + 'OpenCode provider settings could not run the runtime command.', + `Resolved runtime binary: ${input.context.binaryPath}`, + `Command: ${formatCommandForDisplay(input.context)}`, + ]; + + if (input.context.projectPath) { + lines.push(`Project path: ${input.context.projectPath}`); + } + + lines.push(`Likely cause: ${likelyCause}`); + if (sanitizedError) { + lines.push('Error:', sanitizedError); + } + lines.push(...hints); + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings could not run the runtime command.', + likelyCause, + binaryPath: input.context.binaryPath, + command: formatCommandForDisplay(input.context), + projectPath: input.context.projectPath, + exitCode: null, + stderrPreview: sanitizedError || null, + stdoutPreview: null, + hints, + }, + }; +} + +function isCommandTimeoutMessage(value: string): boolean { + const normalized = value.toLowerCase(); + return normalized.includes('timed out') || normalized.includes('timeout'); +} + +function formatCommandTimeoutError(input: { + context: RuntimeProviderCommandContext; + errorMessage: string; + stdout?: string | null; + stderr?: string | null; +}): RuntimeProviderCommandFailure { + const stdoutPreview = getOutputPreview(input.stdout ?? null); + const stderrPreview = getOutputPreview(input.stderr ?? null); + const sanitizedError = sanitizeCommandErrorMessage(input.errorMessage); + const likelyCause = + 'The Agent Teams runtime command did not return JSON before the desktop timeout.'; + const hints = [ + 'This is not enough evidence to conclude that OpenCode auth is missing.', + 'Run the shown command from the same project path to see the runtime-side OpenCode diagnostics.', + 'If the command hangs before printing JSON, check OpenCode CLI startup, provider/model listing, local OpenCode plugins, cache/profile corruption, and Windows security software delays.', + 'If the runtime binary is stale, update Agent Teams so the runtime can return a degraded OpenCode diagnostic instead of timing out.', + ]; + const lines = [ + 'OpenCode provider settings timed out while waiting for the Agent Teams runtime.', + `Resolved runtime binary: ${input.context.binaryPath}`, + `Command: ${formatCommandForDisplay(input.context)}`, + ]; + + if (input.context.projectPath) { + lines.push(`Project path: ${input.context.projectPath}`); + } + + lines.push(`Likely cause: ${likelyCause}`); + if (sanitizedError) { + lines.push('Timeout detail:', sanitizedError); + } + if (stderrPreview) { + lines.push('stderr preview:', stderrPreview); + } + if (stdoutPreview) { + lines.push('stdout preview:', stdoutPreview); + } + lines.push(...hints); + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings timed out while waiting for the Agent Teams runtime.', + likelyCause, + binaryPath: input.context.binaryPath, + command: formatCommandForDisplay(input.context), + projectPath: input.context.projectPath, + exitCode: null, + stderrPreview: stderrPreview ?? sanitizedError, + stdoutPreview, + hints, + }, + }; +} + +function formatMissingRuntimeBinaryError( + projectPath: string | null +): RuntimeProviderCommandFailure { + const likelyCause = + 'The Agent Teams runtime/orchestrator CLI could not be resolved from the current environment.'; + const hints = [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'If you are developing locally, start the desktop app from a shell that can resolve the orchestrator CLI.', + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.', + ]; + const lines = [ + 'OpenCode provider settings could not find the Agent Teams runtime binary.', + `Likely cause: ${likelyCause}`, + ...hints, + ]; + + if (projectPath) { + lines.splice(1, 0, `Project path: ${projectPath}`); + } + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings could not find the Agent Teams runtime binary.', + likelyCause, + binaryPath: null, + command: null, + projectPath, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints, + }, + }; +} + +function missingRuntimeBinaryResponse( + runtimeId: RuntimeProviderManagementRuntimeId, + projectPath: string | null +): T { + return commandFailureResponse( + runtimeId, + formatMissingRuntimeBinaryError(projectPath), + 'runtime-missing' + ); +} + +function rejectWrongRuntimeBinary( + runtimeId: RuntimeProviderManagementRuntimeId, + context: RuntimeProviderCommandContext +): T | null { + if (!binaryLooksLikeOpenCode(context.binaryPath)) { + return null; + } + ClaudeBinaryResolver.clearCache(); + return commandFailureResponse( + runtimeId, + formatWrongRuntimeBinaryError(context), + 'runtime-misconfigured' + ); +} + +function extractJsonObjectWithContext( + raw: string, + context: RuntimeProviderCommandContext, + stderr: string | null = null +): T { + try { + return sanitizeRuntimeProviderResponse(extractJsonObject(raw)); + } catch { + throw new RuntimeProviderCommandOutputError( + formatNonJsonCliOutputError({ context, stdout: raw, stderr }) + ); + } +} + +function tryExtractJsonObject( + raw: string | null +): T | null { if (!raw) { return null; } try { - return extractJsonObject(raw); + return sanitizeRuntimeProviderResponse(extractJsonObject(raw)); } catch { return null; } @@ -85,7 +779,9 @@ function readErrorTextProperty(error: unknown, propertyName: 'stderr' | 'stdout' return null; } -function extractJsonObjectFromError(error: unknown): T | null { +function extractJsonObjectFromError( + error: unknown +): T | null { return ( tryExtractJsonObject(readErrorTextProperty(error, 'stdout')) ?? tryExtractJsonObject(readErrorTextProperty(error, 'stderr')) @@ -99,19 +795,59 @@ function truncateCommandErrorDetail(message: string): string { return `${message.slice(0, COMMAND_ERROR_DETAIL_LIMIT).trimEnd()}...`; } -function normalizeCommandFailure(error: unknown): string { - const stderr = readErrorTextProperty(error, 'stderr'); - if (stderr) { - return truncateCommandErrorDetail(stderr); +function normalizeCommandFailure( + error: unknown, + context?: RuntimeProviderCommandContext +): RuntimeProviderCommandFailure { + if (error instanceof RuntimeProviderCommandOutputError) { + return { + message: truncateCommandErrorDetail(error.message), + diagnostics: error.diagnostics, + }; } + const stderr = readErrorTextProperty(error, 'stderr'); const stdout = readErrorTextProperty(error, 'stdout'); + const message = error instanceof Error ? error.message : String(error); + if (context && isCommandTimeoutMessage(message)) { + return formatCommandTimeoutError({ + context, + errorMessage: message, + stdout, + stderr, + }); + } + if ( + context && + (outputLooksLikeOpenCodeCliHelp(stdout) || + outputLooksLikeOpenCodeCliHelp(stderr) || + (stdout && !stderr && binaryLooksLikeOpenCode(context.binaryPath))) + ) { + return formatNonJsonCliOutputError({ context, stdout, stderr }); + } + if (context && (stdout || stderr)) { + return formatNonJsonCliOutputError({ context, stdout, stderr }); + } + if (stderr) { + return { message: sanitizeCommandErrorMessage(stderr) }; + } if (stdout) { - return truncateCommandErrorDetail(stdout); + return { message: sanitizeCommandErrorMessage(stdout) }; } if (error instanceof Error && error.message.trim()) { - return truncateCommandErrorDetail(error.message); + if (context) { + return formatCommandExecutionError({ context, errorMessage: error.message }); + } + return { message: sanitizeCommandErrorMessage(error.message) }; } - return 'Runtime provider management command failed'; + return { message: 'Runtime provider management command failed' }; +} + +function createCommandContext( + binaryPath: string, + args: readonly string[], + projectPath: string | null +): RuntimeProviderCommandContext { + return { binaryPath, args, projectPath }; } function normalizeProjectPath(projectPath: string | null | undefined): string | null { @@ -156,6 +892,15 @@ async function resolveCliEnv(): Promise<{ }, }; } + if (binaryLooksLikeOpenCode(binaryPath)) { + return { + binaryPath, + env: { + ...process.env, + ...shellEnv, + }, + }; + } const providerAware = await buildProviderAwareCliEnv({ binaryPath, @@ -172,10 +917,11 @@ async function resolveCliEnv(): Promise<{ function collectSpawnOutput( child: ChildProcessWithoutNullStreams, stdinValue: string -): Promise<{ stdout: string; stderr: string; code: number | null }> { +): Promise<{ stdout: string; stderr: string; code: number | null; stdinError: string | null }> { return new Promise((resolve, reject) => { const stdout: Buffer[] = []; const stderr: Buffer[] = []; + let stdinError: string | null = null; let settled = false; const timeout = setTimeout(() => { @@ -184,17 +930,23 @@ function collectSpawnOutput( } settled = true; killProcessTree(child, 'SIGKILL'); - reject(new Error('Runtime provider management command timed out')); + const error = new Error('Runtime provider management command timed out'); + Object.assign(error, readSpawnOutputSnapshot(stdout, stderr)); + reject(error); }, COMMAND_TIMEOUT_MS); child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.stdin.once('error', (error: Error) => { + stdinError = error.message; + }); child.once('error', (error) => { if (settled) { return; } settled = true; clearTimeout(timeout); + Object.assign(error, readSpawnOutputSnapshot(stdout, stderr)); reject(error); }); child.once('close', (code) => { @@ -207,46 +959,96 @@ function collectSpawnOutput( stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), code, + stdinError, }); }); - child.stdin.write(stdinValue); - child.stdin.end(); + try { + child.stdin.write(stdinValue); + child.stdin.end(); + } catch (error) { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error instanceof Error) { + Object.assign(error, readSpawnOutputSnapshot(stdout, stderr)); + reject(error); + return; + } + const fallbackError = new Error('Runtime provider management command stdin write failed'); + Object.assign(fallbackError, readSpawnOutputSnapshot(stdout, stderr)); + reject(fallbackError); + } }); } +function mergeSpawnStderrWithStdinError(result: { + stderr: string; + stdinError: string | null; +}): string { + if (!result.stdinError?.trim()) { + return result.stderr; + } + const stdinErrorLine = `stdin error: ${result.stdinError.trim()}`; + return result.stderr.trim() ? `${result.stderr.trimEnd()}\n${stdinErrorLine}` : stdinErrorLine; +} + +function readSpawnOutputSnapshot( + stdout: readonly Buffer[], + stderr: readonly Buffer[] +): { stdout: string; stderr: string } { + return { + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }; +} + export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProviderManagementApi { async loadView( input: RuntimeProviderManagementLoadViewInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + ['runtime', 'providers', 'view', '--runtime', input.runtimeId, '--json', '--compact'], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - ['runtime', 'providers', 'view', '--runtime', input.runtimeId, '--json', '--compact'], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -254,16 +1056,15 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async loadProviderDirectory( input: RuntimeProviderManagementLoadDirectoryInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); const args = ['runtime', 'providers', 'directory', '--runtime', input.runtimeId, '--json']; appendOptionalArg(args, '--project-path', projectPath); appendOptionalArg(args, '--query', input.query ?? null); @@ -275,23 +1076,35 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (input.refresh) { args.push('--refresh'); } + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -299,44 +1112,56 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async loadSetupForm( input: RuntimeProviderManagementLoadSetupFormInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'setup-form', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'setup-form', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -344,33 +1169,41 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async connectProvider( input: RuntimeProviderManagementConnectInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'connect', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--stdin-json', + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { const child = spawnCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'connect', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--stdin-json', - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions( { env, @@ -388,15 +1221,26 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv }) ); if (result.code === 0) { - return extractJsonObject(result.stdout); + return extractJsonObjectWithContext( + result.stdout, + context, + mergeSpawnStderrWithStdinError(result) + ); } try { - return extractJsonObject(result.stdout); + return sanitizeRuntimeProviderResponse( + extractJsonObject(result.stdout) + ); } catch { - return errorResponse( + return commandFailureResponse( input.runtimeId, - `Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.` + formatNonJsonCliOutputError({ + context, + stdout: result.stdout, + stderr: mergeSpawnStderrWithStdinError(result), + exitCode: result.code, + }) ); } } catch (error) { @@ -404,9 +1248,9 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -414,33 +1258,41 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async connectWithApiKey( input: RuntimeProviderManagementConnectApiKeyInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'connect-api-key', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--stdin-key', + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { const child = spawnCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'connect-api-key', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--stdin-key', - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions( { env, @@ -451,15 +1303,26 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ) as ChildProcessWithoutNullStreams; const result = await collectSpawnOutput(child, input.apiKey); if (result.code === 0) { - return extractJsonObject(result.stdout); + return extractJsonObjectWithContext( + result.stdout, + context, + mergeSpawnStderrWithStdinError(result) + ); } try { - return extractJsonObject(result.stdout); + return sanitizeRuntimeProviderResponse( + extractJsonObject(result.stdout) + ); } catch { - return errorResponse( + return commandFailureResponse( input.runtimeId, - `Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.` + formatNonJsonCliOutputError({ + context, + stdout: result.stdout, + stderr: mergeSpawnStderrWithStdinError(result), + exitCode: result.code, + }) ); } } catch (error) { @@ -467,9 +1330,9 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -477,43 +1340,55 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async forgetCredential( input: RuntimeProviderManagementForgetInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'forget', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'forget', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -521,16 +1396,15 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async loadModels( input: RuntimeProviderManagementLoadModelsInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); let args = [ 'runtime', 'providers', @@ -548,21 +1422,33 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv args.push('--limit', String(Math.floor(input.limit))); } args = appendProjectPathArgs(args, projectPath); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli(binaryPath, args, { + const { stdout, stderr } = await execCli(binaryPath, args, { ...runtimeProviderCommandOptions({ env }, projectPath), timeout: COMMAND_TIMEOUT_MS, }); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -570,46 +1456,58 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async testModel( input: RuntimeProviderManagementTestModelInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'test-model', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--model', + input.modelId, + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'test-model', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--model', - input.modelId, - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: PROBE_COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error), + normalizeCommandFailure(error, context), 'model-test-failed' ); } @@ -618,49 +1516,61 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async setDefaultModel( input: RuntimeProviderManagementSetDefaultModelInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'set-default', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--model', + input.modelId, + '--scope', + input.scope === 'all_projects' ? 'all-projects' : 'project', + '--probe', + '--compact', + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'set-default', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--model', - input.modelId, - '--scope', - input.scope === 'all_projects' ? 'all-projects' : 'project', - '--probe', - '--compact', - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: PROBE_COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error), + normalizeCommandFailure(error, context), 'model-test-failed' ); } diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index ee60bdd8..e9b343f6 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -13,6 +13,7 @@ import type { RuntimeProviderDefaultScopeDto, RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryFilterDto, + RuntimeProviderManagementErrorDiagnosticsDto, RuntimeProviderManagementRuntimeId, RuntimeProviderManagementViewDto, RuntimeProviderModelDto, @@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState { directoryLoading: boolean; directoryRefreshing: boolean; directoryError: string | null; + directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; directoryTotalCount: number | null; directoryNextCursor: string | null; @@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState { setupForm: RuntimeProviderSetupFormDto | null; setupFormLoading: boolean; setupFormError: string | null; + setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; setupSubmitError: string | null; + setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; setupMetadata: Readonly>; apiKeyValue: string; modelPickerProviderId: string | null; @@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState { models: readonly RuntimeProviderModelDto[]; modelsLoading: boolean; modelsError: string | null; + modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; selectedModelId: string | null; testingModelIds: readonly string[]; savingDefaultModelId: string | null; @@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState { loading: boolean; savingProviderId: string | null; error: string | null; + errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; successMessage: string | null; } @@ -219,6 +225,8 @@ export function useRuntimeProviderManagement( const [directoryLoading, setDirectoryLoading] = useState(false); const [directoryRefreshing, setDirectoryRefreshing] = useState(false); const [directoryError, setDirectoryError] = useState(null); + const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] = + useState(null); const [directoryEntries, setDirectoryEntries] = useState< readonly RuntimeProviderDirectoryEntryDto[] >([]); @@ -234,7 +242,11 @@ export function useRuntimeProviderManagement( const [setupForm, setSetupForm] = useState(null); const [setupFormLoading, setSetupFormLoading] = useState(false); const [setupFormError, setSetupFormError] = useState(null); + const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] = + useState(null); const [setupSubmitError, setSetupSubmitError] = useState(null); + const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] = + useState(null); const [setupMetadata, setSetupMetadata] = useState>({}); const [apiKeyValue, setApiKeyValue] = useState(''); const [modelPickerProviderId, setModelPickerProviderId] = useState(null); @@ -245,6 +257,8 @@ export function useRuntimeProviderManagement( const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); + const [modelsErrorDiagnostics, setModelsErrorDiagnostics] = + useState(null); const [selectedModelId, setSelectedModelId] = useState(null); const [testingModelIds, setTestingModelIds] = useState([]); const [savingDefaultModelId, setSavingDefaultModelId] = useState(null); @@ -254,6 +268,8 @@ export function useRuntimeProviderManagement( const [loading, setLoading] = useState(false); const [savingProviderId, setSavingProviderId] = useState(null); const [error, setError] = useState(null); + const [errorDiagnostics, setErrorDiagnostics] = + useState(null); const [successMessage, setSuccessMessage] = useState(null); const viewLoadRequestSeq = useRef(0); const directoryRequestSeq = useRef(0); @@ -296,6 +312,7 @@ export function useRuntimeProviderManagement( setModels([]); setModelsLoading(false); setModelsError(null); + setModelsErrorDiagnostics(null); setSelectedModelId(null); setModelResults({}); setTestingModelIds([]); @@ -313,6 +330,7 @@ export function useRuntimeProviderManagement( setModels([]); setModelsLoading(false); setModelsError(null); + setModelsErrorDiagnostics(null); setSelectedModelId(null); setModelResults({}); setTestingModelIds([]); @@ -323,24 +341,31 @@ export function useRuntimeProviderManagement( setupFormRequestSeq.current += 1; modelLoadRequestSeq.current += 1; modelProbeGenerationRef.current += 1; + setDirectoryLoading(false); + setDirectoryRefreshing(false); setDirectoryEntries([]); setDirectoryTotalCount(null); setDirectoryNextCursor(null); setDirectoryError(null); + setDirectoryErrorDiagnostics(null); setDirectorySelectedProviderId(null); setDirectoryLoaded(false); setSetupForm(null); setSetupFormLoading(false); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setActiveFormProviderId(null); setApiKeyValue(''); setSetupMetadata({}); setModels([]); setModelsLoading(false); setModelsError(null); + setModelsErrorDiagnostics(null); setSelectedModelId(null); setTestingModelIds([]); + setSavingProviderId(null); setSavingDefaultModelId(null); setModelResults({}); setSuccessMessage(null); @@ -361,6 +386,7 @@ export function useRuntimeProviderManagement( setLoading(true); } setError(null); + setErrorDiagnostics(null); try { const response = await api.runtimeProviderManagement.loadView({ runtimeId: options.runtimeId, @@ -374,6 +400,7 @@ export function useRuntimeProviderManagement( setView(null); } setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics ?? null); return; } const nextView = response.view ?? null; @@ -392,6 +419,7 @@ export function useRuntimeProviderManagement( setView(null); } setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); + setErrorDiagnostics(null); } finally { if (!silent && requestIsCurrent()) { setLoading(false); @@ -434,6 +462,7 @@ export function useRuntimeProviderManagement( setDirectoryLoading(true); } setDirectoryError(null); + setDirectoryErrorDiagnostics(null); try { const response = await api.runtimeProviderManagement.loadProviderDirectory({ @@ -450,6 +479,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setDirectoryError(response.error.message); + setDirectoryErrorDiagnostics(response.error.diagnostics ?? null); if ( response.error.code === 'unsupported-action' || response.error.message.toLowerCase().includes('unknown command') @@ -461,6 +491,7 @@ export function useRuntimeProviderManagement( const directory = response.directory; if (!directory) { setDirectoryError('Provider directory response was empty'); + setDirectoryErrorDiagnostics(null); return; } setDirectoryLoaded(true); @@ -474,6 +505,7 @@ export function useRuntimeProviderManagement( setDirectoryError( loadError instanceof Error ? loadError.message : 'Failed to load provider directory' ); + setDirectoryErrorDiagnostics(null); } } finally { if (requestIsCurrent()) { @@ -495,23 +527,37 @@ export function useRuntimeProviderManagement( useEffect(() => { if (!options.enabled) { viewLoadRequestSeq.current += 1; + directoryRequestSeq.current += 1; + setupFormRequestSeq.current += 1; appliedInitialProviderRef.current = null; + setView(null); + setSelectedProviderId(null); setProviderQuery(''); + setLoading(false); + setSavingProviderId(null); + setSavingDefaultModelId(null); + setError(null); + setErrorDiagnostics(null); + setSuccessMessage(null); setDirectoryLoading(false); setDirectoryRefreshing(false); setDirectoryError(null); + setDirectoryErrorDiagnostics(null); setDirectoryEntries([]); setDirectoryTotalCount(null); setDirectoryNextCursor(null); setDirectoryQuery(''); setDirectoryLoaded(false); setDirectorySelectedProviderId(null); + setDirectorySupported(true); setApiKeyValue(''); setSetupMetadata({}); setSetupForm(null); setSetupFormLoading(false); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setActiveFormProviderId(null); closeModelPickerState(); return; @@ -537,12 +583,20 @@ export function useRuntimeProviderManagement( ); return () => window.clearTimeout(timeout); - }, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]); + }, [ + currentProjectPath, + directoryLoaded, + directoryQuery, + directorySupported, + loadDirectoryPage, + options.enabled, + ]); useEffect(() => { if (!options.enabled || !modelPickerProviderId) { modelLoadRequestSeq.current += 1; setModelsLoading(false); + setModelsErrorDiagnostics(null); return; } @@ -557,6 +611,7 @@ export function useRuntimeProviderManagement( let cancelled = false; setModelsLoading(true); setModelsError(null); + setModelsErrorDiagnostics(null); void withUiTimeout( api.runtimeProviderManagement.loadModels({ runtimeId: options.runtimeId, @@ -574,6 +629,7 @@ export function useRuntimeProviderManagement( if (response.error) { setModels([]); setModelsError(response.error.message); + setModelsErrorDiagnostics(response.error.diagnostics ?? null); return; } const nextModels = response.models?.models ?? []; @@ -593,6 +649,7 @@ export function useRuntimeProviderManagement( ? modelsLoadError.message : 'Failed to load provider models' ); + setModelsErrorDiagnostics(null); } }) .finally(() => { @@ -678,7 +735,9 @@ export function useRuntimeProviderManagement( setActiveFormProviderId(null); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSetupMetadata({}); setApiKeyValue(''); @@ -704,6 +763,7 @@ export function useRuntimeProviderManagement( const searchAllProviders = useCallback((query: string): void => { setDirectoryQuery(query); setDirectoryError(null); + setDirectoryErrorDiagnostics(null); setDirectoryNextCursor(null); }, []); @@ -716,9 +776,12 @@ export function useRuntimeProviderManagement( setSetupMetadata({}); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSetupFormLoading(true); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); const requestSeq = setupFormRequestSeq.current + 1; @@ -740,11 +803,13 @@ export function useRuntimeProviderManagement( } if (response.error) { setSetupFormError(response.error.message); + setSetupFormErrorDiagnostics(response.error.diagnostics ?? null); return; } setSetupForm(response.setupForm ?? null); if (!response.setupForm) { setSetupFormError('Provider setup form response was empty'); + setSetupFormErrorDiagnostics(null); } }) .catch((setupError) => { @@ -754,6 +819,7 @@ export function useRuntimeProviderManagement( setSetupFormError( setupError instanceof Error ? setupError.message : 'Failed to load provider setup form' ); + setSetupFormErrorDiagnostics(null); }) .finally(() => { if (requestIsCurrent()) { @@ -784,13 +850,17 @@ export function useRuntimeProviderManagement( setSetupForm(null); setSetupFormLoading(false); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setError(null); + setErrorDiagnostics(null); }, []); const updateApiKeyValue = useCallback((value: string): void => { setApiKeyValue(value); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); }, []); const setSetupMetadataValue = useCallback((key: string, value: string): void => { @@ -799,29 +869,35 @@ export function useRuntimeProviderManagement( [key]: value, })); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); }, []); const submitConnect = useCallback( async (providerId: string): Promise => { if (!setupForm) { setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); + setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null); return; } if (!setupForm.supported) { setSetupSubmitError( setupForm.disabledReason ?? 'Provider setup is not supported in the app' ); + setSetupSubmitErrorDiagnostics(null); return; } const apiKey = apiKeyValue.trim(); if (setupForm.secret?.required && !apiKey) { setSetupSubmitError(`${setupForm.secret.label} is required`); + setSetupSubmitErrorDiagnostics(null); return; } setSavingProviderId(providerId); setError(null); + setErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); try { @@ -841,6 +917,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setSetupSubmitError(response.error.message); + setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null); return; } if (response.provider) { @@ -852,7 +929,9 @@ export function useRuntimeProviderManagement( setSetupMetadata({}); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); try { await options.onProviderChanged?.(); if (!isProjectContextCurrent(projectContext)) { @@ -869,6 +948,7 @@ export function useRuntimeProviderManagement( setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' ); + setErrorDiagnostics(null); } } catch (connectError) { if (!isProjectContextCurrent(projectContext)) { @@ -877,6 +957,7 @@ export function useRuntimeProviderManagement( setSetupSubmitError( connectError instanceof Error ? connectError.message : 'Failed to connect provider' ); + setSetupSubmitErrorDiagnostics(null); } finally { if (isProjectContextCurrent(projectContext)) { setSavingProviderId(null); @@ -892,6 +973,7 @@ export function useRuntimeProviderManagement( refresh, setupForm, setupFormError, + setupFormErrorDiagnostics, setupMetadata, ] ); @@ -900,6 +982,7 @@ export function useRuntimeProviderManagement( async (providerId: string): Promise => { setSavingProviderId(providerId); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); try { @@ -916,6 +999,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics ?? null); return; } if (response.provider) { @@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement( setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' ); + setErrorDiagnostics(null); } if (!isProjectContextCurrent(projectContext)) { return; @@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement( setError( forgetError instanceof Error ? forgetError.message : 'Failed to forget credential' ); + setErrorDiagnostics(null); } finally { if (isProjectContextCurrent(projectContext)) { setSavingProviderId(null); @@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement( setActiveFormProviderId(null); openModelPickerState(providerId, mode); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); }, [openModelPickerState] @@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement( setSelectedModelId(modelId); setSuccessMessage(null); setError(null); + setErrorDiagnostics(null); }, []); const testModel = useCallback( @@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement( current.includes(modelId) ? current : [...current, modelId] ); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); try { const response = await withUiTimeout( @@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement( 100_000 ); if (response.error) { + if (response.error.diagnostics && shouldRecordProbeResult()) { + setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics); + } if (shouldRecordProbeResult()) { const result = buildFailedModelTestResult(providerId, modelId, response.error.message); setModelResults((current) => ({ @@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement( ): Promise => { setSavingDefaultModelId(modelId); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); try { @@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics ?? null); return; } const proofResult: RuntimeProviderModelTestResultDto = { @@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement( setError( defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default' ); + setErrorDiagnostics(null); } finally { if (isProjectContextCurrent(projectContext)) { setSavingDefaultModelId(null); @@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement( setActiveFormProviderId(null); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSetupMetadata({}); setApiKeyValue(''); if (activeModelPickerProviderRef.current !== providerId) { @@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement( directoryLoading, directoryRefreshing, directoryError, + directoryErrorDiagnostics, directoryEntries, directoryTotalCount, directoryNextCursor, @@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement( setupForm, setupFormLoading, setupFormError, + setupFormErrorDiagnostics, setupSubmitError, + setupSubmitErrorDiagnostics, setupMetadata, apiKeyValue, modelPickerProviderId, @@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement( models, modelsLoading, modelsError, + modelsErrorDiagnostics, selectedModelId, testingModelIds, savingDefaultModelId, @@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement( loading, savingProviderId, error, + errorDiagnostics, successMessage, }), [ activeFormProviderId, apiKeyValue, setupForm, + setupFormErrorDiagnostics, setupFormError, setupFormLoading, + setupSubmitErrorDiagnostics, setupSubmitError, setupMetadata, directoryEntries, directoryError, + directoryErrorDiagnostics, directoryLoaded, directoryLoading, directoryNextCursor, @@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement( directorySupported, directoryTotalCount, error, + errorDiagnostics, loading, modelPickerMode, modelPickerProviderId, modelQuery, modelResults, models, + modelsErrorDiagnostics, modelsError, modelsLoading, providerQuery, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 4646607f..9cff5066 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -20,7 +20,9 @@ import { } from '@renderer/utils/openCodeModelRecommendations'; import { AlertTriangle, + Check, CheckCircle2, + ClipboardList, KeyRound, Loader2, RefreshCcw, @@ -47,6 +49,7 @@ import type { RuntimeProviderDefaultModelSourceDto, RuntimeProviderDefaultScopeDto, RuntimeProviderDirectoryEntryDto, + RuntimeProviderManagementErrorDiagnosticsDto, RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, RuntimeProviderSetupPromptDto, @@ -84,6 +87,12 @@ interface ProviderRowProps { readonly actions: RuntimeProviderManagementActions; } +interface RuntimeProviderErrorAlertProps { + readonly message: string; + readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null; + readonly testId: string; +} + type OpenCodeSettingsSection = 'models' | 'providers'; const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__'; @@ -338,8 +347,11 @@ function ProviderSetupFormPanel({ const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null; const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId; const error = state.setupFormError; + const errorDiagnostics = state.setupFormErrorDiagnostics; const submitError = state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null; + const submitErrorDiagnostics = + state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null; const canSubmit = setupFormCanSubmit(state, provider.providerId); return ( @@ -356,9 +368,11 @@ function ProviderSetupFormPanel({ ) : null} {!loading && error ? ( -
- {error} -
+ ) : null} {!loading && form ? ( @@ -445,8 +459,12 @@ function ProviderSetupFormPanel({ ) : null} {submitError ? ( -
- {submitError} +
+
) : null} @@ -668,6 +686,228 @@ function RuntimeProviderLoadingPlaceholder(): JSX.Element { ); } +function formatRuntimeProviderDiagnosticsCopyText( + message: string, + diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined +): string { + const lines = ['OpenCode provider settings diagnostics', '', 'Message:', message.trim()]; + if (!diagnostics) { + return lines.join('\n'); + } + const hints = diagnostics.hints ?? []; + + const fields: Array<[string, string | number | null]> = [ + ['Error code', diagnostics.errorCode ?? null], + ['Summary', diagnostics.summary], + ['Likely cause', diagnostics.likelyCause], + ['Resolved runtime binary', diagnostics.binaryPath], + ['Command', diagnostics.command], + ['Project path', diagnostics.projectPath], + ['Exit code', diagnostics.exitCode], + ]; + + lines.push('', 'Structured diagnostics:'); + for (const [label, value] of fields) { + if (value !== null && value !== '') { + lines.push(`${label}: ${String(value)}`); + } + } + + if (hints.length > 0) { + lines.push('', 'Hints:', ...hints.map((hint) => `- ${hint}`)); + } + if (diagnostics.stderrPreview) { + lines.push('', 'stderr preview:', diagnostics.stderrPreview); + } + if (diagnostics.stdoutPreview) { + lines.push('', 'stdout preview:', diagnostics.stdoutPreview); + } + + return lines.join('\n'); +} + +function getRuntimeProviderDiagnosticRows( + diagnostics: RuntimeProviderManagementErrorDiagnosticsDto +): Array<[string, string]> { + const rows: Array<[string, string | number | null]> = [ + ['Code', diagnostics.errorCode ?? null], + ['Binary', diagnostics.binaryPath], + ['Command', diagnostics.command], + ['Project', diagnostics.projectPath], + ['Exit', diagnostics.exitCode], + ]; + return rows + .filter(([, value]) => value !== null && value !== '') + .map(([label, value]) => [label, String(value)]); +} + +async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall back to the selection API below. + } + } + + return copyRuntimeProviderDiagnosticsWithSelection(text); +} + +function copyRuntimeProviderDiagnosticsWithSelection(text: string): boolean { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + textarea.remove(); + } +} + +const RuntimeProviderErrorAlert = ({ + message, + diagnostics = null, + testId, +}: RuntimeProviderErrorAlertProps): JSX.Element => { + const [copied, setCopied] = useState(false); + const [headline = message, ...detailLines] = message.trim().split(/\r?\n/); + const fallbackDetails = detailLines.join('\n').trim(); + const hints = diagnostics?.hints ?? []; + const copyText = useMemo( + () => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics), + [diagnostics, message] + ); + const diagnosticRows = diagnostics ? getRuntimeProviderDiagnosticRows(diagnostics) : []; + const copyDiagnostics = useCallback(async (): Promise => { + setCopied(await writeRuntimeProviderDiagnosticsToClipboard(copyText)); + }, [copyText]); + + useEffect(() => { + if (!copied) { + return; + } + const timeout = window.setTimeout(() => setCopied(false), 1_500); + return () => window.clearTimeout(timeout); + }, [copied]); + + return ( +
+ +
+
+
+ {headline || message} +
+ +
+ {diagnostics ? ( +
+ {diagnostics.likelyCause ? ( +
+ Likely cause: + {diagnostics.likelyCause} +
+ ) : null} + {diagnosticRows.length > 0 ? ( +
+ {diagnosticRows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ) : null} + {hints.length > 0 ? ( +
+
Hints
+
    + {hints.map((hint, index) => ( +
  • + {hint} +
  • + ))} +
+
+ ) : null} + {diagnostics.stderrPreview ? ( +
+                {`stderr preview:\n${diagnostics.stderrPreview}`}
+              
+ ) : null} + {diagnostics.stdoutPreview ? ( +
+                {`stdout preview:\n${diagnostics.stdoutPreview}`}
+              
+ ) : null} +
+ ) : fallbackDetails ? ( +
+            {fallbackDetails}
+          
+ ) : null} +
+
+ ); +}; + function RuntimeProviderModelLoadingSkeleton(): JSX.Element { return (
@@ -1756,9 +1996,11 @@ function ProviderModelList({
{state.modelsError ? ( -
- {state.modelsError} -
+ ) : null}
void actions.refresh()} /> {state.error ? ( -
- - {state.error} -
+ ) : null} {state.successMessage ? ( @@ -1988,9 +2224,11 @@ export function RuntimeProviderManagementPanelView({ ) : null} {state.directoryError ? ( -
- {state.directoryError} -
+ ) : null}
diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index f7caca21..a8a6e34b 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -1,9 +1,13 @@ import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const buildProviderAwareCliEnvMock = vi.fn(); const resolveBinaryMock = vi.fn(); +const clearBinaryCacheMock = vi.fn(); const execCliMock = vi.fn(); const spawnCliMock = vi.fn(); const resolveInteractiveShellEnvMock = vi.fn(); @@ -15,12 +19,14 @@ function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): { stdin: { write: ReturnType; end: ReturnType; + once: EventEmitter['once']; }; once: EventEmitter['once']; }; stdinWrite: ReturnType; } { const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); const stdout = new EventEmitter(); const stderr = new EventEmitter(); const stdinWrite = vi.fn(); @@ -38,6 +44,7 @@ function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): { stdin: { write: stdinWrite, end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), }, once: processEvents.once.bind(processEvents), }, @@ -52,6 +59,7 @@ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: () => resolveBinaryMock(), + clearCache: () => clearBinaryCacheMock(), }, })); @@ -94,8 +102,382 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { modelId: 'opencode/nemotron-3-super-free', }); - expect(response.error?.message).toBe('./cli-dev: line 47: exec: bun: not found'); - expect(response.error?.message).not.toContain('runtime providers test-model'); + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('./cli-dev: line 47: exec: bun: not found'); + expect(response.error?.diagnostics?.command).toContain('runtime providers test-model'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + './cli-dev: line 47: exec: bun: not found' + ); + }); + + it('redacts secrets from generic command stderr details', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stderr: 'Provider failed with api_key: sk-secret-value-123456\n', + stdout: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('Provider failed with api_key: ...redacted'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'Provider failed with api_key: ...redacted' + ); + expect(response.error?.diagnostics?.command).toBe( + '/repo/cli-dev runtime providers view --runtime opencode --json --compact' + ); + }); + + it('strips terminal formatting and redacts bearer tokens from command previews', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers models'); + Object.assign(error, { + stderr: + '\u001B]8;;https://logs.example/secret\u0007\u001B[31mAuthorization: Bearer live-token-123456789\u001B[0m\u001B]8;;\u0007\n', + stdout: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadModels({ + runtimeId: 'opencode', + providerId: 'openrouter', + }); + + expect(response.error?.message).toContain('Authorization: Bearer ...redacted'); + expect(response.error?.message).not.toContain('live-token-123456789'); + expect(response.error?.message).not.toContain('logs.example/secret'); + expect(response.error?.message).not.toContain('[31m'); + expect(response.error?.message).not.toContain(']8;;'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'Authorization: Bearer ...redacted' + ); + }); + + it('redacts non-OpenAI provider keys and generic token labels from diagnostics', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stderr: + 'Google key=AIzaSyD-test-secret-value-123456789 and token=provider-token-123456789 and OPENAI_API_KEY=plain_provider_secret_123456 and PROVIDER_TOKEN=provider_token_value_123456\n', + stdout: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('key=...redacted'); + expect(response.error?.message).toContain('token=...redacted'); + expect(response.error?.message).toContain('OPENAI_API_KEY=...redacted'); + expect(response.error?.message).toContain('PROVIDER_TOKEN=...redacted'); + expect(response.error?.message).not.toContain('AIzaSyD-test-secret-value-123456789'); + expect(response.error?.message).not.toContain('provider-token-123456789'); + expect(response.error?.message).not.toContain('plain_provider_secret_123456'); + expect(response.error?.message).not.toContain('provider_token_value_123456'); + expect(response.error?.diagnostics?.stderrPreview).toContain('key=...redacted'); + expect(response.error?.diagnostics?.stderrPreview).toContain('token=...redacted'); + }); + + it('returns structured diagnostics for empty non-JSON command output', async () => { + execCliMock.mockResolvedValue({ + stdout: '', + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('No stdout or stderr was captured'); + expect(response.error?.diagnostics?.command).toBe( + '/repo/cli-dev runtime providers view --runtime opencode --json --compact' + ); + expect(response.error?.diagnostics?.stdoutPreview).toBeNull(); + expect(response.error?.diagnostics?.stderrPreview).toBeNull(); + }); + + it('keeps stderr diagnostics when a zero-exit command prints malformed stdout', async () => { + execCliMock.mockResolvedValue({ + stdout: 'not json', + stderr: 'warning: api_key: sk-secret-value-123456\n', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('warning: api_key: ...redacted'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.diagnostics?.stdoutPreview).toBe('not json'); + expect(response.error?.diagnostics?.stderrPreview).toBe('warning: api_key: ...redacted'); + }); + + it('returns structured diagnostics when the runtime binary cannot be resolved', async () => { + resolveBinaryMock.mockResolvedValue(null); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/project', + }); + + expect(response.error?.code).toBe('runtime-missing'); + expect(response.error?.message).toContain( + 'OpenCode provider settings could not find the Agent Teams runtime binary.' + ); + expect(response.error?.diagnostics?.summary).toBe( + 'OpenCode provider settings could not find the Agent Teams runtime binary.' + ); + expect(response.error?.diagnostics?.binaryPath).toBeNull(); + expect(response.error?.diagnostics?.command).toBeNull(); + expect(response.error?.diagnostics?.projectPath).toBe('/Users/test/project'); + expect(response.error?.diagnostics?.hints).toContain( + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.' + ); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + }); + + it('returns structured diagnostics for process errors without stdout or stderr', async () => { + execCliMock.mockRejectedValue( + new Error('spawn EACCES /repo/cli-dev with api_key: sk-secret-value-123456') + ); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/project', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not run the runtime command.' + ); + expect(response.error?.message).toContain( + 'Error:\nspawn EACCES /repo/cli-dev with api_key: ...redacted' + ); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.diagnostics?.command).toBe( + '/repo/cli-dev runtime providers view --runtime opencode --json --compact --project-path /Users/test/project' + ); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'spawn EACCES /repo/cli-dev with api_key: ...redacted' + ); + }); + + it('parses the runtime JSON response after noisy brace logs', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: `debug {"noise":true}\n${JSON.stringify(validResponse)}\n`, + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + expect(response.view?.runtime.cliPath).toBe('/opt/homebrew/bin/opencode'); + }); + + it('accepts successful runtime responses that include an explicit null error field', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: null, + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + }); + + it('skips contract-looking noise that does not include a response payload', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + debug: 'preflight', + }), + JSON.stringify(validResponse), + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + expect(response.view?.title).toBe('OpenCode'); + }); + + it('does not treat JSON logs without a response payload as a successful runtime response', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + debug: 'preflight', + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.diagnostics?.stdoutPreview).toContain('"debug":"preflight"'); + expect(response.view).toBeUndefined(); + }); + + it('does not treat malformed view payloads as successful runtime responses', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.diagnostics?.stdoutPreview).toContain('"title":"OpenCode"'); + expect(response.view).toBeUndefined(); + }); + + it('does not pass malformed provider entries to the renderer', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [ + { + providerId: 'openrouter', + displayName: 'OpenRouter', + state: 'connected', + ownership: ['managed'], + recommended: true, + modelCount: 4, + defaultModelId: null, + authMethods: ['api'], + detail: null, + }, + ], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.view).toBeUndefined(); }); it('parses JSON error responses from stdout when the CLI exits non-zero', async () => { @@ -127,6 +509,261 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { ); }); + it('redacts secrets from structured JSON error responses returned by the runtime', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'auth-failed', + message: 'Provider failed with api_key: sk-secret-value-123456', + recoverable: true, + diagnostics: { + summary: 'Auth failed for sk-secret-value-123456', + likelyCause: 'Authorization: Bearer live-token-123456789 was rejected', + binaryPath: '/repo/cli-dev', + command: '/repo/cli-dev runtime providers view', + projectPath: null, + exitCode: 1, + stderrPreview: 'api_key: sk-secret-value-123456', + stdoutPreview: 'Authorization: Bearer live-token-123456789', + hints: ['Remove sk-secret-value-123456 from config output.'], + }, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + const serialized = JSON.stringify(response); + + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.summary).toBe('Auth failed for sk-...redacted'); + expect(response.error?.diagnostics?.errorCode).toBe('auth-failed'); + expect(response.error?.diagnostics?.likelyCause).toBe( + 'Authorization: Bearer ...redacted was rejected' + ); + expect(response.error?.diagnostics?.stderrPreview).toBe('api_key: ...redacted'); + expect(response.error?.diagnostics?.stdoutPreview).toBe( + 'Authorization: Bearer ...redacted' + ); + expect(response.error?.diagnostics?.hints[0]).toBe( + 'Remove sk-...redacted from config output.' + ); + expect(serialized).not.toContain('sk-secret-value-123456'); + expect(serialized).not.toContain('live-token-123456789'); + }); + + it('redacts secrets from successful runtime diagnostics before they reach the renderer', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [ + { + providerId: 'openrouter', + displayName: 'OpenRouter', + state: 'connected', + ownership: ['managed'], + recommended: true, + modelCount: 4, + defaultModelId: null, + authMethods: ['api'], + actions: [], + detail: 'Connected with api_key: sk-secret-value-123456', + }, + ], + defaultModel: null, + fallbackModel: null, + diagnostics: [ + 'Authorization: Bearer live-token-123456789', + '\u001B[31mapi_key: sk-secret-value-123456\u001B[0m', + ], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + const serialized = JSON.stringify(response); + + expect(response.view?.diagnostics).toEqual([ + 'Authorization: Bearer ...redacted', + 'api_key: ...redacted', + ]); + expect(response.view?.providers[0]?.detail).toBe('Connected with api_key: ...redacted'); + expect(serialized).not.toContain('sk-secret-value-123456'); + expect(serialized).not.toContain('live-token-123456789'); + expect(serialized).not.toContain('[31m'); + }); + + it('keeps structured runtime errors when optional diagnostic fields are malformed', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-unhealthy', + message: 'Runtime returned malformed diagnostics', + recoverable: true, + diagnostics: { + summary: 'Runtime returned malformed diagnostics', + likelyCause: null, + binaryPath: '/repo/cli-dev', + command: '/repo/cli-dev runtime providers view', + projectPath: null, + exitCode: '1', + stderrPreview: null, + stdoutPreview: null, + }, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toBe('Runtime returned malformed diagnostics'); + expect(response.error?.diagnostics?.summary).toBe('Runtime returned malformed diagnostics'); + expect(response.error?.diagnostics?.exitCode).toBeNull(); + expect(response.error?.diagnostics?.hints).toEqual([]); + }); + + it('normalizes malformed structured runtime error objects instead of leaking them to the renderer', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'not-a-real-code', + message: 123, + recoverable: 'yes', + diagnostics: { + summary: 'api_key: sk-secret-value-123456', + }, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.code).toBe('runtime-unhealthy'); + expect(response.error?.message).toBe('Runtime provider management command failed'); + expect(response.error?.diagnostics?.summary).toBe('api_key: ...redacted'); + expect(JSON.stringify(response)).not.toContain('sk-secret-value-123456'); + }); + + it('does not let non-object error logs shadow a later valid runtime response', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: 'debug preflight', + }), + JSON.stringify(validResponse), + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + }); + + it('does not let non-contract error object logs shadow a later valid runtime response', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { debug: true }, + }), + JSON.stringify(validResponse), + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + }); + it('parses JSON error responses from failed forget commands', async () => { const error = new Error('Command failed: /repo/cli-dev runtime providers forget'); Object.assign(error, { @@ -155,6 +792,327 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { ); }); + it('rejects the OpenCode CLI binary before running runtime provider commands', async () => { + resolveBinaryMock.mockResolvedValue('/opt/homebrew/bin/opencode'); + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ shouldNotRun: true }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/My Project', + }); + + expect(execCliMock).not.toHaveBeenCalled(); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + expect(clearBinaryCacheMock).toHaveBeenCalledTimes(1); + expect(response.error?.code).toBe('runtime-misconfigured'); + expect(response.error?.message).toContain( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + expect(response.error?.message).toContain( + 'Command that was blocked: /opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact --project-path' + ); + expect(response.error?.message).toContain( + 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.' + ); + expect(response.error?.diagnostics?.errorCode).toBe('runtime-misconfigured'); + expect(response.error?.diagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + expect(response.error?.diagnostics?.command).toBe( + "/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact --project-path '/Users/test/My Project'" + ); + expect(response.error?.diagnostics?.projectPath).toBe('/Users/test/My Project'); + expect(response.error?.diagnostics?.stdoutPreview).toBeNull(); + expect(response.error?.diagnostics?.stderrPreview).toBeNull(); + expect(response.error?.diagnostics?.hints).toContain( + 'Those environment variables must not point to opencode.' + ); + }); + + it('rejects runtime symlinks that resolve to the OpenCode CLI binary', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-runtime-')); + const opencodeTarget = path.join(tempDir, 'opencode'); + const runtimeLink = path.join(tempDir, 'claude-multimodel'); + try { + fs.writeFileSync(opencodeTarget, '#!/bin/sh\n'); + fs.symlinkSync(opencodeTarget, runtimeLink); + resolveBinaryMock.mockResolvedValue(runtimeLink); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(execCliMock).not.toHaveBeenCalled(); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + expect(clearBinaryCacheMock).toHaveBeenCalledTimes(1); + expect(response.error?.code).toBe('runtime-misconfigured'); + expect(response.error?.diagnostics?.binaryPath).toBe(runtimeLink); + expect(response.error?.message).toContain( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects OpenCode CLI connect commands before spawning or writing secrets', async () => { + resolveBinaryMock.mockResolvedValue('/opt/homebrew/bin/opencode.cmd'); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectProvider({ + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-secret-value-123456', + metadata: { + region: 'us', + }, + projectPath: '/Users/test/project', + }); + + expect(spawnCliMock).not.toHaveBeenCalled(); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + expect(clearBinaryCacheMock).toHaveBeenCalledTimes(1); + expect(response.error?.code).toBe('runtime-misconfigured'); + expect(response.error?.diagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode.cmd'); + expect(response.error?.diagnostics?.command).toBe( + '/opt/homebrew/bin/opencode.cmd runtime providers connect --runtime opencode --provider openrouter --stdin-json --json --project-path /Users/test/project' + ); + expect(JSON.stringify(response)).not.toContain('sk-secret-value-123456'); + }); + + it('does not reject valid orchestrator paths that only contain opencode in a parent directory', async () => { + resolveBinaryMock.mockResolvedValue('/repo/opencode-runtime/cli-source'); + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.cliPath).toBe('/opt/homebrew/bin/opencode'); + expect(execCliMock).toHaveBeenCalledWith( + '/repo/opencode-runtime/cli-source', + expect.arrayContaining(['runtime', 'providers', 'view']), + expect.any(Object) + ); + }); + + it('explains OpenCode CLI help output instead of returning a generic JSON error', async () => { + execCliMock.mockResolvedValue({ + stdout: [ + 'Usage: opencode [command]', + '', + 'Commands:', + ' opencode providers', + ' opencode models', + 'api_key: sk-secret-value-123456', + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/My Project', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.message).toContain( + 'Expected a JSON object from the Agent Teams runtime provider command.' + ); + expect(response.error?.message).toContain( + 'Resolved runtime binary: /repo/cli-dev' + ); + expect(response.error?.message).toContain( + "Command: /repo/cli-dev runtime providers view --runtime opencode --json --compact --project-path '/Users/test/My Project'" + ); + expect(response.error?.message).toContain( + 'Likely cause: The app is launching the OpenCode CLI itself instead of the Agent Teams runtime' + ); + expect(response.error?.message).toContain('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH'); + expect(response.error?.message).toContain('stdout preview:'); + expect(response.error?.message).toContain('opencode providers'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.binaryPath).toBe('/repo/cli-dev'); + expect(response.error?.diagnostics?.command).toBe( + "/repo/cli-dev runtime providers view --runtime opencode --json --compact --project-path '/Users/test/My Project'" + ); + expect(response.error?.diagnostics?.projectPath).toBe('/Users/test/My Project'); + expect(response.error?.diagnostics?.likelyCause).toContain('OpenCode CLI itself'); + expect(response.error?.diagnostics?.hints).toContain( + 'Those environment variables must not point to opencode.' + ); + expect(response.error?.diagnostics?.stdoutPreview).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.stdoutPreview).not.toContain('sk-secret-value-123456'); + }); + + it('formats non-JSON spawn output with exit code and stderr preview', async () => { + const { child } = createSpawnProcess('not-json', 1); + const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(); + const stdinEnd = vi.fn(() => { + queueMicrotask(() => { + stdout.emit('data', Buffer.from('not-json')); + stderr.emit('data', Buffer.from('runtime crashed before JSON')); + processEvents.emit('close', 1); + }); + }); + spawnCliMock.mockReturnValue({ + ...child, + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), + }, + once: processEvents.once.bind(processEvents), + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectProvider({ + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-secret-value-123456', + metadata: {}, + }); + + expect(response.error?.message).toContain('Exit code: 1'); + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('runtime crashed before JSON'); + expect(response.error?.message).toContain('stdout preview:'); + expect(response.error?.message).toContain('not-json'); + expect(response.error?.diagnostics?.exitCode).toBe(1); + expect(response.error?.diagnostics?.stderrPreview).toBe('runtime crashed before JSON'); + expect(response.error?.diagnostics?.stdoutPreview).toBe('not-json'); + expect(stdinWrite).toHaveBeenCalledWith( + JSON.stringify({ + method: 'api', + apiKey: 'sk-secret-value-123456', + metadata: {}, + }) + ); + }); + + it('captures provider stdin errors without dropping runtime diagnostics', async () => { + const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(() => { + queueMicrotask(() => { + stdinEvents.emit('error', new Error('write EPIPE sk-secret-value-123456')); + stdout.emit('data', Buffer.from('not-json')); + processEvents.emit('close', 1); + }); + }); + const stdinEnd = vi.fn(); + spawnCliMock.mockReturnValue({ + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), + }, + once: processEvents.once.bind(processEvents), + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectWithApiKey({ + runtimeId: 'opencode', + providerId: 'openrouter', + apiKey: 'sk-input-secret-value-123456', + }); + + expect(response.error?.message).toContain('stdin error: write EPIPE sk-...redacted'); + expect(response.error?.message).toContain('stdout preview:'); + expect(response.error?.message).toContain('not-json'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).not.toContain('sk-input-secret-value-123456'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'stdin error: write EPIPE sk-...redacted' + ); + expect(response.error?.diagnostics?.stdoutPreview).toBe('not-json'); + expect(stdinWrite).toHaveBeenCalledWith('sk-input-secret-value-123456'); + }); + + it('keeps partial spawn stdout and stderr when a provider command times out', async () => { + vi.useFakeTimers(); + const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(); + const stdinEnd = vi.fn(() => { + stdout.emit('data', Buffer.from('partial non-json stdout')); + stderr.emit('data', Buffer.from('api_key: sk-secret-value-123456')); + }); + spawnCliMock.mockReturnValue({ + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), + }, + once: processEvents.once.bind(processEvents), + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const responsePromise = client.connectWithApiKey({ + runtimeId: 'opencode', + providerId: 'openrouter', + apiKey: 'sk-input-secret-value-123456', + }); + + await vi.advanceTimersByTimeAsync(45_000); + const response = await responsePromise; + vi.useRealTimers(); + + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.message).toContain('partial non-json stdout'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).not.toContain('sk-input-secret-value-123456'); + expect(response.error?.diagnostics?.stderrPreview).toBe('api_key: ...redacted'); + expect(response.error?.diagnostics?.stdoutPreview).toBe('partial non-json stdout'); + expect(stdinWrite).toHaveBeenCalledWith('sk-input-secret-value-123456'); + }); + it('passes project path as cwd and CLI flag for project-aware provider management', async () => { execCliMock.mockResolvedValue({ stdout: JSON.stringify({ diff --git a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts index df517664..6716a774 100644 --- a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts +++ b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main'; import { RUNTIME_PROVIDER_MANAGEMENT_CONNECT, RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, @@ -9,16 +8,17 @@ import { RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, RUNTIME_PROVIDER_MANAGEMENT_VIEW, } from '../../../../src/features/runtime-provider-management/contracts'; +import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main'; -import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main'; import type { RuntimeProviderManagementDirectoryResponse, + RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementViewResponse, - RuntimeProviderManagementModelsResponse, - RuntimeProviderManagementModelTestResponse, } from '../../../../src/features/runtime-provider-management/contracts'; +import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main'; import type { IpcMain } from 'electron'; describe('registerRuntimeProviderManagementIpc', () => { @@ -234,4 +234,151 @@ describe('registerRuntimeProviderManagementIpc', () => { limit: 10, }); }); + + it('sanitizes unexpected IPC error messages before returning them to the renderer', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handlers = new Map Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: RuntimeProviderManagementFeatureFacade = { + loadView: vi.fn(() => + Promise.reject( + new Error( + '\u001B]8;;https://logs.example/secret\u0007\u001B[31mProvider failed with api_key: sk-secret-value-123456 and Authorization: Bearer live-token-123456789 and key=AIzaSyD-test-secret-value-123456789 and OPENAI_API_KEY=plain_provider_secret_123456 and PROVIDER_TOKEN=provider_token_value_123456\u001B[0m\u001B]8;;\u0007' + ) + ) + ), + loadProviderDirectory: vi.fn(), + loadSetupForm: vi.fn(), + connectProvider: vi.fn(), + connectWithApiKey: vi.fn(), + forgetCredential: vi.fn(), + loadModels: vi.fn(), + testModel: vi.fn(), + setDefaultModel: vi.fn(), + }; + + registerRuntimeProviderManagementIpc(ipcMain, feature); + + const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.( + {}, + { runtimeId: 'opencode' } + )) as RuntimeProviderManagementViewResponse; + + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.message).toContain('Authorization: Bearer ...redacted'); + expect(response.error?.message).toContain('key=...redacted'); + expect(response.error?.message).toContain('OPENAI_API_KEY=...redacted'); + expect(response.error?.message).toContain('PROVIDER_TOKEN=...redacted'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).not.toContain('live-token-123456789'); + expect(response.error?.message).not.toContain('AIzaSyD-test-secret-value-123456789'); + expect(response.error?.message).not.toContain('plain_provider_secret_123456'); + expect(response.error?.message).not.toContain('provider_token_value_123456'); + expect(response.error?.message).not.toContain('logs.example/secret'); + expect(response.error?.message).not.toContain('[31m'); + expect(response.error?.message).not.toContain(']8;;'); + expect(response.error?.diagnostics?.summary).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.errorCode).toBe('runtime-unhealthy'); + expect(response.error?.diagnostics?.stderrPreview).toContain( + 'Authorization: Bearer ...redacted' + ); + expect(JSON.stringify(response.error?.diagnostics)).not.toContain('sk-secret-value-123456'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('key=...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('live-token-123456789'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain( + 'AIzaSyD-test-secret-value-123456789' + ); + consoleErrorSpy.mockRestore(); + }); + + it('bounds unexpected IPC diagnostics before returning them to the renderer', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handlers = new Map Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: RuntimeProviderManagementFeatureFacade = { + loadView: vi.fn(() => Promise.reject(new Error(`x${'y'.repeat(3_000)}`))), + loadProviderDirectory: vi.fn(), + loadSetupForm: vi.fn(), + connectProvider: vi.fn(), + connectWithApiKey: vi.fn(), + forgetCredential: vi.fn(), + loadModels: vi.fn(), + testModel: vi.fn(), + setDefaultModel: vi.fn(), + }; + + registerRuntimeProviderManagementIpc(ipcMain, feature); + + const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.( + {}, + { runtimeId: 'opencode' } + )) as RuntimeProviderManagementViewResponse; + + expect(response.error?.message.endsWith('...')).toBe(true); + expect(response.error?.message.length).toBeLessThanOrEqual(1_603); + expect(response.error?.diagnostics?.stderrPreview).toBe(response.error?.message); + consoleErrorSpy.mockRestore(); + }); + + it('does not log raw secrets when connect handlers throw non-Error values', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handlers = new Map Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: RuntimeProviderManagementFeatureFacade = { + loadView: vi.fn(), + loadProviderDirectory: vi.fn(), + loadSetupForm: vi.fn(), + connectProvider: vi.fn(() => + Promise.reject( + 'Provider failed with api_key: sk-secret-value-123456 and token=provider-token-123456789' + ) + ), + connectWithApiKey: vi.fn(), + forgetCredential: vi.fn(), + loadModels: vi.fn(), + testModel: vi.fn(), + setDefaultModel: vi.fn(), + }; + + registerRuntimeProviderManagementIpc(ipcMain, feature); + + const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT)?.( + {}, + { + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-input-secret-value', + metadata: {}, + } + )) as RuntimeProviderManagementProviderResponse; + + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.message).toContain('token=...redacted'); + expect(response.error?.diagnostics?.errorCode).toBe('auth-failed'); + expect(response.error?.diagnostics?.stderrPreview).toContain('token=...redacted'); + expect(JSON.stringify(response)).not.toContain('sk-input-secret-value'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('token=...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('provider-token-123456789'); + consoleErrorSpy.mockRestore(); + }); }); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 5e14073d..d865671d 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -57,6 +57,7 @@ function createState( directoryLoading: false, directoryRefreshing: false, directoryError: null, + directoryErrorDiagnostics: null, directoryEntries: [], directoryTotalCount: null, directoryNextCursor: null, @@ -67,7 +68,9 @@ function createState( setupForm: null, setupFormLoading: false, setupFormError: null, + setupFormErrorDiagnostics: null, setupSubmitError: null, + setupSubmitErrorDiagnostics: null, setupMetadata: {}, apiKeyValue: '', modelPickerProviderId: null, @@ -76,6 +79,7 @@ function createState( models: [], modelsLoading: false, modelsError: null, + modelsErrorDiagnostics: null, selectedModelId: null, testingModelIds: [], savingDefaultModelId: null, @@ -83,6 +87,7 @@ function createState( loading: false, savingProviderId: null, error: null, + errorDiagnostics: null, successMessage: null, ...overrides, }; @@ -170,6 +175,397 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet'); }); + it('renders runtime command errors with a readable headline and multiline details', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const message = [ + 'OpenCode provider settings could not read the runtime response.', + 'Expected a JSON object from the Agent Teams runtime provider command.', + 'Resolved runtime binary: /opt/homebrew/bin/opencode', + 'Command: /opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + 'stdout preview:', + 'Commands:', + ' opencode providers', + ].join('\n'); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ error: message }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const alert = host.querySelector('[data-testid="runtime-provider-error"]'); + const details = alert?.querySelector('pre'); + + expect(alert?.getAttribute('role')).toBe('alert'); + expect(alert?.textContent).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(details?.textContent).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode'); + expect(details?.textContent).toContain(' opencode providers'); + expect(details?.className).toContain('whitespace-pre-wrap'); + expect(details?.className).toContain('font-mono'); + }); + + it('copies fallback error text when structured diagnostics are unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const writeText = vi.fn((_text: string) => Promise.resolve()); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'Runtime provider crashed\nstderr preview:\nmissing bun', + errorDiagnostics: null, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith( + 'OpenCode provider settings diagnostics\n\nMessage:\nRuntime provider crashed\nstderr preview:\nmissing bun' + ); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('copies diagnostics with the selection fallback when clipboard API is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand'); + const execCommand = vi.fn(() => true); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'Runtime provider crashed\nstderr preview:\nmissing bun', + errorDiagnostics: null, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(host.textContent).toContain('Copied'); + expect(document.querySelector('textarea')).toBeNull(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + if (execCommandDescriptor) { + Object.defineProperty(document, 'execCommand', execCommandDescriptor); + } else { + Reflect.deleteProperty(document, 'execCommand'); + } + }); + + it('renders structured runtime diagnostics and copies the full redacted report', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const writeText = vi.fn((_text: string) => Promise.resolve()); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'OpenCode provider settings could not read the runtime response.', + errorDiagnostics: { + errorCode: 'runtime-unhealthy', + summary: 'OpenCode provider settings could not read the runtime response.', + likelyCause: + 'The app is launching the OpenCode CLI itself instead of the Agent Teams runtime.', + binaryPath: '/opt/homebrew/bin/opencode', + command: + '/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + projectPath: '/Users/test/project', + exitCode: 1, + stderrPreview: 'Command failed before JSON', + stdoutPreview: 'Commands:\n opencode providers', + hints: [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'Those environment variables must not point to opencode.', + ], + }, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Likely cause'); + expect(host.textContent).toContain('/opt/homebrew/bin/opencode'); + expect(host.textContent).toContain('Command failed before JSON'); + expect( + host.querySelector('[data-testid="runtime-provider-error-stderr-preview"]')?.textContent + ).toContain('stderr preview'); + expect( + host.querySelector('[data-testid="runtime-provider-error-stdout-preview"]')?.textContent + ).toContain('opencode providers'); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toContain('OpenCode provider settings diagnostics'); + expect(writeText.mock.calls[0][0]).toContain('Error code: runtime-unhealthy'); + expect(writeText.mock.calls[0][0]).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode'); + expect(writeText.mock.calls[0][0]).toContain('stderr preview:'); + expect(writeText.mock.calls[0][0]).toContain('stdout preview:'); + expect(host.textContent).toContain('Copied'); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('does not activate a provider row when copying model diagnostics', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const writeText = vi.fn((_text: string) => Promise.resolve()); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const actions = createActions(); + const base = createState(); + const provider = { + ...base.view!.providers[0], + state: 'connected' as const, + modelCount: 2, + actions: [ + { + id: 'test' as const, + label: 'Test', + enabled: true, + disabledReason: null, + requiresSecret: false, + ownershipScope: 'runtime' as const, + }, + ], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + view: { + ...base.view!, + providers: [provider], + }, + providers: [provider], + selectedProviderId: provider.providerId, + modelPickerProviderId: provider.providerId, + modelPickerMode: 'use', + modelsError: 'Model list failed', + modelsErrorDiagnostics: { + summary: 'Model list failed', + likelyCause: 'The runtime returned a malformed models response.', + binaryPath: '/repo/cli-dev', + command: '/repo/cli-dev runtime providers models --runtime opencode', + projectPath: '/Users/test/project', + exitCode: 1, + stderrPreview: 'bad models payload', + stdoutPreview: null, + hints: ['Retry after refreshing the runtime.'], + }, + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(actions.selectProvider).not.toHaveBeenCalled(); + expect(actions.startConnect).not.toHaveBeenCalled(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('renders structured diagnostics in provider form and model picker errors', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const provider = { + ...createState().view!.providers[0], + state: 'connected' as const, + modelCount: 4, + actions: [ + { + id: 'test' as const, + label: 'Test', + enabled: true, + disabledReason: null, + requiresSecret: false, + ownershipScope: 'runtime' as const, + }, + ], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + providers: [provider], + selectedProviderId: provider.providerId, + activeFormProviderId: provider.providerId, + modelPickerProviderId: provider.providerId, + modelPickerMode: 'use', + setupSubmitError: 'Provider connect failed before JSON.', + setupSubmitErrorDiagnostics: { + summary: 'Provider connect failed before JSON.', + likelyCause: 'The runtime command printed CLI help instead of JSON.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers connect', + projectPath: null, + exitCode: 1, + stderrPreview: 'unknown command', + stdoutPreview: 'Commands:\n opencode providers', + hints: ['Check the resolved runtime binary.'], + }, + modelsError: 'Provider models failed before JSON.', + modelsErrorDiagnostics: { + summary: 'Provider models failed before JSON.', + likelyCause: 'The runtime command printed CLI help instead of JSON.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers models', + projectPath: null, + exitCode: 1, + stderrPreview: 'unknown command', + stdoutPreview: 'Commands:\n opencode providers', + hints: ['Check the resolved runtime binary.'], + }, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect( + host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent + ).toContain('Provider connect failed before JSON.'); + expect( + host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent + ).toContain('/opt/homebrew/bin/opencode'); + expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain( + 'Provider models failed before JSON.' + ); + expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain( + 'opencode providers' + ); + }); + + it('renders provider directory errors with preserved multiline details', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const message = [ + 'OpenCode provider settings could not read the runtime response.', + 'stderr preview:', + 'runtime crashed before JSON', + ].join('\n'); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + directoryError: message, + directoryLoaded: true, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const alert = host.querySelector( + '[data-testid="runtime-provider-directory-error"]' + ); + const details = alert?.querySelector('pre'); + + expect(alert?.getAttribute('role')).toBe('alert'); + expect(details?.textContent).toContain('stderr preview:'); + expect(details?.textContent).toContain('runtime crashed before JSON'); + expect(details?.className).toContain('whitespace-pre-wrap'); + }); + it('keeps project context out of the runtime summary and labels it as validation context', async () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -554,6 +950,57 @@ describe('RuntimeProviderManagementPanelView', () => { expect(duplicateKeyWarnings).toHaveLength(0); }); + it('renders duplicate structured diagnostic hints without React key warnings', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'OpenCode provider settings are using the wrong runtime binary.', + errorDiagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: + 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: + '/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: [ + 'Those environment variables must not point to opencode.', + 'Those environment variables must not point to opencode.', + ], + }, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const duplicateHints = host.textContent?.match( + /Those environment variables must not point to opencode\./g + ); + const duplicateKeyWarnings = consoleError.mock.calls.filter((call) => + call.some( + (argument) => + typeof argument === 'string' && + argument.includes('Encountered two children with the same key') + ) + ); + consoleError.mockRestore(); + + expect(duplicateHints).toHaveLength(2); + expect(duplicateKeyWarnings).toHaveLength(0); + }); + it('renders provider actions and opens API-key form state without exposing a raw secret', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index 82a383d2..6c85685c 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -16,7 +16,10 @@ import { import type { RuntimeProviderConnectionDto, RuntimeProviderDirectoryEntryDto, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementModelTestResponse, + RuntimeProviderManagementProviderResponse, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementViewDto, RuntimeProviderManagementViewResponse, } from '../../../../src/features/runtime-provider-management/contracts'; @@ -112,6 +115,20 @@ describe('useRuntimeProviderManagement', () => { return React.createElement('div'); } + function ConfigurableHarness(props: { + enabled: boolean; + projectPath?: string | null; + }): React.ReactElement { + const hook = useRuntimeProviderManagement({ + runtimeId: 'opencode', + enabled: props.enabled, + projectPath: props.projectPath, + }); + state = hook[0]; + actions = hook[1]; + return React.createElement('div'); + } + beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); host = document.createElement('div'); @@ -174,6 +191,174 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('clears structured errors and stale provider state when disabled', async () => { + const loadView = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-misconfigured', + message: 'OpenCode provider settings are using the wrong runtime binary.', + recoverable: true, + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: + 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: + '/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: ['Those environment variables must not point to opencode.'], + }, + }, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(state?.error).toContain('wrong runtime binary'); + expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: false })); + await Promise.resolve(); + }); + + expect(state?.view).toBeNull(); + expect(state?.selectedProviderId).toBeNull(); + expect(state?.error).toBeNull(); + expect(state?.errorDiagnostics).toBeNull(); + expect(state?.loading).toBe(false); + }); + + it('ignores pending directory and setup-form responses after being disabled', async () => { + let resolveDirectory: + | ((response: RuntimeProviderManagementDirectoryResponse) => void) + | null = null; + let resolveSetupForm: + | ((response: RuntimeProviderManagementSetupFormResponse) => void) + | null = null; + const directoryResponse = new Promise( + (resolve) => { + resolveDirectory = resolve; + } + ); + const setupFormResponse = new Promise( + (resolve) => { + resolveSetupForm = resolve; + } + ); + const loadView = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: createRuntimeView(), + }) + ); + const loadProviderDirectory = vi.fn(() => directoryResponse); + const loadSetupForm = vi.fn(() => setupFormResponse); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadSetupForm, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: true })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.waitFor(() => { + expect(loadProviderDirectory).toHaveBeenCalled(); + }); + actions?.startConnect('openrouter'); + }); + await act(async () => { + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: false })); + await Promise.resolve(); + }); + + await act(async () => { + resolveDirectory?.({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + fetchedAt: '2026-05-22T00:00:00.000Z', + }, + }); + resolveSetupForm?.({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(state?.directoryEntries).toEqual([]); + expect(state?.directoryLoaded).toBe(false); + expect(state?.setupForm).toBeNull(); + expect(state?.activeFormProviderId).toBeNull(); + expect(state?.setupFormLoading).toBe(false); + }); + it('ignores stale provider views after project context changes', async () => { let resolveProjectA: | ((response: { @@ -246,6 +431,143 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('restarts provider directory loading when project context changes while loading', async () => { + let resolveProjectADirectory: + | ((response: RuntimeProviderManagementDirectoryResponse) => void) + | null = null; + let resolveProjectBDirectory: + | ((response: RuntimeProviderManagementDirectoryResponse) => void) + | null = null; + const projectBEntry: RuntimeProviderDirectoryEntryDto = { + ...createOpenAiLocalDirectoryEntry(), + providerId: 'project-b-provider', + displayName: 'Project B Provider', + }; + const loadView = vi.fn((input: { projectPath?: string | null }) => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + ...createRuntimeView(), + projectPath: input.projectPath ?? null, + }, + }) + ); + const loadProviderDirectory = vi.fn((input: { projectPath?: string | null }) => { + if (input.projectPath === '/tmp/project-a') { + return new Promise((resolve) => { + resolveProjectADirectory = resolve; + }); + } + return new Promise((resolve) => { + resolveProjectBDirectory = resolve; + }); + }); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' })); + await Promise.resolve(); + }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 10)); + await vi.waitFor(() => { + expect(loadProviderDirectory).toHaveBeenCalledWith({ + runtimeId: 'opencode', + projectPath: '/tmp/project-a', + query: null, + filter: 'all', + limit: 50, + cursor: null, + refresh: false, + }); + }); + }); + + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' })); + await Promise.resolve(); + }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 10)); + await vi.waitFor(() => { + expect(loadProviderDirectory).toHaveBeenCalledWith({ + runtimeId: 'opencode', + projectPath: '/tmp/project-b', + query: null, + filter: 'all', + limit: 50, + cursor: null, + refresh: false, + }); + }); + }); + + await act(async () => { + resolveProjectBDirectory?.({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-05-22T00:00:00.000Z', + entries: [projectBEntry], + diagnostics: [], + }, + }); + await Promise.resolve(); + }); + + expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([ + 'project-b-provider', + ]); + + await act(async () => { + resolveProjectADirectory?.({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-05-22T00:00:00.000Z', + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + }, + }); + await Promise.resolve(); + }); + + expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([ + 'project-b-provider', + ]); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('drops stale model probe results after project context changes', async () => { const modelId = 'llama.cpp/qwen-test:0.5b'; let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null; @@ -413,6 +735,153 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('clears pending provider save state after project context changes', async () => { + const connectedProvider: RuntimeProviderConnectionDto = { + ...createOpenAiLocalProvider(), + ownership: ['managed'], + detail: 'Connected via managed OpenCode credential', + }; + let resolveConnect: ((value: RuntimeProviderManagementProviderResponse) => void) | null = + null; + const loadView = vi.fn((input: { projectPath?: string | null }) => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + ...createRuntimeView(), + projectPath: input.projectPath ?? null, + defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null, + }, + }) + ); + const loadProviderDirectory = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + }, + }) + ); + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openai', + displayName: 'OpenAI', + method: 'api', + supported: true, + title: 'Connect OpenAI', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }) + ); + const connectProvider = vi.fn( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadSetupForm, + connectProvider, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' })); + await Promise.resolve(); + }); + + await act(async () => { + actions?.startConnect('openai'); + actions?.setApiKeyValue('sk-project-a'); + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + let submitPromise: Promise | null = null; + await act(async () => { + submitPromise = actions?.submitConnect('openai') ?? null; + await vi.waitFor(() => { + expect(connectProvider).toHaveBeenCalledWith({ + runtimeId: 'opencode', + providerId: 'openai', + method: 'api', + apiKey: 'sk-project-a', + metadata: {}, + projectPath: '/tmp/project-a', + }); + }); + await Promise.resolve(); + }); + + expect(state?.savingProviderId).toBe('openai'); + + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' })); + await Promise.resolve(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(loadView).toHaveBeenCalledWith({ + runtimeId: 'opencode', + projectPath: '/tmp/project-b', + }); + }); + + expect(state?.savingProviderId).toBeNull(); + expect(state?.activeFormProviderId).toBeNull(); + + await act(async () => { + resolveConnect?.({ + schemaVersion: 1, + runtimeId: 'opencode', + provider: connectedProvider, + }); + await submitPromise; + }); + + expect(state?.view?.providers).toEqual([]); + expect(state?.savingProviderId).toBeNull(); + expect(state?.setupSubmitError).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => { const localProvider = createOpenAiLocalProvider(); const loadView = vi.fn(() => @@ -1040,6 +1509,68 @@ describe('useRuntimeProviderManagement', () => { expect(state?.apiKeyValue).toBe('sk-bad-value'); }); + it('keeps setup form diagnostics available when submit is attempted after form load failure', async () => { + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-misconfigured', + message: 'OpenCode provider settings are using the wrong runtime binary.', + recoverable: true, + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers setup-form', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: ['Those environment variables must not point to opencode.'], + }, + }, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadSetupForm, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + act(() => { + actions?.startConnect('openrouter'); + }); + await act(async () => { + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + expect(state?.setupFormError).toBe( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + expect(state?.setupFormErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + + await act(async () => { + await actions?.submitConnect('openrouter'); + }); + + expect(state?.setupSubmitError).toBe( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + expect(state?.setupSubmitErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + }); + it('submits a supported setup form without a secret as a null API key', async () => { const loadSetupForm = vi.fn(() => Promise.resolve({ @@ -1443,6 +1974,47 @@ describe('useRuntimeProviderManagement', () => { expect(state?.modelResults[modelId]?.message).toBe(message); }); + it('promotes structured model probe failures to the global diagnostics alert state', async () => { + const modelId = 'openrouter/anthropic/claude-3.5-haiku'; + installRuntimeProviderManagementApi({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-misconfigured', + message: 'OpenCode provider settings are using the wrong runtime binary.', + recoverable: true, + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers test-model', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: ['Those environment variables must not point to opencode.'], + }, + }, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + await actions?.testModel('openrouter', modelId); + }); + + expect(state?.error).toBe('OpenCode provider settings are using the wrong runtime binary.'); + expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + expect(state?.modelResults[modelId]).toMatchObject({ + ok: false, + message: 'OpenCode provider settings are using the wrong runtime binary.', + }); + }); + it('keeps successful model probes scoped to the model card instead of a global success banner', async () => { const modelId = 'openrouter/openai/gpt-oss-20b:free'; installRuntimeProviderManagementApi({