From 3ceef1fb82cb5b353d925c2585a1b5c16a4e04b2 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 01:19:13 +0300 Subject: [PATCH] fix(opencode): hide app mcp child processes on windows --- mcp-server/src/index.ts | 79 ++++++- mcp-server/test/startOptions.test.ts | 54 +++++ src/main/index.ts | 40 +++- .../services/team/AgentTeamsMcpHttpServer.ts | 212 ++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 72 ++++++ .../OpenCodeManagedHostProcessCleanup.ts | 82 +++++-- .../team/AgentTeamsMcpHttpServer.test.ts | 185 +++++++++++++++ .../OpenCodeManagedHostProcessCleanup.test.ts | 91 +++++++- .../TeamProvisioningServicePrepare.test.ts | 76 +++++++ 9 files changed, 854 insertions(+), 37 deletions(-) create mode 100644 mcp-server/test/startOptions.test.ts create mode 100644 src/main/services/team/AgentTeamsMcpHttpServer.ts create mode 100644 test/main/services/team/AgentTeamsMcpHttpServer.test.ts diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 0ca07344..fff620d3 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -5,6 +5,24 @@ import { FastMCP } from 'fastmcp'; import { registerTools } from './tools'; +const HTTP_TRANSPORT = 'httpStream'; +const STDIO_TRANSPORT = 'stdio'; +const DEFAULT_HTTP_HOST = '127.0.0.1'; +const DEFAULT_HTTP_ENDPOINT = '/mcp'; + +export type AgentTeamsMcpStartOptions = + | { + transportType: typeof STDIO_TRANSPORT; + } + | { + transportType: typeof HTTP_TRANSPORT; + httpStream: { + host: string; + port: number; + endpoint: `/${string}`; + }; + }; + export function createServer() { const server = new FastMCP({ name: 'agent-teams-mcp', @@ -16,9 +34,64 @@ export function createServer() { return server; } +function getArgValue(argv: string[], name: string): string | null { + const directPrefix = `${name}=`; + for (let index = 2; index < argv.length; index += 1) { + const value = argv[index]; + if (value === name) { + return argv[index + 1] ?? null; + } + if (value.startsWith(directPrefix)) { + return value.slice(directPrefix.length); + } + } + return null; +} + +function normalizeEndpoint(value: string | null | undefined): `/${string}` { + const trimmed = value?.trim(); + if (!trimmed) { + return DEFAULT_HTTP_ENDPOINT; + } + return (trimmed.startsWith('/') ? trimmed : `/${trimmed}`) as `/${string}`; +} + +function parsePort(value: string | null | undefined): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error(`Invalid agent-teams MCP HTTP port: ${value ?? ''}`); + } + return parsed; +} + +export function resolveStartOptions( + argv: string[] = process.argv, + env: NodeJS.ProcessEnv = process.env +): AgentTeamsMcpStartOptions { + const transport = + getArgValue(argv, '--transport') ?? + getArgValue(argv, '--transportType') ?? + env.AGENT_TEAMS_MCP_TRANSPORT ?? + STDIO_TRANSPORT; + + if (transport !== HTTP_TRANSPORT) { + return { transportType: STDIO_TRANSPORT }; + } + + return { + transportType: HTTP_TRANSPORT, + httpStream: { + host: + getArgValue(argv, '--host')?.trim() || + env.AGENT_TEAMS_MCP_HTTP_HOST?.trim() || + DEFAULT_HTTP_HOST, + port: parsePort(getArgValue(argv, '--port') ?? env.AGENT_TEAMS_MCP_HTTP_PORT), + endpoint: normalizeEndpoint(getArgValue(argv, '--endpoint') ?? env.AGENT_TEAMS_MCP_HTTP_ENDPOINT), + }, + }; +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const server = createServer(); - void server.start({ - transportType: 'stdio', - }); + void server.start(resolveStartOptions()); } diff --git a/mcp-server/test/startOptions.test.ts b/mcp-server/test/startOptions.test.ts new file mode 100644 index 00000000..867a1918 --- /dev/null +++ b/mcp-server/test/startOptions.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveStartOptions } from '../src/index'; + +describe('agent-teams MCP start options', () => { + it('defaults to stdio transport', () => { + expect(resolveStartOptions(['node', 'index.js'], {})).toEqual({ + transportType: 'stdio', + }); + }); + + it('resolves HTTP stream transport from CLI args', () => { + expect( + resolveStartOptions( + [ + 'node', + 'index.js', + '--transport', + 'httpStream', + '--host', + '127.0.0.1', + '--port', + '43123', + '--endpoint', + 'mcp', + ], + {} + ) + ).toEqual({ + transportType: 'httpStream', + httpStream: { + host: '127.0.0.1', + port: 43123, + endpoint: '/mcp', + }, + }); + }); + + it('resolves HTTP stream transport from environment', () => { + expect( + resolveStartOptions(['node', 'index.js'], { + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_PORT: '43124', + }) + ).toEqual({ + transportType: 'httpStream', + httpStream: { + host: '127.0.0.1', + port: 43124, + endpoint: '/mcp', + }, + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index e569293a..2fe0b20c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -133,6 +133,7 @@ import { import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; import { clearAutoResumeService } from './services/team/AutoResumeService'; +import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer'; import { LaunchIoGovernor } from './services/team/LaunchIoGovernor'; import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; import { @@ -381,23 +382,37 @@ async function createOpenCodeRuntimeAdapterRegistry( ); } try { - reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ - onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message), - }); - const mcpEntry = mcpLaunchSpec.args[0]; - if (mcpEntry) { - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); - } + reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...'); + const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...'); } catch (error) { logger.warn( - `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${ error instanceof Error ? error.message : String(error) }` ); } + if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { + try { + reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ + onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message), + }); + const mcpEntry = mcpLaunchSpec.args[0]; + if (mcpEntry) { + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); + } + } catch (error) { + logger.warn( + `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); const bridgeClient = new OpenCodeBridgeCommandClient({ @@ -2081,6 +2096,9 @@ async function shutdownServices(): Promise { () => cleanupOpenCodeHostsForLifecycle('shutdown'), 10_000 ); + await runShutdownStep('Agent Teams MCP HTTP server cleanup', () => + agentTeamsMcpHttpServer.stop() + ); await runShutdownStep('tracked CLI subprocess cleanup', () => killTrackedCliProcesses('SIGKILL') ); diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts new file mode 100644 index 00000000..e6149912 --- /dev/null +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -0,0 +1,212 @@ +import { spawnCli, killProcessTree } from '@main/utils/childProcess'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { type ChildProcess } from 'child_process'; +import http from 'http'; +import net from 'net'; + +import { type McpLaunchSpec, resolveAgentTeamsMcpLaunchSpec } from './TeamMcpConfigBuilder'; + +const logger = createLogger('Service:AgentTeamsMcpHttpServer'); +const MCP_HTTP_HOST = '127.0.0.1'; +const MCP_HTTP_ENDPOINT = '/mcp'; +const MCP_HTTP_READY_TIMEOUT_MS = 5_000; +const MCP_HTTP_READY_POLL_MS = 100; + +export interface AgentTeamsMcpHttpServerHandle { + url: string; + port: number; + pid: number | null; +} + +export interface AgentTeamsMcpHttpServerDeps { + resolveLaunchSpec?: () => Promise; + allocatePort?: () => Promise; + spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess; + waitForPort?: (host: string, port: number, timeoutMs: number) => Promise; +} + +async function allocateLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, MCP_HTTP_HOST, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate Agent Teams MCP HTTP port'))); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function isHealthReady(host: string, port: number): Promise { + return new Promise((resolve) => { + const request = http.get( + { + host, + port, + path: '/health', + timeout: MCP_HTTP_READY_POLL_MS, + }, + (response) => { + response.resume(); + resolve((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300); + } + ); + request.once('timeout', () => { + request.destroy(); + resolve(false); + }); + request.once('error', () => { + resolve(false); + }); + }); +} + +async function waitForLoopbackPort(host: string, port: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await isHealthReady(host, port)) { + return; + } + await sleep(MCP_HTTP_READY_POLL_MS); + } + throw new Error( + `Agent Teams MCP HTTP server did not become healthy at ${host}:${port} in ${timeoutMs}ms` + ); +} + +function defaultSpawnProcess( + command: string, + args: string[], + env: NodeJS.ProcessEnv +): ChildProcess { + return spawnCli(command, args, { + env, + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); +} + +function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] { + return [ + ...launchSpec.args, + '--transport', + 'httpStream', + '--host', + MCP_HTTP_HOST, + '--port', + String(port), + '--endpoint', + MCP_HTTP_ENDPOINT, + ]; +} + +export class AgentTeamsMcpHttpServer { + private startPromise: Promise | null = null; + private child: ChildProcess | null = null; + private handle: AgentTeamsMcpHttpServerHandle | null = null; + + constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} + + async ensureStarted(): Promise { + if (this.handle) { + return this.handle; + } + if (this.startPromise) { + return this.startPromise; + } + + this.startPromise = this.startOnce().finally(() => { + this.startPromise = null; + }); + return this.startPromise; + } + + async stop(): Promise { + const child = this.child; + this.child = null; + this.handle = null; + if (child) { + killProcessTree(child, 'SIGKILL'); + } + } + + private async startOnce(): Promise { + const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; + const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort; + const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess; + const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort; + const launchSpec = await resolveLaunchSpec(); + const port = await allocatePort(); + const args = buildHttpServerArgs(launchSpec, port); + const child = spawnProcess(launchSpec.command, args, { + ...process.env, + AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST, + AGENT_TEAMS_MCP_HTTP_PORT: String(port), + AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT, + }); + + const clearIfCurrent = (): void => { + if (this.child === child) { + this.child = null; + this.handle = null; + } + }; + child.once('exit', (code, signal) => { + clearIfCurrent(); + logger.warn( + `Agent Teams MCP HTTP server exited${typeof code === 'number' ? ` with code ${code}` : ''}${ + signal ? ` (${signal})` : '' + }` + ); + }); + child.once('error', (error) => { + clearIfCurrent(); + logger.warn( + `Agent Teams MCP HTTP server process error: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + child.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8').trim(); + if (text) { + logger.debug(`Agent Teams MCP HTTP stderr: ${text.slice(0, 1000)}`); + } + }); + + try { + await waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS); + } catch (error) { + killProcessTree(child, 'SIGKILL'); + throw error; + } + + this.child = child; + this.handle = { + url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`, + port, + pid: child.pid ?? null, + }; + logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`); + return this.handle; + } +} + +export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 51c5dd4e..833cb9a5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5996,6 +5996,10 @@ export class TeamProvisioningService { private toolApprovalSettingsByTeam = new Map(); private pendingTimeouts = new Map(); private inFlightResponses = new Set(); + private readonly prepareForProvisioningInFlight = new Map< + string, + Promise + >(); private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null; @@ -18064,6 +18068,74 @@ export class TeamProvisioningService { limitContext?: boolean; modelVerificationMode?: TeamProvisioningModelVerificationMode; } + ): Promise { + const inFlightKey = this.createPrepareForProvisioningInFlightKey(cwd, opts); + const inFlight = this.prepareForProvisioningInFlight.get(inFlightKey); + if (inFlight) { + return this.clonePrepareForProvisioningResult(await inFlight); + } + + const request = this.prepareForProvisioningOnce(cwd, opts).finally(() => { + if (this.prepareForProvisioningInFlight.get(inFlightKey) === request) { + this.prepareForProvisioningInFlight.delete(inFlightKey); + } + }); + this.prepareForProvisioningInFlight.set(inFlightKey, request); + return this.clonePrepareForProvisioningResult(await request); + } + + private createPrepareForProvisioningInFlightKey( + cwd?: string, + opts?: { + forceFresh?: boolean; + providerId?: TeamProviderId; + providerIds?: TeamProviderId[]; + modelIds?: string[]; + limitContext?: boolean; + modelVerificationMode?: TeamProvisioningModelVerificationMode; + } + ): string { + const providerIds = Array.from( + new Set( + [opts?.providerId, ...(opts?.providerIds ?? [])] + .map((providerId) => resolveTeamProviderId(providerId)) + .filter((providerId): providerId is TeamProviderId => Boolean(providerId)) + ) + ); + const modelIds = Array.from( + new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) + ); + return JSON.stringify({ + cwd: cwd?.trim() || process.cwd(), + forceFresh: opts?.forceFresh === true, + providerIds, + modelIds, + limitContext: opts?.limitContext === true, + modelVerificationMode: opts?.modelVerificationMode ?? null, + }); + } + + private clonePrepareForProvisioningResult( + result: TeamProvisioningPrepareResult + ): TeamProvisioningPrepareResult { + return { + ...result, + details: result.details ? [...result.details] : undefined, + warnings: result.warnings ? [...result.warnings] : undefined, + issues: result.issues?.map((issue) => ({ ...issue })), + }; + } + + private async prepareForProvisioningOnce( + cwd?: string, + opts?: { + forceFresh?: boolean; + providerId?: TeamProviderId; + providerIds?: TeamProviderId[]; + modelIds?: string[]; + limitContext?: boolean; + modelVerificationMode?: TeamProvisioningModelVerificationMode; + } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); await this.validatePrepareCwd(targetCwdForValidation); diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index cf06b3f0..3abb90ad 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -3,6 +3,7 @@ import { type RuntimeProcessTableRow, } from '@features/tmux-installer/main'; import { killProcessByPid } from '@main/utils/processKill'; +import { listWindowsProcessTable } from '@main/utils/windowsProcessTable'; import { execFile, type ExecFileException } from 'child_process'; export type OpenCodeManagedHostCleanupMode = 'orphaned' | 'force'; @@ -37,11 +38,15 @@ export interface OpenCodeManagedHostProcessCleanupOptions { sleepMs?: (ms: number) => Promise; } -const OPENCODE_SERVE_COMMAND_RE = /(^|[/\\\s])opencode(?:\.exe)?(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i; +const OPENCODE_SERVE_COMMAND_RE = + /(^|[/\\\s"])opencode(?:\.exe)?(?:"?)(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i; +const WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE = + /[\\/]runtimes[\\/]opencode[\\/]versions[\\/][^"'\s]+[\\/]opencode-windows-[^"'\s]+[\\/]opencode\.exe(?:"|\s|$)/i; const MANAGED_ENV_MARKERS = ['CLAUDE_MULTIMODEL_DATA_HOME=', 'OPENCODE_CONFIG_CONTENT='] as const; const MANAGED_ENV_IDENTITY_MARKERS = [ 'AGENT_TEAMS_MCP_CLAUDE_DIR=', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=', + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=', ] as const; export async function cleanupManagedOpenCodeServeProcesses( @@ -55,18 +60,18 @@ export async function cleanupManagedOpenCodeServeProcesses( diagnostics: [], }; - if (platform === 'win32') { - result.diagnostics.push( - 'Managed OpenCode serve process fallback cleanup is skipped on Windows.' - ); - return result; - } - - const rows = await (options.listProcessRows ?? listRuntimeProcessesForCurrentTmuxPlatform)(); + const rows = await ( + options.listProcessRows ?? + (platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform) + )(); const excludePids = options.excludePids ?? new Set(); const requiredDetailsMarkers = options.requiredDetailsMarkers ?? []; - const readDetails = options.readProcessDetails ?? readNativeProcessCommandWithEnv; - const readStartTimeMs = options.readProcessStartTimeMs ?? readNativeProcessStartTimeMs; + const readDetails = + options.readProcessDetails ?? + (platform === 'win32' ? async () => null : readNativeProcessCommandWithEnv); + const readStartTimeMs = + options.readProcessStartTimeMs ?? + (platform === 'win32' ? readWindowsProcessStartTimeMs : readNativeProcessStartTimeMs); const disposeServeHost = options.disposeServeHost ?? disposeOpenCodeServeHost; const killProcess = options.killProcess ?? killProcessByPid; const forceKillProcess = @@ -91,16 +96,23 @@ export async function cleanupManagedOpenCodeServeProcesses( } const details = await readDetails(row.pid); - if ( - !details || - !isManagedOpenCodeServeProcessDetails(details) || - !processDetailsIncludeMarkers(details, requiredDetailsMarkers) - ) { + const isManaged = + platform === 'win32' + ? isAppManagedWindowsOpenCodeServeCommand(row.command) || + Boolean(details && isManagedOpenCodeServeProcessDetails(details)) + : Boolean(details && isManagedOpenCodeServeProcessDetails(details)); + const hasRequiredDetailsMarkers = + requiredDetailsMarkers.length === 0 || + Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers)); + if (!isManaged || !hasRequiredDetailsMarkers) { result.candidates.push({ pid: row.pid, ppid: row.ppid, action: 'kept_unmanaged', - reason: 'process does not carry Agent Teams managed OpenCode environment markers', + reason: + platform === 'win32' + ? 'process is not an app-managed Windows OpenCode serve command' + : 'process does not carry Agent Teams managed OpenCode environment markers', }); continue; } @@ -122,7 +134,9 @@ export async function cleanupManagedOpenCodeServeProcesses( }); continue; } - if (row.ppid !== 1) { + const parentMayStillOwnProcess = + platform === 'win32' ? row.ppid > 0 && isProcessAlive(row.ppid) : row.ppid !== 1; + if (parentMayStillOwnProcess) { result.candidates.push({ pid: row.pid, ppid: row.ppid, @@ -177,6 +191,14 @@ export function isOpenCodeServeCommand(command: string): boolean { return OPENCODE_SERVE_COMMAND_RE.test(command.trim()); } +export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolean { + const normalizedCommand = command.trim().replace(/\//g, '\\'); + return ( + isOpenCodeServeCommand(normalizedCommand) && + WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE.test(normalizedCommand) + ); +} + export function isManagedOpenCodeServeProcessDetails(details: string): boolean { return ( processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) && @@ -251,6 +273,30 @@ async function readNativeProcessStartTimeMs(pid: number): Promise return Number.isFinite(parsed) ? parsed : null; } +async function readWindowsProcessStartTimeMs(pid: number): Promise { + const normalizedPid = Math.trunc(pid); + if (!Number.isFinite(normalizedPid) || normalizedPid <= 0) { + return null; + } + + const script = [ + '$ErrorActionPreference = "Stop"', + `$process = Get-Process -Id ${normalizedPid} -ErrorAction Stop`, + '$process.StartTime.ToUniversalTime().ToString("o")', + ].join('; '); + const output = await execFileText( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + 2_000, + 64 * 1024 + ); + if (!output) { + return null; + } + const parsed = Date.parse(output.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + function isNativeProcessAlive(pid: number): boolean { try { process.kill(pid, 0); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts new file mode 100644 index 00000000..0801ef07 --- /dev/null +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -0,0 +1,185 @@ +import { EventEmitter } from 'events'; +import http from 'http'; +import net from 'net'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + killProcessTreeMock: vi.fn(), + spawnCliMock: vi.fn(), +})); + +vi.mock('@main/utils/childProcess', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args), + spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args), + }; +}); + +import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer'; + +class FakeChildProcess extends EventEmitter { + pid = 43123; + stderr = new EventEmitter(); +} + +async function allocateLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate port'))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +describe('AgentTeamsMcpHttpServer', () => { + beforeEach(() => { + hoisted.killProcessTreeMock.mockReset(); + hoisted.spawnCliMock.mockReset(); + }); + + it('starts the MCP server over HTTP with hidden app-owned process env', async () => { + const child = new FakeChildProcess(); + const spawnProcess = vi.fn(() => child as any); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41001, + spawnProcess, + waitForPort: vi.fn(async () => undefined), + }); + + const handle = await server.ensureStarted(); + + expect(handle).toEqual({ + url: 'http://127.0.0.1:41001/mcp', + port: 41001, + pid: 43123, + }); + expect(spawnProcess).toHaveBeenCalledWith( + 'node', + [ + 'mcp-server/dist/index.js', + '--transport', + 'httpStream', + '--host', + '127.0.0.1', + '--port', + '41001', + '--endpoint', + '/mcp', + ], + expect.objectContaining({ + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_PORT: '41001', + AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp', + }) + ); + }); + + it('uses a hidden default spawn without holding stdout open', async () => { + const child = new FakeChildProcess(); + hoisted.spawnCliMock.mockReturnValue(child as any); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41005, + waitForPort: vi.fn(async () => undefined), + }); + + const handle = await server.ensureStarted(); + + expect(handle.pid).toBe(43123); + expect(hoisted.spawnCliMock).toHaveBeenCalledWith( + 'node', + [ + 'mcp-server/dist/index.js', + '--transport', + 'httpStream', + '--host', + '127.0.0.1', + '--port', + '41005', + '--endpoint', + '/mcp', + ], + expect.objectContaining({ + env: expect.objectContaining({ + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_PORT: '41005', + AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp', + }), + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }) + ); + }); + + it('coalesces concurrent starts', async () => { + const child = new FakeChildProcess(); + const spawnProcess = vi.fn(() => child as any); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41002, + spawnProcess, + waitForPort: async () => undefined, + }); + + const [first, second] = await Promise.all([server.ensureStarted(), server.ensureStarted()]); + + expect(first).toBe(second); + expect(spawnProcess).toHaveBeenCalledTimes(1); + }); + + it('waits for the HTTP health endpoint before marking the server ready', async () => { + const child = new FakeChildProcess(); + const port = await allocateLoopbackPort(); + let healthRequests = 0; + const healthServer = http.createServer((request, response) => { + if (request.url === '/health') { + healthRequests += 1; + response.writeHead(200, { 'content-type': 'text/plain' }); + response.end('ok'); + return; + } + response.writeHead(404); + response.end(); + }); + const spawnProcess = vi.fn((_command: string, args: string[]) => { + expect(args).toContain(String(port)); + healthServer.listen(port, '127.0.0.1'); + return child as any; + }); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => port, + spawnProcess, + }); + + try { + const handle = await server.ensureStarted(); + + expect(handle.url).toBe(`http://127.0.0.1:${port}/mcp`); + expect(healthRequests).toBeGreaterThan(0); + } finally { + await new Promise((resolve) => healthServer.close(() => resolve())); + } + }); +}); diff --git a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts index dd8d17a6..9561c40c 100644 --- a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts +++ b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { cleanupManagedOpenCodeServeProcesses, getOpenCodeServeLoopbackBaseUrl, + isAppManagedWindowsOpenCodeServeCommand, isManagedOpenCodeServeProcessDetails, isOpenCodeServeCommand, } from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup'; @@ -14,6 +15,12 @@ const MANAGED_DETAILS = [ 'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=/tmp/mcp-entry.js', ].join(' '); +const MANAGED_DETAILS_WITH_REMOTE_MCP = [ + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', + 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', + 'OPENCODE_CONFIG_CONTENT={}', + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=http://127.0.0.1:58461/mcp', +].join(' '); const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [ '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', @@ -34,8 +41,27 @@ describe('OpenCodeManagedHostProcessCleanup', () => { expect(isOpenCodeServeCommand('node mcp-server/src/index.ts')).toBe(false); }); + it('identifies app-managed Windows OpenCode serve commands', () => { + expect( + isAppManagedWindowsOpenCodeServeCommand( + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913' + ) + ).toBe(true); + expect( + isAppManagedWindowsOpenCodeServeCommand( + 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1 --port 49913' + ) + ).toBe(false); + expect( + isAppManagedWindowsOpenCodeServeCommand( + 'C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe auth login' + ) + ).toBe(false); + }); + it('requires Agent Teams managed environment markers', () => { expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true); + expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true); expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true); expect( isManagedOpenCodeServeProcessDetails( @@ -347,7 +373,62 @@ describe('OpenCodeManagedHostProcessCleanup', () => { ]); }); - it('skips fallback cleanup on Windows because environment markers are unavailable', async () => { + it('kills old orphaned app-managed Windows OpenCode serve processes', async () => { + const killProcess = vi.fn(); + const disposeServeHost = vi.fn(() => resolved(undefined)); + + const result = await cleanupManagedOpenCodeServeProcesses({ + mode: 'orphaned', + platform: 'win32', + startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'), + listProcessRows: () => + resolved([ + { + pid: 71628, + ppid: 86256, + command: + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913', + }, + ]), + readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')), + disposeServeHost, + isProcessAlive: () => false, + killProcess, + }); + + expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:49913'); + expect(killProcess).toHaveBeenCalledWith(71628); + expect(result.killed).toBe(1); + expect(result.scanned).toBe(1); + expect(result.diagnostics).toEqual([]); + }); + + it('keeps app-managed Windows OpenCode serve processes while their parent is still alive', async () => { + const killProcess = vi.fn(); + + const result = await cleanupManagedOpenCodeServeProcesses({ + mode: 'orphaned', + platform: 'win32', + startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'), + listProcessRows: () => + resolved([ + { + pid: 71628, + ppid: 86256, + command: + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913', + }, + ]), + readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')), + isProcessAlive: (pid) => pid === 86256, + killProcess, + }); + + expect(killProcess).not.toHaveBeenCalled(); + expect(result.candidates[0]).toMatchObject({ pid: 71628, action: 'kept_recent' }); + }); + + it('does not kill unmanaged Windows OpenCode serve commands', async () => { const killProcess = vi.fn(); const result = await cleanupManagedOpenCodeServeProcesses({ @@ -358,15 +439,15 @@ describe('OpenCodeManagedHostProcessCleanup', () => { { pid: 500, ppid: 1, - command: 'opencode.exe serve --hostname 127.0.0.1', + command: 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1', }, ]), - readProcessDetails: () => resolved(MANAGED_DETAILS), killProcess, }); expect(killProcess).not.toHaveBeenCalled(); - expect(result.scanned).toBe(0); - expect(result.diagnostics[0]).toContain('skipped on Windows'); + expect(result.scanned).toBe(1); + expect(result.diagnostics).toEqual([]); + expect(result.candidates[0]).toMatchObject({ pid: 500, action: 'kept_unmanaged' }); }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 52f10712..83647c8a 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -661,6 +661,82 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => { + const prepareGate: { release?: () => void } = {}; + const prepare = vi.fn( + async () => + new Promise<{ + ok: true; + providerId: 'opencode'; + modelId: null; + diagnostics: string[]; + warnings: string[]; + }>((resolve) => { + prepareGate.release = () => + resolve({ + ok: true, + providerId: 'opencode', + modelId: null, + diagnostics: [], + warnings: [], + }); + }) + ); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare, + getLastOpenCodeTeamLaunchReadiness: vi.fn(() => ({ + state: 'ready', + launchAllowed: true, + modelId: 'opencode/big-pickle', + availableModels: ['opencode/big-pickle'], + opencodeVersion: '1.0.0', + installMethod: 'unknown', + binaryPath: 'opencode', + hostHealthy: true, + appMcpConnected: true, + requiredToolsPresent: true, + permissionBridgeReady: true, + issues: [], + warnings: [], + diagnostics: [], + })), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(registry); + const opts = { + providerId: 'opencode' as const, + forceFresh: true, + modelIds: ['opencode/big-pickle'], + modelVerificationMode: 'compatibility' as const, + }; + + const first = svc.prepareForProvisioning(tempRoot, opts); + const second = svc.prepareForProvisioning(tempRoot, opts); + + for (let attempt = 0; attempt < 20 && prepare.mock.calls.length === 0; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepareGate.release).toBeTypeOf('function'); + prepareGate.release?.(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(prepare).toHaveBeenCalledTimes(1); + expect(firstResult).not.toBe(secondResult); + expect(firstResult.ready).toBe(true); + expect(secondResult.ready).toBe(true); + expect(firstResult.details).toContain( + 'Selected model opencode/big-pickle is compatible. Deep verification pending.' + ); + }); + it('checks every selected OpenCode model instead of only the first one', async () => { const prepare = vi.fn(async (input: { model?: string }) => { if (input.model === 'opencode/nemotron-3-super-free') {