From bc48613bbd06e8ca17d4b8acff466690d152f8ba Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 21:47:54 -0400 Subject: [PATCH] refactor(cliproxy/executor): extract browser-setup and account-resolution Phases 04+05 of #1162. Splits two more concerns out of the orchestrator: - src/cliproxy/executor/browser-launch-setup.ts (118 LOC): resolveBrowserLaunchFlags + resolveBrowserRuntime. Encapsulates browser flag resolution, attach config, blocked-override warning, and runtime setup including MCP sync. - src/cliproxy/executor/account-resolution.ts (197 LOC): resolveRuntimeQuotaMonitorProviders, resolveAccounts (--accounts / --use / --nickname / OAuth ban-risk warn / default touch), applyAccountSafetyGuards, touchDefaultAccount. - New tests: 184 + 430 LOC covering both modules. index.ts: 1045 -> 895 LOC (-150). resolveRuntimeQuotaMonitorProviders re-exported from index.ts for __testExports backwards compat. Behavior unchanged; full suite passes 1824/1824. Refs #1162 --- .../__tests__/account-resolution.test.ts | 430 ++++++++++++++++++ .../__tests__/browser-launch-setup.test.ts | 184 ++++++++ src/cliproxy/executor/account-resolution.ts | 197 ++++++++ src/cliproxy/executor/browser-launch-setup.ts | 118 +++++ src/cliproxy/executor/index.ts | 206 ++------- 5 files changed, 957 insertions(+), 178 deletions(-) create mode 100644 src/cliproxy/executor/__tests__/account-resolution.test.ts create mode 100644 src/cliproxy/executor/__tests__/browser-launch-setup.test.ts create mode 100644 src/cliproxy/executor/account-resolution.ts create mode 100644 src/cliproxy/executor/browser-launch-setup.ts diff --git a/src/cliproxy/executor/__tests__/account-resolution.test.ts b/src/cliproxy/executor/__tests__/account-resolution.test.ts new file mode 100644 index 00000000..1fabba98 --- /dev/null +++ b/src/cliproxy/executor/__tests__/account-resolution.test.ts @@ -0,0 +1,430 @@ +/** + * Unit tests for account-resolution.ts (Phase 05) + * + * Tests cover: + * - resolveRuntimeQuotaMonitorProviders: single provider, composite, dedup + * - resolveAccounts: --accounts early exit, --use switching, --nickname rename, + * default touch (no --use), warnOAuthBanRisk delegation + * - applyAccountSafetyGuards: delegates to safety functions (isolation + warn) + * + * Strategy: pure unit tests on the exported functions. Account-manager and + * account-safety modules are mocked to avoid file I/O. + */ + +import { afterEach, beforeEach, describe, expect, it, jest, mock } from 'bun:test'; + +// ── resolveRuntimeQuotaMonitorProviders ─────────────────────────────────────── + +describe('resolveRuntimeQuotaMonitorProviders', () => { + // Import the module under test directly (no heavy deps needed for this fn) + it('returns empty array when provider is not managed', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + const result = resolveRuntimeQuotaMonitorProviders('kiro', []); + expect(result).toEqual([]); + }); + + it('returns [provider] for single managed provider', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + const result = resolveRuntimeQuotaMonitorProviders('agy', []); + expect(result).toContain('agy'); + expect(result).toHaveLength(1); + }); + + it('returns composite providers that are managed, deduped', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + // agy is managed; kiro is not; duplicate agy should be deduped + const result = resolveRuntimeQuotaMonitorProviders('gemini', ['agy', 'kiro', 'agy']); + expect(result).toContain('agy'); + expect(result).not.toContain('kiro'); + expect(result.filter((p) => p === 'agy')).toHaveLength(1); + }); + + it('ignores base provider when compositeProviders is non-empty', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + // base provider is gemini (managed), but composite list contains only kiro (not managed) + const result = resolveRuntimeQuotaMonitorProviders('gemini', ['kiro']); + expect(result).toEqual([]); + }); +}); + +// ── resolveAccounts — --accounts early exit ─────────────────────────────────── + +describe('resolveAccounts — --accounts early exit', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('calls process.exit(0) and returns earlyExit=true when showAccounts=true (no accounts)', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: (a: { email?: string }) => a.email ?? 'unknown', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + const result = await resolveAccounts({ + provider: 'gemini', + showAccounts: true, + useAccount: undefined, + setNickname: undefined, + addAccount: false, + }); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(result.earlyExit).toBe(true); + }); + + it('prints account list when accounts exist', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [ + { id: 'acc1', email: 'test@example.com', isDefault: true, nickname: 'main' }, + ], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'test@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: true, + useAccount: undefined, + setNickname: undefined, + addAccount: false, + }); + + const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('test@example.com'); + expect(allOutput).toContain('(default)'); + }); +}); + +// ── resolveAccounts — --use switching ───────────────────────────────────────── + +describe('resolveAccounts — --use switching', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('calls setDefaultAccount + touchAccount and logs success', async () => { + const setDefaultMock = jest.fn(); + const touchMock = jest.fn(); + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => ({ id: 'acc1', email: 'user@example.com', nickname: undefined }), + setDefaultAccount: setDefaultMock, + touchAccount: touchMock, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'user@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: 'user@example.com', + setNickname: undefined, + addAccount: false, + }); + + expect(setDefaultMock).toHaveBeenCalledWith('gemini', 'acc1'); + expect(touchMock).toHaveBeenCalledWith('gemini', 'acc1'); + const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('Switched to account'); + }); + + it('calls process.exit(1) when account not found', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'x', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: 'nonexistent', + setNickname: undefined, + addAccount: false, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ── resolveAccounts — --nickname rename ─────────────────────────────────────── + +describe('resolveAccounts — --nickname rename', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('renames default account and exits 0 on success', async () => { + const renameMock = jest.fn(() => true); + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: renameMock, + getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }), + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'user@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: undefined, + setNickname: 'work', + addAccount: false, + }); + + expect(renameMock).toHaveBeenCalledWith('gemini', 'acc1', 'work'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when no default account found', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => false, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'x', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: undefined, + setNickname: 'work', + addAccount: false, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('skips rename when addAccount=true (--auth flow)', async () => { + const renameMock = jest.fn(() => true); + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: renameMock, + getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }), + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'user@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + const result = await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: undefined, + setNickname: 'work', + addAccount: true, // suppresses rename + }); + + expect(renameMock).not.toHaveBeenCalled(); + expect(result.earlyExit).toBe(false); + }); +}); + +// ── applyAccountSafetyGuards — delegation ───────────────────────────────────── + +describe('applyAccountSafetyGuards', () => { + it('calls cleanupStaleAutoPauses and enforceProviderIsolation', async () => { + const cleanupMock = jest.fn(); + const enforceMock = jest.fn(() => 0); + const warnDupMock = jest.fn(() => false); + + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: warnDupMock, + cleanupStaleAutoPauses: cleanupMock, + enforceProviderIsolation: enforceMock, + restoreAutoPausedAccounts: () => {}, + })); + + const { applyAccountSafetyGuards } = await import('../account-resolution'); + applyAccountSafetyGuards('gemini', []); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + expect(enforceMock).toHaveBeenCalledWith('gemini'); + }); + + it('calls warnCrossProviderDuplicates when isolation returns 0', async () => { + const warnDupMock = jest.fn(() => false); + const enforceMock = jest.fn(() => 0); + + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: warnDupMock, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: enforceMock, + restoreAutoPausedAccounts: () => {}, + })); + + const { applyAccountSafetyGuards } = await import('../account-resolution'); + applyAccountSafetyGuards('gemini', []); + + expect(warnDupMock).toHaveBeenCalledWith('gemini'); + }); + + it('does NOT call warnCrossProviderDuplicates when isolation is enforced', async () => { + const warnDupMock = jest.fn(() => false); + const enforceMock = jest.fn(() => 2); // 2 accounts isolated + + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: warnDupMock, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: enforceMock, + restoreAutoPausedAccounts: () => {}, + })); + + const { applyAccountSafetyGuards } = await import('../account-resolution'); + applyAccountSafetyGuards('gemini', []); + + expect(warnDupMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts b/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts new file mode 100644 index 00000000..574cfe75 --- /dev/null +++ b/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts @@ -0,0 +1,184 @@ +/** + * Unit tests for browser-launch-setup.ts (Phase 04) + * + * Tests cover: + * - resolveBrowserLaunchFlags: no flags (default), --browser-launch override, + * blocked override warning emitted, process.exit on parse error + * - resolveBrowserRuntime: no attach (disabled), active runtime env, MCP sync + * error propagation + * + * Strategy: mock the utils/browser and unified-config-loader modules so that + * no real browser detection or file I/O occurs. + */ + +import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Minimal BrowserConfig stub */ +function makeBrowserConfig(enabled = false, policy: 'auto' | 'always' | 'never' = 'auto'): object { + return { + claude: { enabled, policy, user_data_dir: '', devtools_port: 9222 }, + codex: { enabled: false, policy: 'auto' }, + }; +} + +// ── resolveBrowserLaunchFlags — no flags ────────────────────────────────────── + +describe('resolveBrowserLaunchFlags — no browser flags', () => { + it('returns undefined override and passes args through unchanged', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (_args: string[]) => ({ + override: undefined, + argsWithoutFlags: _args, + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }), + resolveBrowserExposure: () => ({ exposeForLaunch: false }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(false), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup'); + const args = ['--model', 'claude-opus-4-5']; + const result = resolveBrowserLaunchFlags(args); + expect(result.browserLaunchOverride).toBeUndefined(); + expect(result.argsWithoutBrowserFlags).toEqual(args); + }); +}); + +// ── resolveBrowserLaunchFlags — --browser-launch override ───────────────────── + +describe('resolveBrowserLaunchFlags — with browser-launch override', () => { + it('returns override and strips the browser flag from args', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (_args: string[]) => ({ + override: 'force-enable' as const, + argsWithoutFlags: ['--model', 'claude-opus-4-5'], + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }), + resolveBrowserExposure: () => ({ exposeForLaunch: true }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(true, 'auto'), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup'); + const result = resolveBrowserLaunchFlags(['--browser-launch', '--model', 'claude-opus-4-5']); + expect(result.browserLaunchOverride).toBe('force-enable'); + expect(result.argsWithoutBrowserFlags).toEqual(['--model', 'claude-opus-4-5']); + }); +}); + +// ── resolveBrowserLaunchFlags — blocked override warning emitted ────────────── + +describe('resolveBrowserLaunchFlags — blocked override warning', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it('emits warn() when getBlockedBrowserOverrideWarning returns a message', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (args: string[]) => ({ + override: 'force-enable' as const, + argsWithoutFlags: args, + }), + getBlockedBrowserOverrideWarning: () => 'Browser override is blocked by policy', + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }), + resolveBrowserExposure: () => ({ exposeForLaunch: false }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(false, 'never'), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup'); + resolveBrowserLaunchFlags(['--model', 'claude-opus-4-5']); + expect(stderrSpy).toHaveBeenCalledTimes(1); + expect(stderrSpy.mock.calls[0][0]).toContain('Browser override is blocked by policy'); + }); +}); + +// ── resolveBrowserRuntime — attach disabled ─────────────────────────────────── + +describe('resolveBrowserRuntime — attach disabled', () => { + it('returns undefined browserRuntimeEnv when browser attach is disabled', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (a: string[]) => ({ + override: undefined, + argsWithoutFlags: a, + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }), + resolveBrowserExposure: () => ({ exposeForLaunch: false }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(false), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserRuntime } = await import('../browser-launch-setup'); + const result = await resolveBrowserRuntime(undefined, undefined); + expect(result.browserRuntimeEnv).toBeUndefined(); + }); +}); + +// ── resolveBrowserRuntime — active runtime env ──────────────────────────────── + +describe('resolveBrowserRuntime — active runtime env', () => { + it('returns runtimeEnv when browser attach resolves successfully', async () => { + const fakeRuntimeEnv = { CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1:9222/json' }; + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (a: string[]) => ({ + override: 'force-enable' as const, + argsWithoutFlags: a, + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }), + resolveBrowserExposure: () => ({ exposeForLaunch: true }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: fakeRuntimeEnv }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(true, 'always'), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserRuntime } = await import('../browser-launch-setup'); + const result = await resolveBrowserRuntime('force-enable', undefined); + expect(result.browserRuntimeEnv).toEqual(fakeRuntimeEnv); + }); +}); diff --git a/src/cliproxy/executor/account-resolution.ts b/src/cliproxy/executor/account-resolution.ts new file mode 100644 index 00000000..9d1c49fb --- /dev/null +++ b/src/cliproxy/executor/account-resolution.ts @@ -0,0 +1,197 @@ +/** + * Account Resolution — Executor-level account management + * + * Extracted from executor/index.ts (Phase 05). + * Handles: + * - --accounts listing (early exit) + * - --use switching + * - --nickname rename + * - Default account touch (lastUsedAt update) + * - Account safety guards (cross-provider isolation, ban risk, stale pauses) + * - Runtime quota monitor provider resolution + */ + +import { ok, fail, info } from '../../utils/ui'; +import { + findAccountByQuery, + getProviderAccounts, + setDefaultAccount, + touchAccount, + renameAccount, + getDefaultAccount, +} from '../accounts/account-manager'; +import { formatAccountDisplayName } from '../accounts/email-account-identity'; +import { getProviderConfig } from '../config/config-generator'; +import { CLIProxyProvider } from '../types'; +import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager'; +import { + warnCrossProviderDuplicates, + warnOAuthBanRisk, + cleanupStaleAutoPauses, + enforceProviderIsolation, + restoreAutoPausedAccounts, +} from '../accounts/account-safety'; + +// ── Quota provider resolution ───────────────────────────────────────────────── + +/** + * Determine which managed quota providers need runtime monitoring. + * For composite variants, checks all tier providers; otherwise checks the + * single active provider. + */ +export function resolveRuntimeQuotaMonitorProviders( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): ManagedQuotaProvider[] { + const candidates = compositeProviders.length > 0 ? compositeProviders : [provider]; + const resolved: ManagedQuotaProvider[] = []; + + for (const candidate of candidates) { + if ( + MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) && + !resolved.includes(candidate as ManagedQuotaProvider) + ) { + resolved.push(candidate as ManagedQuotaProvider); + } + } + + return resolved; +} + +// ── Account safety guards ───────────────────────────────────────────────────── + +/** + * Apply account safety guards: stale auto-pause cleanup, provider isolation, + * cross-provider duplicate warnings, and OAuth ban risk warnings. + * + * Registers process.on('exit') restore handler when isolation is enforced. + */ +export function applyAccountSafetyGuards( + provider: CLIProxyProvider, + _compositeProviders: CLIProxyProvider[] +): void { + cleanupStaleAutoPauses(); + const isolated = enforceProviderIsolation(provider); + if (isolated === 0) { + // No enforcement — still warn about duplicates for awareness + warnCrossProviderDuplicates(provider); + } else { + // 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs + process.on('exit', () => { + restoreAutoPausedAccounts(provider); + }); + } +} + +// ── Context / Result types ──────────────────────────────────────────────────── + +export interface AccountResolutionContext { + provider: CLIProxyProvider; + /** True when running in composite variant mode */ + showAccounts: boolean; + useAccount: string | undefined; + setNickname: string | undefined; + addAccount: boolean; +} + +export interface AccountResolutionResult { + /** true if --accounts listing was shown (caller should return early) */ + earlyExit: boolean; +} + +// ── Main account resolution ─────────────────────────────────────────────────── + +/** + * Handle account management CLI flags: + * --accounts (list + early exit), --use (switch), --nickname (rename). + * + * Also calls warnOAuthBanRisk for the active provider. + */ +export async function resolveAccounts( + ctx: AccountResolutionContext +): Promise { + const { provider, showAccounts, useAccount, setNickname, addAccount } = ctx; + const providerConfig = getProviderConfig(provider); + + // Warn about OAuth ban risk for this provider (always) + warnOAuthBanRisk(provider); + + // Handle --accounts + if (showAccounts) { + const accounts = getProviderAccounts(provider); + if (accounts.length === 0) { + console.log(info(`No accounts registered for ${providerConfig.displayName}`)); + console.log(` Run "ccs ${provider} --auth" to add an account`); + } else { + console.log(`\n${providerConfig.displayName} Accounts:\n`); + for (const acct of accounts) { + const defaultMark = acct.isDefault ? ' (default)' : ''; + const nickname = acct.nickname ? `[${acct.nickname}]` : ''; + console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`); + } + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + } + process.exit(0); + return { earlyExit: true }; + } + + // Handle --use + if (useAccount) { + const account = findAccountByQuery(provider, useAccount); + if (!account) { + console.error(fail(`Account not found: "${useAccount}"`)); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.error(` Available accounts:`); + for (const acct of accounts) { + const displayName = formatAccountDisplayName(acct); + const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName; + console.error(` - ${label}`); + } + } + process.exit(1); + } + setDefaultAccount(provider, account.id); + touchAccount(provider, account.id); + const switchedLabel = account.nickname + ? `${account.nickname} (${formatAccountDisplayName(account)})` + : formatAccountDisplayName(account); + console.log(ok(`Switched to account: ${switchedLabel}`)); + } + + // Handle --nickname (rename account) — only when not in --auth flow + if (setNickname && !addAccount) { + const defaultAccount = getDefaultAccount(provider); + if (!defaultAccount) { + console.error(fail(`No account found for ${providerConfig.displayName}`)); + console.error(` Run "ccs ${provider} --auth" to add an account first`); + process.exit(1); + } + try { + const success = renameAccount(provider, defaultAccount.id, setNickname); + if (success) { + console.log(ok(`Renamed account to: ${setNickname}`)); + } else { + console.error(fail('Failed to rename account')); + process.exit(1); + } + } catch (err) { + console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); + process.exit(1); + } + process.exit(0); + } + + return { earlyExit: false }; +} + +/** + * Touch the default account's lastUsedAt timestamp. + * Called after authentication succeeds and before proxy spawn. + */ +export function touchDefaultAccount(provider: CLIProxyProvider): void { + const usedAccount = getDefaultAccount(provider); + if (usedAccount) { + touchAccount(provider, usedAccount.id); + } +} diff --git a/src/cliproxy/executor/browser-launch-setup.ts b/src/cliproxy/executor/browser-launch-setup.ts new file mode 100644 index 00000000..129eccdf --- /dev/null +++ b/src/cliproxy/executor/browser-launch-setup.ts @@ -0,0 +1,118 @@ +/** + * Browser Launch Setup — Executor-level browser initialization + * + * Extracted from executor/index.ts (Phase 04). + * Handles: + * 1. Browser launch flag resolution and override parsing + * 2. Browser attach config + exposure resolution + blocked-override warning + * 3. Optional browser attach runtime resolution (devtools WebSocket) + * 4. Browser MCP ensure + sync-to-config-dir + */ + +import { warn } from '../../utils/ui'; +import { + type BrowserLaunchOverride, + ensureBrowserMcpOrThrow, + getBlockedBrowserOverrideWarning, + getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + resolveBrowserLaunchFlagResolution, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { getBrowserConfig } from '../../config/unified-config-loader'; + +export interface BrowserLaunchSetupResult { + /** CLI override flag if --browser-launch / --no-browser-launch was passed */ + browserLaunchOverride: BrowserLaunchOverride | undefined; + /** args list with --browser-launch* flags removed */ + argsWithoutBrowserFlags: string[]; + /** Devtools WebSocket env vars if browser attach runtime is active */ + browserRuntimeEnv: Record | undefined; +} + +/** + * Phase 1 — resolve browser CLI flags and attach config. + * Call this immediately after resolveExecutorProxy so that + * argsWithoutBrowserFlags is available for downstream parsing. + * + * @returns partial setup result (no async work yet) + */ +export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { + browserLaunchOverride: BrowserLaunchOverride | undefined; + argsWithoutBrowserFlags: string[]; +} { + let browserLaunchOverride: BrowserLaunchOverride | undefined; + let argsWithoutBrowserFlags = argsWithoutProxy; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy); + browserLaunchOverride = browserLaunchFlags.override; + argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(warn((error as Error).message)); + process.exit(1); + return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; + } + + const browserConfig = getBrowserConfig(); + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const claudeBrowserExposure = resolveBrowserExposure( + { + enabled: browserAttachConfig.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ); + const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning( + 'Claude Browser Attach', + claudeBrowserExposure + ); + if (blockedBrowserOverrideWarning) { + console.error(warn(blockedBrowserOverrideWarning)); + } + + return { browserLaunchOverride, argsWithoutBrowserFlags }; +} + +/** + * Phase 2 — resolve async browser attach runtime and MCP setup. + * Must be called AFTER phase-1 and AFTER ensureWebSearchMcpOrThrow(). + */ +export async function resolveBrowserRuntime( + browserLaunchOverride: BrowserLaunchOverride | undefined, + inheritedClaudeConfigDir: string | undefined +): Promise> { + const browserConfig = getBrowserConfig(); + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const claudeBrowserExposure = resolveBrowserExposure( + { + enabled: browserAttachConfig.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ); + + const browserAttachRuntime = + browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch + ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + : undefined; + + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + } + + // Sync browser MCP config into inherited Claude instance if browser is active + if (browserRuntimeEnv && inheritedClaudeConfigDir) { + if (!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + } + + return { browserRuntimeEnv }; +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 074fea99..d40e5d3b 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -37,15 +37,6 @@ import { } from '../model-catalog'; import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; -import { - findAccountByQuery, - getProviderAccounts, - setDefaultAccount, - touchAccount, - renameAccount, - getDefaultAccount, -} from '../accounts/account-manager'; -import { formatAccountDisplayName } from '../accounts/email-account-identity'; import { ensureWebSearchMcpOrThrow, displayWebSearchStatus, @@ -57,26 +48,20 @@ import { syncImageAnalysisMcpToConfigDir, appendThirdPartyImageAnalysisToolArgs, } from '../../utils/image-analysis'; -import { - appendBrowserToolArgs, - type BrowserLaunchOverride, - ensureBrowserMcpOrThrow, - getBlockedBrowserOverrideWarning, - getEffectiveClaudeBrowserAttachConfig, - resolveBrowserExposure, - resolveBrowserLaunchFlagResolution, - resolveOptionalBrowserAttachRuntime, - syncBrowserMcpToConfigDir, -} from '../../utils/browser'; -import { - getBrowserConfig, - loadOrCreateUnifiedConfig, - getThinkingConfig, -} from '../../config/unified-config-loader'; +import { getDefaultAccount } from '../accounts/account-manager'; +import { appendBrowserToolArgs } from '../../utils/browser'; +import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance'; // Import modular components +import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup'; +import { + resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders, + applyAccountSafetyGuards, + resolveAccounts, + touchDefaultAccount, +} from './account-resolution'; import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager'; import { buildClaudeEnvironment, @@ -84,15 +69,8 @@ import { resolveCliproxyImageAnalysisEnv, } from './env-resolver'; import { handleTokenExpiration, handleQuotaCheck } from './retry-handler'; -import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager'; +import { MANAGED_QUOTA_PROVIDERS } from '../quota/quota-manager'; import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; -import { - warnCrossProviderDuplicates, - warnOAuthBanRisk, - cleanupStaleAutoPauses, - enforceProviderIsolation, - restoreAutoPausedAccounts, -} from '../accounts/account-safety'; import { ensureCliAntigravityResponsibility, ANTIGRAVITY_ACCEPT_RISK_FLAGS, @@ -107,24 +85,8 @@ import { shouldStartHttpsTunnel } from './https-tunnel-policy'; import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser'; import { resolveExecutorProxy } from './proxy-resolver'; -function resolveRuntimeQuotaMonitorProviders( - provider: CLIProxyProvider, - compositeProviders: CLIProxyProvider[] -): ManagedQuotaProvider[] { - const candidates = compositeProviders.length > 0 ? compositeProviders : [provider]; - const resolved: ManagedQuotaProvider[] = []; - - for (const candidate of candidates) { - if ( - MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) && - !resolved.includes(candidate as ManagedQuotaProvider) - ) { - resolved.push(candidate as ManagedQuotaProvider); - } - } - - return resolved; -} +/** Local alias so internal call sites need no change */ +const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -193,53 +155,16 @@ export async function execClaudeWithCLIProxy( log, }); - let browserLaunchOverride: BrowserLaunchOverride | undefined; - let argsWithoutBrowserFlags = argsWithoutProxy; - try { - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy); - browserLaunchOverride = browserLaunchFlags.override; - argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } - const browserConfig = getBrowserConfig(); - const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); - const claudeBrowserExposure = resolveBrowserExposure( - { - enabled: browserAttachConfig.enabled, - policy: browserConfig.claude.policy, - }, - browserLaunchOverride - ); - const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning( - 'Claude Browser Attach', - claudeBrowserExposure - ); - if (blockedBrowserOverrideWarning) { - console.error(warn(blockedBrowserOverrideWarning)); - } + const { browserLaunchOverride, argsWithoutBrowserFlags } = + resolveBrowserLaunchFlags(argsWithoutProxy); // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - const browserAttachRuntime = - browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch - ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) - : undefined; - const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; - if (browserAttachRuntime?.warning) { - process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); - } - if (browserRuntimeEnv) { - ensureBrowserMcpOrThrow(); - } displayWebSearchStatus(); const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); - warnOAuthBanRisk(provider); // Variables for local proxy mode let sessionId: string | undefined; @@ -300,70 +225,8 @@ export async function execClaudeWithCLIProxy( ); } - // Handle --accounts - if (showAccounts) { - const accounts = getProviderAccounts(provider); - if (accounts.length === 0) { - console.log(info(`No accounts registered for ${providerConfig.displayName}`)); - console.log(` Run "ccs ${provider} --auth" to add an account`); - } else { - console.log(`\n${providerConfig.displayName} Accounts:\n`); - for (const acct of accounts) { - const defaultMark = acct.isDefault ? ' (default)' : ''; - const nickname = acct.nickname ? `[${acct.nickname}]` : ''; - console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`); - } - console.log(`\n Use "ccs ${provider} --use " to switch accounts`); - } - process.exit(0); - } - - // Handle --use - if (useAccount) { - const account = findAccountByQuery(provider, useAccount); - if (!account) { - console.error(fail(`Account not found: "${useAccount}"`)); - const accounts = getProviderAccounts(provider); - if (accounts.length > 0) { - console.error(` Available accounts:`); - for (const acct of accounts) { - const displayName = formatAccountDisplayName(acct); - const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName; - console.error(` - ${label}`); - } - } - process.exit(1); - } - setDefaultAccount(provider, account.id); - touchAccount(provider, account.id); - const switchedLabel = account.nickname - ? `${account.nickname} (${formatAccountDisplayName(account)})` - : formatAccountDisplayName(account); - console.log(ok(`Switched to account: ${switchedLabel}`)); - } - - // Handle --nickname (rename account) - if (setNickname && !addAccount) { - const defaultAccount = getDefaultAccount(provider); - if (!defaultAccount) { - console.error(fail(`No account found for ${providerConfig.displayName}`)); - console.error(` Run "ccs ${provider} --auth" to add an account first`); - process.exit(1); - } - try { - const success = renameAccount(provider, defaultAccount.id, setNickname); - if (success) { - console.log(ok(`Renamed account to: ${setNickname}`)); - } else { - console.error(fail('Failed to rename account')); - process.exit(1); - } - } catch (err) { - console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); - process.exit(1); - } - process.exit(0); - } + // Handle --accounts / --use / --nickname (warnOAuthBanRisk emitted inside) + await resolveAccounts({ provider, showAccounts, useAccount, setNickname, addAccount }); // Handle --config if (forceConfig && supportsModelConfig(provider)) { @@ -559,10 +422,7 @@ export async function execClaudeWithCLIProxy( } // 3a-1. Update lastUsedAt - const usedAccount = getDefaultAccount(provider); - if (usedAccount) { - touchAccount(provider, usedAccount.id); - } + touchDefaultAccount(provider); } // 3b. Preflight quota check (providers with quota-based rotation) @@ -581,17 +441,7 @@ export async function execClaudeWithCLIProxy( // 3c. Account safety: enforce cross-provider isolation if (!skipLocalAuth) { - cleanupStaleAutoPauses(); - const isolated = enforceProviderIsolation(provider); - if (isolated === 0) { - // No enforcement — still warn about duplicates for awareness - warnCrossProviderDuplicates(provider); - } else { - // 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs - process.on('exit', () => { - restoreAutoPausedAccounts(provider); - }); - } + applyAccountSafetyGuards(provider, compositeProviders); } // 4. First-run model configuration @@ -784,15 +634,12 @@ export async function execClaudeWithCLIProxy( } syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); - if ( - browserRuntimeEnv && - inheritedClaudeConfigDir && - !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) - ) { - throw new Error( - 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' - ); - } + + // Resolve browser attach runtime and sync browser MCP (needs inheritedClaudeConfigDir) + const { browserRuntimeEnv } = await resolveBrowserRuntime( + browserLaunchOverride, + inheritedClaudeConfigDir + ); // Build initial env vars to get ANTHROPIC_BASE_URL const initialEnvVars = buildClaudeEnvironment({ @@ -1038,6 +885,9 @@ export { isPortAvailable, findAvailablePort } from './lifecycle-manager'; // import from index or directly from ./arg-parser) export { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from './arg-parser'; +// Re-export account-resolution helpers for backwards compat with __testExports consumers +export { resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders } from './account-resolution'; + export const __testExports = { resolveRuntimeQuotaMonitorProviders, };