fix(opencode): harden local runtime bridge support

This commit is contained in:
infiniti 2026-05-25 14:31:57 +03:00 committed by GitHub
parent 26a57f87d4
commit 2cee9cabaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 188 additions and 16 deletions

View file

@ -88,6 +88,7 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
import {
APP_GET_WINDOWS_ELEVATION_STATUS,
@ -396,7 +397,10 @@ async function createOpenCodeRuntimeAdapterRegistry(
}
reportProgress('runtime-environment', 'Preparing runtime environment...');
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({
...process.env,
PATH: buildMergedCliPath(binaryPath),
});
applyAgentTeamsIdentityEnv(bridgeEnv);
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();

View file

@ -727,13 +727,42 @@ function mergeRuntimeCapabilitiesForCatalogHydration(
};
}
function shouldPromoteHydratedAuthState(
liveProvider: CliProviderStatus,
hydratedProvider: CliProviderStatus
): boolean {
return (
liveProvider.providerId === 'opencode' &&
liveProvider.authenticated !== true &&
hydratedProvider.authenticated === true
);
}
function mergeProviderCatalogFields(
liveProvider: CliProviderStatus,
hydratedProvider: CliProviderStatus
): CliProviderStatus {
const modelCatalog = hydratedProvider.modelCatalog ?? liveProvider.modelCatalog ?? null;
const promoteHydratedAuthState = shouldPromoteHydratedAuthState(liveProvider, hydratedProvider);
return {
...liveProvider,
authenticated: promoteHydratedAuthState
? hydratedProvider.authenticated
: liveProvider.authenticated,
authMethod: promoteHydratedAuthState ? hydratedProvider.authMethod : liveProvider.authMethod,
verificationState: promoteHydratedAuthState
? hydratedProvider.verificationState
: liveProvider.verificationState,
capabilities: promoteHydratedAuthState
? hydratedProvider.capabilities
: liveProvider.capabilities,
statusMessage: promoteHydratedAuthState
? hydratedProvider.statusMessage
: liveProvider.statusMessage,
detailMessage: promoteHydratedAuthState
? hydratedProvider.detailMessage
: liveProvider.detailMessage,
backend: promoteHydratedAuthState ? hydratedProvider.backend : liveProvider.backend,
models: hydratedProvider.models.length > 0 ? hydratedProvider.models : liveProvider.models,
modelCatalog,
modelCatalogRefreshState: modelCatalog

View file

@ -78,8 +78,9 @@ function buildOpenCodeBridgeSupportCopyText(input: {
const command = formatDiagnosticValue(input.details.command, input.result.command);
const requestId = formatDiagnosticValue(input.details.requestId, input.result.requestId);
const stderrPreview = formatPreview(input.details.stderrPreview);
const likelyCause = formatLikelyCause(input.details);
return [
const lines = [
'Agent Teams OpenCode diagnostics',
`Time: ${input.createdAt}`,
'Provider: opencode',
@ -93,6 +94,8 @@ function buildOpenCodeBridgeSupportCopyText(input: {
'Bridge command:',
`command: ${command}`,
`requestId: ${requestId}`,
`binaryPath: ${formatDiagnosticPathValue(input.details.binaryPath)}`,
`cwd: ${formatDiagnosticPathValue(input.details.cwd)}`,
`attempts: ${formatDiagnosticValue(input.details.attempts)}`,
`exitCode: ${formatDiagnosticValue(input.details.exitCode)}`,
`timedOut: ${formatDiagnosticValue(input.details.timedOut)}`,
@ -108,10 +111,22 @@ function buildOpenCodeBridgeSupportCopyText(input: {
`appVersion: ${formatDiagnosticValue(input.appVersion)}`,
`projectPath: ${redactDiagnosticPath(input.projectPath)}`,
`selectedModel: ${formatDiagnosticValue(input.selectedModel)}`,
'',
'stderrPreview:',
stderrPreview,
].join('\n');
];
if (likelyCause) {
lines.push('', 'Likely cause:', likelyCause);
}
lines.push('', 'stderrPreview:', stderrPreview);
return lines.join('\n');
}
function formatLikelyCause(details: Record<string, unknown>): string | null {
if (details.exitCode !== 9009) {
return null;
}
return 'Windows could not start the bridge launcher. Check that the runtime launcher and its dependencies, such as Bun for cli-dev.cmd, are available in PATH.';
}
function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): string {
@ -128,6 +143,13 @@ function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): s
return redactBridgeDiagnosticText(JSON.stringify(resolved));
}
function formatDiagnosticPathValue(value: unknown): string {
if (typeof value !== 'string') {
return formatDiagnosticValue(value);
}
return redactDiagnosticPath(value);
}
function formatPreview(value: unknown): string {
const formatted = formatDiagnosticValue(value);
return formatted === '(none)' ? '(empty)' : formatted;

View file

@ -408,9 +408,7 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(provider.detailMessage).toContain(
'OpenCode runtime status did not return before the desktop timeout.'
);
expect(provider.detailMessage).toContain(
'not necessarily that OpenCode auth is missing'
);
expect(provider.detailMessage).toContain('not necessarily that OpenCode auth is missing');
expect(provider.detailMessage).toContain('provider/model inventory');
expect(provider.detailMessage).toContain('Raw timeout detail: Command timed out after 30000ms');
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual([
@ -508,11 +506,7 @@ describe('ClaudeMultimodelBridgeService', () => {
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
expect(execCliMock).toHaveBeenCalledTimes(3);
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([
30000,
30000,
30000,
]);
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([30000, 30000, 30000]);
expect(calls).toEqual([
'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex --summary',
@ -524,8 +518,9 @@ describe('ClaudeMultimodelBridgeService', () => {
'opencode',
]);
expect(providers.every((provider) => provider.verificationState === 'error')).toBe(true);
expect(providers.every((provider) => provider.statusMessage === 'Provider status unavailable'))
.toBe(true);
expect(
providers.every((provider) => provider.statusMessage === 'Provider status unavailable')
).toBe(true);
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
expect.stringContaining(
'Provider-scoped runtime status timed out for anthropic, codex, opencode'
@ -795,6 +790,128 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']);
});
it('promotes OpenCode auth when full catalog hydration proves built-in free access', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider opencode --summary') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
opencode: {
providerId: 'opencode',
displayName: 'OpenCode',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: 'No OpenCode providers connected',
models: [],
capabilities: { teamLaunch: false, oneShot: false },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider opencode') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
opencode: {
providerId: 'opencode',
displayName: 'OpenCode',
supported: true,
authenticated: true,
authMethod: 'opencode_builtin_free',
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: null,
detailMessage: '3 built-in free models',
models: ['opencode/big-pickle'],
capabilities: { teamLaunch: true, oneShot: false },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
backend: {
kind: 'opencode-cli',
label: 'OpenCode CLI',
authMethodDetail: 'built-in free models',
},
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-25T00:00:00.000Z',
staleAt: '2026-05-25T00:10:00.000Z',
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const onCatalogUpdate = vi.fn();
const provider = await service.getProviderStatus(
'/mock/agent_teams_orchestrator',
'opencode',
onCatalogUpdate
);
expect(provider).toMatchObject({
authenticated: false,
statusMessage: 'No OpenCode providers connected',
modelCatalogRefreshState: 'loading',
});
await vi.waitFor(() => {
expect(onCatalogUpdate).toHaveBeenCalledTimes(1);
});
expect(onCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
authenticated: true,
authMethod: 'opencode_builtin_free',
statusMessage: null,
capabilities: { teamLaunch: true },
modelCatalogRefreshState: 'ready',
backend: { authMethodDetail: 'built-in free models' },
});
});
it('hydrates a single provider catalog after summary refresh', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';