diff --git a/src/main/index.ts b/src/main/index.ts index 4e8069e0..928a1edf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -145,6 +145,10 @@ import { OpenCodeBridgeCommandHandshakePort, } from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { cleanupManagedOpenCodeServeProcesses } from './services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup'; +import { + clearOpenCodeLocalMcpLaunchEnv, + isOpenCodeMcpHttpBridgeEnabled, +} from './services/team/opencode/bridge/OpenCodeMcpBridgeEnv'; import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { @@ -353,10 +357,11 @@ async function createOpenCodeRuntimeAdapterRegistry( const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId; bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); - const useHttpMcpBridge = bridgeEnv.CLAUDE_TEAM_OPENCODE_MCP_HTTP === '1'; + const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv); if (!useHttpMcpBridge) { - // The OpenCode bridge direct tools/list proof currently requires a local MCP command. delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; + } else { + clearOpenCodeLocalMcpLaunchEnv(bridgeEnv); } const applyMcpLaunchSpecEnv = async ( targetEnv: NodeJS.ProcessEnv, @@ -418,6 +423,7 @@ async function createOpenCodeRuntimeAdapterRegistry( reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...'); const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + clearOpenCodeLocalMcpLaunchEnv(bridgeEnv); reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...'); } catch (error) { logger.warn( @@ -427,45 +433,26 @@ async function createOpenCodeRuntimeAdapterRegistry( ); } } - if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { + if (!useHttpMcpBridge && !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true }); } reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); const resolveBridgeCommandEnv = async (): Promise => { const nextEnv = { ...bridgeEnv }; - if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { + if (!useHttpMcpBridge) { return nextEnv; } try { const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; - delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND; - delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY; - delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON; + clearOpenCodeLocalMcpLaunchEnv(bridgeEnv); + clearOpenCodeLocalMcpLaunchEnv(nextEnv); } catch (error) { + delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; - if ( - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND && - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY && - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON - ) { - nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND; - nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY; - nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON; - } else { - await applyMcpLaunchSpecEnv(nextEnv); - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = - nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = - nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = - nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON; - } + clearOpenCodeLocalMcpLaunchEnv(nextEnv); logger.warn( `[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${ error instanceof Error ? error.message : String(error) diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index f5602d64..87778ee2 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -57,6 +57,25 @@ export interface OpenCodeBridgeCommandClientOptions { const DEFAULT_STDOUT_LIMIT_BYTES = 1_000_000; const DEFAULT_STDERR_LIMIT_BYTES = 256_000; +const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']); + +export function resolveOpenCodeBridgeProcessCwd( + binaryPath: string, + requestedCwd: string, + platform: NodeJS.Platform = process.platform +): string { + if (platform !== 'win32') { + return requestedCwd; + } + + const extension = path.win32.extname(binaryPath).toLowerCase(); + if (!WINDOWS_BATCH_EXTENSIONS.has(extension)) { + return requestedCwd; + } + + const launcherDirectory = path.win32.dirname(binaryPath); + return launcherDirectory && launcherDirectory !== '.' ? launcherDirectory : requestedCwd; +} export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner { async run(input: OpenCodeBridgeProcessRunInput): Promise { @@ -146,7 +165,7 @@ export class OpenCodeBridgeCommandClient { const processResult = await this.processRunner.run({ binaryPath: this.binaryPath, args: ['runtime', 'opencode-command', '--json', '--input', inputPath], - cwd: options.cwd, + cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd), timeoutMs: options.timeoutMs, stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES, stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, diff --git a/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts b/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts new file mode 100644 index 00000000..c6f516ea --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv.ts @@ -0,0 +1,24 @@ +const DISABLED_HTTP_MCP_VALUES = new Set(['0', 'false', 'no', 'off']); + +const LOCAL_MCP_LAUNCH_ENV_KEYS = [ + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND', + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY', + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON', +] as const; + +export interface OpenCodeMcpHttpBridgeEnv { + CLAUDE_TEAM_OPENCODE_MCP_HTTP?: string; +} + +export function isOpenCodeMcpHttpBridgeEnabled( + env: OpenCodeMcpHttpBridgeEnv = process.env +): boolean { + const rawValue = env.CLAUDE_TEAM_OPENCODE_MCP_HTTP?.trim().toLowerCase(); + return rawValue ? !DISABLED_HTTP_MCP_VALUES.has(rawValue) : true; +} + +export function clearOpenCodeLocalMcpLaunchEnv(env: NodeJS.ProcessEnv): void { + for (const key of LOCAL_MCP_LAUNCH_ENV_KEYS) { + delete env[key]; + } +} diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index ef0c7479..e5700370 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OpenCodeBridgeCommandClient, redactBridgeDiagnosticText, + resolveOpenCodeBridgeProcessCwd, type OpenCodeBridgeDiagnosticsSink, type OpenCodeBridgeProcessRunInput, type OpenCodeBridgeProcessRunResult, @@ -41,10 +42,14 @@ describe('OpenCodeBridgeCommandClient', () => { }; const client = createClient(); - const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); expect(result).toMatchObject({ ok: true, @@ -83,10 +88,14 @@ describe('OpenCodeBridgeCommandClient', () => { }; const client = createClient(); - const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); expect(result).toMatchObject({ ok: false, @@ -116,10 +125,14 @@ describe('OpenCodeBridgeCommandClient', () => { }; const client = createClient(); - const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); expect(result).toMatchObject({ ok: false, @@ -148,10 +161,14 @@ describe('OpenCodeBridgeCommandClient', () => { }; const client = createClient(); - const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); expect(result).toMatchObject({ ok: false, @@ -175,10 +192,14 @@ describe('OpenCodeBridgeCommandClient', () => { }; const client = createClient(); - const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); expect(result).toMatchObject({ ok: false, @@ -208,14 +229,22 @@ describe('OpenCodeBridgeCommandClient', () => { }, }); - await client.execute('opencode.launchTeam', { runId: 'run-1' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); - await client.execute('opencode.launchTeam', { runId: 'run-2' }, { - cwd: '/tmp/project', - timeoutMs: 10_000, - }); + await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); + await client.execute( + 'opencode.launchTeam', + { runId: 'run-2' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); expect(runner.calls[0].env).toMatchObject({ CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:5001/mcp', @@ -226,6 +255,36 @@ describe('OpenCodeBridgeCommandClient', () => { OPENCODE_DISABLE_AUTOUPDATE: '1', }); }); + + it('runs Windows batch launchers from their launcher directory while preserving envelope cwd', async () => { + runner.nextResult = { + stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`, + stderr: '', + exitCode: 0, + timedOut: false, + }; + const client = createClient({ + binaryPath: 'C:\\runtime\\agent_teams_orchestrator\\cli-dev.cmd', + }); + + await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: 'C:\\projects\\team workspace', + timeoutMs: 10_000, + } + ); + + expect(runner.calls[0].cwd).toBe( + process.platform === 'win32' + ? 'C:\\runtime\\agent_teams_orchestrator' + : 'C:\\projects\\team workspace' + ); + expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({ + cwd: 'C:\\projects\\team workspace', + }); + }); }); describe('redactBridgeDiagnosticText', () => { @@ -242,6 +301,34 @@ describe('redactBridgeDiagnosticText', () => { }); }); +describe('resolveOpenCodeBridgeProcessCwd', () => { + it('keeps non-Windows launchers on the requested project cwd', () => { + expect( + resolveOpenCodeBridgeProcessCwd('/usr/local/bin/claude-multimodel', '/repo', 'linux') + ).toBe('/repo'); + }); + + it('uses the launcher directory for Windows batch launchers', () => { + expect( + resolveOpenCodeBridgeProcessCwd( + 'C:\\runtime\\agent_teams_orchestrator\\cli-dev.cmd', + 'C:\\projects\\team workspace', + 'win32' + ) + ).toBe('C:\\runtime\\agent_teams_orchestrator'); + }); + + it('keeps Windows exe launchers on the requested project cwd', () => { + expect( + resolveOpenCodeBridgeProcessCwd( + 'C:\\runtime-cache\\claude-multimodel.exe', + 'C:\\projects\\team workspace', + 'win32' + ) + ).toBe('C:\\projects\\team workspace'); + }); +}); + function createClient( overrides: Partial[0]> = {} ): OpenCodeBridgeCommandClient { diff --git a/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts b/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts new file mode 100644 index 00000000..4622061e --- /dev/null +++ b/test/main/services/team/OpenCodeMcpBridgeEnv.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { + clearOpenCodeLocalMcpLaunchEnv, + isOpenCodeMcpHttpBridgeEnabled, +} from '@main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv'; + +describe('OpenCodeMcpBridgeEnv', () => { + it('uses the app-owned HTTP MCP bridge by default', () => { + expect(isOpenCodeMcpHttpBridgeEnabled({})).toBe(true); + expect(isOpenCodeMcpHttpBridgeEnabled({ CLAUDE_TEAM_OPENCODE_MCP_HTTP: '1' })).toBe(true); + expect(isOpenCodeMcpHttpBridgeEnabled({ CLAUDE_TEAM_OPENCODE_MCP_HTTP: 'true' })).toBe(true); + }); + + it('keeps the legacy local MCP command path behind an explicit opt-out', () => { + expect(isOpenCodeMcpHttpBridgeEnabled({ CLAUDE_TEAM_OPENCODE_MCP_HTTP: '0' })).toBe(false); + expect(isOpenCodeMcpHttpBridgeEnabled({ CLAUDE_TEAM_OPENCODE_MCP_HTTP: ' false ' })).toBe( + false + ); + expect(isOpenCodeMcpHttpBridgeEnabled({ CLAUDE_TEAM_OPENCODE_MCP_HTTP: 'off' })).toBe(false); + }); + + it('removes local MCP launch env when HTTP MCP is active', () => { + const env: NodeJS.ProcessEnv = { + 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_URL: 'http://127.0.0.1:41001/mcp', + }; + + clearOpenCodeLocalMcpLaunchEnv(env); + + 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_URL).toBe('http://127.0.0.1:41001/mcp'); + }); +});