From 8b7e7f4847eec98b39cd1bac883cf4dd5aa30a2d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 22:03:46 -0400 Subject: [PATCH] refactor(cliproxy/executor): extract auth-coordinator from index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 06 of #1162. Splits the largest remaining concern out of the orchestrator into a focused module: - src/cliproxy/executor/auth-coordinator.ts (397 LOC): handleLogout, handleImport, resolveSkipLocalAuth, runAntigravityGate, ensureProviderAuthentication, runPreflightQuotaCheck, runAccountSafetyGuards, ensureModelConfiguration, ensureProviderSettingsFile. Preserves load-bearing ordering of antigravity gate -> auth -> token refresh -> quota check. - 36 unit tests covering --auth/--logout/--import early exit, antigravity gate refusal/acceptance, OAuth trigger paths, composite providers, remote-proxy skipLocalAuth. index.ts: 895 -> 716 LOC (-179). Behavior unchanged; full suite passes 1824/1824. Module is 397 LOC (over the <200 ideal) — kept whole because the auth ordering contract should not be split across files. Refs #1162 --- .../__tests__/auth-coordinator.test.ts | 487 ++++++++++++++++++ src/cliproxy/executor/auth-coordinator.ts | 397 ++++++++++++++ src/cliproxy/executor/index.ts | 259 ++-------- 3 files changed, 924 insertions(+), 219 deletions(-) create mode 100644 src/cliproxy/executor/__tests__/auth-coordinator.test.ts create mode 100644 src/cliproxy/executor/auth-coordinator.ts diff --git a/src/cliproxy/executor/__tests__/auth-coordinator.test.ts b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts new file mode 100644 index 00000000..d7617fe6 --- /dev/null +++ b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts @@ -0,0 +1,487 @@ +/** + * Auth Coordinator Tests (Phase 06) + * + * Tests for auth-coordinator.ts exported functions. + * + * Strategy: + * - jest.mock() at top level to intercept all imports (static + dynamic) + * before the module-under-test is loaded + * - process.exit spied in beforeEach to prevent runner exit + * - Dynamic-import paths (e.g. '../auth/auth-handler') are mocked via + * jest.mock() from the coordinator's perspective (relative to executor/) + */ + +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +import type { ExecutorConfig } from '../../types'; +import type { UnifiedConfig } from '../../../config/schemas/unified-config'; +import type { AuthCoordinationContext } from '../auth-coordinator'; +import type { ParsedExecutorFlags } from '../arg-parser'; + +// ── Module mocks (must be top-level, before any import of subject) ──────────── + +const mockIsAuthenticated = jest.fn(() => false); +const mockClearAuth = jest.fn(() => true); +const mockTriggerOAuth = jest.fn(async () => true); + +jest.mock('../../auth/auth-handler', () => ({ + isAuthenticated: (...args: unknown[]) => mockIsAuthenticated(...args), + clearAuth: (...args: unknown[]) => mockClearAuth(...args), + triggerOAuth: (...args: unknown[]) => mockTriggerOAuth(...args), +})); + +const mockEnsureAntigravity = jest.fn(async () => true); +const MOCK_ANTIGRAVITY_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk']; + +jest.mock('../../auth/antigravity-responsibility', () => ({ + ensureCliAntigravityResponsibility: (...args: unknown[]) => mockEnsureAntigravity(...args), + ANTIGRAVITY_ACCEPT_RISK_FLAGS: MOCK_ANTIGRAVITY_FLAGS, +})); + +const mockHandleTokenExpiration = jest.fn(async () => {}); +const mockHandleQuotaCheck = jest.fn(async () => {}); + +jest.mock('../retry-handler', () => ({ + handleTokenExpiration: (...args: unknown[]) => mockHandleTokenExpiration(...args), + handleQuotaCheck: (...args: unknown[]) => mockHandleQuotaCheck(...args), +})); + +const mockApplyAccountSafetyGuards = jest.fn(() => {}); +const mockTouchDefaultAccount = jest.fn(() => {}); + +jest.mock('../account-resolution', () => ({ + applyAccountSafetyGuards: (...args: unknown[]) => mockApplyAccountSafetyGuards(...args), + touchDefaultAccount: (...args: unknown[]) => mockTouchDefaultAccount(...args), +})); + +const mockConfigureProviderModel = jest.fn(async () => {}); +const mockGetCurrentModel = jest.fn(() => 'claude-opus'); + +jest.mock('../../config/model-config', () => ({ + configureProviderModel: (...args: unknown[]) => mockConfigureProviderModel(...args), + getCurrentModel: (...args: unknown[]) => mockGetCurrentModel(...args), +})); + +const mockReconcileCodexModel = jest.fn(async () => {}); + +jest.mock('../../ai-providers/codex-plan-compatibility', () => ({ + reconcileCodexModelForActivePlan: (...args: unknown[]) => mockReconcileCodexModel(...args), +})); + +// Do NOT mock quota-manager, model-catalog, config-generator — pure modules +// with no I/O side effects; mocking them would strip exports needed by the +// wider module graph and cause "Export not found" errors. + +// ── Import subject AFTER mocks ───────────────────────────────────────────────── + +const { + resolveSkipLocalAuth, + handleLogout, + handleImport, + runAntigravityGate, + ensureProviderAuthentication, + runPreflightQuotaCheck, + runAccountSafetyGuards, + ensureModelConfiguration, +} = await import('../auth-coordinator'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeFlags(overrides: Partial> = {}): ParsedExecutorFlags { + return { + forceAuth: false, + pasteCallback: false, + portForward: false, + forceHeadless: false, + forceLogout: false, + forceConfig: false, + addAccount: false, + showAccounts: false, + forceImport: false, + gitlabTokenLogin: false, + acceptAgyRisk: false, + incognitoFlag: false, + noIncognitoFlag: false, + noIncognito: false, + useAccount: undefined, + setNickname: undefined, + kiroAuthMethod: undefined, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: { + value: undefined, + sourceFlag: undefined, + sourceDisplay: 'none', + duplicateDisplays: [], + } as ParsedExecutorFlags['thinkingParse'], + ...overrides, + } as unknown as ParsedExecutorFlags; +} + +function makeCtx(overrides: Partial = {}): AuthCoordinationContext { + return { + provider: 'gemini', + compositeProviders: [], + parsedFlags: makeFlags(), + cfg: { port: 8090, timeout: 5000, verbose: false, pollInterval: 100 } as ExecutorConfig, + unifiedConfig: {} as UnifiedConfig, + verbose: false, + log: () => {}, + ...overrides, + }; +} + +// ── resolveSkipLocalAuth ────────────────────────────────────────────────────── + +describe('resolveSkipLocalAuth', () => { + it('returns false when useRemoteProxy=false', () => { + expect(resolveSkipLocalAuth('tok', false)).toBe(false); + }); + + it('returns false when token is whitespace only', () => { + expect(resolveSkipLocalAuth(' ', true)).toBe(false); + }); + + it('returns false when token is undefined', () => { + expect(resolveSkipLocalAuth(undefined, true)).toBe(false); + }); + + it('returns true when useRemoteProxy=true and token non-empty', () => { + expect(resolveSkipLocalAuth('tok123', true)).toBe(true); + }); +}); + +// ── handleLogout ────────────────────────────────────────────────────────────── + +describe('handleLogout', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('returns false and does not exit when forceLogout=false', async () => { + const result = await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: false }) })); + expect(result).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('exits 0 when forceLogout=true and clearAuth succeeds', async () => { + mockClearAuth.mockReturnValue(true); + await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: true }) })); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 0 even when no auth found (clearAuth returns false)', async () => { + mockClearAuth.mockReturnValue(false); + await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: true }) })); + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); + +// ── handleImport ────────────────────────────────────────────────────────────── + +describe('handleImport', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockTriggerOAuth.mockResolvedValue(true); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('returns false when forceImport=false', async () => { + const result = await handleImport(makeCtx({ parsedFlags: makeFlags({ forceImport: false }) })); + expect(result).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('exits 1 for non-kiro provider', async () => { + await handleImport( + makeCtx({ provider: 'gemini', parsedFlags: makeFlags({ forceImport: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits 1 when --import + --auth combined', async () => { + await handleImport( + makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true, forceAuth: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits 1 when --import + --logout combined', async () => { + await handleImport( + makeCtx({ + provider: 'kiro', + parsedFlags: makeFlags({ forceImport: true, forceLogout: true }), + }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits 0 on successful kiro import', async () => { + mockTriggerOAuth.mockResolvedValue(true); + await handleImport( + makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when triggerOAuth returns false', async () => { + mockTriggerOAuth.mockResolvedValue(false); + await handleImport( + makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ── runAntigravityGate ──────────────────────────────────────────────────────── + +describe('runAntigravityGate', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockEnsureAntigravity.mockResolvedValue(true); + mockIsAuthenticated.mockReturnValue(false); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('returns earlyReturn=false for non-agy provider without calling gate', async () => { + const result = await runAntigravityGate(makeCtx({ provider: 'gemini' }), false); + expect(result.earlyReturn).toBe(false); + expect(mockEnsureAntigravity).not.toHaveBeenCalled(); + }); + + it('agy + forceAuth + skipLocalAuth + acknowledged → earlyReturn=true', async () => { + mockEnsureAntigravity.mockResolvedValue(true); + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: true }) }); + const result = await runAntigravityGate(ctx, true); + expect(result.earlyReturn).toBe(true); + expect(mockEnsureAntigravity).toHaveBeenCalledWith( + expect.objectContaining({ context: 'oauth' }) + ); + }); + + it('agy + forceAuth + skipLocalAuth + refused → throws', async () => { + mockEnsureAntigravity.mockResolvedValue(false); + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: true }) }); + await expect(runAntigravityGate(ctx, true)).rejects.toThrow('Antigravity auth blocked'); + }); + + it('agy + no forceAuth + skipLocalAuth + acknowledged → earlyReturn=false, no exit', async () => { + mockEnsureAntigravity.mockResolvedValue(true); + mockIsAuthenticated.mockReturnValue(true); // already authenticated → run context triggers + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: false }) }); + const result = await runAntigravityGate(ctx, true); + expect(result.earlyReturn).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + expect(mockEnsureAntigravity).toHaveBeenCalledWith(expect.objectContaining({ context: 'run' })); + }); + + it('agy + no forceAuth + skipLocalAuth + refused → process.exit(1)', async () => { + mockEnsureAntigravity.mockResolvedValue(false); + mockIsAuthenticated.mockReturnValue(true); + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: false }) }); + await runAntigravityGate(ctx, true); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ── ensureProviderAuthentication ────────────────────────────────────────────── + +describe('ensureProviderAuthentication', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsAuthenticated.mockReturnValue(false); + mockTriggerOAuth.mockResolvedValue(true); + mockHandleTokenExpiration.mockResolvedValue(undefined); + mockTouchDefaultAccount.mockImplementation(() => {}); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('already authenticated → no OAuth trigger, no exit', async () => { + mockIsAuthenticated.mockReturnValue(true); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockTriggerOAuth).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('not authenticated → triggerOAuth called', async () => { + mockIsAuthenticated.mockReturnValue(false); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockTriggerOAuth).toHaveBeenCalledWith('gemini', expect.any(Object)); + }); + + it('forceAuth=true → exits 0 after successful OAuth', async () => { + await ensureProviderAuthentication( + makeCtx({ provider: 'gemini', parsedFlags: makeFlags({ forceAuth: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('OAuth fails → throws Authentication required error', async () => { + mockTriggerOAuth.mockResolvedValue(false); + await expect(ensureProviderAuthentication(makeCtx({ provider: 'gemini' }))).rejects.toThrow( + 'Authentication required' + ); + }); + + it('calls touchDefaultAccount after successful single-provider auth', async () => { + mockIsAuthenticated.mockReturnValue(true); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockTouchDefaultAccount).toHaveBeenCalledWith('gemini'); + }); + + it('runs token refresh after single-provider auth', async () => { + mockIsAuthenticated.mockReturnValue(true); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockHandleTokenExpiration).toHaveBeenCalledWith('gemini', false); + }); + + it('composite + forceAuth: all succeed → exit(0)', async () => { + mockTriggerOAuth.mockResolvedValue(true); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: true }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockTriggerOAuth).toHaveBeenCalledTimes(2); + }); + + it('composite + forceAuth: partial failure → exit(1)', async () => { + let call = 0; + mockTriggerOAuth.mockImplementation(async () => ++call === 1); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: true }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('composite no forceAuth + unauthenticated → exit(1)', async () => { + mockIsAuthenticated.mockReturnValue(false); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: false }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('composite all authenticated → token refresh for each provider', async () => { + mockIsAuthenticated.mockReturnValue(true); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: false }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).not.toHaveBeenCalled(); + expect(mockHandleTokenExpiration).toHaveBeenCalledTimes(2); + }); +}); + +// ── runPreflightQuotaCheck ──────────────────────────────────────────────────── + +describe('runPreflightQuotaCheck', () => { + beforeEach(() => jest.clearAllMocks()); + + it('single provider → handleQuotaCheck called once with that provider', async () => { + await runPreflightQuotaCheck('gemini', []); + expect(mockHandleQuotaCheck).toHaveBeenCalledWith('gemini'); + expect(mockHandleQuotaCheck).toHaveBeenCalledTimes(1); + }); + + it('composite list → checks only managed providers from the list', async () => { + // Real MANAGED_QUOTA_PROVIDERS = ['agy','claude','codex','gemini','ghcp'] + // 'gemini' and 'codex' are both managed; 'kiro' is not + await runPreflightQuotaCheck('agy', ['gemini', 'kiro']); + expect(mockHandleQuotaCheck).toHaveBeenCalledWith('gemini'); + expect(mockHandleQuotaCheck).not.toHaveBeenCalledWith('kiro'); + }); +}); + +// ── runAccountSafetyGuards ──────────────────────────────────────────────────── + +describe('runAccountSafetyGuards', () => { + beforeEach(() => jest.clearAllMocks()); + + it('delegates to applyAccountSafetyGuards with correct args', () => { + runAccountSafetyGuards('gemini', ['codex']); + expect(mockApplyAccountSafetyGuards).toHaveBeenCalledWith('gemini', ['codex']); + }); +}); + +// ── ensureModelConfiguration ────────────────────────────────────────────────── + +describe('ensureModelConfiguration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('non-composite provider with model support → configureProviderModel called', async () => { + const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig; + await ensureModelConfiguration('gemini', cfg, false); + expect(mockConfigureProviderModel).toHaveBeenCalledWith('gemini', false, undefined); + }); + + it('composite variant → configureProviderModel NOT called', async () => { + const cfg = { isComposite: true } as ExecutorConfig; + await ensureModelConfiguration('gemini', cfg, false); + expect(mockConfigureProviderModel).not.toHaveBeenCalled(); + }); + + it('agy (has model support) → configureProviderModel IS called', async () => { + // agy IS in MODEL_CATALOG so supportsModelConfig returns true + const cfg = { isComposite: false } as ExecutorConfig; + await ensureModelConfiguration('agy', cfg, false); + expect(mockConfigureProviderModel).toHaveBeenCalled(); + }); + + it('codex non-composite → reconcileCodexModelForActivePlan called', async () => { + const cfg = { isComposite: false } as ExecutorConfig; + await ensureModelConfiguration('codex', cfg, false); + expect(mockReconcileCodexModel).toHaveBeenCalled(); + }); + + it('codex composite → reconcileCodexModelForActivePlan NOT called', async () => { + const cfg = { isComposite: true } as ExecutorConfig; + await ensureModelConfiguration('codex', cfg, false); + expect(mockReconcileCodexModel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cliproxy/executor/auth-coordinator.ts b/src/cliproxy/executor/auth-coordinator.ts new file mode 100644 index 00000000..4e1f2f42 --- /dev/null +++ b/src/cliproxy/executor/auth-coordinator.ts @@ -0,0 +1,397 @@ +/** + * Auth Coordinator — Executor-level authentication handoff + * + * Extracted from executor/index.ts (Phase 06). + * Handles: + * - --logout early exit + * - --import token early exit (Kiro only) + * - --auth / forceAuth OAuth flow (single + composite providers) + * - Antigravity responsibility gate (run and oauth contexts) + * - isAuthenticated checks + automatic OAuth trigger + * - Proactive token refresh (multi-provider for composite) + * - lastUsedAt touch via touchDefaultAccount + * - Preflight quota check + * - Account safety guards (cross-provider isolation) + * - First-run model configuration + * + * ORDERING (load-bearing — do not change): + * 1. logout/import/forceAuth early exits + * 2. Remote-proxy auth skip detection + * 3. Antigravity gate (oauth context: remote+forceAuth | run context: else branch) + * 4. OAuth check / trigger (single or composite) + * 5. Token refresh + * 6. lastUsedAt touch + * 7. Preflight quota check + * 8. Account safety guards + * 9. First-run model configuration + */ + +import { ok, fail, info } from '../../utils/ui'; +import { isAuthenticated } from '../auth/auth-handler'; +import { CLIProxyProvider, ExecutorConfig } from '../types'; +import { getProviderConfig, ensureProviderSettings } from '../config/config-generator'; +import { configureProviderModel, getCurrentModel } from '../config/model-config'; +import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility'; +import { supportsModelConfig } from '../model-catalog'; +import { + ensureCliAntigravityResponsibility, + ANTIGRAVITY_ACCEPT_RISK_FLAGS, +} from '../auth/antigravity-responsibility'; +import { handleTokenExpiration, handleQuotaCheck } from './retry-handler'; +import { applyAccountSafetyGuards, touchDefaultAccount } from './account-resolution'; +import { MANAGED_QUOTA_PROVIDERS } from '../quota/quota-manager'; +import type { ParsedExecutorFlags } from './arg-parser'; +import type { UnifiedConfig } from '../../config/schemas/unified-config'; + +// ── Context / Result types ───────────────────────────────────────────────────── + +export interface AuthCoordinationContext { + provider: CLIProxyProvider; + compositeProviders: CLIProxyProvider[]; + parsedFlags: ParsedExecutorFlags; + cfg: ExecutorConfig; + unifiedConfig: UnifiedConfig; + verbose: boolean; + log: (msg: string) => void; +} + +// ── 1. Special early-exit auth flags (logout / import) ──────────────────────── + +/** + * Handle --logout: clear auth and exit 0. + * Returns true if early exit occurred (caller should return immediately). + */ +export async function handleLogout(context: AuthCoordinationContext): Promise { + const { provider, parsedFlags } = context; + if (!parsedFlags.forceLogout) return false; + + const providerConfig = getProviderConfig(provider); + const { clearAuth } = await import('../auth/auth-handler'); + if (clearAuth(provider)) { + console.log(ok(`Logged out from ${providerConfig.displayName}`)); + } else { + console.log(info(`No authentication found for ${providerConfig.displayName}`)); + } + process.exit(0); +} + +/** + * Handle --import: Kiro-only token import flow, exits when done. + * Returns true if early exit occurred. + */ +export async function handleImport(context: AuthCoordinationContext): Promise { + const { provider, parsedFlags, verbose } = context; + const { + forceImport, + forceAuth, + forceLogout, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + setNickname, + } = parsedFlags; + + if (!forceImport) return false; + + if (provider !== 'kiro') { + console.error(fail('--import is only available for Kiro')); + console.error(` Run "ccs ${provider} --auth" to authenticate`); + process.exit(1); + } + if (forceAuth) { + console.error(fail('Cannot use --import with --auth')); + console.error(' --import: Import existing token from Kiro IDE'); + console.error(' --auth: Trigger new OAuth flow in browser'); + process.exit(1); + } + if (forceLogout) { + console.error(fail('Cannot use --import with --logout')); + process.exit(1); + } + + const { triggerOAuth } = await import('../auth/auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + import: true, + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), + ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), + ...(kiroIDCRegion ? { kiroIDCRegion } : {}), + ...(kiroIDCFlow ? { kiroIDCFlow } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + }); + if (!authSuccess) { + console.error(fail('Failed to import Kiro token from IDE')); + console.error(' Make sure you are logged into Kiro IDE first'); + process.exit(1); + } + process.exit(0); +} + +// ── 2. Remote proxy auth skip ────────────────────────────────────────────────── + +/** + * Returns true when a remote proxy with an authToken is active — + * local OAuth is not needed in this case. + */ +export function resolveSkipLocalAuth( + remoteAuthToken: string | undefined, + useRemoteProxy: boolean +): boolean { + return useRemoteProxy && !!remoteAuthToken?.trim(); +} + +// ── 3. Antigravity responsibility gate ──────────────────────────────────────── + +/** + * Runs the Antigravity responsibility gate for the relevant code path. + * + * Two scenarios (must match original ordering in index.ts): + * A. provider=agy + forceAuth + skipLocalAuth → oauth-context gate; if refused, log and return + * (caller must return early — this function returns { earlyReturn: true }) + * B. provider=agy + !forceAuth + (skipLocalAuth || !requiresAuthNow) → run-context gate; + * if refused, log and process.exit(1) + * + * Returns { earlyReturn: true } when the caller should return immediately (case A refused). + */ +export async function runAntigravityGate( + context: AuthCoordinationContext, + skipLocalAuth: boolean +): Promise<{ earlyReturn: boolean }> { + const { provider, parsedFlags } = context; + const { forceAuth, acceptAgyRisk } = parsedFlags; + + if (provider !== 'agy') return { earlyReturn: false }; + + const providerConfig = getProviderConfig(provider); + const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); + + if (forceAuth && skipLocalAuth) { + // Case A: remote proxy + forceAuth — gate in oauth context, skip local OAuth + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + throw new Error( + `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ); + } + console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); + return { earlyReturn: true }; + } + + if (!forceAuth) { + // Case B: run-context gate (only when auth not immediately required) + if (skipLocalAuth || !requiresAuthNow) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'run', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + console.error( + fail( + `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ) + ); + process.exit(1); + } + } + } + + return { earlyReturn: false }; +} + +// ── 4–6. OAuth check / trigger + token refresh + account touch ───────────────── + +/** + * Ensure provider(s) are authenticated; trigger OAuth if needed. + * Handles both composite (multi-provider) and single-provider flows. + * Also runs proactive token refresh and touches lastUsedAt. + * + * Only called when providerConfig.requiresOAuth && !skipLocalAuth. + */ +export async function ensureProviderAuthentication( + context: AuthCoordinationContext +): Promise { + const { provider, compositeProviders, parsedFlags, verbose, log } = context; + const { + forceAuth, + addAccount, + acceptAgyRisk, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + gitlabTokenLogin, + gitlabBaseUrl, + forceHeadless, + setNickname, + noIncognito, + pasteCallback, + portForward, + } = parsedFlags; + + log(`Checking authentication for ${provider}`); + + // Multi-provider path (composite variants) + if (compositeProviders.length > 0) { + if (forceAuth) { + const { triggerOAuth } = await import('../auth/auth-handler'); + const failures: string[] = []; + for (const p of compositeProviders) { + const authSuccess = await triggerOAuth(p, { + verbose, + add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), + ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), + ...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}), + ...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}), + ...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}), + ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), + }); + if (!authSuccess) { + failures.push(p); + } + } + if (failures.length > 0) { + const succeeded = compositeProviders.filter((p) => !failures.includes(p)); + console.error(fail(`Auth failed for: ${failures.join(', ')}`)); + if (succeeded.length > 0) { + console.error(info(`Succeeded: ${succeeded.join(', ')}`)); + } + process.exit(1); + } + process.exit(0); + } + + // Check for unauthenticated providers + const unauthenticatedProviders: string[] = []; + for (const p of compositeProviders) { + if (!isAuthenticated(p)) { + unauthenticatedProviders.push(p); + } + } + if (unauthenticatedProviders.length > 0) { + console.error(fail('Composite variant requires authentication for multiple providers:')); + for (const p of unauthenticatedProviders) { + console.error(` - ${p} (run "ccs ${p} --auth")`); + } + process.exit(1); + } + } else { + // Single-provider path + if (forceAuth || !isAuthenticated(provider)) { + const { triggerOAuth } = await import('../auth/auth-handler'); + const providerConfig = getProviderConfig(provider); + const authSuccess = await triggerOAuth(provider, { + verbose, + add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), + ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), + ...(kiroIDCRegion ? { kiroIDCRegion } : {}), + ...(kiroIDCFlow ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl ? { gitlabBaseUrl } : {}), + ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), + }); + if (!authSuccess) { + throw new Error(`Authentication required for ${providerConfig.displayName}`); + } + if (forceAuth) { + process.exit(0); + } + } else { + log(`${provider} already authenticated`); + } + } + + // 3a. Proactive token refresh (multi-provider for composite) + if (compositeProviders.length > 0) { + for (const p of compositeProviders) { + await handleTokenExpiration(p, verbose); + } + } else { + await handleTokenExpiration(provider, verbose); + } + + // 3a-1. Update lastUsedAt + touchDefaultAccount(provider); +} + +// ── 7. Preflight quota check ────────────────────────────────────────────────── + +/** + * Preflight quota check for providers with quota-based rotation. + * Only runs when !skipLocalAuth. + */ +export async function runPreflightQuotaCheck( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): Promise { + if (compositeProviders.length > 0) { + for (const managedProvider of MANAGED_QUOTA_PROVIDERS) { + if (compositeProviders.includes(managedProvider)) { + await handleQuotaCheck(managedProvider); + } + } + } else { + await handleQuotaCheck(provider); + } +} + +// ── 8. Account safety guards ────────────────────────────────────────────────── + +/** + * Enforce cross-provider account isolation. Only runs when !skipLocalAuth. + */ +export function runAccountSafetyGuards( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): void { + applyAccountSafetyGuards(provider, compositeProviders); +} + +// ── 9. First-run model configuration ───────────────────────────────────────── + +/** + * Ensure provider model is configured on first run. + * Skipped for composite variants and remote proxy mode. + * Also reconciles Codex model for active plan. + */ +export async function ensureModelConfiguration( + provider: CLIProxyProvider, + cfg: ExecutorConfig, + verbose: boolean +): Promise { + if (!cfg.isComposite && supportsModelConfig(provider)) { + await configureProviderModel(provider, false, cfg.customSettingsPath); + } + + if (provider === 'codex' && !cfg.isComposite) { + await reconcileCodexModelForActivePlan({ + currentModel: getCurrentModel(provider, cfg.customSettingsPath), + verbose, + }); + } +} + +// ── Ensure provider settings file ──────────────────────────────────────────── + +/** + * Ensure the provider settings file exists. + */ +export function ensureProviderSettingsFile(provider: CLIProxyProvider): void { + ensureProviderSettings(provider); +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d40e5d3b..df8ebdb2 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -14,22 +14,19 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { ok, fail, info, warn } from '../../utils/ui'; +import { fail, info, warn } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor'; import { generateConfig, getProviderConfig, - ensureProviderSettings, getProviderSettingsPath, CLIPROXY_DEFAULT_PORT, } from '../config/config-generator'; -import { isAuthenticated } from '../auth/auth-handler'; -import { CLIProxyProvider, ExecutorConfig } from '../types'; import { configureProviderModel, getCurrentModel } from '../config/model-config'; -import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility'; +import { supportsModelConfig } from '../model-catalog'; +import { CLIProxyProvider, ExecutorConfig } from '../types'; import { - supportsModelConfig, isModelBroken, getModelIssueUrl, findModel, @@ -58,9 +55,7 @@ import { resolveProfileContinuityInheritance } from '../../auth/profile-continui import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup'; import { resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders, - applyAccountSafetyGuards, resolveAccounts, - touchDefaultAccount, } from './account-resolution'; import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager'; import { @@ -68,14 +63,19 @@ import { logEnvironment, resolveCliproxyImageAnalysisEnv, } from './env-resolver'; -import { handleTokenExpiration, handleQuotaCheck } from './retry-handler'; -import { MANAGED_QUOTA_PROVIDERS } from '../quota/quota-manager'; import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; -import { - ensureCliAntigravityResponsibility, - ANTIGRAVITY_ACCEPT_RISK_FLAGS, -} from '../auth/antigravity-responsibility'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; +import { + handleLogout, + handleImport, + resolveSkipLocalAuth, + runAntigravityGate, + ensureProviderAuthentication, + runPreflightQuotaCheck, + runAccountSafetyGuards, + ensureModelConfiguration, + ensureProviderSettingsFile, +} from './auth-coordinator'; import { buildThinkingStartupStatus, resolveRuntimeThinkingOverride, @@ -182,25 +182,11 @@ export async function execClaudeWithCLIProxy( if (process.exitCode === 1) return; const { - forceAuth, - pasteCallback, - portForward, - forceHeadless, - forceLogout, forceConfig, addAccount, showAccounts, - forceImport, - gitlabTokenLogin, - acceptAgyRisk, - noIncognito, useAccount, setNickname, - kiroAuthMethod, - kiroIDCStartUrl, - kiroIDCRegion, - kiroIDCFlow, - gitlabBaseUrl, extendedContextOverride, thinkingParse, } = parsedFlags; @@ -244,209 +230,51 @@ export async function execClaudeWithCLIProxy( } } - // Handle --logout - if (forceLogout) { - const { clearAuth } = await import('../auth/auth-handler'); - if (clearAuth(provider)) { - console.log(ok(`Logged out from ${providerConfig.displayName}`)); - } else { - console.log(info(`No authentication found for ${providerConfig.displayName}`)); - } - process.exit(0); - } + // Build auth coordination context (used for logout/import/antigravity/oauth) + const authCtx = { + provider, + compositeProviders, + parsedFlags, + cfg, + unifiedConfig, + verbose, + log, + }; - // Handle --import (Kiro only) - if (forceImport) { - if (provider !== 'kiro') { - console.error(fail('--import is only available for Kiro')); - console.error(` Run "ccs ${provider} --auth" to authenticate`); - process.exit(1); - } - if (forceAuth) { - console.error(fail('Cannot use --import with --auth')); - console.error(' --import: Import existing token from Kiro IDE'); - console.error(' --auth: Trigger new OAuth flow in browser'); - process.exit(1); - } - if (forceLogout) { - console.error(fail('Cannot use --import with --logout')); - process.exit(1); - } - const { triggerOAuth } = await import('../auth/auth-handler'); - const authSuccess = await triggerOAuth(provider, { - verbose, - import: true, - ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), - ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), - ...(kiroIDCRegion ? { kiroIDCRegion } : {}), - ...(kiroIDCFlow ? { kiroIDCFlow } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - }); - if (!authSuccess) { - console.error(fail('Failed to import Kiro token from IDE')); - console.error(' Make sure you are logged into Kiro IDE first'); - process.exit(1); - } - process.exit(0); - } + // Handle --logout (early exit) + await handleLogout(authCtx); + + // Handle --import (early exit, Kiro only) + await handleImport(authCtx); // 3. Ensure OAuth completed (if provider requires it) const remoteAuthToken = proxyConfig.authToken?.trim(); - const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; + const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy); if (skipLocalAuth) { log(`Using remote proxy authentication (skipping local OAuth)`); } - if (provider === 'agy' && forceAuth && skipLocalAuth) { - const acknowledged = await ensureCliAntigravityResponsibility({ - context: 'oauth', - acceptedByFlag: acceptAgyRisk, - }); - if (!acknowledged) { - throw new Error( - `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` - ); - } - console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); - return; - } - - if (provider === 'agy' && !forceAuth) { - const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); - if (skipLocalAuth || !requiresAuthNow) { - const acknowledged = await ensureCliAntigravityResponsibility({ - context: 'run', - acceptedByFlag: acceptAgyRisk, - }); - if (!acknowledged) { - console.error( - fail( - `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` - ) - ); - process.exit(1); - } - } - } + // Antigravity gate (runs before OAuth check) + const { earlyReturn: agyEarlyReturn } = await runAntigravityGate(authCtx, skipLocalAuth); + if (agyEarlyReturn) return; if (providerConfig.requiresOAuth && !skipLocalAuth) { - log(`Checking authentication for ${provider}`); - - // Multi-provider auth check for composite variants - if (compositeProviders.length > 0) { - // Handle forceAuth for composite providers - if (forceAuth) { - const { triggerOAuth } = await import('../auth/auth-handler'); - const failures: string[] = []; - for (const p of compositeProviders) { - const authSuccess = await triggerOAuth(p, { - verbose, - add: addAccount, - ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), - ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), - ...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}), - ...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}), - ...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}), - ...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}), - ...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}), - ...(forceHeadless ? { headless: true } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - ...(noIncognito ? { noIncognito: true } : {}), - ...(pasteCallback ? { pasteCallback: true } : {}), - ...(portForward ? { portForward: true } : {}), - }); - if (!authSuccess) { - failures.push(p); - } - } - if (failures.length > 0) { - const succeeded = compositeProviders.filter((p) => !failures.includes(p)); - console.error(fail(`Auth failed for: ${failures.join(', ')}`)); - if (succeeded.length > 0) { - console.error(info(`Succeeded: ${succeeded.join(', ')}`)); - } - process.exit(1); - } - process.exit(0); - } - - // Check for unauthenticated providers - const unauthenticatedProviders: string[] = []; - for (const p of compositeProviders) { - if (!isAuthenticated(p)) { - unauthenticatedProviders.push(p); - } - } - if (unauthenticatedProviders.length > 0) { - console.error(fail('Composite variant requires authentication for multiple providers:')); - for (const p of unauthenticatedProviders) { - console.error(` - ${p} (run "ccs ${p} --auth")`); - } - process.exit(1); - } - } else if (forceAuth || !isAuthenticated(provider)) { - const { triggerOAuth } = await import('../auth/auth-handler'); - const authSuccess = await triggerOAuth(provider, { - verbose, - add: addAccount, - ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), - ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), - ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), - ...(kiroIDCRegion ? { kiroIDCRegion } : {}), - ...(kiroIDCFlow ? { kiroIDCFlow } : {}), - ...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}), - ...(gitlabBaseUrl ? { gitlabBaseUrl } : {}), - ...(forceHeadless ? { headless: true } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - ...(noIncognito ? { noIncognito: true } : {}), - ...(pasteCallback ? { pasteCallback: true } : {}), - ...(portForward ? { portForward: true } : {}), - }); - if (!authSuccess) { - throw new Error(`Authentication required for ${providerConfig.displayName}`); - } - if (forceAuth) { - process.exit(0); - } - } else { - log(`${provider} already authenticated`); - } - - // 3a. Proactive token refresh (multi-provider for composite) - if (compositeProviders.length > 0) { - for (const p of compositeProviders) { - await handleTokenExpiration(p, verbose); - } - } else { - await handleTokenExpiration(provider, verbose); - } - - // 3a-1. Update lastUsedAt - touchDefaultAccount(provider); + await ensureProviderAuthentication(authCtx); } // 3b. Preflight quota check (providers with quota-based rotation) if (!skipLocalAuth) { - // Multi-tier quota check for composite variants (check if any tier uses a managed provider) - if (compositeProviders.length > 0) { - for (const managedProvider of MANAGED_QUOTA_PROVIDERS) { - if (compositeProviders.includes(managedProvider)) { - await handleQuotaCheck(managedProvider); - } - } - } else { - await handleQuotaCheck(provider); - } + await runPreflightQuotaCheck(provider, compositeProviders); } // 3c. Account safety: enforce cross-provider isolation if (!skipLocalAuth) { - applyAccountSafetyGuards(provider, compositeProviders); + runAccountSafetyGuards(provider, compositeProviders); } - // 4. First-run model configuration - if (!cfg.isComposite && supportsModelConfig(provider) && !skipLocalAuth) { - await configureProviderModel(provider, false, cfg.customSettingsPath); + // 4. First-run model configuration + codex plan reconcile + if (!skipLocalAuth) { + await ensureModelConfiguration(provider, cfg, verbose); } // 5. Check for broken models (multi-tier for composite) @@ -497,14 +325,7 @@ export async function execClaudeWithCLIProxy( } // 6. Ensure user settings file exists - ensureProviderSettings(provider); - - if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) { - await reconcileCodexModelForActivePlan({ - currentModel: getCurrentModel(provider, cfg.customSettingsPath), - verbose, - }); - } + ensureProviderSettingsFile(provider); // Local proxy mode: generate config, spawn/join proxy, track session let proxy: ChildProcess | null = null;