diff --git a/src/main/index.ts b/src/main/index.ts index 3ece25ff..e3aea4f5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -392,6 +392,10 @@ async function createOpenCodeRuntimeAdapterRegistry( bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv); const explicitLocalMcpLaunchEnv = snapshotOpenCodeLocalMcpLaunchEnv(bridgeEnv); + delete bridgeEnv.ELECTRON_RUN_AS_NODE; + if (explicitLocalMcpLaunchEnv) { + copyOpenCodeLocalMcpLaunchEnv(explicitLocalMcpLaunchEnv, bridgeEnv); + } delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; const applyMcpLaunchSpecEnv = async ( targetEnv: NodeJS.ProcessEnv, @@ -408,12 +412,12 @@ async function createOpenCodeRuntimeAdapterRegistry( }); const mcpEntry = mcpLaunchSpec.args[0]; if (mcpEntry) { - for (const [key, value] of Object.entries(mcpLaunchSpec.env ?? {})) { - targetEnv[key] = value; - } targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); + targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON = JSON.stringify( + mcpLaunchSpec.env ?? {} + ); } } catch (error) { logger.warn( diff --git a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts index a54cd3b5..51b2446c 100644 --- a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts +++ b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts @@ -8,6 +8,8 @@ const logger = createLogger('Runtime:AgentTeamsMcpLaunchEnv'); const MCP_COMMAND_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND'; const MCP_ENTRY_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY'; const MCP_ARGS_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON'; +const MCP_ENV_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON'; +const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; export type AgentTeamsMcpLaunchEnv = Record; @@ -17,11 +19,24 @@ export function hasAgentTeamsMcpLocalLaunchEnv(env: AgentTeamsMcpLaunchEnv): boo ); } +function ensureLegacyMcpChildEnvJson(env: AgentTeamsMcpLaunchEnv): void { + if (env[MCP_ENV_JSON_ENV]?.trim()) { + return; + } + const electronRunAsNode = env[ELECTRON_RUN_AS_NODE_ENV]?.trim(); + if (electronRunAsNode) { + env[MCP_ENV_JSON_ENV] = JSON.stringify({ + [ELECTRON_RUN_AS_NODE_ENV]: electronRunAsNode, + }); + } +} + export async function ensureAgentTeamsMcpLocalLaunchEnv( env: AgentTeamsMcpLaunchEnv, resolveLaunchSpec: () => Promise = resolveAgentTeamsMcpLaunchSpec ): Promise { if (hasAgentTeamsMcpLocalLaunchEnv(env)) { + ensureLegacyMcpChildEnvJson(env); return; } @@ -33,12 +48,10 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv( return; } - for (const [key, value] of Object.entries(launchSpec.env ?? {})) { - env[key] = value; - } env[MCP_COMMAND_ENV] = command; env[MCP_ENTRY_ENV] = entry; env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args); + env[MCP_ENV_JSON_ENV] = JSON.stringify(launchSpec.env ?? {}); } catch (error) { logger.warn( `Unable to resolve Agent Teams MCP local launch env: ${ diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index cedafaaa..a6bdd0ae 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -11,6 +11,7 @@ import { providerConnectionService } from './ProviderConnectionService'; import type { CliProviderId, TeamProviderId } from '@shared/types'; type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; +const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; export interface ProviderAwareCliEnvOptions { binaryPath?: string | null; @@ -29,6 +30,10 @@ export interface ProviderAwareCliEnvResult { providerArgs: string[]; } +function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void { + delete env[ELECTRON_RUN_AS_NODE_ENV]; +} + export async function buildProviderAwareCliEnv( options: ProviderAwareCliEnvOptions = {} ): Promise { @@ -79,6 +84,7 @@ export async function buildProviderAwareCliEnv( options.providerBackendId, ...storedApiKeyAccessArgs ); + removeGlobalElectronRunAsNodeEnv(env); return { env, connectionIssues: {}, @@ -92,22 +98,25 @@ export async function buildProviderAwareCliEnv( options.providerBackendId, ...storedApiKeyAccessArgs ); + removeGlobalElectronRunAsNodeEnv(env); + const providerArgs = await providerConnectionService.getConfiguredConnectionLaunchArgs( + env, + resolvedProviderId, + options.providerBackendId, + options.binaryPath + ); + const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues( + env, + [resolvedProviderId], + resolvedProviderId === 'codex' || resolvedProviderId === 'gemini' + ? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined } + : undefined + ); return { env, - providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs( - env, - resolvedProviderId, - options.providerBackendId, - options.binaryPath - ), - connectionIssues: await providerConnectionService.getConfiguredConnectionIssues( - env, - [resolvedProviderId], - resolvedProviderId === 'codex' || resolvedProviderId === 'gemini' - ? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined } - : undefined - ), + providerArgs, + connectionIssues, }; } @@ -116,6 +125,7 @@ export async function buildProviderAwareCliEnv( env, ...storedApiKeyAccessArgs ); + removeGlobalElectronRunAsNodeEnv(env); return { env, connectionIssues: {}, @@ -124,9 +134,11 @@ export async function buildProviderAwareCliEnv( } await providerConnectionService.applyAllConfiguredConnectionEnv(env, ...storedApiKeyAccessArgs); + removeGlobalElectronRunAsNodeEnv(env); + const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(env); return { env, - connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env), + connectionIssues, providerArgs: [], }; } diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index 2d5c8075..da29fe72 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -405,7 +405,12 @@ function buildStatePath(): string { } function buildLaunchSpecHash(launchSpec: McpLaunchSpec): string { - return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args })); + const env = launchSpec.env + ? Object.fromEntries( + Object.entries(launchSpec.env).sort(([left], [right]) => left.localeCompare(right)) + ) + : {}; + return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args, env })); } function buildExpectedIdentity( diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 51c64f98..a4a3e95e 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -46,6 +46,7 @@ const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; +const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; /** * Stale configs older than this are removed on startup (best-effort). * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved @@ -93,20 +94,21 @@ function getWorkspaceRoot(): string { function shouldUsePackagedElectronNodeRuntime(): boolean { return ( - isPackagedApp() && - process.platform === 'linux' && - typeof process.execPath === 'string' && - process.execPath.trim().length > 0 + isPackagedApp() && typeof process.execPath === 'string' && process.execPath.trim().length > 0 ); } +function getPackagedElectronNodeEnv(): Record { + return { + [ELECTRON_RUN_AS_NODE_ENV]: '1', + }; +} + function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec { return { - command: process.execPath, + command: process.execPath.trim(), args: [entry], - env: { - [ELECTRON_RUN_AS_NODE_ENV]: '1', - }, + env: getPackagedElectronNodeEnv(), }; } @@ -200,9 +202,11 @@ async function hasValidServerCopy(dir: string): Promise { } let _resolvedNodePath: string | undefined; +let _packagedElectronNodeRuntimeProbe: { ok: true } | { ok: false; error: unknown } | undefined; export function clearResolvedNodePathForTests(): void { _resolvedNodePath = undefined; + _packagedElectronNodeRuntimeProbe = undefined; } function emitProgress( @@ -323,6 +327,37 @@ async function probeNodeRuntimePath( return { ok: false, error: lastError ?? 'no Node.js candidates were available' }; } +async function probePackagedElectronNodeRuntime( + options?: McpLaunchSpecResolveOptions +): Promise<{ ok: true } | { ok: false; error: unknown }> { + if (_packagedElectronNodeRuntimeProbe) { + return _packagedElectronNodeRuntimeProbe; + } + + emitProgress(options, 'electron-node-runtime', 'Checking bundled Electron Node runtime...'); + try { + const { stdout } = await execCli( + process.execPath.trim(), + ['-e', 'process.stdout.write("agent-teams-electron-node-ok")'], + { + encoding: 'utf-8', + timeout: ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS, + env: { + ...process.env, + ...getPackagedElectronNodeEnv(), + }, + } + ); + if (stdout.trim() !== 'agent-teams-electron-node-ok') { + throw new Error('Electron Node runtime probe did not return the expected marker'); + } + _packagedElectronNodeRuntimeProbe = { ok: true }; + } catch (error) { + _packagedElectronNodeRuntimeProbe = { ok: false, error }; + } + return _packagedElectronNodeRuntimeProbe; +} + async function probeShellNodeRuntimePath( options?: McpLaunchSpecResolveOptions ): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> { @@ -473,12 +508,25 @@ export async function resolveAgentTeamsMcpLaunchSpec( checked.push(packagedEntry); if (await pathExists(packagedEntry)) { if (shouldUsePackagedElectronNodeRuntime()) { + const electronProbe = await probePackagedElectronNodeRuntime(options); + if (electronProbe.ok) { + emitProgress( + options, + 'electron-node-runtime-found', + 'Using bundled Electron Node runtime...' + ); + return buildPackagedElectronNodeLaunchSpec(packagedEntry); + } + logger.warn( + `Bundled Electron Node runtime is unavailable for Agent Teams MCP; falling back to Node.js runtime: ${stringifyError( + electronProbe.error + )}` + ); emitProgress( options, - 'electron-node-runtime-found', - 'Using bundled Electron Node runtime...' + 'electron-node-runtime-fallback', + 'Bundled Electron Node runtime unavailable, resolving Node.js fallback...' ); - return buildPackagedElectronNodeLaunchSpec(packagedEntry); } return { command: await resolveNodePath(options), diff --git a/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts b/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts index cbf99f2e..06edb4a1 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts @@ -5,6 +5,8 @@ const LOCAL_MCP_LAUNCH_ENV_KEYS = [ 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON', ] as const; +const OPTIONAL_LOCAL_MCP_LAUNCH_ENV_KEYS = ['CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON'] as const; +const LEGACY_LOCAL_MCP_CHILD_ENV_KEYS = ['ELECTRON_RUN_AS_NODE'] as const; export type OpenCodeMcpBridgeEnv = Record; @@ -17,6 +19,17 @@ export function hasOpenCodeLocalMcpLaunchEnv(env: OpenCodeMcpBridgeEnv): boolean return LOCAL_MCP_LAUNCH_ENV_KEYS.every((key) => Boolean(env[key]?.trim())); } +function buildLegacyLocalMcpEnvJson(env: OpenCodeMcpBridgeEnv): string | null { + const legacyEnv: Record = {}; + for (const key of LEGACY_LOCAL_MCP_CHILD_ENV_KEYS) { + const value = env[key]?.trim(); + if (value) { + legacyEnv[key] = value; + } + } + return Object.keys(legacyEnv).length > 0 ? JSON.stringify(legacyEnv) : null; +} + export function shouldEnsureOpenCodeLocalMcpLaunchEnv(input: { httpBridgeEnabled: boolean; mcpUrl: string | undefined; @@ -36,6 +49,20 @@ export function copyOpenCodeLocalMcpLaunchEnv( delete targetEnv[key]; } } + for (const key of OPTIONAL_LOCAL_MCP_LAUNCH_ENV_KEYS) { + const value = sourceEnv[key]?.trim(); + if (value) { + targetEnv[key] = value; + } else { + delete targetEnv[key]; + } + } + if (!targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON?.trim()) { + const legacyEnvJson = buildLegacyLocalMcpEnvJson(sourceEnv); + if (legacyEnvJson) { + targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON = legacyEnvJson; + } + } } export function snapshotOpenCodeLocalMcpLaunchEnv( @@ -54,4 +81,10 @@ export function clearOpenCodeLocalMcpLaunchEnv(env: OpenCodeMcpBridgeEnv): void for (const key of LOCAL_MCP_LAUNCH_ENV_KEYS) { delete env[key]; } + for (const key of OPTIONAL_LOCAL_MCP_LAUNCH_ENV_KEYS) { + delete env[key]; + } + for (const key of LEGACY_LOCAL_MCP_CHILD_ENV_KEYS) { + delete env[key]; + } } diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 3ac078fb..7d4c3265 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -276,7 +276,7 @@ describe('buildProviderAwareCliEnv', () => { ); }); - it('propagates Agent Teams MCP launch env overrides for OpenCode provider commands', async () => { + it('serializes Agent Teams MCP launch env overrides for OpenCode provider commands', async () => { resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({ command: '/opt/Agent Teams AI/agent-teams-ai', args: ['/app/mcp-server/index.js'], @@ -287,9 +287,13 @@ describe('buildProviderAwareCliEnv', () => { const result = await buildProviderAwareCliEnv({ providerId: 'opencode', + env: { ELECTRON_RUN_AS_NODE: 'inherited-global-value' }, }); - expect(result.env.ELECTRON_RUN_AS_NODE).toBe('1'); + expect(result.env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON).toBe( + '{"ELECTRON_RUN_AS_NODE":"1"}' + ); expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe( '/opt/Agent Teams AI/agent-teams-ai' ); @@ -304,6 +308,7 @@ describe('buildProviderAwareCliEnv', () => { CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'custom-node', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: '/custom/mcp.js', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["/custom/mcp.js"]', + ELECTRON_RUN_AS_NODE: '1', }, }); @@ -311,6 +316,10 @@ describe('buildProviderAwareCliEnv', () => { expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe('custom-node'); expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBe('/custom/mcp.js'); expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toBe('["/custom/mcp.js"]'); + expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON).toBe( + '{"ELECTRON_RUN_AS_NODE":"1"}' + ); + expect(result.env.ELECTRON_RUN_AS_NODE).toBeUndefined(); }); it('allows OpenCode auto-update only behind an explicit app override', async () => { diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index 4da2b028..5add59a5 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -45,8 +45,21 @@ function sha256Hex(value: string): string { return createHash('sha256').update(value).digest('hex'); } -function buildLaunchSpecHash(launchSpec: { command: string; args: string[] }): string { - return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args })); +type TestLaunchSpec = { + command: string; + args: string[]; + env?: Record; +}; + +function buildLaunchSpecHash(launchSpec: TestLaunchSpec): string { + const env = launchSpec.env + ? Object.fromEntries( + Object.entries(launchSpec.env).sort(([left], [right]) => left.localeCompare(right)) + ) + : {}; + return sha256Hex( + JSON.stringify({ command: launchSpec.command, args: launchSpec.args, env }) + ); } async function createTempStatePath(): Promise<{ root: string; statePath: string }> { @@ -58,7 +71,7 @@ async function createTempStatePath(): Promise<{ root: string; statePath: string function buildIdentity(input: { port: number; - launchSpec: { command: string; args: string[] }; + launchSpec: TestLaunchSpec; ownerInstanceId?: string; }) { return { @@ -77,7 +90,7 @@ function buildIdentity(input: { function buildState(input: { port: number; pid?: number | null; - launchSpec: { command: string; args: string[] }; + launchSpec: TestLaunchSpec; ownerInstanceId?: string; }) { const identity = buildIdentity(input); @@ -433,6 +446,45 @@ describe('AgentTeamsMcpHttpServer', () => { } }); + it('ignores persistent state when the MCP child env changed', async () => { + const { root, statePath } = await createTempStatePath(); + const previousLaunchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; + const currentLaunchSpec = { + command: 'node', + args: ['mcp-server/dist/index.js'], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }; + await writeFile( + statePath, + `${JSON.stringify( + buildState({ port: 41029, pid: 51235, launchSpec: previousLaunchSpec }), + null, + 2 + )}\n` + ); + const child = new FakeChildProcess(); + const spawnProcess = vi.fn(() => child as unknown as ChildProcess); + const server = new AgentTeamsMcpHttpServer({ + statePath, + disableOrphanCleanup: true, + resolveLaunchSpec: async () => currentLaunchSpec, + allocatePort: async () => 41030, + spawnProcess, + waitForPort: vi.fn(async () => undefined), + }); + + try { + const handle = await server.ensureStarted(); + + expect(handle.port).toBe(41030); + expect(handle.diagnostics).toContain('opencode_app_mcp_state_ignored:identity_mismatch'); + expect(spawnProcess).toHaveBeenCalledTimes(1); + } finally { + await rm(root, { recursive: true, force: true }); + vi.mocked(console.warn).mockClear(); + } + }); + it('adopts a healthy matching MCP HTTP server on the configured stable port', async () => { const { root, statePath } = await createTempStatePath(); const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; diff --git a/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts b/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts index 349028ff..05d6bc49 100644 --- a/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts +++ b/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it } from 'vitest'; - import { clearOpenCodeLocalMcpLaunchEnv, copyOpenCodeLocalMcpLaunchEnv, @@ -8,6 +6,7 @@ import { shouldEnsureOpenCodeLocalMcpLaunchEnv, snapshotOpenCodeLocalMcpLaunchEnv, } from '@main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv'; +import { describe, expect, it } from 'vitest'; describe('OpenCodeMcpBridgeEnv', () => { it('uses the app-owned HTTP MCP bridge by default', () => { @@ -61,6 +60,7 @@ describe('OpenCodeMcpBridgeEnv', () => { CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: 'mcp-server/dist/index.js', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["mcp-server/dist/index.js"]', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: '{"ELECTRON_RUN_AS_NODE":"1"}', }, target ); @@ -68,6 +68,9 @@ describe('OpenCodeMcpBridgeEnv', () => { expect(target.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe('node'); expect(target.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBe('mcp-server/dist/index.js'); expect(target.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toBe('["mcp-server/dist/index.js"]'); + expect(target.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON).toBe( + '{"ELECTRON_RUN_AS_NODE":"1"}' + ); expect(target.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL).toBe('http://127.0.0.1:41001/mcp'); }); @@ -101,6 +104,7 @@ describe('OpenCodeMcpBridgeEnv', () => { CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: ' node ', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: ' mcp-server/dist/index.js ', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: ' ["mcp-server/dist/index.js"] ', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: ' {"ELECTRON_RUN_AS_NODE":"1"} ', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:41001/mcp', }; @@ -111,9 +115,25 @@ describe('OpenCodeMcpBridgeEnv', () => { CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: 'mcp-server/dist/index.js', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["mcp-server/dist/index.js"]', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: '{"ELECTRON_RUN_AS_NODE":"1"}', }); expect(hasOpenCodeLocalMcpLaunchEnv(snapshot ?? {})).toBe(true); expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBeUndefined(); + expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON).toBeUndefined(); + }); + + it('migrates legacy MCP child env into the local MCP env JSON snapshot', () => { + const snapshot = snapshotOpenCodeLocalMcpLaunchEnv({ + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: 'mcp-server/dist/index.js', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["mcp-server/dist/index.js"]', + ELECTRON_RUN_AS_NODE: '1', + }); + + expect(snapshot?.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON).toBe( + '{"ELECTRON_RUN_AS_NODE":"1"}' + ); + expect(snapshot?.ELECTRON_RUN_AS_NODE).toBeUndefined(); }); it('removes local MCP launch env when explicitly requested', () => { @@ -121,7 +141,9 @@ describe('OpenCodeMcpBridgeEnv', () => { CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: 'mcp-server/dist/index.js', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["mcp-server/dist/index.js"]', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: '{"ELECTRON_RUN_AS_NODE":"1"}', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:41001/mcp', + ELECTRON_RUN_AS_NODE: '1', }; clearOpenCodeLocalMcpLaunchEnv(env); @@ -129,6 +151,8 @@ describe('OpenCodeMcpBridgeEnv', () => { expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBeUndefined(); expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBeUndefined(); expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toBeUndefined(); + expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON).toBeUndefined(); + expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); expect(env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL).toBe('http://127.0.0.1:41001/mcp'); }); }); diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index 9fd1b633..d8386520 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -77,10 +77,10 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, - ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, @@ -190,10 +190,10 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, - ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 294bc809..5e15d969 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -69,10 +69,10 @@ liveDescribe('OpenCode team provisioning live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, - ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, @@ -228,10 +228,10 @@ liveDescribe('OpenCode team provisioning live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, - ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 9374488d..1681e5b8 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -339,6 +339,9 @@ describe('TeamMcpConfigBuilder', () => { HOME: '/Users/tester', }; hoisted.resolveInteractiveShellEnvMock.mockResolvedValue(hoisted.cachedShellEnv); + hoisted.execCliMock.mockImplementationOnce(async () => { + throw new Error('Electron-as-Node unavailable'); + }); hoisted.execCliMock.mockImplementationOnce(async (command, _args, options) => { expect(command).toBe('node'); const env = options?.env as NodeJS.ProcessEnv | undefined; @@ -355,18 +358,27 @@ describe('TeamMcpConfigBuilder', () => { expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); }); - it('uses the packaged Electron Node runtime for Linux packaged MCP launches', async () => { + it.each(['linux', 'darwin', 'win32'] as const)( + 'uses the packaged Electron Node runtime for %s packaged MCP launches', + async (platform) => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); - const electronBinary = '/opt/Agent Teams AI/agent-teams-ai'; + const electronBinary = + platform === 'win32' + ? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe' + : '/opt/Agent Teams AI/agent-teams-ai'; setPackagedMode(true, '3.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); - createPackagedServerBundle(resourcesDir, '// packaged linux server'); + createPackagedServerBundle(resourcesDir, '// packaged server'); setResourcesPath(resourcesDir); + hoisted.execCliMock.mockResolvedValue({ + stdout: 'agent-teams-electron-node-ok', + stderr: '', + }); Object.defineProperty(process, 'platform', { - value: 'linux', + value: platform, configurable: true, }); Object.defineProperty(process, 'execPath', { @@ -391,7 +403,14 @@ describe('TeamMcpConfigBuilder', () => { expect(server?.command).toBe(electronBinary); expect(server?.args).toEqual([expectedEntry]); expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); - expect(hoisted.execCliMock).not.toHaveBeenCalled(); + expect(hoisted.execCliMock).toHaveBeenCalledTimes(1); + expect(hoisted.execCliMock).toHaveBeenCalledWith( + electronBinary, + ['-e', 'process.stdout.write("agent-teams-electron-node-ok")'], + expect.objectContaining({ + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }), + }) + ); } finally { if (platformDescriptor) { Object.defineProperty(process, 'platform', platformDescriptor); @@ -400,7 +419,8 @@ describe('TeamMcpConfigBuilder', () => { Object.defineProperty(process, 'execPath', execPathDescriptor); } } - }); + } + ); it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => { mockBuiltWorkspaceEntryAvailable(); diff --git a/test/main/services/team/openCodeLiveTestHarness.ts b/test/main/services/team/openCodeLiveTestHarness.ts index c25da789..e3cf11b1 100644 --- a/test/main/services/team/openCodeLiveTestHarness.ts +++ b/test/main/services/team/openCodeLiveTestHarness.ts @@ -76,10 +76,10 @@ export async function createOpenCodeLiveHarness(input: { PATH: withBunOnPath(process.env.PATH ?? ''), AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), CLAUDE_TEAM_CONTROL_URL: controlApi.baseUrl, - ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ teamsBasePath: getTeamsBasePath(),