fix: make Windows agent launches independent of tmux
This commit is contained in:
parent
8c84720246
commit
5a8a934d8d
15 changed files with 375 additions and 46 deletions
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||
const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/');
|
||||
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
||||
const config: {
|
||||
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||
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<void> {
|
||||
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<boolean>,
|
||||
timeoutMs: number,
|
||||
pollMs = 1_000
|
||||
pollMs = 1_000,
|
||||
describeState?: () => string
|
||||
): Promise<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<string, string> } | undefined {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<string, { command?: string; args?: string[] }>;
|
||||
mcpServers?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }>;
|
||||
};
|
||||
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-'));
|
||||
|
|
|
|||
179
test/main/utils/AgentCliLaunch.live-e2e.test.ts
Normal file
179
test/main/utils/AgentCliLaunch.live-e2e.test.ts
Normal file
|
|
@ -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<string | null>;
|
||||
};
|
||||
|
||||
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<string> {
|
||||
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<string> {
|
||||
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'),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue