diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts index 1f232e6a..ab765ce7 100644 --- a/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts @@ -67,7 +67,7 @@ function buildManualHints(platform: TmuxPlatform): TmuxInstallHint[] { }, { title: 'Install Ubuntu', - description: 'Recommended WSL distro for the tmux runtime path.', + description: 'Recommended WSL distro for optional tmux pane transport.', command: 'wsl --install -d Ubuntu --no-launch', }, { diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts index b6fe3d0c..78a674a4 100644 --- a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts @@ -24,7 +24,7 @@ export function buildTmuxEffectiveAvailability( binaryPath: input.wsl.tmuxBinaryPath, runtimeReady: false, detail: - 'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.', + 'tmux is available inside WSL. On Windows it is optional and is not required for teammate runtime startup.', }; } @@ -36,7 +36,7 @@ export function buildTmuxEffectiveAvailability( binaryPath: input.host.binaryPath, runtimeReady: false, detail: - 'tmux was found on Windows, but the app currently relies on a WSL-backed tmux runtime for the most reliable teammate path.', + 'tmux was found on Windows. Native process teammates do not require it; tmux remains optional for pane-based terminal transport.', }; } @@ -49,7 +49,7 @@ export function buildTmuxEffectiveAvailability( runtimeReady: false, detail: input.wsl?.statusDetail ?? - 'You can keep using the app, but Windows needs WSL before tmux can improve teammate reliability.', + 'You can keep using the app without tmux. Install WSL only if you want optional tmux pane transport.', }; } @@ -61,7 +61,7 @@ export function buildTmuxEffectiveAvailability( runtimeReady: false, detail: input.wsl?.statusDetail ?? - 'WSL is available, but tmux is not ready there yet. Finish the Linux setup, install tmux, then re-check.', + 'WSL is available, but tmux is not ready there yet. Install tmux only if you want optional pane transport.', }; } @@ -72,7 +72,7 @@ export function buildTmuxEffectiveAvailability( version: input.host.version, binaryPath: input.host.binaryPath, runtimeReady: input.nativeSupported, - detail: 'tmux is available for the persistent teammate runtime.', + detail: 'tmux is available as an optional pane transport for teammate sessions.', }; } @@ -84,9 +84,9 @@ export function buildTmuxEffectiveAvailability( runtimeReady: false, detail: input.platform === 'darwin' - ? 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.' + ? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.' : input.platform === 'linux' - ? 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.' - : 'You can keep using the app, but tmux improves persistent teammate reliability.', + ? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.' + : 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.', }; } diff --git a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts index cadabecd..14d1ae4d 100644 --- a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -177,7 +177,7 @@ export class TmuxInstallerRunnerAdapter strategy: 'wsl', message: 'Preparing the Windows WSL tmux setup...', detail: - 'The app can keep working without tmux, but WSL-backed tmux gives the most reliable persistent teammate path on Windows.', + 'The app can keep working without tmux. WSL-backed tmux is optional and only adds pane-based terminal transport on Windows.', error: null, canCancel: true, acceptsInput: false, diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts index ab99dc20..4ba5607b 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts @@ -171,22 +171,22 @@ export class TmuxInstallStrategyResolver { if (input.effective.available) { return input.effective.location === 'wsl' ? 'tmux is available inside WSL on Windows.' - : 'tmux is available for persistent teammate runtime.'; + : 'tmux is available as an optional pane transport for teammate sessions.'; } if (input.platform === 'darwin') { - return 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.'; + return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'; } if (input.platform === 'linux') { - return 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.'; + return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'; } if (input.platform === 'win32') { return ( input.wsl?.statusDetail ?? - 'You can keep using the app, but tmux on Windows goes through WSL for the best teammate experience.' + 'You can keep using the app without tmux. On Windows, tmux setup uses WSL and is only needed for optional pane transport.' ); } - return 'You can keep using the app, but tmux improves persistent teammate reliability.'; + return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'; } #buildCommand( @@ -329,7 +329,7 @@ export class TmuxInstallStrategyResolver { if (status.wslInstalled && !status.distroName) { this.#prependUniqueHint(manualHints, { title: 'Install Ubuntu', - description: 'Recommended WSL distro for the tmux runtime path.', + description: 'Recommended WSL distro for optional tmux pane transport.', command: 'wsl --install -d Ubuntu --no-launch', }); } diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index c989089e..79b10973 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -83,14 +83,14 @@ export class TmuxInstallerBannerAdapter { snapshot.message ?? status?.effective.detail ?? status?.wsl?.statusDetail ?? - 'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.'; + 'tmux is optional. Install it only if you want pane-based terminal transport for long-running teammate sessions.'; const benefitsBody = status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null; const runtimeReadyLabel = status ? status.effective.runtimeReady - ? 'Ready for persistent teammates' + ? 'Pane transport ready' : status.effective.available - ? 'Installed, but not active yet' + ? 'Installed, optional transport inactive' : null : null; const versionLabel = diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index e3d5af09..0d3ab57e 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -20,7 +20,7 @@ const baseStatus: TmuxStatus = { version: null, binaryPath: null, runtimeReady: false, - detail: 'tmux improves persistent teammate reliability.', + detail: 'tmux is optional. Install it only if you want pane transport.', }, error: null, autoInstall: { @@ -72,9 +72,9 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.progressPercent).toBeNull(); expect(result.manualHints).toHaveLength(1); expect(result.manualHintsCollapsible).toBe(false); - expect(result.body).toContain('persistent teammate reliability'); - expect(result.benefitsBody).toContain('Optional, but recommended'); - expect(result.benefitsBody).toContain('multi-agent teams that mix providers'); + expect(result.body).toContain('tmux is optional'); + expect(result.benefitsBody).toContain('Optional'); + expect(result.benefitsBody).toContain('pane-based terminal transport'); expect(result.installButtonPrimary).toBe(true); expect(result.showRefreshButton).toBe(true); }); @@ -102,7 +102,7 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.title).toBe('Installing tmux'); expect(result.body).toBe('Renderer bridge failed'); - expect(result.benefitsBody).toContain('Optional, but recommended'); + expect(result.benefitsBody).toContain('Optional'); expect(result.error).toBe('Renderer bridge failed'); expect(result.installDisabled).toBe(true); expect(result.canCancel).toBe(true); @@ -171,7 +171,7 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install'); expect(result.progressPercent).toBe(82); expect(result.manualHintsCollapsible).toBe(true); - expect(result.benefitsBody).toContain('With tmux in WSL'); + expect(result.benefitsBody).toContain('WSL-backed tmux'); expect(result.showRefreshButton).toBe(true); }); @@ -188,7 +188,7 @@ describe('TmuxInstallerBannerAdapter', () => { version: 'tmux 3.4', binaryPath: 'C:\\tmux.exe', runtimeReady: false, - detail: 'tmux was found on Windows, but WSL-backed tmux is still preferred.', + detail: 'tmux was found on Windows. Native process teammates do not require it.', }, }, snapshot: idleSnapshot, @@ -199,7 +199,7 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.visible).toBe(false); expect(result.locationLabel).toBe('Host runtime'); - expect(result.runtimeReadyLabel).toBe('Installed, but not active yet'); + expect(result.runtimeReadyLabel).toBe('Installed, optional transport inactive'); expect(result.versionLabel).toBe('tmux 3.4'); expect(result.benefitsBody).toBeNull(); }); @@ -216,7 +216,7 @@ describe('TmuxInstallerBannerAdapter', () => { version: 'tmux 3.6a', binaryPath: '/opt/homebrew/bin/tmux', runtimeReady: true, - detail: 'tmux is available for persistent teammates.', + detail: 'tmux is available as an optional pane transport.', }, }, snapshot: { diff --git a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx index 8f6cc321..2ec3595c 100644 --- a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx +++ b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx @@ -25,7 +25,7 @@ const baseStatus: TmuxStatus = { version: null, binaryPath: null, runtimeReady: false, - detail: 'tmux improves persistent teammate reliability.', + detail: 'tmux is optional. Install it only if you want pane transport.', }, error: null, autoInstall: { diff --git a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx index 5766f8f1..c477700d 100644 --- a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx +++ b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx @@ -21,7 +21,7 @@ const baseViewModel: TmuxInstallerBannerViewModel = { title: 'tmux is not installed', body: 'WSL is available, but no Linux distribution is installed yet.', benefitsBody: - 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable. Without tmux, creating multi-agent teams that mix providers may be blocked.', + 'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.', error: null, platformLabel: 'Windows', locationLabel: null, @@ -94,8 +94,8 @@ describe('TmuxInstallerBannerView', () => { const { host, root } = renderBanner(baseViewModel); expect(host.textContent).toContain('tmux is not installed'); - expect(host.textContent).toContain('Optional, but recommended'); - expect(host.textContent).toContain('multi-agent teams that mix providers'); + expect(host.textContent).toContain('Optional'); + expect(host.textContent).toContain('pane-based terminal transport'); expect(host.textContent).not.toContain( 'WSL is available, but no Linux distribution is installed yet.' ); diff --git a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts index ca0d4f69..02d4eea6 100644 --- a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts +++ b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts @@ -67,12 +67,9 @@ export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): strin return null; } - const mixedProviderLimit = - 'Without tmux, creating multi-agent teams that mix providers may be blocked.'; - if (platform === 'win32') { - return `Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`; + return 'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.'; } - return `Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`; + return 'Optional. The app works without tmux. Install tmux only if you want pane-based terminal transport for long-running teammate sessions.'; } diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 6f91ea84..f4d7ebd4 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,4 +1,8 @@ -import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder'; +import { + getClaudeBasePath, + getMcpConfigsBasePath, + getMcpServerBasePath, +} from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; import { randomUUID } from 'crypto'; @@ -13,6 +17,7 @@ export interface McpLaunchSpec { } const MCP_SERVER_NAME = 'agent-teams'; +const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; @@ -273,6 +278,9 @@ export class TeamMcpConfigBuilder { [MCP_SERVER_NAME]: { command: launchSpec.command, args: launchSpec.args, + env: { + [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), + }, }, }; diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 58dd66f2..6bc72d94 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -137,7 +137,7 @@ function resolveGeneratedBunLauncher( } function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null { - const scriptMatch = /"%_prog%"\s+"([^"]+\.(?:cjs|mjs|js))"\s+%\*/i.exec(content); + const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content); const scriptTemplate = scriptMatch?.[1]; if (!scriptTemplate) { return null; diff --git a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts index 786306e4..1c05b0e7 100644 --- a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts +++ b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts @@ -29,6 +29,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => { let previousDisableRuntimeBootstrap: string | undefined; let previousHome: string | undefined; let previousUserProfile: string | undefined; + let previousNodeEnv: string | undefined; let svc: TeamProvisioningService | null; let teamName: string | null; @@ -45,8 +46,10 @@ liveDescribe('Anthropic runtime memory live e2e', () => { previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousHome = process.env.HOME; previousUserProfile = process.env.USERPROFILE; + previousNodeEnv = process.env.NODE_ENV; process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; + process.env.NODE_ENV = 'production'; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; @@ -67,7 +70,13 @@ liveDescribe('Anthropic runtime memory live e2e', () => { restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); restoreEnv('HOME', previousHome); restoreEnv('USERPROFILE', previousUserProfile); - await fs.rm(tempDir, { recursive: true, force: true }); + restoreEnv('NODE_ENV', previousNodeEnv); + if (process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE_KEEP_TEMP === '1') { + // Live-debug only: preserve process/runtime logs for failed Windows liveness triage. + process.stderr.write(`Preserving Anthropic runtime memory live temp dir: ${tempDir}\n`); + return; + } + await removeTempDirWithRetries(tempDir); }); it('creates a real Anthropic team and reports teammate RSS in the runtime snapshot', async () => { @@ -79,6 +88,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => { teamName = `anthropic-memory-live-${Date.now()}`; const projectPath = path.join(tempDir, 'project'); await fs.mkdir(projectPath, { recursive: true }); + await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); await fs.writeFile( path.join(projectPath, 'README.md'), '# Anthropic runtime memory live e2e\n', @@ -133,7 +143,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => { typeof alice.rssBytes === 'number' && alice.rssBytes > 0 ); - }, 60_000); + }, 180_000, 1_000, () => JSON.stringify(snapshot, null, 2)); expect(snapshot!.members.alice).toMatchObject({ alive: true, @@ -158,10 +168,53 @@ async function assertExecutable(filePath: string): Promise { await fs.access(filePath, fsConstants.X_OK); } +async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { + const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/'); + const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20); + const config: { + projects: Record; + customApiKeyResponses?: { approved: string[]; rejected: string[] }; + } = { + projects: { + [normalizedProjectPath]: { + hasTrustDialogAccepted: true, + }, + }, + }; + if (approvedApiKeySuffix) { + config.customApiKeyResponses = { + approved: [approvedApiKeySuffix], + rejected: [], + }; + } + await fs.writeFile( + path.join(configDir, '.claude.json'), + `${JSON.stringify(config, null, 2)}\n`, + 'utf8' + ); +} + +async function removeTempDirWithRetries(dirPath: string): Promise { + const attempts = process.platform === 'win32' ? 20 : 1; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await fs.rm(dirPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} + async function waitUntil( predicate: () => Promise, timeoutMs: number, - pollMs = 1_000 + pollMs = 1_000, + describeState?: () => string ): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; @@ -178,7 +231,8 @@ async function waitUntil( } const suffix = lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : ''; - throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}`); + const state = describeState ? ` Last state: ${describeState()}` : ''; + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${state}`); } function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string { diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 22e70f16..91f68eaa 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -45,7 +45,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }; }); -import { setAppDataBasePath } from '@main/utils/pathDecoder'; +import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; describe('TeamMcpConfigBuilder', () => { @@ -77,10 +77,10 @@ describe('TeamMcpConfigBuilder', () => { function readGeneratedServer( configPath: string - ): { command?: string; args?: string[] } | undefined { + ): { command?: string; args?: string[]; env?: Record } | undefined { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { - mcpServers?: Record; + mcpServers?: Record }>; }; return parsed.mcpServers?.['agent-teams']; } @@ -180,6 +180,7 @@ describe('TeamMcpConfigBuilder', () => { afterEach(() => { setAppDataBasePath(null); + setClaudeBasePathOverride(null); setPackagedMode(false); setResourcesPath(originalResourcesPath); moduleInternal._load = originalModuleLoad; @@ -370,6 +371,20 @@ describe('TeamMcpConfigBuilder', () => { expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry); }); + it('passes the configured Claude root to the MCP server', async () => { + const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-')); + createdDirs.push(claudeRoot); + setClaudeBasePathOverride(claudeRoot); + + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.env).toMatchObject({ + AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot, + }); + }); + it('ignores malformed user MCP file', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); diff --git a/test/main/utils/AgentCliLaunch.live-e2e.test.ts b/test/main/utils/AgentCliLaunch.live-e2e.test.ts new file mode 100644 index 00000000..582a5d5b --- /dev/null +++ b/test/main/utils/AgentCliLaunch.live-e2e.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; + +import { describe, expect, it } from 'vitest'; + +import { CodexBinaryResolver } from '@main/services/infrastructure/codexAppServer/CodexBinaryResolver'; +import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; + +const execFileAsync = promisify(execFile); +const liveDescribe = process.env.AGENT_CLI_LAUNCH_LIVE_E2E === '1' ? describe : describe.skip; +const CLI_LAUNCH_TIMEOUT_MS = 15_000; + +type AgentCliProvider = 'opencode' | 'codex' | 'claude'; + +type AgentCliSpec = { + providerId: AgentCliProvider; + command: string; + overrideEnv: string; + versionPattern: RegExp; + resolver?: () => Promise; +}; + +const AGENT_CLI_SPECS: AgentCliSpec[] = [ + { + providerId: 'opencode', + command: 'opencode', + overrideEnv: 'OPENCODE_CLI_PATH', + versionPattern: /\b\d+\.\d+\.\d+\b/, + }, + { + providerId: 'codex', + command: 'codex', + overrideEnv: 'CODEX_CLI_PATH', + versionPattern: /\b(?:codex-cli\s+)?\d+\.\d+\.\d+\b/i, + resolver: () => CodexBinaryResolver.resolve(), + }, + { + providerId: 'claude', + command: 'claude', + overrideEnv: 'CLAUDE_CLI_PATH', + versionPattern: /\b\d+\.\d+\.\d+\b.*Claude Code/i, + }, +]; + +liveDescribe('agent CLI launch live e2e', () => { + it.each(AGENT_CLI_SPECS)( + 'resolves and executes $providerId through execCli without tmux', + async (spec) => { + const binaryPath = await resolveCliBinary(spec); + expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy(); + + const result = await execCli(binaryPath, ['--version'], { + timeout: CLI_LAUNCH_TIMEOUT_MS, + windowsHide: true, + }); + const output = `${result.stdout}\n${result.stderr}`.trim(); + + expect(output).toMatch(spec.versionPattern); + expect(output).not.toMatch(/tmux/i); + expect(output).not.toMatch(/running scripts is disabled/i); + expect(output).not.toMatch(/not digitally signed/i); + }, + CLI_LAUNCH_TIMEOUT_MS + 5_000 + ); + + it.each(AGENT_CLI_SPECS)( + 'spawns $providerId through spawnCli and exits cleanly without tmux', + async (spec) => { + const binaryPath = await resolveCliBinary(spec); + expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy(); + + const result = await spawnAndCollect(binaryPath, ['--version']); + const output = `${result.stdout}\n${result.stderr}`.trim(); + + expect(result.exitCode).toBe(0); + expect(output).toMatch(spec.versionPattern); + expect(output).not.toMatch(/tmux/i); + expect(output).not.toMatch(/running scripts is disabled/i); + expect(output).not.toMatch(/not digitally signed/i); + }, + CLI_LAUNCH_TIMEOUT_MS + 5_000 + ); +}); + +async function resolveCliBinary(spec: AgentCliSpec): Promise { + const override = process.env[spec.overrideEnv]?.trim(); + if (override) { + return override; + } + + if (spec.resolver) { + const resolved = await spec.resolver(); + if (resolved) { + return preferWindowsCmdShim(resolved); + } + } + + return preferWindowsCmdShim(await resolveCommandFromPath(spec.command)); +} + +async function resolveCommandFromPath(command: string): Promise { + if (process.platform === 'win32') { + const { stdout } = await execFileAsync('where.exe', [command], { + timeout: CLI_LAUNCH_TIMEOUT_MS, + windowsHide: true, + }); + const candidates = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const cmdCandidate = candidates.find((candidate) => /\.cmd$/i.test(candidate)); + return cmdCandidate ?? candidates[0] ?? command; + } + + const { stdout } = await execFileAsync('which', [command], { + timeout: CLI_LAUNCH_TIMEOUT_MS, + }); + return stdout.trim().split(/\r?\n/)[0] ?? command; +} + +function preferWindowsCmdShim(binaryPath: string): string { + if (process.platform !== 'win32') { + return binaryPath; + } + + const extension = path.extname(binaryPath).toLowerCase(); + if (extension === '.cmd') { + return binaryPath; + } + + const cmdPeer = extension ? `${binaryPath.slice(0, -extension.length)}.cmd` : `${binaryPath}.cmd`; + return fs.existsSync(cmdPeer) ? cmdPeer : binaryPath; +} + +function spawnAndCollect( + binaryPath: string, + args: string[] +): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawnCli(binaryPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + killProcessTree(child, 'SIGKILL'); + reject(new Error(`Timed out launching ${binaryPath}`)); + } + }, CLI_LAUNCH_TIMEOUT_MS); + + child.stdout?.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr?.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.once('error', (error) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + reject(error); + } + }); + child.once('close', (exitCode) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + resolve({ + exitCode, + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + }); + } + }); + }); +} diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index feeb50bc..14f5982c 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -64,6 +64,41 @@ function createGeneratedBunLauncher(): { dir: string; launcher: string; target: return { dir, launcher, target }; } +function createExtensionlessNpmNodeLauncher(): { + dir: string; + launcher: string; + target: string; +} { + const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-npm-launcher-')); + const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin'); + mkdirSync(targetDir, { recursive: true }); + const target = path.join(targetDir, 'opencode'); + writeFileSync(target, 'console.log("ok")', 'utf8'); + const launcher = path.join(dir, 'opencode.cmd'); + writeFileSync( + launcher, + [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ') ELSE (', + ' SET "_prog=node"', + ')', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode" %*', + '', + ].join('\r\n'), + 'utf8' + ); + return { dir, launcher, target }; +} + describe('cli child process helpers', () => { beforeEach(() => { vi.resetAllMocks(); @@ -152,6 +187,24 @@ describe('cli child process helpers', () => { } }); + it('runs extensionless npm node cmd launchers directly', () => { + setPlatform('win32'); + const fake = {} as any; + const spawnMock = child.spawn as unknown as Mock; + spawnMock.mockReturnValue(fake); + const { dir, launcher, target } = createExtensionlessNpmNodeLauncher(); + try { + const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe('node'); + expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']); + expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell'); + expect(result).toBe(fake); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('uses shell directly when path contains non-ASCII on windows', () => { setPlatform('win32'); const fake = {} as any; @@ -281,6 +334,29 @@ describe('cli child process helpers', () => { } }); + it('executes extensionless npm node cmd launchers directly', async () => { + setPlatform('win32'); + const execFileMock = child.execFile as unknown as Mock; + const execMock = child.exec as unknown as Mock; + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => { + cb(null, 'ok', ''); + return {} as any; + } + ); + const { dir, launcher, target } = createExtensionlessNpmNodeLauncher(); + try { + const result = await execCli(launcher, ['--model', 'test%PATH%"arg']); + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(execFileMock.mock.calls[0][0]).toBe('node'); + expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']); + expect(execMock).not.toHaveBeenCalled(); + expect(result.stdout).toBe('ok'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('skips straight to shell when path contains non-ASCII on windows', async () => { setPlatform('win32'); const execFileMock = child.execFile as unknown as Mock;