mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
refactor(cliproxy/executor): extract auth-coordinator from index.ts
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
This commit is contained in:
@@ -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<Record<string, unknown>> = {}): 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> = {}): 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<typeof jest.spyOn>;
|
||||
|
||||
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<typeof jest.spyOn>;
|
||||
|
||||
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<typeof jest.spyOn>;
|
||||
|
||||
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<typeof jest.spyOn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
+40
-219
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user