fix(opencode): improve runtime preflight diagnostics

This commit is contained in:
777genius 2026-05-18 11:11:45 +03:00
parent 88e01ae87d
commit 5e0d552cb9
29 changed files with 1573 additions and 171 deletions

View file

@ -226,7 +226,7 @@ import {
TeamTaskStallSnapshotSource,
TeamTranscriptSourceLocator,
UpdaterService,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedOpenCodeRuntimeBinaryPath,
} from './services';
import type { FileChangeEvent } from '@main/types';
@ -343,10 +343,8 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
}
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
const manifestBinaryPath = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
if (manifestBinaryPath) {
return manifestBinaryPath;
}
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
if (resolvedBinaryPath) return resolvedBinaryPath;
try {
const status = await openCodeRuntimeInstallerService?.getStatus();
@ -435,7 +433,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv,
bridgeEnv,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
onWarning: (message) => logger.warn(message),
});
};

View file

@ -1,7 +1,7 @@
import { execCli } from '@main/utils/childProcess';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash, randomUUID } from 'crypto';
@ -22,6 +22,7 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
const VERSION_TIMEOUT_MS = 10_000;
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
interface NpmPackageMetadata {
name?: string;
@ -134,9 +135,15 @@ function splitPathEnv(pathValue: string | undefined): string[] {
.filter(Boolean);
}
function resolvePathOpenCodeBinary(): string | null {
function resolvePathOpenCodeBinary(
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
): string | null {
const shellEnv = getCachedShellEnv() ?? {};
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
const pathEntries = [
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
...splitPathEnv(shellEnv.PATH),
...splitPathEnv(process.env.PATH),
];
const seen = new Set<string>();
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
@ -154,6 +161,57 @@ function resolvePathOpenCodeBinary(): string | null {
return null;
}
type OpenCodeBinaryVersionProbe =
| { ok: true; version: string | null }
| { ok: false; error: string };
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return { ok: true, version: stdout.trim() || null };
} catch (error) {
return { ok: false, error: getErrorMessage(error) };
}
}
async function resolvePathOpenCodeBinaryWithBestEffortEnv(
options: { shellEnvTimeoutMs?: number } = {}
): Promise<string | null> {
const cachedCandidate = resolvePathOpenCodeBinary();
if (cachedCandidate) {
return cachedCandidate;
}
const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
fallbackEnv: process.env,
});
return resolvePathOpenCodeBinary([shellEnv]);
}
async function resolveVerifiedPathOpenCodeBinaryPath(
options: { shellEnvTimeoutMs?: number } = {}
): Promise<string | null> {
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
if (!binaryPath) {
return null;
}
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
}
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
options: { shellEnvTimeoutMs?: number } = {}
): Promise<string | null> {
return (
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
(await resolveVerifiedPathOpenCodeBinaryPath(options))
);
}
function isLinuxMuslRuntime(): boolean {
if (process.platform !== 'linux') {
return false;
@ -466,31 +524,27 @@ export class OpenCodeRuntimeInstallerService {
}
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
const binaryPath = resolvePathOpenCodeBinary();
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
if (!binaryPath) {
return { installed: false, source: 'missing', state: 'idle' };
}
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return {
installed: true,
binaryPath,
version: stdout.trim() || undefined,
source: 'path',
state: 'ready',
};
} catch (error) {
const version = await probeOpenCodeBinaryVersion(binaryPath);
if (!version.ok) {
return {
installed: false,
binaryPath,
source: 'path',
state: 'failed',
error: getErrorMessage(error),
error: version.error,
};
}
return {
installed: true,
binaryPath,
version: version.version ?? undefined,
source: 'path',
state: 'ready',
};
}
private async installInternal(): Promise<OpenCodeRuntimeStatus> {

View file

@ -1,3 +1,6 @@
import { existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { getErrorMessage } from '@shared/utils/errorHandling';
import {
@ -9,16 +12,76 @@ import {
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
targetEnv: NodeJS.ProcessEnv;
bridgeEnv?: NodeJS.ProcessEnv;
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
onWarning?: (message: string) => void;
}
function resolveExistingFilePath(filePath: string): string | null {
const resolvedPath = path.resolve(filePath.trim());
if (!existsSync(resolvedPath)) {
return null;
}
try {
return statSync(resolvedPath).isFile() ? resolvedPath : null;
} catch {
return null;
}
}
function getOpenCodeRuntimeBinaryEnvValues(env: NodeJS.ProcessEnv): string[] {
return [
env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim(),
env[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim(),
].filter((value): value is string => Boolean(value));
}
function resolveExistingOpenCodeRuntimeBinaryEnvPath(env: NodeJS.ProcessEnv): string | null {
for (const value of getOpenCodeRuntimeBinaryEnvValues(env)) {
const resolvedPath = resolveExistingFilePath(value);
if (resolvedPath) {
return resolvedPath;
}
}
return null;
}
function clearOpenCodeRuntimeBinaryEnvValues(
env: NodeJS.ProcessEnv,
invalidValues: Set<string>
): void {
for (const key of [OPENCODE_RUNTIME_BINARY_PATH_ENV, OPENCODE_LEGACY_BINARY_PATH_ENV]) {
const value = env[key]?.trim();
if (value && invalidValues.has(value)) {
delete env[key];
}
}
}
export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv,
bridgeEnv = targetEnv,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedOpenCodeRuntimeBinaryPath,
onWarning,
}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> {
if (
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
) {
const existingBinaryPath = resolveExistingOpenCodeRuntimeBinaryEnvPath(targetEnv);
if (!existingBinaryPath) {
const invalidValues = new Set(getOpenCodeRuntimeBinaryEnvValues(targetEnv));
clearOpenCodeRuntimeBinaryEnvValues(targetEnv, invalidValues);
if (targetEnv !== bridgeEnv) {
clearOpenCodeRuntimeBinaryEnvValues(bridgeEnv, invalidValues);
}
} else {
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] = existingBinaryPath;
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV] = existingBinaryPath;
applyOpenCodeRuntimeBinaryEnv(targetEnv, existingBinaryPath);
return;
}
}
if (
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
@ -28,8 +91,8 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
}
try {
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary);
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
applyOpenCodeRuntimeBinaryEnv(targetEnv, openCodeBinary);
if (
targetEnv !== bridgeEnv &&
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] &&
@ -38,8 +101,6 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]);
}
} catch (error) {
onWarning?.(
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}`
);
onWarning?.(`[OpenCode] Runtime adapter OpenCode binary unresolved: ${getErrorMessage(error)}`);
}
}

View file

@ -1,7 +1,7 @@
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
@ -45,8 +45,8 @@ export async function buildProviderAwareCliEnv(
env: options.env,
});
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary);
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
applyOpenCodeRuntimeBinaryEnv(env, openCodeBinary);
}
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
if (

View file

@ -12,6 +12,7 @@ const logger = createLogger('Service:AgentTeamsMcpHttpServer');
const MCP_HTTP_HOST = '127.0.0.1';
const MCP_HTTP_ENDPOINT = '/mcp';
const MCP_HTTP_READY_TIMEOUT_MS = 5_000;
const MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS = 3_000;
const MCP_HTTP_READY_POLL_MS = 100;
export interface AgentTeamsMcpHttpServerHandle {
@ -120,18 +121,18 @@ export class AgentTeamsMcpHttpServer {
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
private child: ChildProcess | null = null;
private handle: AgentTeamsMcpHttpServerHandle | null = null;
private readonly expectedStopChildren = new WeakSet<ChildProcess>();
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
if (this.handle) {
return this.handle;
}
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = this.startOnce().finally(() => {
this.startPromise = (
this.handle ? this.reuseOrRestartExistingHandle(this.handle) : this.startOnce()
).finally(() => {
this.startPromise = null;
});
return this.startPromise;
@ -142,10 +143,34 @@ export class AgentTeamsMcpHttpServer {
this.child = null;
this.handle = null;
if (child) {
this.expectedStopChildren.add(child);
killProcessTree(child, 'SIGKILL');
}
}
private async reuseOrRestartExistingHandle(
handle: AgentTeamsMcpHttpServerHandle
): Promise<AgentTeamsMcpHttpServerHandle> {
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
try {
await waitForPort(MCP_HTTP_HOST, handle.port, MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS);
if (this.handle === handle) {
return handle;
}
} catch (error) {
if (this.handle === handle) {
logger.warn(
`Agent Teams MCP HTTP server at ${handle.url} failed health reuse check, restarting: ${
error instanceof Error ? error.message : String(error)
}`
);
await this.stop();
}
}
return this.startOnce();
}
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
@ -181,14 +206,21 @@ export class AgentTeamsMcpHttpServer {
let startupSettled = false;
const startupFailure = new Promise<never>((_, reject) => {
child.once('exit', (code, signal) => {
const expectedStop = this.expectedStopChildren.delete(child);
clearIfCurrent();
const codeSuffix = typeof code === 'number' ? ` with code ${code}` : '';
const signalSuffix = signal ? ` (${signal})` : '';
const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`;
if (!startupSettled) {
if (!startupSettled && !expectedStop) {
reject(new Error(message));
logger.warn(message);
return;
}
if (startupSettled && !expectedStop) {
logger.warn(
`Agent Teams MCP HTTP server exited after startup${codeSuffix}${signalSuffix}`
);
}
logger.warn(message);
});
child.once('error', (error) => {
clearIfCurrent();
@ -216,6 +248,7 @@ export class AgentTeamsMcpHttpServer {
this.child = null;
this.handle = null;
}
this.expectedStopChildren.add(child);
killProcessTree(child, 'SIGKILL');
throw error;
}

View file

@ -915,6 +915,10 @@ const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([
'mcp_unavailable',
'adapter_disabled',
]);
const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC =
'OpenCode runtime binary is not installed or not reachable by launch preflight.';
const OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC =
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.';
function pushUniqueLine(lines: string[], line: string): void {
const trimmed = line.trim();
@ -931,10 +935,41 @@ function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean {
lower.includes('mcp_unavailable') ||
lower.includes('runtime store') ||
lower.includes('opencode cli') ||
lower.includes('opencode runtime binary') ||
lower.includes('unable to connect')
);
}
function normalizeOpenCodePrepareDiagnostic(value: string, reason?: string): string {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
return OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC;
}
const lower = trimmed.toLowerCase();
if (
lower.includes('unable to connect') &&
(lower.includes('/experimental/tool') ||
lower.includes('mcp_unavailable') ||
reason === 'mcp_unavailable')
) {
const detail = trimmed.includes(' - ') ? trimmed.split(' - ').pop()?.trim() : trimmed;
return detail && detail !== trimmed
? `${OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC} Details: ${detail}`
: OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC;
}
if (reason === 'mcp_unavailable' && lower.includes('mcp_unavailable')) {
return 'OpenCode app MCP is unavailable. Retry launch to refresh the app MCP bridge.';
}
return trimmed;
}
function isRetryableOpenCodePreflightBusyDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
if (!lower) {
@ -18553,10 +18588,21 @@ export class TeamProvisioningService {
expectedMembers: [],
previousLaunchState: null,
});
details.push(...prepare.diagnostics);
warnings.push(...prepare.warnings);
const prepareReason = prepare.ok ? undefined : prepare.reason;
details.push(
...prepare.diagnostics.map((diagnostic) =>
normalizeOpenCodePrepareDiagnostic(diagnostic, prepareReason)
)
);
warnings.push(
...prepare.warnings.map((warning) =>
normalizeOpenCodePrepareDiagnostic(warning, prepareReason)
)
);
if (!prepare.ok) {
blockingMessages.push(`OpenCode: ${prepare.reason}`);
blockingMessages.push(
normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
);
}
continue;
}
@ -18876,7 +18922,12 @@ export class TeamProvisioningService {
}
const { modelId, prepare } = result;
warnings.push(...prepare.warnings);
const prepareReason = prepare.ok ? undefined : prepare.reason;
warnings.push(
...prepare.warnings.map((warning) =>
normalizeOpenCodePrepareDiagnostic(warning, prepareReason)
)
);
if (prepare.ok) {
details.push(
verificationMode === 'compatibility'
@ -18886,8 +18937,10 @@ export class TeamProvisioningService {
continue;
}
const primaryReason =
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason;
const primaryReason = normalizeOpenCodePrepareDiagnostic(
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason,
prepare.reason
);
if (
prepare.retryable &&
[primaryReason, prepare.reason, ...prepare.diagnostics].some(
@ -19031,7 +19084,12 @@ export class TeamProvisioningService {
};
}
warnings.push(...sharedPrepare.warnings);
const sharedPrepareReason = sharedPrepare.ok ? undefined : sharedPrepare.reason;
warnings.push(
...sharedPrepare.warnings.map((warning) =>
normalizeOpenCodePrepareDiagnostic(warning, sharedPrepareReason)
)
);
appendPreflightDebugLog('opencode_compatibility_batch_shared_prepare', {
cwd,
modelIds,
@ -19042,8 +19100,10 @@ export class TeamProvisioningService {
});
if (!sharedPrepare.ok) {
const primaryReason =
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason;
const primaryReason = normalizeOpenCodePrepareDiagnostic(
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason,
sharedPrepare.reason
);
if (primaryReason.trim().length > 0) {
details.push(primaryReason);
blockingMessages.push(primaryReason);

View file

@ -99,6 +99,9 @@ export interface OpenCodeTeamLaunchReadinessServiceOptions {
versionPolicy?: OpenCodeSupportedVersionPolicy;
}
const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC =
'OpenCode runtime binary is not installed or not reachable by launch preflight.';
export class OpenCodeTeamLaunchReadinessService {
constructor(
private readonly inventory: OpenCodeRuntimeInventoryPort,
@ -124,7 +127,7 @@ export class OpenCodeTeamLaunchReadinessService {
inventory,
modelId: input.selectedModel,
diagnostics: appendDiagnostics(inventory.diagnostics, [
'OpenCode CLI not detected on PATH',
OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC,
]),
});
}

View file

@ -27,6 +27,7 @@ import {
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
getProviderDisconnectAction,
isOpenCodeCatalogHydrating,
isConnectionManagedRuntimeProvider,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
@ -868,13 +869,16 @@ const InstalledBanner = ({
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
anthropicRateLimitsLoading));
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading';
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading' ||
isOpenCodeCatalogHydrating(provider);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
connectionModeSummary ||
credentialSummary ||
provider.models.length === 0
provider.models.length === 0 ||
modelCatalogLoading
);
return (
@ -936,9 +940,7 @@ const InstalledBanner = ({
) : null}
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && modelCatalogLoading ? (
<span>Loading models...</span>
) : null}
{modelCatalogLoading ? <span>Loading models...</span> : null}
{provider.models.length === 0 && !modelCatalogLoading && (
<span>Models unavailable for this runtime build</span>
)}
@ -1121,7 +1123,7 @@ const InstalledBanner = ({
</button>
</div>
</div>
{!showSkeleton && provider.models.length > 0 && (
{!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && (
<div className="col-span-2">
<ProviderModelBadges
providerId={provider.providerId}

View file

@ -99,6 +99,45 @@ export function isProviderInventoryOnlyFallback(provider: CliProviderStatus): bo
);
}
export function isOpenCodeCatalogHydrating(
provider:
| Pick<
CliProviderStatus,
| 'providerId'
| 'models'
| 'modelCatalog'
| 'modelCatalogRefreshState'
| 'runtimeCapabilities'
>
| null
| undefined
): boolean {
if (provider?.providerId !== 'opencode') {
return false;
}
if (provider.modelCatalog?.providerId === 'opencode' && provider.modelCatalog.models.length > 0) {
return false;
}
if (
provider.modelCatalogRefreshState === 'ready' ||
provider.modelCatalogRefreshState === 'error'
) {
return false;
}
const hasOnlySummaryFallback =
provider.models.length === 0 ||
provider.models.every((model) => model.trim() === 'opencode/big-pickle');
return (
hasOnlySummaryFallback &&
(provider.modelCatalogRefreshState === 'loading' ||
provider.runtimeCapabilities?.modelCatalog?.dynamic === true)
);
}
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
return provider.providerId === 'codex';
}

View file

@ -21,6 +21,7 @@ import {
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
getProviderDisconnectAction,
isOpenCodeCatalogHydrating,
isConnectionManagedRuntimeProvider,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
@ -524,7 +525,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
? 'Checking...'
: formatProviderStatusText(provider);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading';
provider.modelCatalogRefreshState === 'loading' ||
isOpenCodeCatalogHydrating(provider);
const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider);
const disconnectAction = getProviderDisconnectAction(provider);
@ -533,7 +535,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
runtimeSummary ||
connectionModeSummary ||
credentialSummary ||
provider.models.length === 0
provider.models.length === 0 ||
modelCatalogLoading
);
return (
@ -585,9 +588,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<span>{connectionModeSummary}</span>
) : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && modelCatalogLoading ? (
<span>Loading models...</span>
) : null}
{modelCatalogLoading ? <span>Loading models...</span> : null}
{provider.models.length === 0 && !modelCatalogLoading && (
<span>Models unavailable for this runtime build</span>
)}
@ -645,16 +646,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
) : null}
</div>
</div>
{!effectiveShowSkeleton && provider.models.length > 0 && (
<div className="col-span-2">
<ProviderModelBadges
providerId={provider.providerId}
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
/>
</div>
)}
{!effectiveShowSkeleton &&
!modelCatalogLoading &&
provider.models.length > 0 && (
<div className="col-span-2">
<ProviderModelBadges
providerId={provider.providerId}
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
/>
</div>
)}
</>
);
})()}

View file

@ -2368,7 +2368,7 @@ export const CreateTeamDialog = ({
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - launch is blocked
Runtime environment is not available - launch is blocked
</p>
<p className="mt-0.5 text-red-300/80">
{effectivePrepare.message ?? 'Failed to prepare environment'}

View file

@ -2981,8 +2981,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
blocked
Runtime environment is not available - {isRelaunch ? 'relaunch' : 'launch'}{' '}
is blocked
</p>
<p className="mt-0.5 text-red-300/80">
{effectivePrepare.message ?? 'Failed to prepare environment'}

View file

@ -135,6 +135,8 @@ export function failIncompleteProviderChecks(
type ProvisioningDetailSummary =
| 'CLI binary missing'
| 'OpenCode runtime missing'
| 'OpenCode app MCP unreachable'
| 'Working directory missing'
| 'CLI binary could not be started'
| 'CLI preflight did not complete'
@ -198,6 +200,16 @@ function summarizeDetail(
if (lower.includes('spawn ') && lower.includes(' enoent')) {
return 'CLI binary missing';
}
if (lower.includes('opencode runtime binary is not installed')) {
return 'OpenCode runtime missing';
}
if (
lower.includes('opencode app mcp is unreachable') ||
(lower.includes('unable to connect') &&
(lower.includes('/experimental/tool') || lower.includes('mcp_unavailable')))
) {
return 'OpenCode app MCP unreachable';
}
if (lower.includes('working directory does not exist:')) {
return 'Working directory missing';
}
@ -406,6 +418,8 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
check.status === 'failed'
? (summarizedDetails.find(
(detail) =>
detail === 'OpenCode app MCP unreachable' ||
detail === 'OpenCode runtime missing' ||
detail === 'Selected model unavailable' ||
detail === 'Selected model check failed' ||
detail === 'Authentication required' ||
@ -436,6 +450,8 @@ function getDetailTone(
summary === 'Selected model unavailable' ||
summary === 'Selected model check failed' ||
summary === 'CLI binary missing' ||
summary === 'OpenCode runtime missing' ||
summary === 'OpenCode app MCP unreachable' ||
summary === 'Working directory missing' ||
summary === 'CLI binary could not be started' ||
summary === 'CLI preflight did not complete' ||
@ -697,6 +713,13 @@ export function getProvisioningFailureHint(
) {
return 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.';
}
if (
combined.includes('opencode app mcp is unreachable') ||
(combined.includes('unable to connect') &&
(combined.includes('/experimental/tool') || combined.includes('mcp_unavailable')))
) {
return 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.';
}
if (
combined.includes('spawn ') ||
combined.includes(' enoent') ||

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { isOpenCodeCatalogHydrating } from '@renderer/components/runtime/providerConnectionUi';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { HoverTooltip } from '@renderer/components/ui/hover-tooltip';
import { Input } from '@renderer/components/ui/input';
@ -999,12 +1000,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const visibleConcreteModelOptionCount =
visibleModelOptions.length - visibleDefaultModelOptions.length;
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
const shouldShowOpenCodeCatalogLoading =
effectiveProviderId === 'opencode' &&
runtimeProviderStatus?.modelCatalogRefreshState === 'loading' &&
runtimeProviderStatus.modelCatalog?.providerId !== 'opencode' &&
(runtimeProviderStatus.models.length === 0 ||
runtimeProviderStatus.models.every((model) => model.trim() === 'opencode/big-pickle'));
const shouldShowOpenCodeCatalogLoading = isOpenCodeCatalogHydrating(runtimeProviderStatus);
const shouldShowModelSearch = !shouldShowOpenCodeCatalogLoading && concreteModelOptionCount > 8;
const trimmedModelQuery = modelQuery.trim();
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;

View file

@ -77,7 +77,7 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function uniquePrepareLines(lines: Array<string | null | undefined>): string[] {
function uniquePrepareLines(lines: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const uniqueLines: string[] = [];
for (const line of lines) {
@ -252,7 +252,8 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
lower.includes('mcp_unavailable') ||
lower.includes('unable to connect') ||
lower.includes('runtime store') ||
lower.includes('opencode cli')
lower.includes('opencode cli') ||
lower.includes('opencode runtime binary')
);
}
@ -271,13 +272,6 @@ function getBlockingProviderIssue(
);
}
function getBlockingProviderIssueMessage(
providerId: TeamProviderId,
result: TeamProvisioningPrepareResult
): string | null {
return getBlockingProviderIssue(providerId, result)?.message.trim() ?? null;
}
function isAdvisoryOpenCodeDeepVerificationIssue(
issue: TeamProvisioningPrepareIssue | null,
reason: string | null | undefined
@ -304,7 +298,8 @@ function isAdvisoryOpenCodeDeepVerificationIssue(
lower.includes('api key') ||
lower.includes('/experimental/tool') ||
lower.includes('runtime store') ||
lower.includes('opencode cli');
lower.includes('opencode cli') ||
lower.includes('opencode runtime binary');
if (hasHardRuntimeMarker) {
return false;
}
@ -437,10 +432,17 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
}
function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] {
return uniquePrepareLines(result.warnings ?? []);
return uniquePrepareLines(
(result.warnings ?? [])
.map((warning) => normalizeRuntimeFailureDetailLine(warning))
.filter(Boolean)
);
}
function normalizeRuntimeFailureDetailLine(detail: string | null | undefined): string | null {
function normalizeRuntimeFailureDetailLine(
detail: string | null | undefined,
code?: string | null
): string | null {
const trimmed = detail?.trim();
if (!trimmed) {
return null;
@ -450,6 +452,20 @@ function normalizeRuntimeFailureDetailLine(detail: string | null | undefined): s
return 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
}
const lower = trimmed.toLowerCase();
if (
lower.includes('unable to connect') &&
(lower.includes('/experimental/tool') ||
lower.includes('mcp_unavailable') ||
code?.trim().toLowerCase() === 'mcp_unavailable')
) {
const connectionDetail = trimmed.includes(' - ') ? trimmed.split(' - ').pop()?.trim() : trimmed;
const base = 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.';
return connectionDetail && connectionDetail !== trimmed
? `${base} Details: ${connectionDetail}`
: base;
}
return trimmed;
}
@ -458,7 +474,9 @@ function createRuntimeFailureDetailLines(
message: string | null | undefined
): string[] {
return uniquePrepareLines(
[...runtimeDetailLines, message].map(normalizeRuntimeFailureDetailLine).filter(Boolean)
[...runtimeDetailLines, message]
.map((detail) => normalizeRuntimeFailureDetailLine(detail))
.filter(Boolean)
);
}
@ -1033,15 +1051,20 @@ export async function runProviderPrepareDiagnostics({
uncachedModelIds,
compatibilityResult
);
const structuredProviderScopedFailure = getBlockingProviderIssueMessage(
const structuredProviderScopedIssue = getBlockingProviderIssue(
providerId,
compatibilityResult
);
const structuredProviderScopedFailure =
structuredProviderScopedIssue?.message.trim() ?? null;
if (structuredProviderScopedFailure || providerScopedFailure) {
return {
status: 'failed',
details: [
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
normalizeRuntimeFailureDetailLine(
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
structuredProviderScopedIssue?.code
) ?? 'OpenCode failed',
],
warnings: [],
modelResultsById: {},
@ -1195,7 +1218,12 @@ export async function runProviderPrepareDiagnostics({
} else {
return {
status: 'failed',
details: [failureReason],
details: [
normalizeRuntimeFailureDetailLine(
failureReason,
structuredProviderScopedIssue?.code
) ?? failureReason,
],
warnings: [],
modelResultsById: {},
};

View file

@ -6,21 +6,33 @@ import { gzipSync } from 'zlib';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const execCliMock = vi.hoisted(() => vi.fn());
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
vi.mock('@main/utils/childProcess', () => ({
execCli: execCliMock,
}));
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => getCachedShellEnvMock(),
resolveInteractiveShellEnvBestEffort: (
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
) => resolveInteractiveShellEnvBestEffortMock(...args),
}));
import {
extractOpenCodeRuntimeBinaryFromTarball,
getOpenCodeRuntimePlatformCandidates,
OpenCodeRuntimeInstallerService,
resolveAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedOpenCodeRuntimeBinaryPath,
verifyOpenCodeRuntimePackageIntegrity,
} from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
import { setAppDataBasePath } from '@main/utils/pathDecoder';
let tempRoot: string | null = null;
let originalPath: string | undefined;
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
const encoded = value
@ -63,12 +75,24 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-'));
setAppDataBasePath(tempRoot);
originalPath = process.env.PATH;
process.env.PATH = '';
execCliMock.mockReset();
execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' });
getCachedShellEnvMock.mockReset();
getCachedShellEnvMock.mockReturnValue(null);
resolveInteractiveShellEnvBestEffortMock.mockReset();
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env);
});
afterEach(async () => {
setAppDataBasePath(null);
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
originalPath = undefined;
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
@ -163,6 +187,46 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBeNull();
});
it('returns a verified OpenCode binary from best-effort shell PATH when app-managed runtime is absent', async () => {
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: path.dirname(binaryPath),
});
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 0,
fallbackEnv: process.env,
})
);
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
});
it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => {
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: path.dirname(binaryPath),
});
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
installed: true,
source: 'path',
state: 'ready',
binaryPath,
version: 'opencode 1.0.0',
});
});
});
describe('OpenCodeRuntimeInstallerService package safety helpers', () => {

View file

@ -0,0 +1,199 @@
// @vitest-environment node
/* eslint-disable security/detect-non-literal-fs-filename */
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const augmentConfiguredConnectionEnvMock = vi.hoisted(() =>
vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env))
);
const applyConfiguredConnectionEnvMock = vi.hoisted(() =>
vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env))
);
const getConfiguredConnectionIssuesMock = vi.hoisted(() => vi.fn(() => Promise.resolve({})));
const getConfiguredConnectionLaunchArgsMock = vi.hoisted(() => vi.fn(() => Promise.resolve([])));
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.hoisted(() =>
vi.fn(() => Promise.resolve(null))
);
vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({
configManager: {
getConfig: () => ({
runtime: {
providerBackends: {
codex: 'codex-native',
gemini: 'cli',
},
},
}),
},
}));
vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => ({
providerConnectionService: {
augmentConfiguredConnectionEnv: (
...args: Parameters<typeof augmentConfiguredConnectionEnvMock>
) => augmentConfiguredConnectionEnvMock(...args),
applyConfiguredConnectionEnv: (...args: Parameters<typeof applyConfiguredConnectionEnvMock>) =>
applyConfiguredConnectionEnvMock(...args),
getConfiguredConnectionIssues: (
...args: Parameters<typeof getConfiguredConnectionIssuesMock>
) => getConfiguredConnectionIssuesMock(...args),
getConfiguredConnectionLaunchArgs: (
...args: Parameters<typeof getConfiguredConnectionLaunchArgsMock>
) => getConfiguredConnectionLaunchArgsMock(...args),
},
}));
vi.mock('@features/codex-runtime-installer/main', () => ({
resolveVerifiedAppManagedCodexRuntimeBinaryPath: () =>
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
}));
import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService';
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
import { buildProviderAwareCliEnv } from '../../../../src/main/services/runtime/providerAwareCliEnv';
import { execCli } from '../../../../src/main/utils/childProcess';
import { setAppDataBasePath } from '../../../../src/main/utils/pathDecoder';
import { clearShellEnvCache } from '../../../../src/main/utils/shellEnv';
const describePosix = process.platform === 'win32' ? describe.skip : describe;
describePosix('OpenCode packaged-runtime preflight integration', () => {
let tempDir: string | null = null;
let originalPath: string | undefined;
let originalShell: string | undefined;
let originalFakeOpenCodeBinDir: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-prod-preflight-'));
setAppDataBasePath(path.join(tempDir, 'app-data'));
clearShellEnvCache();
originalPath = process.env.PATH;
originalShell = process.env.SHELL;
originalFakeOpenCodeBinDir = process.env.FAKE_OPENCODE_BIN_DIR;
process.env.PATH = '';
vi.clearAllMocks();
});
afterEach(async () => {
clearShellEnvCache();
setAppDataBasePath(null);
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
if (originalFakeOpenCodeBinDir === undefined) {
delete process.env.FAKE_OPENCODE_BIN_DIR;
} else {
process.env.FAKE_OPENCODE_BIN_DIR = originalFakeOpenCodeBinDir;
}
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
async function createFakeOpenCodeBinary(): Promise<{ binDir: string; binaryPath: string }> {
const binDir = path.join(tempDir!, 'homebrew', 'bin');
const binaryPath = path.join(binDir, 'opencode');
await mkdir(binDir, { recursive: true });
await writeFile(
binaryPath,
[
'#!/bin/sh',
'if [ "$1" = "--version" ]; then',
' echo "opencode 9.9.9"',
' exit 0',
'fi',
'echo "unexpected opencode args: $*" >&2',
'exit 2',
].join('\n'),
'utf8'
);
await chmod(binaryPath, 0o755);
return { binDir, binaryPath };
}
async function createFakeInteractiveShell(binDir: string): Promise<string> {
const shellPath = path.join(tempDir!, 'fake-login-shell');
process.env.FAKE_OPENCODE_BIN_DIR = binDir;
await writeFile(
shellPath,
[
'#!/bin/sh',
'printf "%s\\0" "PATH=$FAKE_OPENCODE_BIN_DIR" "HOME=$HOME" "SHELL=$0"',
].join('\n'),
'utf8'
);
await chmod(shellPath, 0o755);
return shellPath;
}
it('keeps OpenCode launch preflight and bridge commands working when packaged Electron starts with an empty PATH', async () => {
const { binDir, binaryPath } = await createFakeOpenCodeBinary();
process.env.SHELL = await createFakeInteractiveShell(binDir);
const providerEnv = await buildProviderAwareCliEnv({
providerId: 'opencode',
connectionMode: 'augment',
shellEnv: {},
env: {
PATH: '',
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node',
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: '/mock/mcp-server/index.js',
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["/mock/mcp-server/index.js"]',
},
});
expect(providerEnv.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(providerEnv.env.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(providerEnv.env.PATH?.split(path.delimiter)[0]).toBe(binDir);
expect(augmentConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
CLAUDE_CODE_ENTRY_PROVIDER: 'opencode',
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: binaryPath,
OPENCODE_BIN_PATH: binaryPath,
}),
'opencode',
undefined
);
const bridgeEnv: NodeJS.ProcessEnv = { PATH: '' };
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: bridgeEnv,
bridgeEnv,
resolveVerifiedOpenCodeRuntimeBinaryPath,
});
const commandEnv = { ...bridgeEnv };
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: commandEnv,
bridgeEnv,
resolveVerifiedOpenCodeRuntimeBinaryPath,
});
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(commandEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(binDir);
const version = await execCli('opencode', ['--version'], {
env: commandEnv,
timeout: 2_000,
windowsHide: true,
});
expect(version.stdout.trim()).toBe('opencode 9.9.9');
});
});

