mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +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 fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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 { getCcsDir } from '../../utils/config-manager';
|
||||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||||
import {
|
import {
|
||||||
generateConfig,
|
generateConfig,
|
||||||
getProviderConfig,
|
getProviderConfig,
|
||||||
ensureProviderSettings,
|
|
||||||
getProviderSettingsPath,
|
getProviderSettingsPath,
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
} from '../config/config-generator';
|
} from '../config/config-generator';
|
||||||
import { isAuthenticated } from '../auth/auth-handler';
|
|
||||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
|
||||||
import { configureProviderModel, getCurrentModel } from '../config/model-config';
|
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 {
|
import {
|
||||||
supportsModelConfig,
|
|
||||||
isModelBroken,
|
isModelBroken,
|
||||||
getModelIssueUrl,
|
getModelIssueUrl,
|
||||||
findModel,
|
findModel,
|
||||||
@@ -58,9 +55,7 @@ import { resolveProfileContinuityInheritance } from '../../auth/profile-continui
|
|||||||
import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup';
|
import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup';
|
||||||
import {
|
import {
|
||||||
resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders,
|
resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders,
|
||||||
applyAccountSafetyGuards,
|
|
||||||
resolveAccounts,
|
resolveAccounts,
|
||||||
touchDefaultAccount,
|
|
||||||
} from './account-resolution';
|
} from './account-resolution';
|
||||||
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
||||||
import {
|
import {
|
||||||
@@ -68,14 +63,19 @@ import {
|
|||||||
logEnvironment,
|
logEnvironment,
|
||||||
resolveCliproxyImageAnalysisEnv,
|
resolveCliproxyImageAnalysisEnv,
|
||||||
} from './env-resolver';
|
} 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 { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||||
import {
|
|
||||||
ensureCliAntigravityResponsibility,
|
|
||||||
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
|
|
||||||
} from '../auth/antigravity-responsibility';
|
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||||
|
import {
|
||||||
|
handleLogout,
|
||||||
|
handleImport,
|
||||||
|
resolveSkipLocalAuth,
|
||||||
|
runAntigravityGate,
|
||||||
|
ensureProviderAuthentication,
|
||||||
|
runPreflightQuotaCheck,
|
||||||
|
runAccountSafetyGuards,
|
||||||
|
ensureModelConfiguration,
|
||||||
|
ensureProviderSettingsFile,
|
||||||
|
} from './auth-coordinator';
|
||||||
import {
|
import {
|
||||||
buildThinkingStartupStatus,
|
buildThinkingStartupStatus,
|
||||||
resolveRuntimeThinkingOverride,
|
resolveRuntimeThinkingOverride,
|
||||||
@@ -182,25 +182,11 @@ export async function execClaudeWithCLIProxy(
|
|||||||
if (process.exitCode === 1) return;
|
if (process.exitCode === 1) return;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
forceAuth,
|
|
||||||
pasteCallback,
|
|
||||||
portForward,
|
|
||||||
forceHeadless,
|
|
||||||
forceLogout,
|
|
||||||
forceConfig,
|
forceConfig,
|
||||||
addAccount,
|
addAccount,
|
||||||
showAccounts,
|
showAccounts,
|
||||||
forceImport,
|
|
||||||
gitlabTokenLogin,
|
|
||||||
acceptAgyRisk,
|
|
||||||
noIncognito,
|
|
||||||
useAccount,
|
useAccount,
|
||||||
setNickname,
|
setNickname,
|
||||||
kiroAuthMethod,
|
|
||||||
kiroIDCStartUrl,
|
|
||||||
kiroIDCRegion,
|
|
||||||
kiroIDCFlow,
|
|
||||||
gitlabBaseUrl,
|
|
||||||
extendedContextOverride,
|
extendedContextOverride,
|
||||||
thinkingParse,
|
thinkingParse,
|
||||||
} = parsedFlags;
|
} = parsedFlags;
|
||||||
@@ -244,209 +230,51 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle --logout
|
// Build auth coordination context (used for logout/import/antigravity/oauth)
|
||||||
if (forceLogout) {
|
const authCtx = {
|
||||||
const { clearAuth } = await import('../auth/auth-handler');
|
provider,
|
||||||
if (clearAuth(provider)) {
|
compositeProviders,
|
||||||
console.log(ok(`Logged out from ${providerConfig.displayName}`));
|
parsedFlags,
|
||||||
} else {
|
cfg,
|
||||||
console.log(info(`No authentication found for ${providerConfig.displayName}`));
|
unifiedConfig,
|
||||||
}
|
verbose,
|
||||||
process.exit(0);
|
log,
|
||||||
}
|
};
|
||||||
|
|
||||||
// Handle --import (Kiro only)
|
// Handle --logout (early exit)
|
||||||
if (forceImport) {
|
await handleLogout(authCtx);
|
||||||
if (provider !== 'kiro') {
|
|
||||||
console.error(fail('--import is only available for Kiro'));
|
// Handle --import (early exit, Kiro only)
|
||||||
console.error(` Run "ccs ${provider} --auth" to authenticate`);
|
await handleImport(authCtx);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Ensure OAuth completed (if provider requires it)
|
// 3. Ensure OAuth completed (if provider requires it)
|
||||||
const remoteAuthToken = proxyConfig.authToken?.trim();
|
const remoteAuthToken = proxyConfig.authToken?.trim();
|
||||||
const skipLocalAuth = useRemoteProxy && !!remoteAuthToken;
|
const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy);
|
||||||
if (skipLocalAuth) {
|
if (skipLocalAuth) {
|
||||||
log(`Using remote proxy authentication (skipping local OAuth)`);
|
log(`Using remote proxy authentication (skipping local OAuth)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'agy' && forceAuth && skipLocalAuth) {
|
// Antigravity gate (runs before OAuth check)
|
||||||
const acknowledged = await ensureCliAntigravityResponsibility({
|
const { earlyReturn: agyEarlyReturn } = await runAntigravityGate(authCtx, skipLocalAuth);
|
||||||
context: 'oauth',
|
if (agyEarlyReturn) return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerConfig.requiresOAuth && !skipLocalAuth) {
|
if (providerConfig.requiresOAuth && !skipLocalAuth) {
|
||||||
log(`Checking authentication for ${provider}`);
|
await ensureProviderAuthentication(authCtx);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3b. Preflight quota check (providers with quota-based rotation)
|
// 3b. Preflight quota check (providers with quota-based rotation)
|
||||||
if (!skipLocalAuth) {
|
if (!skipLocalAuth) {
|
||||||
// Multi-tier quota check for composite variants (check if any tier uses a managed provider)
|
await runPreflightQuotaCheck(provider, compositeProviders);
|
||||||
if (compositeProviders.length > 0) {
|
|
||||||
for (const managedProvider of MANAGED_QUOTA_PROVIDERS) {
|
|
||||||
if (compositeProviders.includes(managedProvider)) {
|
|
||||||
await handleQuotaCheck(managedProvider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await handleQuotaCheck(provider);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3c. Account safety: enforce cross-provider isolation
|
// 3c. Account safety: enforce cross-provider isolation
|
||||||
if (!skipLocalAuth) {
|
if (!skipLocalAuth) {
|
||||||
applyAccountSafetyGuards(provider, compositeProviders);
|
runAccountSafetyGuards(provider, compositeProviders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. First-run model configuration
|
// 4. First-run model configuration + codex plan reconcile
|
||||||
if (!cfg.isComposite && supportsModelConfig(provider) && !skipLocalAuth) {
|
if (!skipLocalAuth) {
|
||||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
await ensureModelConfiguration(provider, cfg, verbose);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Check for broken models (multi-tier for composite)
|
// 5. Check for broken models (multi-tier for composite)
|
||||||
@@ -497,14 +325,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Ensure user settings file exists
|
// 6. Ensure user settings file exists
|
||||||
ensureProviderSettings(provider);
|
ensureProviderSettingsFile(provider);
|
||||||
|
|
||||||
if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) {
|
|
||||||
await reconcileCodexModelForActivePlan({
|
|
||||||
currentModel: getCurrentModel(provider, cfg.customSettingsPath),
|
|
||||||
verbose,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local proxy mode: generate config, spawn/join proxy, track session
|
// Local proxy mode: generate config, spawn/join proxy, track session
|
||||||
let proxy: ChildProcess | null = null;
|
let proxy: ChildProcess | null = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user