import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { spawnCli } from '@main/utils/childProcess'; import { setAppDataBasePath } from '@main/utils/pathDecoder'; import { EventEmitter } from 'events'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ paths: { claudeRoot: '', teamsBase: '', tasksBase: '', }, })); let tempClaudeRoot = ''; let tempTeamsBase = ''; let tempTasksBase = ''; vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: vi.fn() }, })); vi.mock('@main/utils/childProcess', () => ({ execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { if (args[0] === '-e' && args[1]?.includes('process.execPath')) { return { stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }), stderr: '', }; } if (args.includes('model') && args.includes('list')) { return { stdout: JSON.stringify({ schemaVersion: 1, providers: { codex: { defaultModel: 'gpt-5.4', models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }], }, }, }), stderr: '', }; } if (args.includes('runtime') && args.includes('status')) { return { stdout: JSON.stringify({ providers: { codex: { runtimeCapabilities: { modelCatalog: { dynamic: false, source: 'runtime' }, reasoningEffort: { supported: true, values: ['low', 'medium', 'high'], configPassthrough: false, }, }, }, }, }), stderr: '', }; } return { stdout: '', stderr: '' }; }), spawnCli: vi.fn(), killProcessTree: vi.fn(), })); vi.mock('@main/utils/shellEnv', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getCachedShellEnv: () => ({ PATH: process.env.PATH ?? '', HOME: hoisted.paths.claudeRoot }), getShellPreferredHome: () => hoisted.paths.claudeRoot || actual.getShellPreferredHome(), resolveInteractiveShellEnv: vi.fn(async () => ({ PATH: process.env.PATH ?? '', HOME: hoisted.paths.claudeRoot, })), }; }); vi.mock('@main/utils/pathDecoder', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot, getClaudeBasePath: () => hoisted.paths.claudeRoot, getTeamsBasePath: () => hoisted.paths.teamsBase, getTasksBasePath: () => hoisted.paths.tasksBase, }; }); type BootstrapSpec = { members?: Array<{ name?: string; provider?: string; model?: string; mcpConfigPath?: string; mcpSettingSources?: string; strictMcpConfig?: boolean; }>; }; type BootstrapSpecMember = NonNullable[number]; type ProvisioningServiceOverrides = { buildProvisioningEnv: () => Promise<{ env: Record; authSource: string; geminiRuntimeAuth: null; providerArgs: string[]; }>; resolveProviderDefaultModel: () => Promise; normalizeTeamConfigForLaunch: () => Promise; updateConfigProjectPath: () => Promise; restorePrelaunchConfig: () => Promise; assertConfigLeadOnlyForLaunch: () => Promise; persistLaunchStateSnapshot: () => Promise; validateAgentTeamsMcpRuntime: () => Promise; pathExists: () => Promise; startFilesystemMonitor: () => void; }; function createFakeChild() { const child = Object.assign(new EventEmitter(), { pid: 12345, stdin: Object.assign(new EventEmitter(), { writable: true, write: vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => { if (typeof cb === 'function') cb(null); return true; }), end: vi.fn(), on: vi.fn(), unref: vi.fn(), }), stdout: Object.assign(new EventEmitter(), { pipe: vi.fn(), unref: vi.fn(), }), stderr: Object.assign(new EventEmitter(), { pipe: vi.fn(), unref: vi.fn(), }), kill: vi.fn(), unref: vi.fn(), }); return child; } function extractBootstrapSpec(): BootstrapSpec { const args = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[] | undefined; const specFlagIndex = args?.indexOf('--team-bootstrap-spec') ?? -1; const specPath = specFlagIndex >= 0 ? args?.[specFlagIndex + 1] : null; if (!specPath) { throw new Error('Failed to extract bootstrap spec path from spawn args'); } return JSON.parse(fs.readFileSync(specPath, 'utf8')) as BootstrapSpec; } function extractMcpConfigPathFromArgs(args: string[]): string { const mcpFlagIndex = args.indexOf('--mcp-config'); if (mcpFlagIndex < 0 || !args[mcpFlagIndex + 1]) { throw new Error('Failed to extract MCP config path from launch args'); } return args[mcpFlagIndex + 1]; } function configureLaunchStubs(svc: TeamProvisioningService): void { const overrides = svc as unknown as ProvisioningServiceOverrides; overrides.buildProvisioningEnv = vi.fn(async () => ({ env: { PATH: '/usr/bin', CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43123' }, authSource: 'codex_runtime', geminiRuntimeAuth: null, providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], })); overrides.resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); overrides.normalizeTeamConfigForLaunch = vi.fn(async () => {}); overrides.updateConfigProjectPath = vi.fn(async () => {}); overrides.restorePrelaunchConfig = vi.fn(async () => {}); overrides.assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); overrides.persistLaunchStateSnapshot = vi.fn(async () => {}); overrides.validateAgentTeamsMcpRuntime = vi.fn(async () => {}); overrides.pathExists = vi.fn(async () => false); overrides.startFilesystemMonitor = vi.fn(); } function writeProjectMcpConfig(projectDir: string): void { fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { tavily: { command: 'node', args: ['tavily.js'] }, 'brave-real-browser': { command: 'node', args: ['brave.js'] }, }, }), 'utf8' ); } function expectAppOnlyMcpConfigPath(mcpConfigPath: string): void { const memberMcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')) as { mcpServers: Record< string, { command?: string; args?: string[]; enabled?: boolean; env?: Record } >; }; expect(Object.keys(memberMcpConfig.mcpServers)).toEqual(['agent-teams']); expect(memberMcpConfig.mcpServers['agent-teams']).toMatchObject({ enabled: true, env: { AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43123', }, }); expect(memberMcpConfig.mcpServers.tavily).toBeUndefined(); expect(memberMcpConfig.mcpServers['brave-real-browser']).toBeUndefined(); } function expectAppOnlyMemberMcpConfig(member: BootstrapSpecMember | undefined): void { expect(member?.mcpConfigPath).toEqual(expect.any(String)); expectAppOnlyMcpConfigPath(member?.mcpConfigPath ?? ''); } async function expectPathRemovedEventually(targetPath: string): Promise { for (let attempt = 0; attempt < 20; attempt += 1) { if (!fs.existsSync(targetPath)) { return; } await new Promise((resolve) => setTimeout(resolve, 25)); } expect(fs.existsSync(targetPath)).toBe(false); } function writeCodexTeamWithAppOnlyMeta(teamName: string): void { const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), JSON.stringify({ name: teamName, members: [ { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' }, ], }), 'utf8' ); fs.writeFileSync( path.join(teamDir, 'members.meta.json'), JSON.stringify({ version: 1, providerBackendId: 'codex-native', members: [ { name: 'alice', role: 'developer', providerId: 'codex', model: 'gpt-5.4', mcpPolicy: { mode: 'appOnly' }, }, ], }), 'utf8' ); } describe('TeamProvisioningService member MCP config safe e2e', () => { beforeEach(() => { vi.clearAllMocks(); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude team member mcp-')); tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTasksBase = path.join(tempClaudeRoot, 'tasks'); hoisted.paths.claudeRoot = tempClaudeRoot; hoisted.paths.teamsBase = tempTeamsBase; hoisted.paths.tasksBase = tempTasksBase; setAppDataBasePath(tempClaudeRoot); fs.mkdirSync(tempTeamsBase, { recursive: true }); fs.mkdirSync(tempTasksBase, { recursive: true }); }); afterEach(() => { setAppDataBasePath(null); fs.rmSync(tempClaudeRoot, { recursive: true, force: true }); }); it('createTeam preserves request Agent Teams MCP only in the real member MCP config', async () => { const teamName = 'codex-app-only-create'; const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex app-only project-')); writeProjectMcpConfig(projectDir); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never); const svc = new TeamProvisioningService(); configureLaunchStubs(svc); svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123'); let runId: string | undefined; try { const created = await svc.createTeam( { teamName, cwd: projectDir, providerId: 'codex', providerBackendId: 'codex-native', model: 'gpt-5.4', members: [ { name: 'alice', role: 'developer', providerId: 'codex', model: 'gpt-5.4', mcpPolicy: { mode: 'appOnly' }, }, ], }, () => {} ); runId = created.runId; const member = extractBootstrapSpec().members?.[0]; expect(member).toEqual( expect.objectContaining({ name: 'alice', provider: 'codex', model: 'gpt-5.4', mcpSettingSources: 'user,project,local', strictMcpConfig: true, }) ); expectAppOnlyMemberMcpConfig(member); const memberMcpConfigPath = member?.mcpConfigPath ?? ''; await svc.cancelProvisioning(runId); runId = undefined; await expectPathRemovedEventually(memberMcpConfigPath); } finally { if (runId) { await svc.cancelProvisioning(runId); } fs.rmSync(projectDir, { recursive: true, force: true }); } }); it('launchTeam preserves members.meta Agent Teams MCP only in the real member MCP config', async () => { const teamName = 'codex-app-only-meta-launch'; const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-app-only-project-')); writeCodexTeamWithAppOnlyMeta(teamName); writeProjectMcpConfig(projectDir); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never); const svc = new TeamProvisioningService(); configureLaunchStubs(svc); svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123'); let runId: string | undefined; try { const launched = await svc.launchTeam( { teamName, cwd: projectDir, providerId: 'codex', providerBackendId: 'codex-native', model: 'gpt-5.4', clearContext: true, }, () => {} ); runId = launched.runId; const member = extractBootstrapSpec().members?.[0]; expect(member).toEqual( expect.objectContaining({ name: 'alice', provider: 'codex', model: 'gpt-5.4', mcpSettingSources: 'user,project,local', strictMcpConfig: true, }) ); expectAppOnlyMemberMcpConfig(member); const memberMcpConfigPath = member?.mcpConfigPath ?? ''; await svc.cancelProvisioning(runId); runId = undefined; await expectPathRemovedEventually(memberMcpConfigPath); } finally { if (runId) { await svc.cancelProvisioning(runId); } fs.rmSync(projectDir, { recursive: true, force: true }); } }); it('live add-member MCP prep writes and discards a real Agent Teams MCP only config', async () => { const teamName = 'codex-app-only-live-add'; const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex live add project-')); writeProjectMcpConfig(projectDir); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never); const svc = new TeamProvisioningService(); configureLaunchStubs(svc); svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123'); let runId: string | undefined; let liveMcpConfigPath = ''; try { const created = await svc.createTeam( { teamName, cwd: projectDir, providerId: 'codex', providerBackendId: 'codex-native', model: 'gpt-5.4', members: [{ name: 'alice', role: 'developer', providerId: 'codex', model: 'gpt-5.4' }], }, () => {} ); runId = created.runId; (svc as unknown as { aliveRunByTeam: Map }).aliveRunByTeam.set( teamName, runId ); const liveMcpConfig = await svc.prepareLiveMemberMcpLaunchConfig({ teamName, cwd: projectDir, mcpPolicy: { mode: 'appOnly' }, }); expect(liveMcpConfig).toEqual( expect.objectContaining({ mcpConfigPath: expect.any(String), mcpSettingSources: 'user,project,local', strictMcpConfig: true, }) ); liveMcpConfigPath = liveMcpConfig?.mcpConfigPath ?? ''; expectAppOnlyMcpConfigPath(liveMcpConfigPath); await svc.discardLiveMemberMcpLaunchConfig({ teamName, mcpLaunchConfig: liveMcpConfig, }); await expectPathRemovedEventually(liveMcpConfigPath); await svc.cancelProvisioning(runId); runId = undefined; } finally { if (runId) { await svc.cancelProvisioning(runId); } fs.rmSync(projectDir, { recursive: true, force: true }); } }); it('restartMember direct process preserves Agent Teams MCP only in the real restart args', async () => { const teamName = 'codex-app-only-process-restart'; const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex restart project-')); writeProjectMcpConfig(projectDir); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never); const svc = new TeamProvisioningService(); configureLaunchStubs(svc); let runId: string | undefined; let restartMcpConfigPath = ''; try { const created = await svc.createTeam( { teamName, cwd: projectDir, providerId: 'codex', providerBackendId: 'codex-native', model: 'gpt-5.4', members: [ { name: 'alice', role: 'developer', providerId: 'codex', model: 'gpt-5.4', mcpPolicy: { mode: 'appOnly' }, }, ], }, () => {} ); runId = created.runId; (svc as unknown as { aliveRunByTeam: Map }).aliveRunByTeam.set( teamName, runId ); (svc as unknown as { readConfigForStrictDecision: () => Promise }).readConfigForStrictDecision = vi.fn(async () => ({ name: teamName, projectPath: projectDir, members: [ { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, { name: 'alice', role: 'developer', providerId: 'codex', model: 'gpt-5.4', mcpPolicy: { mode: 'appOnly' }, }, ], })); (svc as unknown as { readPersistedRuntimeMembers: () => unknown[] }).readPersistedRuntimeMembers = vi.fn(() => [{ name: 'alice', backendType: 'process', cwd: projectDir }]); ( svc as unknown as { getLiveTeamAgentRuntimeMetadata: () => Promise> } ).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); ( svc as unknown as { buildTeamRuntimeLaunchArgsPlan: () => Promise<{ fastModeArgs: string[]; runtimeTurnSettledHookArgs: string[]; providerArgs: string[]; settingsArgs: string[]; extraArgs: string[]; inheritedProviderArgs: string[]; }>; } ).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({ fastModeArgs: [], runtimeTurnSettledHookArgs: [], providerArgs: [], settingsArgs: [], extraArgs: [], inheritedProviderArgs: [], })); ( svc as unknown as { updateDirectTmuxRestartMemberConfig: () => Promise } ).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {}); (svc as unknown as { enqueueDirectRestartPrompt: () => void }).enqueueDirectRestartPrompt = vi.fn(); vi.mocked(spawnCli).mockClear(); await svc.restartMember(teamName, 'alice'); const restartArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[] | undefined; expect(restartArgs).toEqual( expect.arrayContaining([ '--teammate-runtime', 'headless', '--setting-sources', 'user,project,local', '--strict-mcp-config', ]) ); restartMcpConfigPath = extractMcpConfigPathFromArgs(restartArgs ?? []); expectAppOnlyMcpConfigPath(restartMcpConfigPath); await svc.cancelProvisioning(runId); runId = undefined; await expectPathRemovedEventually(restartMcpConfigPath); } finally { if (runId) { await svc.cancelProvisioning(runId); } fs.rmSync(projectDir, { recursive: true, force: true }); } }); });