View file

@ -1,10 +1,31 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
let tempDir: string | null = null;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-runtime-env-'));
});
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
async function writeExecutable(relativePath: string): Promise<string> {
const binaryPath = path.join(tempDir!, relativePath);
await writeFile(binaryPath, 'binary', { mode: 0o755 });
return binaryPath;
}
it('makes an app-managed OpenCode binary visible to PATH-based bridge inventory', async () => {
const binaryPath = path.join(process.cwd(), 'managed opencode', 'bin', 'opencode');
const env: NodeJS.ProcessEnv = {
@ -13,7 +34,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: env,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath),
resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath),
});
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
@ -35,7 +56,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: bridgeEnv,
bridgeEnv,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
});
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
@ -45,7 +66,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: commandEnv,
bridgeEnv,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
});
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
@ -57,7 +78,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
});
it('honors a legacy OpenCode binary override already present in the command env', async () => {
const binaryPath = path.join(process.cwd(), 'legacy opencode', 'opencode');
const binaryPath = await writeExecutable('legacy-opencode');
const env: NodeJS.ProcessEnv = {
OPENCODE_BIN_PATH: binaryPath,
PATH: '/usr/bin',
@ -66,7 +87,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: env,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
});
expect(resolver).not.toHaveBeenCalled();
@ -75,7 +96,52 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
});
it('keeps bridge startup non-fatal when the managed resolver fails', async () => {
it('normalizes a relative OpenCode binary override before exposing it to the bridge', async () => {
const binaryPath = await writeExecutable('relative-opencode');
const relativeBinaryPath = path.relative(process.cwd(), binaryPath);
const env: NodeJS.ProcessEnv = {
OPENCODE_BIN_PATH: relativeBinaryPath,
PATH: '/usr/bin',
};
const resolver = vi.fn<() => Promise<string | null>>();
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: env,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
});
expect(resolver).not.toHaveBeenCalled();
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(env.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
});
it('replaces stale bridge-owned OpenCode binary env with a fresh verified resolver result', async () => {
const staleBinaryPath = path.join(tempDir!, 'missing-opencode');
const binaryPath = path.join(process.cwd(), 'fresh managed opencode', 'opencode');
const bridgeEnv: NodeJS.ProcessEnv = {
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: staleBinaryPath,
OPENCODE_BIN_PATH: staleBinaryPath,
PATH: '/usr/bin',
};
const commandEnv = { ...bridgeEnv };
const resolver = vi.fn<() => Promise<string | null>>().mockResolvedValue(binaryPath);
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: commandEnv,
bridgeEnv,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
});
expect(resolver).toHaveBeenCalledTimes(1);
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(commandEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(bridgeEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
});
it('keeps bridge startup non-fatal when the runtime binary resolver fails', async () => {
const onWarning = vi.fn();
const env: NodeJS.ProcessEnv = {
PATH: '/usr/bin',
@ -84,14 +150,14 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
await expect(
ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: env,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () =>
resolveVerifiedOpenCodeRuntimeBinaryPath: () =>
Promise.reject(new Error('manifest unreadable')),
onWarning,
})
).resolves.toBeUndefined();
expect(onWarning).toHaveBeenCalledWith(
'[OpenCode] Runtime adapter bundled OpenCode binary unresolved: manifest unreadable'
'[OpenCode] Runtime adapter OpenCode binary unresolved: manifest unreadable'
);
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
expect(env.OPENCODE_BIN_PATH).toBeUndefined();

View file

@ -12,7 +12,7 @@ const applyConfiguredConnectionEnvMock = vi.fn();
const applyAllConfiguredConnectionEnvMock = vi.fn();
const getConfiguredConnectionIssuesMock = vi.fn();
const getConfiguredConnectionLaunchArgsMock = vi.fn();
const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn();
const resolveVerifiedOpenCodeRuntimeBinaryPathMock = vi.fn();
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn();
const resolveAgentTeamsMcpLaunchSpecMock = vi.fn();
@ -62,8 +62,7 @@ vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () =>
}));
vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService', () => ({
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () =>
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(),
resolveVerifiedOpenCodeRuntimeBinaryPath: () => resolveVerifiedOpenCodeRuntimeBinaryPathMock(),
}));
vi.mock('@features/codex-runtime-installer/main', () => ({
@ -100,7 +99,7 @@ describe('buildProviderAwareCliEnv', () => {
);
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
getConfiguredConnectionIssuesMock.mockResolvedValue({});
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({
command: 'node',
@ -360,7 +359,7 @@ describe('buildProviderAwareCliEnv', () => {
'current',
'opencode'
);
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath);
resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
@ -399,7 +398,7 @@ describe('buildProviderAwareCliEnv', () => {
});
it('does not inject the app-managed OpenCode binary into non-OpenCode provider launches', async () => {
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(
resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(
'/Users/tester/App Support/runtimes/opencode/current/opencode'
);

View file

@ -0,0 +1,469 @@
// @vitest-environment node
/* eslint-disable security/detect-non-literal-fs-filename, sonarjs/publicly-writable-directories */
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
import { OpenCodeBridgeCommandClient } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
const FAKE_MCP_HTTP_SERVER_SOURCE = String.raw`
const fs = require('node:fs');
const http = require('node:http');
function readArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
const host = readArg('--host') || '127.0.0.1';
const endpoint = readArg('--endpoint') || '/mcp';
const port = Number(readArg('--port'));
const controlFile = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
function readControl() {
if (!controlFile) {
return 'healthy';
}
try {
return fs.readFileSync(controlFile, 'utf8').trim() || 'healthy';
} catch {
return 'healthy';
}
}
function isUnhealthy() {
const control = readControl();
return control === 'unhealthy-all' || control === 'unhealthy-port:' + port;
}
const server = http.createServer((request, response) => {
if (request.url === '/health') {
if (isUnhealthy()) {
response.writeHead(503, { 'content-type': 'text/plain' });
response.end('unhealthy');
return;
}
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
return;
}
if (request.url === endpoint) {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"jsonrpc":"2.0","result":{}}');
return;
}
response.writeHead(404, { 'content-type': 'text/plain' });
response.end('not found');
});
server.listen(port, host);
function shutdown() {
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 500).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
`;
const FAKE_OPENCODE_BRIDGE_BINARY_SOURCE = String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const http = require('node:http');
function readArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
function readHealthStatus(url) {
return new Promise((resolve) => {
if (!url) {
resolve(null);
return;
}
const target = new URL(url);
target.pathname = '/health';
target.search = '';
target.hash = '';
const request = http.get(
{
host: target.hostname,
port: Number(target.port),
path: target.pathname,
timeout: 750,
},
(response) => {
response.resume();
resolve(response.statusCode || null);
}
);
request.once('timeout', () => {
request.destroy();
resolve(null);
});
request.once('error', () => resolve(null));
});
}
async function main() {
const inputPath = readArg('--input');
if (!inputPath) {
console.error('missing --input');
process.exit(64);
}
const envelope = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
const mcpUrl = process.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL || null;
const healthStatus = await readHealthStatus(mcpUrl);
if (healthStatus !== 200) {
console.error(
JSON.stringify({
kind: 'mcp_unreachable',
mcpUrl,
healthStatus,
})
);
process.exit(7);
}
process.stdout.write(
JSON.stringify({
ok: true,
schemaVersion: envelope.schemaVersion,
requestId: envelope.requestId,
command: envelope.command,
completedAt: new Date().toISOString(),
durationMs: 1,
runtime: {
providerId: 'opencode',
binaryPath: process.argv[1],
binaryFingerprint: 'fake-runtime',
version: 'fake-opencode-bridge-e2e',
capabilitySnapshotId: 'fake-capabilities',
},
diagnostics: [],
data: {
runId: envelope.body && envelope.body.runId ? envelope.body.runId : null,
observedMcpUrl: mcpUrl,
healthStatus,
},
}) + '\n'
);
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
});
`;
const describePosix = process.platform === 'win32' ? describe.skip : describe;
async function allocateLoopbackPort(excluded: Set<number> = new Set<number>()): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate test port')));
return;
}
server.close(() => resolve(address.port));
});
});
if (!excluded.has(port)) {
excluded.add(port);
return port;
}
}
}
async function readHealthStatus(url: string): Promise<number | null> {
const target = new URL(url);
target.pathname = '/health';
target.search = '';
target.hash = '';
return new Promise((resolve) => {
const request = http.get(
{
host: target.hostname,
port: Number(target.port),
path: target.pathname,
timeout: 500,
},
(response) => {
response.resume();
resolve(response.statusCode ?? null);
}
);
request.once('timeout', () => {
request.destroy();
resolve(null);
});
request.once('error', () => resolve(null));
});
}
async function waitForHealthDown(url: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < 5_000) {
if ((await readHealthStatus(url)) !== 200) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Expected ${url} health endpoint to go down`);
}
async function writeFakeMcpHttpServer(tempDir: string): Promise<string> {
const scriptDir = path.join(tempDir, 'fake-mcp');
const scriptPath = path.join(scriptDir, 'server.cjs');
await mkdir(scriptDir, { recursive: true });
await writeFile(scriptPath, FAKE_MCP_HTTP_SERVER_SOURCE, 'utf8');
return scriptPath;
}
async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise<string> {
const scriptDir = path.join(tempDir, 'fake-runtime');
const scriptPath = path.join(scriptDir, 'claude-multimodel-fake');
await mkdir(scriptDir, { recursive: true });
await writeFile(scriptPath, FAKE_OPENCODE_BRIDGE_BINARY_SOURCE, 'utf8');
await chmod(scriptPath, 0o755);
return scriptPath;
}
describePosix('AgentTeamsMcpHttpServer integration', () => {
let tempDir: string | null = null;
let originalControlFileEnv: string | undefined;
const servers: AgentTeamsMcpHttpServer[] = [];
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-'));
originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
});
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.stop()));
vi.mocked(console.warn).mockClear();
if (originalControlFileEnv === undefined) {
delete process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
} else {
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = originalControlFileEnv;
}
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
function createControlledServer(input: {
scriptPath: string;
controlFile: string;
allocatePort?: () => Promise<number>;
}): AgentTeamsMcpHttpServer {
const server = new AgentTeamsMcpHttpServer({
resolveLaunchSpec: () =>
Promise.resolve({
command: process.execPath,
args: [input.scriptPath],
}),
allocatePort: input.allocatePort,
});
servers.push(server);
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = input.controlFile;
return server;
}
it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => {
const server = new AgentTeamsMcpHttpServer();
servers.push(server);
const handle = await server.ensureStarted();
expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
expect(handle.pid).toEqual(expect.any(Number));
expect(await readHealthStatus(handle.url)).toBe(200);
});
it('reuses a healthy cached bridge URL after a real loopback health recheck', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({ scriptPath, controlFile });
const first = await server.ensureStarted();
const second = await server.ensureStarted();
expect(second).toEqual(first);
expect(await readHealthStatus(first.url)).toBe(200);
expect(vi.mocked(console.warn).mock.calls).toEqual([]);
});
it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const first = await server.ensureStarted();
await writeFile(controlFile, `unhealthy-port:${first.port}`, 'utf8');
expect(await readHealthStatus(first.url)).toBe(503);
const second = await server.ensureStarted();
expect(second.port).not.toBe(first.port);
expect(second.pid).not.toBe(first.pid);
expect(await readHealthStatus(second.url)).toBe(200);
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
vi.mocked(console.warn).mockClear();
});
it('recovers when the cached MCP HTTP child dies and the old URL refuses connections', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const first = await server.ensureStarted();
expect(first.pid).toEqual(expect.any(Number));
process.kill(first.pid!, 'SIGTERM');
await waitForHealthDown(first.url);
const second = await server.ensureStarted();
expect(second.port).not.toBe(first.port);
expect(second.pid).not.toBe(first.pid);
expect(await readHealthStatus(second.url)).toBe(200);
});
it('passes a refreshed MCP URL into a real bridge child process after the cached URL goes stale', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const bridgeEnv: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
};
let requestIdCounter = 0;
const client = new OpenCodeBridgeCommandClient({
binaryPath: bridgeBinaryPath,
tempDirectory: path.join(tempDir!, 'bridge-input'),
env: bridgeEnv,
envProvider: async () => {
const mcpHttpServer = await server.ensureStarted();
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
return {
...bridgeEnv,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: mcpHttpServer.url,
};
},
requestIdFactory: () => {
requestIdCounter += 1;
return `req-refresh-${requestIdCounter}`;
},
});
const firstResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(firstResult.ok).toBe(true);
if (!firstResult.ok) {
throw new Error(firstResult.error.message);
}
await writeFile(
controlFile,
`unhealthy-port:${new URL(firstResult.data.observedMcpUrl).port}`,
'utf8'
);
expect(await readHealthStatus(firstResult.data.observedMcpUrl)).toBe(503);
const secondResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-2' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(secondResult.ok).toBe(true);
if (!secondResult.ok) {
throw new Error(secondResult.error.message);
}
expect(secondResult.data.observedMcpUrl).not.toBe(firstResult.data.observedMcpUrl);
expect(await readHealthStatus(secondResult.data.observedMcpUrl)).toBe(200);
});
it('fails closed when a bridge child receives an unreachable MCP URL without env refresh', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({ scriptPath, controlFile });
const first = await server.ensureStarted();
await server.stop();
await waitForHealthDown(first.url);
const client = new OpenCodeBridgeCommandClient({
binaryPath: bridgeBinaryPath,
tempDirectory: path.join(tempDir!, 'bridge-input-stale'),
env: {
PATH: process.env.PATH,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: first.url,
},
requestIdFactory: () => 'req-stale-mcp',
});
const result = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-stale' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected stale MCP URL to fail');
}
expect(result.error.kind).toBe('provider_error');
expect(result.error.details?.stderr).toContain('mcp_unreachable');
expect(result.error.details?.stderr).toContain(first.url);
});
});

