fix(team): harden packaged mcp runtime
This commit is contained in:
parent
71bfab1758
commit
587c974c63
13 changed files with 271 additions and 51 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: ${
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'] };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue