fix(team): harden packaged mcp runtime

This commit is contained in:
777genius 2026-05-22 16:49:02 +03:00
parent 71bfab1758
commit 587c974c63
13 changed files with 271 additions and 51 deletions

View file

@ -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(

View file

@ -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<string, string | undefined>;
@ -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<McpLaunchSpec> = resolveAgentTeamsMcpLaunchSpec
): Promise<void> {
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: ${

View file

@ -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<ProviderAwareCliEnvResult> {
@ -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: [],
};
}

View file

@ -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(

View file

@ -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<string, string> {
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<boolean> {
}
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),

View file

@ -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<string, string | undefined>;
@ -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<string, string> = {};
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];
}
}

View file

@ -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 () => {

View file

@ -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<string, string>;
};
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'] };

View file

@ -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');
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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();

View file

@ -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(),