View file

@ -20,8 +20,13 @@ vi.mock('@main/utils/childProcess', async (importOriginal) => {
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
class FakeChildProcess extends EventEmitter {
pid = 43123;
pid: number;
stderr = new EventEmitter();
constructor(pid = 43123) {
super();
this.pid = pid;
}
}
async function allocateLoopbackPort(): Promise<number> {
@ -145,6 +150,71 @@ describe('AgentTeamsMcpHttpServer', () => {
expect(spawnProcess).toHaveBeenCalledTimes(1);
});
it('reuses an existing handle only after its health check still passes', async () => {
const child = new FakeChildProcess();
const spawnProcess = vi.fn(() => child as any);
const waitForPort = vi.fn(async () => undefined);
const server = new AgentTeamsMcpHttpServer({
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],
}),
allocatePort: async () => 41006,
spawnProcess,
waitForPort,
});
const first = await server.ensureStarted();
const second = await server.ensureStarted();
expect(second).toBe(first);
expect(spawnProcess).toHaveBeenCalledTimes(1);
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 5_000);
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 3_000);
expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled();
});
it('restarts a cached HTTP MCP server handle when the health check goes stale', async () => {
const firstChild = new FakeChildProcess(43123);
const secondChild = new FakeChildProcess(43124);
const spawnProcess = vi
.fn()
.mockReturnValueOnce(firstChild as any)
.mockReturnValueOnce(secondChild as any);
const allocatePort = vi.fn().mockResolvedValueOnce(41007).mockResolvedValueOnce(41008);
const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => {
if (port === 41007 && timeoutMs === 3_000) {
throw new Error('stale health check');
}
});
const server = new AgentTeamsMcpHttpServer({
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],
}),
allocatePort,
spawnProcess,
waitForPort,
});
const first = await server.ensureStarted();
const second = await server.ensureStarted();
expect(first.url).toBe('http://127.0.0.1:41007/mcp');
expect(second).toEqual({
url: 'http://127.0.0.1:41008/mcp',
port: 41008,
pid: 43124,
});
expect(spawnProcess).toHaveBeenCalledTimes(2);
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL');
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000);
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000);
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41008, 5_000);
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
vi.mocked(console.warn).mockClear();
});
it('fails startup promptly when the child exits before readiness', async () => {
const child = new FakeChildProcess();
const server = new AgentTeamsMcpHttpServer({

View file

@ -29,7 +29,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
state: 'not_installed',
launchAllowed: false,
hostHealthy: false,
diagnostics: ['PATH checked', 'OpenCode CLI not detected on PATH'],
diagnostics: [
'PATH checked',
'OpenCode runtime binary is not installed or not reachable by launch preflight.',
],
});
expect(ports.capabilities.detect).not.toHaveBeenCalled();
expect(ports.mcpTools.prove).not.toHaveBeenCalled();
@ -219,19 +222,19 @@ function createPorts(
} {
return {
inventory: {
probe: vi.fn(async () => inventory(overrides.inventory)),
probe: vi.fn(() => Promise.resolve(inventory(overrides.inventory))),
},
capabilities: {
detect: vi.fn(async () => overrides.capabilities ?? capabilities()),
detect: vi.fn(() => Promise.resolve(overrides.capabilities ?? capabilities())),
},
mcpTools: {
prove: vi.fn(async () => overrides.toolProof ?? toolProof()),
prove: vi.fn(() => Promise.resolve(overrides.toolProof ?? toolProof())),
},
runtimeStores: {
check: vi.fn(async () => overrides.runtimeStores ?? runtimeStores()),
check: vi.fn(() => Promise.resolve(overrides.runtimeStores ?? runtimeStores())),
},
modelExecution: {
verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()),
verify: vi.fn(() => Promise.resolve(overrides.modelProbe ?? modelProbe())),
},
};
}

View file

@ -1328,6 +1328,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
it('keeps deep OpenCode runtime failures provider-scoped instead of model-scoped', async () => {
const runtimeFailure =
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
const normalizedRuntimeFailure =
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?';
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
@ -1356,16 +1358,16 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
});
expect(result.ready).toBe(false);
expect(result.message).toBe(runtimeFailure);
expect(result.details).toEqual([runtimeFailure]);
expect(result.warnings).toEqual([runtimeFailure]);
expect(result.message).toBe(normalizedRuntimeFailure);
expect(result.details).toEqual([normalizedRuntimeFailure]);
expect(result.warnings).toEqual([normalizedRuntimeFailure]);
expect(result.issues).toEqual([
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'mcp_unavailable',
message: runtimeFailure,
message: normalizedRuntimeFailure,
},
]);
});
@ -1414,6 +1416,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
});
it('keeps shared OpenCode MCP compatibility failures provider-scoped', async () => {
const normalizedRuntimeFailure =
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?';
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
@ -1444,27 +1448,62 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
});
expect(result.ready).toBe(false);
expect(result.message).toBe(
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'
);
expect(result.details).toEqual([
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
]);
expect(result.warnings).toEqual([
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
]);
expect(result.message).toBe(normalizedRuntimeFailure);
expect(result.details).toEqual([normalizedRuntimeFailure]);
expect(result.warnings).toEqual([normalizedRuntimeFailure]);
expect(result.issues).toEqual([
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'mcp_unavailable',
message:
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
message: normalizedRuntimeFailure,
},
]);
});
it('restores OpenCode MCP context when the bridge reports only a plain connect failure', async () => {
const normalizedRuntimeFailure =
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.';
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'mcp_unavailable',
retryable: true,
diagnostics: ['Unable to connect. Is the computer able to access the url?'],
warnings: [],
}));
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
const result = await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
modelIds: ['opencode/big-pickle'],
modelVerificationMode: 'compatibility',
});
expect(result.ready).toBe(false);
expect(result.message).toBe(normalizedRuntimeFailure);
expect(result.details).toEqual([normalizedRuntimeFailure]);
expect(result.issues?.[0]).toMatchObject({
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'mcp_unavailable',
message: normalizedRuntimeFailure,
});
});
it('normalizes unexpected OpenCode model prepare exceptions into a blocking diagnostic', async () => {
const prepare = vi.fn(async (input: { model?: string }) => {
if (input.model === 'opencode/nemotron-3-super-free') {

View file

@ -615,6 +615,62 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('shows OpenCode model loading instead of the summary-only big-pickle badge', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
{
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: null,
models: ['opencode/big-pickle'],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: false,
},
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: null,
modelCatalogRefreshState: 'idle',
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Loading models...');
expect(host.textContent).not.toContain('big-pickle');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';

View file

@ -6,6 +6,7 @@ import {
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
isProviderInventoryOnlyFallback,
isOpenCodeCatalogHydrating,
isConnectionManagedRuntimeProvider,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
@ -226,6 +227,47 @@ describe('providerConnectionUi', () => {
expect(getProviderCredentialSummary(provider)).toBe('API key also configured in Manage');
});
it('treats the OpenCode summary-only big-pickle model as catalog hydration', () => {
const provider: CliProviderStatus = {
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
modelCatalogRefreshState: 'idle',
statusMessage: null,
models: ['opencode/big-pickle'],
modelCatalog: null,
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
};
expect(isOpenCodeCatalogHydrating(provider)).toBe(true);
expect(
isOpenCodeCatalogHydrating({
...provider,
modelCatalogRefreshState: 'ready',
})
).toBe(false);
expect(
isOpenCodeCatalogHydrating({
...provider,
models: ['opencode/big-pickle', 'openrouter/qwen/qwen3-coder-plus'],
})
).toBe(false);
});
it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => {
const provider = createAnthropicProvider({
authenticated: true,

View file

@ -369,7 +369,13 @@ describe('TeamModelSelector disabled Codex models', () => {
},
models: ['opencode/big-pickle'],
modelCatalog: null,
modelCatalogRefreshState: 'loading',
modelCatalogRefreshState: 'idle',
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
modelVerificationState: 'idle',
modelAvailability: [],
},

View file

@ -107,7 +107,7 @@ describe('ProvisioningProviderStatusList', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode (OpenCode CLI): Needs attention');
expect(host.textContent).toContain('OpenCode (OpenCode CLI): OpenCode app MCP unreachable');
expect(host.textContent).not.toContain('Selected model checks');
expect(host.textContent).not.toContain('model unavailable');
@ -119,12 +119,14 @@ describe('ProvisioningProviderStatusList', () => {
it('gives a concrete hint for missing OpenCode runtime binary failures', () => {
expect(
getProvisioningFailureHint('CLI environment is not available - launch is blocked', [
getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [
{
providerId: 'opencode',
status: 'failed',
backendSummary: null,
details: ['OpenCode runtime binary is not installed or not reachable by launch preflight.'],
details: [
'OpenCode runtime binary is not installed or not reachable by launch preflight.',
],
},
])
).toBe(
@ -132,6 +134,23 @@ describe('ProvisioningProviderStatusList', () => {
);
});
it('gives a concrete hint for stale OpenCode app MCP bridge failures', () => {
expect(
getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [
{
providerId: 'opencode',
status: 'failed',
backendSummary: null,
details: [
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?',
],
},
])
).toBe(
'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
);
});
it('picks the first real failure detail instead of a verified line', () => {
expect(
getPrimaryProvisioningFailureDetail([

View file

@ -9,6 +9,11 @@ import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSel
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
const OPENCODE_RAW_MCP_UNREACHABLE =
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
const OPENCODE_NORMALIZED_MCP_UNREACHABLE =
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?';
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
@ -340,9 +345,7 @@ describe('runProviderPrepareDiagnostics', () => {
Promise.resolve({
ready: false,
message: 'OpenCode: mcp_unavailable',
details: [
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
],
details: [OPENCODE_RAW_MCP_UNREACHABLE],
})
);
@ -355,7 +358,7 @@ describe('runProviderPrepareDiagnostics', () => {
expect(result.status).toBe('failed');
expect(result.details).toEqual([
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
OPENCODE_NORMALIZED_MCP_UNREACHABLE,
'OpenCode: mcp_unavailable',
]);
expect(result.modelResultsById).toEqual({});
@ -406,14 +409,14 @@ describe('runProviderPrepareDiagnostics', () => {
});
expect(result.status).toBe('failed');
expect(result.details).toEqual(['Future OpenCode health check failed without known marker words']);
expect(result.details).toEqual([
'Future OpenCode health check failed without known marker words',
]);
expect(result.modelResultsById).toEqual({});
expect(result.details.join('\n')).not.toContain('big-pickle - unavailable');
});
it('deduplicates repeated OpenCode provider runtime failure details', async () => {
const runtimeFailure =
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
const prepareProvisioning = vi.fn<
(
cwd?: string,
@ -426,9 +429,9 @@ describe('runProviderPrepareDiagnostics', () => {
>(() =>
Promise.resolve({
ready: false,
message: runtimeFailure,
details: [runtimeFailure],
warnings: [runtimeFailure],
message: OPENCODE_RAW_MCP_UNREACHABLE,
details: [OPENCODE_RAW_MCP_UNREACHABLE],
warnings: [OPENCODE_RAW_MCP_UNREACHABLE],
})
);
@ -440,8 +443,8 @@ describe('runProviderPrepareDiagnostics', () => {
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([runtimeFailure]);
expect(result.warnings).toEqual([runtimeFailure]);
expect(result.details).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]);
expect(result.warnings).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]);
expect(result.modelResultsById).toEqual({});
});
@ -543,8 +546,6 @@ describe('runProviderPrepareDiagnostics', () => {
});
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
const runtimeFailure =
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
const prepareProvisioning = vi.fn<
(
cwd?: string,
@ -557,8 +558,10 @@ describe('runProviderPrepareDiagnostics', () => {
>(() =>
Promise.resolve({
ready: false,
message: `Selected model opencode/big-pickle could not be verified. ${runtimeFailure}`,
warnings: [`Selected model opencode/big-pickle could not be verified. ${runtimeFailure}`],
message: `Selected model opencode/big-pickle could not be verified. ${OPENCODE_RAW_MCP_UNREACHABLE}`,
warnings: [
`Selected model opencode/big-pickle could not be verified. ${OPENCODE_RAW_MCP_UNREACHABLE}`,
],
})
);
@ -570,7 +573,7 @@ describe('runProviderPrepareDiagnostics', () => {
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([runtimeFailure]);
expect(result.details).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]);
expect(result.warnings).toEqual([]);
expect(result.modelResultsById).toEqual({});
});
@ -663,9 +666,7 @@ describe('runProviderPrepareDiagnostics', () => {
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
});
}
@ -674,9 +675,7 @@ describe('runProviderPrepareDiagnostics', () => {
return Promise.resolve({
ready: false,
message: 'OpenCode: mcp_unavailable',
details: [
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
],
details: [OPENCODE_RAW_MCP_UNREACHABLE],
});
});
@ -689,7 +688,7 @@ describe('runProviderPrepareDiagnostics', () => {
expect(result.status).toBe('failed');
expect(result.details).toEqual([
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
OPENCODE_NORMALIZED_MCP_UNREACHABLE,
'OpenCode: mcp_unavailable',
]);
expect(result.modelResultsById).toEqual({});
@ -713,9 +712,7 @@ describe('runProviderPrepareDiagnostics', () => {
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
});
}
@ -767,9 +764,7 @@ describe('runProviderPrepareDiagnostics', () => {
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
});
}
@ -830,9 +825,7 @@ describe('runProviderPrepareDiagnostics', () => {
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
});
}
@ -866,6 +859,52 @@ describe('runProviderPrepareDiagnostics', () => {
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('uses structured mcp_unavailable code to explain plain OpenCode connect failures', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => {
if (modelVerificationMode === 'compatibility') {
expect(selectedModels).toEqual(['opencode/big-pickle']);
return Promise.resolve({
ready: false,
message: 'Unable to connect. Is the computer able to access the url?',
issues: [
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'mcp_unavailable',
message: 'Unable to connect. Is the computer able to access the url?',
},
],
});
}
throw new Error('deep verification should not run');
});
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning,
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.',
]);
expect(result.modelResultsById).toEqual({});
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
});
it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => {
const prepareProvisioning = vi.fn<
(
@ -892,7 +931,8 @@ describe('runProviderPrepareDiagnostics', () => {
expect(selectedModels).toEqual(['openrouter/example/not-available']);
return Promise.resolve({
ready: false,
message: 'API Error: 400 {"detail":"The requested model is not available for your account."}',
message:
'API Error: 400 {"detail":"The requested model is not available for your account."}',
});
});

View file

@ -42,4 +42,34 @@ describe('runProviderPrepareDiagnostics OpenCode runtime failures', () => {
'compatibility'
);
});
it('normalizes structured OpenCode provider issue messages that bypass runtime details', async () => {
const prepareProvisioning = vi.fn<PrepareProvisioningFn>().mockResolvedValue({
ready: false,
message: 'not_installed',
details: ['OpenCode CLI not detected on PATH'],
issues: [
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'not_installed',
message: 'OpenCode CLI not detected on PATH',
},
],
});
const result = await runProviderPrepareDiagnostics({
cwd: '/Users/tester/project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning,
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([
'OpenCode runtime binary is not installed or not reachable by launch preflight.',
]);
expect(result.modelResultsById).toEqual({});
});
});