import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: vi.fn() }, })); vi.mock('@main/utils/shellEnv', () => ({ resolveInteractiveShellEnv: vi.fn(), })); const buildProviderAwareCliEnvMock = vi.fn(); vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ buildProviderAwareCliEnv: (...args: Parameters) => buildProviderAwareCliEnvMock(...args), })); const addTeamNotificationMock = vi.fn().mockResolvedValue(null); vi.mock('@main/services/infrastructure/NotificationManager', () => ({ NotificationManager: { getInstance: () => ({ addTeamNotification: addTeamNotificationMock, }), }, })); import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; describe('TeamProvisioningService prepare/auth behavior', () => { let tempRoot = ''; beforeEach(() => { vi.clearAllMocks(); addTeamNotificationMock.mockResolvedValue(null); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-')); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ PATH: '/usr/bin', SHELL: '/bin/zsh', }); buildProviderAwareCliEnvMock.mockImplementation(({ env }: { env: NodeJS.ProcessEnv }) => Promise.resolve({ env, connectionIssues: {}, }) ); delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_AUTH_TOKEN; }); afterEach(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); it('does not create missing directories during prepareForProvisioning', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: {}, authSource: 'none', }); vi.spyOn(svc as any, 'probeClaudeRuntime').mockResolvedValue({}); const missingCwd = path.join(tempRoot, 'missing-project'); await svc.prepareForProvisioning(missingCwd, { forceFresh: true }); expect(fs.existsSync(missingCwd)).toBe(false); }); it('keys the prepare probe cache by cwd', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: {}, authSource: 'none', }); const probeSpy = vi.spyOn(svc as any, 'probeClaudeRuntime').mockResolvedValue({}); const cwdA = fs.mkdtempSync(path.join(tempRoot, 'a-')); const cwdB = fs.mkdtempSync(path.join(tempRoot, 'b-')); await svc.prepareForProvisioning(cwdA, { forceFresh: true }); await svc.prepareForProvisioning(cwdA); await svc.prepareForProvisioning(cwdB); expect(probeSpy).toHaveBeenCalledTimes(2); expect(probeSpy.mock.calls[0]?.[1]).toBe(cwdA); expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB); }); it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => { const svc = new TeamProvisioningService(); const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult'); getCachedOrProbeResult.mockImplementation((_cwd: unknown, providerId: unknown) => { if (providerId === 'codex') { return Promise.resolve({ claudePath: '/fake/claude', authSource: 'none', warning: 'Not logged in to Codex runtime', }); } return Promise.resolve({ claudePath: '/fake/claude', authSource: 'oauth_token', }); }); const result = await svc.prepareForProvisioning(tempRoot, { forceFresh: true, providerId: 'anthropic', providerIds: ['codex', 'anthropic'], }); expect(result.ready).toBe(false); expect(result.message).toBe('Codex: Not logged in to Codex runtime'); expect(getCachedOrProbeResult).toHaveBeenCalledTimes(2); expect(getCachedOrProbeResult.mock.calls.map((call) => call[1])).toEqual([ 'anthropic', 'codex', ]); }); it('verifies the selected Codex model during prepare and records a success detail', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ claudePath: '/fake/claude', authSource: 'codex_runtime', }); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, authSource: 'codex_runtime', geminiRuntimeAuth: null, }); const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ stdout: 'PONG', stderr: '', exitCode: 0, }); const result = await svc.prepareForProvisioning(tempRoot, { forceFresh: true, providerId: 'codex', modelIds: ['gpt-5.4'], }); expect(result.ready).toBe(true); expect(result.details).toContain('Selected model gpt-5.4 verified for launch.'); expect(spawnProbe).toHaveBeenCalledWith( '/fake/claude', expect.arrayContaining(['--model', 'gpt-5.4']), tempRoot, expect.any(Object), 60_000, expect.any(Object) ); }); it('verifies the resolved Codex default model during prepare', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ claudePath: '/fake/claude', authSource: 'codex_runtime', }); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, authSource: 'codex_runtime', geminiRuntimeAuth: null, }); vi.spyOn(svc as any, 'resolveProviderDefaultModel').mockResolvedValue('gpt-5.4-mini'); const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ stdout: 'PONG', stderr: '', exitCode: 0, }); const result = await svc.prepareForProvisioning(tempRoot, { forceFresh: true, providerId: 'codex', modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], }); expect(result.ready).toBe(true); expect(result.details).toContain( `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` ); expect(spawnProbe).toHaveBeenCalledWith( '/fake/claude', expect.arrayContaining(['--model', 'gpt-5.4-mini']), tempRoot, expect.any(Object), 60_000, expect.any(Object) ); }); it('verifies the resolved Anthropic default model during prepare with limitContext', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ claudePath: '/fake/claude', authSource: 'oauth_token', }); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, authSource: 'oauth_token', geminiRuntimeAuth: null, }); const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ stdout: 'PONG', stderr: '', exitCode: 0, }); const result = await svc.prepareForProvisioning(tempRoot, { forceFresh: true, providerId: 'anthropic', modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], limitContext: true, }); expect(result.ready).toBe(true); expect(result.details).toContain( `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` ); expect(spawnProbe).toHaveBeenCalledWith( '/fake/claude', expect.arrayContaining(['--model', 'opus']), tempRoot, expect.any(Object), 60_000, expect.any(Object) ); }); it('fails prepare when the selected Codex model is unavailable', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ claudePath: '/fake/claude', authSource: 'codex_runtime', }); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, authSource: 'codex_runtime', geminiRuntimeAuth: null, }); vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") ); const result = await svc.prepareForProvisioning(tempRoot, { forceFresh: true, providerId: 'codex', modelIds: ['gpt-5.2-codex'], }); expect(result.ready).toBe(false); expect(result.message).toContain('Selected model gpt-5.2-codex is unavailable.'); expect(result.message).toContain('Not available with Codex ChatGPT subscription'); }); it('keeps timed out Codex model verification as a warning with a clean generic reason', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ claudePath: '/fake/claude', authSource: 'codex_runtime', }); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, authSource: 'codex_runtime', geminiRuntimeAuth: null, }); vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( new Error( 'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' ) ); const result = await svc.prepareForProvisioning(tempRoot, { forceFresh: true, providerId: 'codex', modelIds: ['gpt-5.3-codex'], }); expect(result.ready).toBe(true); expect(result.warnings).toContain( 'Selected model gpt-5.3-codex could not be verified. Model verification timed out' ); }); it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ ANTHROPIC_AUTH_TOKEN: 'proxy-token', PATH: '/usr/bin', SHELL: '/bin/zsh', }); const result = await (svc as any).buildProvisioningEnv(); expect(result.authSource).toBe('anthropic_auth_token'); expect(result.env.ANTHROPIC_API_KEY).toBe('proxy-token'); }); it('prefers explicit ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ ANTHROPIC_API_KEY: 'real-key', ANTHROPIC_AUTH_TOKEN: 'proxy-token', PATH: '/usr/bin', SHELL: '/bin/zsh', }); const result = await (svc as any).buildProvisioningEnv(); expect(result.authSource).toBe('anthropic_api_key'); expect(result.env.ANTHROPIC_API_KEY).toBe('real-key'); }); it('allows help-env resolution to continue even when provisioning env warns', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, authSource: 'configured_api_key_missing', geminiRuntimeAuth: null, warning: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', }); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ claudePath: '/fake/claude', authSource: 'none', }); vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ stdout: 'usage: claude [options]', stderr: '', exitCode: 0, }); const output = await svc.getCliHelpOutput(tempRoot); expect(output).toContain('usage: claude'); }); it('surfaces a missing configured Anthropic API key before probing', async () => { const svc = new TeamProvisioningService(); buildProviderAwareCliEnvMock.mockResolvedValue({ env: { PATH: '/usr/bin', SHELL: '/bin/zsh', }, connectionIssues: { anthropic: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', }, }); const result = await (svc as any).buildProvisioningEnv(); expect(result.authSource).toBe('configured_api_key_missing'); expect(result.warning).toContain('ANTHROPIC_API_KEY'); }); it('does not treat assistant-text 401 noise as an auth failure', () => { const svc = new TeamProvisioningService(); expect((svc as any).isAuthFailureWarning('assistant mentioned 401 unauthorized', 'assistant')).toBe( false ); expect((svc as any).isAuthFailureWarning('invalid api key', 'stderr')).toBe(true); }); it('does not re-check auth from stdout json noise during pre-complete finalization', async () => { const svc = new TeamProvisioningService(); const handleAuthFailureInOutput = vi.spyOn(svc as any, 'handleAuthFailureInOutput'); vi.spyOn(svc as any, 'updateConfigPostLaunch').mockResolvedValue(undefined); vi.spyOn(svc as any, 'cleanupPrelaunchBackup').mockResolvedValue(undefined); vi.spyOn(svc as any, 'relayLeadInboxMessages').mockResolvedValue(undefined); const run = { runId: 'run-1', teamName: 'team-alpha', request: { cwd: tempRoot, color: 'blue', members: [{ name: 'dev', role: 'engineer' }], }, progress: { runId: 'run-1', teamName: 'team-alpha', state: 'assembling', message: 'Assembling', startedAt: '2026-03-12T10:00:00.000Z', updatedAt: '2026-03-12T10:00:00.000Z', }, provisioningComplete: false, cancelRequested: false, processKilled: false, stdoutBuffer: '{"type":"assistant","message":{"content":[{"type":"text","text":"invalid api key"}]}}\n', stdoutLogLineBuf: '', stdoutParserCarry: '{"type":"assistant","message":{"content":[{"type":"text","text":"invalid api key"}]}}', stdoutParserCarryIsCompleteJson: true, stdoutParserCarryLooksLikeClaudeJson: true, stderrBuffer: '', stderrLogLineBuf: '', provisioningOutputParts: ['invalid api key'], onProgress: vi.fn(), isLaunch: true, detectedSessionId: null, timeoutHandle: null, fsMonitorHandle: null, claudeLogLines: [], activeToolCalls: new Map(), leadActivityState: 'active', leadContextUsage: null, }; (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); await (svc as any).handleProvisioningTurnComplete(run); expect(handleAuthFailureInOutput).not.toHaveBeenCalledWith(run, expect.any(String), 'pre-complete'); expect(run.onProgress).toHaveBeenCalledWith( expect.objectContaining({ runId: 'run-1', state: 'ready', }) ); }); it('re-checks a trailing plaintext stdout auth failure during pre-complete finalization', async () => { const svc = new TeamProvisioningService(); const handleAuthFailureInOutput = vi .spyOn(svc as any, 'handleAuthFailureInOutput') .mockImplementation(() => undefined); const run = { runId: 'run-2', teamName: 'team-alpha', request: { cwd: tempRoot, color: 'blue', members: [{ name: 'dev', role: 'engineer' }], }, progress: { runId: 'run-2', teamName: 'team-alpha', state: 'assembling', message: 'Assembling', startedAt: '2026-03-12T10:00:00.000Z', updatedAt: '2026-03-12T10:00:00.000Z', }, provisioningComplete: false, cancelRequested: false, processKilled: false, stdoutBuffer: '[ERROR] invalid api key', stdoutLogLineBuf: '', stdoutParserCarry: '[ERROR] invalid api key', stdoutParserCarryIsCompleteJson: false, stdoutParserCarryLooksLikeClaudeJson: false, stderrBuffer: '', stderrLogLineBuf: '', provisioningOutputParts: [], onProgress: vi.fn(), isLaunch: true, detectedSessionId: null, timeoutHandle: null, fsMonitorHandle: null, claudeLogLines: [], activeToolCalls: new Map(), leadActivityState: 'active', leadContextUsage: null, }; (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); await (svc as any).handleProvisioningTurnComplete(run); expect(handleAuthFailureInOutput).toHaveBeenCalledWith(run, '[ERROR] invalid api key', 'pre-complete'); expect(run.onProgress).not.toHaveBeenCalledWith( expect.objectContaining({ runId: 'run-2', state: 'ready', }) ); }); it('preserves a requested 1M Anthropic window when runtime logs strip the [1m] suffix', () => { const svc = new TeamProvisioningService(); const run = { request: { providerId: 'anthropic', model: 'opus[1m]', limitContext: false, }, leadContextUsage: null, } as any; (svc as any).updateLeadContextUsageFromUsage( run, { input_tokens: 12, cache_creation_input_tokens: 34, cache_read_input_tokens: 56, output_tokens: 7, }, 'claude-opus-4-6' ); expect(run.leadContextUsage).toMatchObject({ promptInputTokens: 102, outputTokens: 7, contextUsedTokens: 109, contextWindowTokens: 1_000_000, promptInputSource: 'anthropic_usage', }); }); it('preserves a limited 200K Anthropic window when runtime logs strip the [1m] suffix', () => { const svc = new TeamProvisioningService(); const run = { request: { providerId: 'anthropic', model: 'opus', limitContext: true, }, leadContextUsage: null, } as any; (svc as any).updateLeadContextUsageFromUsage( run, { input_tokens: 12, cache_creation_input_tokens: 34, cache_read_input_tokens: 56, output_tokens: 7, }, 'claude-opus-4-6' ); expect(run.leadContextUsage).toMatchObject({ promptInputTokens: 102, outputTokens: 7, contextUsedTokens: 109, contextWindowTokens: 200_000, promptInputSource: 'anthropic_usage', }); }); it('emits a lead-message refresh after provisioning reaches ready', async () => { const svc = new TeamProvisioningService(); const emitter = vi.fn(); svc.setTeamChangeEmitter(emitter); vi.spyOn(svc as any, 'updateConfigPostLaunch').mockResolvedValue(undefined); vi.spyOn(svc as any, 'cleanupPrelaunchBackup').mockResolvedValue(undefined); vi.spyOn(svc as any, 'relayLeadInboxMessages').mockResolvedValue(undefined); const run = { runId: 'run-3', teamName: 'team-alpha', request: { cwd: tempRoot, color: 'blue', members: [{ name: 'dev', role: 'engineer' }], }, progress: { runId: 'run-3', teamName: 'team-alpha', state: 'assembling', message: 'Assembling', startedAt: '2026-03-12T10:00:00.000Z', updatedAt: '2026-03-12T10:00:00.000Z', }, provisioningComplete: false, cancelRequested: false, processKilled: false, stdoutBuffer: '', stdoutLogLineBuf: '', stdoutParserCarry: '', stdoutParserCarryIsCompleteJson: false, stdoutParserCarryLooksLikeClaudeJson: false, stderrBuffer: '', stderrLogLineBuf: '', provisioningOutputParts: [], onProgress: vi.fn(), isLaunch: true, detectedSessionId: null, timeoutHandle: null, fsMonitorHandle: null, claudeLogLines: [], activeToolCalls: new Map(), leadActivityState: 'active', leadContextUsage: null, }; (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); await (svc as any).handleProvisioningTurnComplete(run); expect(emitter).toHaveBeenCalledWith( expect.objectContaining({ type: 'lead-message', teamName: 'team-alpha', runId: 'run-3', detail: 'lead-session-sync', }) ); }); });