mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
refactor(cliproxy/executor): extract concerns from index.ts (#1166)
refactor(cliproxy/executor): extract concerns from index.ts (#1162)
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Unit tests for account-resolution.ts (Phase 05)
|
||||
*
|
||||
* Tests cover:
|
||||
* - resolveRuntimeQuotaMonitorProviders: single provider, composite, dedup
|
||||
* - resolveAccounts: --accounts early exit, --use switching, --nickname rename,
|
||||
* default touch (no --use), warnOAuthBanRisk delegation
|
||||
* - applyAccountSafetyGuards: delegates to safety functions (isolation + warn)
|
||||
*
|
||||
* Strategy: pure unit tests on the exported functions. Account-manager and
|
||||
* account-safety modules are mocked to avoid file I/O.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest, mock } from 'bun:test';
|
||||
|
||||
// ── resolveRuntimeQuotaMonitorProviders ───────────────────────────────────────
|
||||
|
||||
describe('resolveRuntimeQuotaMonitorProviders', () => {
|
||||
// Import the module under test directly (no heavy deps needed for this fn)
|
||||
it('returns empty array when provider is not managed', async () => {
|
||||
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
|
||||
const result = resolveRuntimeQuotaMonitorProviders('kiro', []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [provider] for single managed provider', async () => {
|
||||
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
|
||||
const result = resolveRuntimeQuotaMonitorProviders('agy', []);
|
||||
expect(result).toContain('agy');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns composite providers that are managed, deduped', async () => {
|
||||
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
|
||||
// agy is managed; kiro is not; duplicate agy should be deduped
|
||||
const result = resolveRuntimeQuotaMonitorProviders('gemini', ['agy', 'kiro', 'agy']);
|
||||
expect(result).toContain('agy');
|
||||
expect(result).not.toContain('kiro');
|
||||
expect(result.filter((p) => p === 'agy')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores base provider when compositeProviders is non-empty', async () => {
|
||||
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
|
||||
// base provider is gemini (managed), but composite list contains only kiro (not managed)
|
||||
const result = resolveRuntimeQuotaMonitorProviders('gemini', ['kiro']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveAccounts — --accounts early exit ───────────────────────────────────
|
||||
|
||||
describe('resolveAccounts — --accounts early exit', () => {
|
||||
let exitSpy: ReturnType<typeof jest.spyOn>;
|
||||
let logSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls process.exit(0) and returns earlyExit=true when showAccounts=true (no accounts)', async () => {
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [],
|
||||
findAccountByQuery: () => undefined,
|
||||
setDefaultAccount: () => {},
|
||||
touchAccount: () => {},
|
||||
renameAccount: () => true,
|
||||
getDefaultAccount: () => undefined,
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: (a: { email?: string }) => a.email ?? 'unknown',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
const result = await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: true,
|
||||
useAccount: undefined,
|
||||
setNickname: undefined,
|
||||
addAccount: false,
|
||||
});
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
expect(result.earlyExit).toBe(true);
|
||||
});
|
||||
|
||||
it('prints account list when accounts exist', async () => {
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [
|
||||
{ id: 'acc1', email: 'test@example.com', isDefault: true, nickname: 'main' },
|
||||
],
|
||||
findAccountByQuery: () => undefined,
|
||||
setDefaultAccount: () => {},
|
||||
touchAccount: () => {},
|
||||
renameAccount: () => true,
|
||||
getDefaultAccount: () => undefined,
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: () => 'test@example.com',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: true,
|
||||
useAccount: undefined,
|
||||
setNickname: undefined,
|
||||
addAccount: false,
|
||||
});
|
||||
|
||||
const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('test@example.com');
|
||||
expect(allOutput).toContain('(default)');
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveAccounts — --use switching ─────────────────────────────────────────
|
||||
|
||||
describe('resolveAccounts — --use switching', () => {
|
||||
let exitSpy: ReturnType<typeof jest.spyOn>;
|
||||
let logSpy: ReturnType<typeof jest.spyOn>;
|
||||
let errSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls setDefaultAccount + touchAccount and logs success', async () => {
|
||||
const setDefaultMock = jest.fn();
|
||||
const touchMock = jest.fn();
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [],
|
||||
findAccountByQuery: () => ({ id: 'acc1', email: 'user@example.com', nickname: undefined }),
|
||||
setDefaultAccount: setDefaultMock,
|
||||
touchAccount: touchMock,
|
||||
renameAccount: () => true,
|
||||
getDefaultAccount: () => undefined,
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: () => 'user@example.com',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: false,
|
||||
useAccount: 'user@example.com',
|
||||
setNickname: undefined,
|
||||
addAccount: false,
|
||||
});
|
||||
|
||||
expect(setDefaultMock).toHaveBeenCalledWith('gemini', 'acc1');
|
||||
expect(touchMock).toHaveBeenCalledWith('gemini', 'acc1');
|
||||
const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('Switched to account');
|
||||
});
|
||||
|
||||
it('calls process.exit(1) when account not found', async () => {
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [],
|
||||
findAccountByQuery: () => undefined,
|
||||
setDefaultAccount: () => {},
|
||||
touchAccount: () => {},
|
||||
renameAccount: () => true,
|
||||
getDefaultAccount: () => undefined,
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: () => 'x',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: false,
|
||||
useAccount: 'nonexistent',
|
||||
setNickname: undefined,
|
||||
addAccount: false,
|
||||
});
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveAccounts — --nickname rename ───────────────────────────────────────
|
||||
|
||||
describe('resolveAccounts — --nickname rename', () => {
|
||||
let exitSpy: ReturnType<typeof jest.spyOn>;
|
||||
let logSpy: ReturnType<typeof jest.spyOn>;
|
||||
let errSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renames default account and exits 0 on success', async () => {
|
||||
const renameMock = jest.fn(() => true);
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [],
|
||||
findAccountByQuery: () => undefined,
|
||||
setDefaultAccount: () => {},
|
||||
touchAccount: () => {},
|
||||
renameAccount: renameMock,
|
||||
getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }),
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: () => 'user@example.com',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: false,
|
||||
useAccount: undefined,
|
||||
setNickname: 'work',
|
||||
addAccount: false,
|
||||
});
|
||||
|
||||
expect(renameMock).toHaveBeenCalledWith('gemini', 'acc1', 'work');
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('exits 1 when no default account found', async () => {
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [],
|
||||
findAccountByQuery: () => undefined,
|
||||
setDefaultAccount: () => {},
|
||||
touchAccount: () => {},
|
||||
renameAccount: () => false,
|
||||
getDefaultAccount: () => undefined,
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: () => 'x',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: false,
|
||||
useAccount: undefined,
|
||||
setNickname: 'work',
|
||||
addAccount: false,
|
||||
});
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('skips rename when addAccount=true (--auth flow)', async () => {
|
||||
const renameMock = jest.fn(() => true);
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getProviderAccounts: () => [],
|
||||
findAccountByQuery: () => undefined,
|
||||
setDefaultAccount: () => {},
|
||||
touchAccount: () => {},
|
||||
renameAccount: renameMock,
|
||||
getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }),
|
||||
}));
|
||||
mock.module('../../accounts/email-account-identity', () => ({
|
||||
formatAccountDisplayName: () => 'user@example.com',
|
||||
}));
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: () => ({ displayName: 'Gemini' }),
|
||||
}));
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: () => false,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: () => 0,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { resolveAccounts } = await import('../account-resolution');
|
||||
const result = await resolveAccounts({
|
||||
provider: 'gemini',
|
||||
showAccounts: false,
|
||||
useAccount: undefined,
|
||||
setNickname: 'work',
|
||||
addAccount: true, // suppresses rename
|
||||
});
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled();
|
||||
expect(result.earlyExit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyAccountSafetyGuards — delegation ─────────────────────────────────────
|
||||
|
||||
describe('applyAccountSafetyGuards', () => {
|
||||
it('calls cleanupStaleAutoPauses and enforceProviderIsolation', async () => {
|
||||
const cleanupMock = jest.fn();
|
||||
const enforceMock = jest.fn(() => 0);
|
||||
const warnDupMock = jest.fn(() => false);
|
||||
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: warnDupMock,
|
||||
cleanupStaleAutoPauses: cleanupMock,
|
||||
enforceProviderIsolation: enforceMock,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { applyAccountSafetyGuards } = await import('../account-resolution');
|
||||
applyAccountSafetyGuards('gemini', []);
|
||||
|
||||
expect(cleanupMock).toHaveBeenCalledTimes(1);
|
||||
expect(enforceMock).toHaveBeenCalledWith('gemini');
|
||||
});
|
||||
|
||||
it('calls warnCrossProviderDuplicates when isolation returns 0', async () => {
|
||||
const warnDupMock = jest.fn(() => false);
|
||||
const enforceMock = jest.fn(() => 0);
|
||||
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: warnDupMock,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: enforceMock,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { applyAccountSafetyGuards } = await import('../account-resolution');
|
||||
applyAccountSafetyGuards('gemini', []);
|
||||
|
||||
expect(warnDupMock).toHaveBeenCalledWith('gemini');
|
||||
});
|
||||
|
||||
it('does NOT call warnCrossProviderDuplicates when isolation is enforced', async () => {
|
||||
const warnDupMock = jest.fn(() => false);
|
||||
const enforceMock = jest.fn(() => 2); // 2 accounts isolated
|
||||
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
warnOAuthBanRisk: () => {},
|
||||
warnCrossProviderDuplicates: warnDupMock,
|
||||
cleanupStaleAutoPauses: () => {},
|
||||
enforceProviderIsolation: enforceMock,
|
||||
restoreAutoPausedAccounts: () => {},
|
||||
}));
|
||||
|
||||
const { applyAccountSafetyGuards } = await import('../account-resolution');
|
||||
applyAccountSafetyGuards('gemini', []);
|
||||
|
||||
expect(warnDupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Unit tests for arg-parser.ts (Phase 02)
|
||||
*
|
||||
* Tests cover:
|
||||
* - readOptionValue: --flag value and --flag=value forms
|
||||
* - hasGitLabTokenLoginFlag
|
||||
* - filterCcsFlags / CCS_FLAGS
|
||||
* - parseExecutorFlags: parse output and early-exit behavior
|
||||
* - validateFlagCombinations: pass and fail cases
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import {
|
||||
readOptionValue,
|
||||
hasGitLabTokenLoginFlag,
|
||||
CCS_FLAGS,
|
||||
filterCcsFlags,
|
||||
parseExecutorFlags,
|
||||
validateFlagCombinations,
|
||||
type ParsedExecutorFlags,
|
||||
} from '../arg-parser';
|
||||
import type { UnifiedConfig } from '../../../config/unified-config-types';
|
||||
|
||||
/** Minimal stub for UnifiedConfig — avoids loading js-yaml via the full loader. */
|
||||
function makeEmptyUnifiedConfig(): UnifiedConfig {
|
||||
return {} as UnifiedConfig;
|
||||
}
|
||||
|
||||
// ── readOptionValue ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('readOptionValue', () => {
|
||||
it('parses space-separated form: --flag value', () => {
|
||||
expect(
|
||||
readOptionValue(
|
||||
['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'],
|
||||
'--kiro-idc-start-url'
|
||||
)
|
||||
).toEqual({
|
||||
present: true,
|
||||
value: 'https://d-123.awsapps.com/start',
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses equals form: --flag=value', () => {
|
||||
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
|
||||
present: true,
|
||||
value: 'device',
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns missingValue=true when flag present but no value (next is a flag)', () => {
|
||||
expect(readOptionValue(['--kiro-idc-region', '--other-flag'], '--kiro-idc-region')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns missingValue=true when flag is last arg', () => {
|
||||
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns missingValue=true for empty equals form: --flag=', () => {
|
||||
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns present=false when flag not in args', () => {
|
||||
expect(readOptionValue(['--other', 'val'], '--kiro-idc-region')).toEqual({
|
||||
present: false,
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the value', () => {
|
||||
const result = readOptionValue(
|
||||
['--gitlab-url', ' https://gitlab.example.com '],
|
||||
'--gitlab-url'
|
||||
);
|
||||
expect(result.value).toBe('https://gitlab.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasGitLabTokenLoginFlag ────────────────────────────────────────────────────
|
||||
|
||||
describe('hasGitLabTokenLoginFlag', () => {
|
||||
it('detects --gitlab-token-login', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true);
|
||||
});
|
||||
|
||||
it('detects --token-login', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when neither flag present', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
|
||||
expect(hasGitLabTokenLoginFlag([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CCS_FLAGS / filterCcsFlags ─────────────────────────────────────────────────
|
||||
|
||||
describe('CCS_FLAGS and filterCcsFlags', () => {
|
||||
it('CCS_FLAGS includes known CCS-specific flags', () => {
|
||||
expect(CCS_FLAGS).toContain('--auth');
|
||||
expect(CCS_FLAGS).toContain('--accounts');
|
||||
expect(CCS_FLAGS).toContain('--use');
|
||||
expect(CCS_FLAGS).toContain('--kiro-auth-method');
|
||||
expect(CCS_FLAGS).toContain('--thinking');
|
||||
expect(CCS_FLAGS).toContain('--1m');
|
||||
expect(CCS_FLAGS).toContain('--no-1m');
|
||||
expect(CCS_FLAGS).toContain('--proxy-host');
|
||||
});
|
||||
|
||||
it('filterCcsFlags strips known flags and their values', () => {
|
||||
const args = ['--use', 'myaccount', '--print', 'hello'];
|
||||
expect(filterCcsFlags(args)).toEqual(['--print', 'hello']);
|
||||
});
|
||||
|
||||
it('filterCcsFlags strips --auth (no value)', () => {
|
||||
expect(filterCcsFlags(['--auth', '--some-claude-flag'])).toEqual(['--some-claude-flag']);
|
||||
});
|
||||
|
||||
it('filterCcsFlags strips equals-form flags', () => {
|
||||
const args = ['--kiro-auth-method=aws', '--model', 'claude-3'];
|
||||
expect(filterCcsFlags(args)).toEqual(['--model', 'claude-3']);
|
||||
});
|
||||
|
||||
it('filterCcsFlags strips --thinking=value', () => {
|
||||
expect(filterCcsFlags(['--thinking=high', '--dangerously-skip-permissions'])).toEqual([
|
||||
'--dangerously-skip-permissions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filterCcsFlags preserves non-CCS args untouched', () => {
|
||||
const args = ['--model', 'claude-opus-4-5', '--verbose'];
|
||||
expect(filterCcsFlags(args)).toEqual(args);
|
||||
});
|
||||
|
||||
it('filterCcsFlags strips --effort and its value', () => {
|
||||
const args = ['--effort', 'xhigh', '--print'];
|
||||
expect(filterCcsFlags(args)).toEqual(['--print']);
|
||||
});
|
||||
|
||||
it('filterCcsFlags strips --1m= and --no-1m= inline forms', () => {
|
||||
expect(filterCcsFlags(['--1m=true'])).toEqual([]);
|
||||
expect(filterCcsFlags(['--no-1m=true'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseExecutorFlags ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseExecutorFlags', () => {
|
||||
let originalExitCode: number | undefined;
|
||||
let errorSpy: ReturnType<typeof jest.spyOn>;
|
||||
let exitSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode as number | undefined;
|
||||
process.exitCode = 0;
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
function makeCtx(provider = 'gemini', compositeProviders: string[] = []) {
|
||||
return { provider, compositeProviders, unifiedConfig: makeEmptyUnifiedConfig() };
|
||||
}
|
||||
|
||||
it('returns default false/undefined for bare args', () => {
|
||||
const result = parseExecutorFlags([], makeCtx());
|
||||
expect(result.forceAuth).toBe(false);
|
||||
expect(result.showAccounts).toBe(false);
|
||||
expect(result.useAccount).toBeUndefined();
|
||||
expect(result.kiroAuthMethod).toBeUndefined();
|
||||
expect(result.extendedContextOverride).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects --auth flag', () => {
|
||||
const result = parseExecutorFlags(['--auth'], makeCtx());
|
||||
expect(result.forceAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('detects --accounts flag', () => {
|
||||
const result = parseExecutorFlags(['--accounts'], makeCtx());
|
||||
expect(result.showAccounts).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --use value', () => {
|
||||
const result = parseExecutorFlags(['--use', 'myaccount'], makeCtx());
|
||||
expect(result.useAccount).toBe('myaccount');
|
||||
});
|
||||
|
||||
it('parses --nickname value', () => {
|
||||
const result = parseExecutorFlags(['--nickname', 'mynick'], makeCtx());
|
||||
expect(result.setNickname).toBe('mynick');
|
||||
});
|
||||
|
||||
it('parses --kiro-auth-method=aws for kiro provider', () => {
|
||||
const result = parseExecutorFlags(['--kiro-auth-method=aws'], makeCtx('kiro'));
|
||||
expect(result.kiroAuthMethod).toBe('aws');
|
||||
});
|
||||
|
||||
it('sets process.exitCode=1 and returns on invalid --kiro-auth-method value', () => {
|
||||
parseExecutorFlags(['--kiro-auth-method=invalid-method'], makeCtx('kiro'));
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets process.exitCode=1 and returns on missing --kiro-auth-method value', () => {
|
||||
parseExecutorFlags(['--kiro-auth-method'], makeCtx('kiro'));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('parses --kiro-idc-start-url', () => {
|
||||
const result = parseExecutorFlags(
|
||||
['--kiro-auth-method=idc', '--kiro-idc-start-url', 'https://d-xxx.awsapps.com/start'],
|
||||
makeCtx('kiro')
|
||||
);
|
||||
expect(result.kiroIDCStartUrl).toBe('https://d-xxx.awsapps.com/start');
|
||||
expect(result.kiroAuthMethod).toBe('idc');
|
||||
});
|
||||
|
||||
it('auto-sets kiroAuthMethod=idc when IDC sub-flags present', () => {
|
||||
const result = parseExecutorFlags(
|
||||
['--kiro-idc-start-url', 'https://d-xxx.awsapps.com/start'],
|
||||
makeCtx('kiro')
|
||||
);
|
||||
expect(result.kiroAuthMethod).toBe('idc');
|
||||
});
|
||||
|
||||
it('parses --1m → extendedContextOverride=true', () => {
|
||||
expect(parseExecutorFlags(['--1m'], makeCtx()).extendedContextOverride).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --no-1m → extendedContextOverride=false', () => {
|
||||
expect(parseExecutorFlags(['--no-1m'], makeCtx()).extendedContextOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('calls process.exit(1) when --1m and --no-1m both present', () => {
|
||||
parseExecutorFlags(['--1m', '--no-1m'], makeCtx());
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('calls process.exit(1) when --paste-callback and --port-forward both present', () => {
|
||||
parseExecutorFlags(['--paste-callback', '--port-forward'], makeCtx());
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('kiro noIncognito defaults to true when provider=kiro and no override', () => {
|
||||
const result = parseExecutorFlags([], makeCtx('kiro'));
|
||||
expect(result.noIncognito).toBe(true);
|
||||
});
|
||||
|
||||
it('--incognito overrides kiro default noIncognito', () => {
|
||||
const result = parseExecutorFlags(['--incognito'], makeCtx('kiro'));
|
||||
expect(result.noIncognito).toBe(false);
|
||||
});
|
||||
|
||||
it('parses gitlabTokenLogin correctly', () => {
|
||||
expect(parseExecutorFlags(['--gitlab-token-login'], makeCtx('gitlab')).gitlabTokenLogin).toBe(
|
||||
true
|
||||
);
|
||||
expect(parseExecutorFlags(['--token-login'], makeCtx('gitlab')).gitlabTokenLogin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateFlagCombinations ───────────────────────────────────────────────────
|
||||
|
||||
describe('validateFlagCombinations', () => {
|
||||
let originalExitCode: number | undefined;
|
||||
let errorSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode as number | undefined;
|
||||
process.exitCode = 0;
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
function baseFlags() {
|
||||
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 as string | undefined,
|
||||
setNickname: undefined as string | undefined,
|
||||
kiroAuthMethod: undefined as ReturnType<typeof parseExecutorFlags>['kiroAuthMethod'],
|
||||
kiroIDCStartUrl: undefined as string | undefined,
|
||||
kiroIDCRegion: undefined as string | undefined,
|
||||
kiroIDCFlow: undefined as ReturnType<typeof parseExecutorFlags>['kiroIDCFlow'],
|
||||
gitlabBaseUrl: undefined as string | undefined,
|
||||
extendedContextOverride: undefined as boolean | undefined,
|
||||
thinkingParse: {
|
||||
value: null,
|
||||
error: null,
|
||||
sourceFlag: null,
|
||||
sourceDisplay: null,
|
||||
duplicateDisplays: [],
|
||||
} as unknown as ReturnType<typeof parseExecutorFlags>['thinkingParse'],
|
||||
};
|
||||
}
|
||||
|
||||
it('passes validation for clean gemini flags', () => {
|
||||
validateFlagCombinations(baseFlags(), { provider: 'gemini', compositeProviders: [] }, []);
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets exitCode=1 when --kiro-auth-method used with non-kiro provider', () => {
|
||||
const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const };
|
||||
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, [
|
||||
'--kiro-auth-method=aws',
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when kiro-auth-method used with kiro provider', () => {
|
||||
const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const };
|
||||
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, [
|
||||
'--kiro-auth-method=aws',
|
||||
]);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('sets exitCode=1 when IDC sub-flags used without kiro provider', () => {
|
||||
const flags = { ...baseFlags(), kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start' };
|
||||
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, []);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('sets exitCode=1 when kiro IDC method missing --kiro-idc-start-url', () => {
|
||||
const flags = { ...baseFlags(), kiroAuthMethod: 'idc' as const };
|
||||
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--kiro-idc-start-url'));
|
||||
});
|
||||
|
||||
it('passes when IDC method has start-url', () => {
|
||||
const flags = {
|
||||
...baseFlags(),
|
||||
kiroAuthMethod: 'idc' as const,
|
||||
kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start',
|
||||
};
|
||||
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('sets exitCode=1 when non-idc method used with IDC sub-flags', () => {
|
||||
const flags = {
|
||||
...baseFlags(),
|
||||
kiroAuthMethod: 'aws' as const,
|
||||
kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start',
|
||||
};
|
||||
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('sets exitCode=1 when --gitlab-token-login used with non-gitlab provider', () => {
|
||||
const flags = { ...baseFlags(), gitlabTokenLogin: true };
|
||||
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, [
|
||||
'--gitlab-token-login',
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('gitlab'));
|
||||
});
|
||||
|
||||
it('passes --gitlab-token-login with gitlab provider', () => {
|
||||
const flags = { ...baseFlags(), gitlabTokenLogin: true };
|
||||
validateFlagCombinations(flags, { provider: 'gitlab', compositeProviders: [] }, [
|
||||
'--gitlab-token-login',
|
||||
]);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('passes kiro flags when kiro is a composite provider', () => {
|
||||
const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const };
|
||||
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: ['kiro'] }, []);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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,184 @@
|
||||
/**
|
||||
* Unit tests for browser-launch-setup.ts (Phase 04)
|
||||
*
|
||||
* Tests cover:
|
||||
* - resolveBrowserLaunchFlags: no flags (default), --browser-launch override,
|
||||
* blocked override warning emitted, process.exit on parse error
|
||||
* - resolveBrowserRuntime: no attach (disabled), active runtime env, MCP sync
|
||||
* error propagation
|
||||
*
|
||||
* Strategy: mock the utils/browser and unified-config-loader modules so that
|
||||
* no real browser detection or file I/O occurs.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test';
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal BrowserConfig stub */
|
||||
function makeBrowserConfig(enabled = false, policy: 'auto' | 'always' | 'never' = 'auto'): object {
|
||||
return {
|
||||
claude: { enabled, policy, user_data_dir: '', devtools_port: 9222 },
|
||||
codex: { enabled: false, policy: 'auto' },
|
||||
};
|
||||
}
|
||||
|
||||
// ── resolveBrowserLaunchFlags — no flags ──────────────────────────────────────
|
||||
|
||||
describe('resolveBrowserLaunchFlags — no browser flags', () => {
|
||||
it('returns undefined override and passes args through unchanged', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (_args: string[]) => ({
|
||||
override: undefined,
|
||||
argsWithoutFlags: _args,
|
||||
}),
|
||||
getBlockedBrowserOverrideWarning: () => null,
|
||||
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }),
|
||||
resolveBrowserExposure: () => ({ exposeForLaunch: false }),
|
||||
ensureBrowserMcpOrThrow: () => true,
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(false),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
}));
|
||||
|
||||
const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup');
|
||||
const args = ['--model', 'claude-opus-4-5'];
|
||||
const result = resolveBrowserLaunchFlags(args);
|
||||
expect(result.browserLaunchOverride).toBeUndefined();
|
||||
expect(result.argsWithoutBrowserFlags).toEqual(args);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveBrowserLaunchFlags — --browser-launch override ─────────────────────
|
||||
|
||||
describe('resolveBrowserLaunchFlags — with browser-launch override', () => {
|
||||
it('returns override and strips the browser flag from args', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (_args: string[]) => ({
|
||||
override: 'force-enable' as const,
|
||||
argsWithoutFlags: ['--model', 'claude-opus-4-5'],
|
||||
}),
|
||||
getBlockedBrowserOverrideWarning: () => null,
|
||||
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }),
|
||||
resolveBrowserExposure: () => ({ exposeForLaunch: true }),
|
||||
ensureBrowserMcpOrThrow: () => true,
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(true, 'auto'),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
}));
|
||||
|
||||
const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup');
|
||||
const result = resolveBrowserLaunchFlags(['--browser-launch', '--model', 'claude-opus-4-5']);
|
||||
expect(result.browserLaunchOverride).toBe('force-enable');
|
||||
expect(result.argsWithoutBrowserFlags).toEqual(['--model', 'claude-opus-4-5']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveBrowserLaunchFlags — blocked override warning emitted ──────────────
|
||||
|
||||
describe('resolveBrowserLaunchFlags — blocked override warning', () => {
|
||||
let stderrSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('emits warn() when getBlockedBrowserOverrideWarning returns a message', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (args: string[]) => ({
|
||||
override: 'force-enable' as const,
|
||||
argsWithoutFlags: args,
|
||||
}),
|
||||
getBlockedBrowserOverrideWarning: () => 'Browser override is blocked by policy',
|
||||
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }),
|
||||
resolveBrowserExposure: () => ({ exposeForLaunch: false }),
|
||||
ensureBrowserMcpOrThrow: () => true,
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(false, 'never'),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
}));
|
||||
|
||||
const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup');
|
||||
resolveBrowserLaunchFlags(['--model', 'claude-opus-4-5']);
|
||||
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
||||
expect(stderrSpy.mock.calls[0][0]).toContain('Browser override is blocked by policy');
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveBrowserRuntime — attach disabled ───────────────────────────────────
|
||||
|
||||
describe('resolveBrowserRuntime — attach disabled', () => {
|
||||
it('returns undefined browserRuntimeEnv when browser attach is disabled', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (a: string[]) => ({
|
||||
override: undefined,
|
||||
argsWithoutFlags: a,
|
||||
}),
|
||||
getBlockedBrowserOverrideWarning: () => null,
|
||||
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }),
|
||||
resolveBrowserExposure: () => ({ exposeForLaunch: false }),
|
||||
ensureBrowserMcpOrThrow: () => true,
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(false),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
}));
|
||||
|
||||
const { resolveBrowserRuntime } = await import('../browser-launch-setup');
|
||||
const result = await resolveBrowserRuntime(undefined, undefined);
|
||||
expect(result.browserRuntimeEnv).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveBrowserRuntime — active runtime env ────────────────────────────────
|
||||
|
||||
describe('resolveBrowserRuntime — active runtime env', () => {
|
||||
it('returns runtimeEnv when browser attach resolves successfully', async () => {
|
||||
const fakeRuntimeEnv = { CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1:9222/json' };
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (a: string[]) => ({
|
||||
override: 'force-enable' as const,
|
||||
argsWithoutFlags: a,
|
||||
}),
|
||||
getBlockedBrowserOverrideWarning: () => null,
|
||||
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }),
|
||||
resolveBrowserExposure: () => ({ exposeForLaunch: true }),
|
||||
ensureBrowserMcpOrThrow: () => true,
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: fakeRuntimeEnv }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(true, 'always'),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
}));
|
||||
|
||||
const { resolveBrowserRuntime } = await import('../browser-launch-setup');
|
||||
const result = await resolveBrowserRuntime('force-enable', undefined);
|
||||
expect(result.browserRuntimeEnv).toEqual(fakeRuntimeEnv);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tests for claude-launcher.ts — Phase 09
|
||||
*
|
||||
* Verifies:
|
||||
* - spawn called with expected args and env
|
||||
* - Windows shell escaping path
|
||||
* - Cleanup handlers registered (setupCleanupHandlers called)
|
||||
*
|
||||
* Strategy: mock child_process.spawn and all side-effectful dependencies so
|
||||
* no real processes are started.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
|
||||
// ── Spawn mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockSpawnResult = {
|
||||
pid: 42,
|
||||
on: jest.fn(),
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
const mockSpawn = jest.fn().mockReturnValue(mockSpawnResult);
|
||||
|
||||
mock.module('child_process', () => ({
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
// ── Dependency mocks ──────────────────────────────────────────────────────────
|
||||
|
||||
const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`);
|
||||
const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe');
|
||||
|
||||
mock.module('../../utils/shell-executor', () => ({
|
||||
escapeShellArg: mockEscapeShellArg,
|
||||
getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell,
|
||||
}));
|
||||
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
getProviderSettingsPath: jest.fn().mockReturnValue('/fake/.ccs/settings/gemini.json'),
|
||||
CLIPROXY_DEFAULT_PORT: 8317,
|
||||
generateConfig: jest.fn(),
|
||||
getProviderConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/websearch-manager', () => ({
|
||||
appendThirdPartyWebSearchToolArgs: (args: string[]) => args,
|
||||
createWebSearchTraceContext: jest.fn().mockReturnValue({}),
|
||||
ensureWebSearchMcpOrThrow: jest.fn(),
|
||||
displayWebSearchStatus: jest.fn(),
|
||||
getWebSearchHookEnv: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/image-analysis', () => ({
|
||||
appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'],
|
||||
syncImageAnalysisMcpToConfigDir: jest.fn(),
|
||||
ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (args: string[]) => [...args, '--browser'],
|
||||
}));
|
||||
|
||||
mock.module('../accounts/account-manager', () => ({
|
||||
getDefaultAccount: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockSetupCleanupHandlers = jest.fn();
|
||||
mock.module('../session-bridge', () => ({
|
||||
setupCleanupHandlers: mockSetupCleanupHandlers,
|
||||
checkOrJoinProxy: jest.fn(),
|
||||
registerProxySession: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockResolveRuntimeQuotaMonitorProviders = jest.fn().mockReturnValue([]);
|
||||
mock.module('../account-resolution', () => ({
|
||||
resolveRuntimeQuotaMonitorProviders: mockResolveRuntimeQuotaMonitorProviders,
|
||||
resolveAccounts: jest.fn(),
|
||||
}));
|
||||
|
||||
// Dynamic import for quota-manager
|
||||
mock.module('../quota/quota-manager', () => ({
|
||||
startQuotaMonitor: jest.fn(),
|
||||
stopQuotaMonitor: jest.fn(),
|
||||
}));
|
||||
|
||||
// ── Subject under test ────────────────────────────────────────────────────────
|
||||
|
||||
import { launchClaude } from '../claude-launcher';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
...overrides,
|
||||
} as ExecutorConfig;
|
||||
}
|
||||
|
||||
function baseContext(overrides: object = {}) {
|
||||
return {
|
||||
claudeCli: '/usr/local/bin/claude',
|
||||
claudeArgs: ['chat', '--model', 'gemini-2.5-pro'],
|
||||
env: { ANTHROPIC_BASE_URL: 'http://localhost:8317' } as NodeJS.ProcessEnv,
|
||||
cfg: makeCfg(),
|
||||
provider: 'gemini' as const,
|
||||
compositeProviders: [] as string[],
|
||||
skipLocalAuth: true,
|
||||
sessionId: undefined,
|
||||
imageAnalysisMcpReady: false,
|
||||
browserRuntimeEnv: undefined,
|
||||
inheritedClaudeConfigDir: undefined,
|
||||
codexReasoningProxy: null,
|
||||
toolSanitizationProxy: null,
|
||||
httpsTunnel: null,
|
||||
verbose: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('launchClaude', () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn.mockClear();
|
||||
mockSetupCleanupHandlers.mockClear();
|
||||
mockEscapeShellArg.mockClear();
|
||||
});
|
||||
|
||||
it('calls spawn with claudeCli and includes --settings arg', async () => {
|
||||
const ctx = baseContext();
|
||||
await launchClaude(ctx);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, spawnArgs] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||
expect(cmd).toBe('/usr/local/bin/claude');
|
||||
expect(spawnArgs).toContain('--settings');
|
||||
});
|
||||
|
||||
it('inherits process env merged with provided env', async () => {
|
||||
const ctx = baseContext({ env: { CUSTOM_KEY: 'custom-value' } as NodeJS.ProcessEnv });
|
||||
await launchClaude(ctx);
|
||||
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv };
|
||||
expect(spawnOpts.env?.CUSTOM_KEY).toBe('custom-value');
|
||||
});
|
||||
|
||||
it('passes stdio: inherit', async () => {
|
||||
await launchClaude(baseContext());
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { stdio: string };
|
||||
expect(spawnOpts.stdio).toBe('inherit');
|
||||
});
|
||||
|
||||
it('appends image analysis args when imageAnalysisMcpReady=true', async () => {
|
||||
await launchClaude(baseContext({ imageAnalysisMcpReady: true }));
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--mcp-image-analysis');
|
||||
});
|
||||
|
||||
it('does not append image analysis args when imageAnalysisMcpReady=false', async () => {
|
||||
await launchClaude(baseContext({ imageAnalysisMcpReady: false }));
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).not.toContain('--mcp-image-analysis');
|
||||
});
|
||||
|
||||
it('appends browser tool args when browserRuntimeEnv is set', async () => {
|
||||
await launchClaude(
|
||||
baseContext({ browserRuntimeEnv: { CCS_BROWSER: '1' } as NodeJS.ProcessEnv })
|
||||
);
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--browser');
|
||||
});
|
||||
|
||||
it('registers cleanup handlers after spawn', async () => {
|
||||
await launchClaude(baseContext());
|
||||
expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1);
|
||||
// First arg should be the ChildProcess returned by spawn
|
||||
expect(mockSetupCleanupHandlers.mock.calls[0][0]).toBe(mockSpawnResult);
|
||||
});
|
||||
|
||||
it('returns the ChildProcess from spawn', async () => {
|
||||
const result = await launchClaude(baseContext());
|
||||
expect(result).toBe(mockSpawnResult);
|
||||
});
|
||||
|
||||
describe('Windows shell escaping', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
});
|
||||
|
||||
it('uses shell mode for .cmd executables on Windows', async () => {
|
||||
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' }));
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean };
|
||||
// shell property should be set (cmd.exe from mock)
|
||||
expect(spawnOpts.shell).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips shell mode for non-script executables on Windows', async () => {
|
||||
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.exe' }));
|
||||
// spawn should be called with separate args array, not a shell cmd string
|
||||
const [, secondArg] = mockSpawn.mock.calls[0];
|
||||
expect(Array.isArray(secondArg)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Characterization tests — CLIProxy Executor (Phase 01)
|
||||
*
|
||||
* Goal: lock in the current observable behavior of the executor's flag-parsing
|
||||
* and validation pipeline so that subsequent module extractions (Phases 03–10)
|
||||
* can be proven non-behavior-changing.
|
||||
*
|
||||
* Approach:
|
||||
* - Test the re-exported surface of index.ts to verify backwards compat.
|
||||
* - Full execClaudeWithCLIProxy integration scenarios (spawn-level) require
|
||||
* mocking the entire module graph including js-yaml / cli-table3 native
|
||||
* deps not installed on this worktree. Those scenarios are marked it.skip
|
||||
* with clear rationale; they will be enabled once bun install is run in CI.
|
||||
* - The meaningful behavioral contracts (flag parsing, CCS flag filtering,
|
||||
* validation guards) are fully tested via arg-parser.test.ts (Phase 02).
|
||||
* These characterization tests deliberately duplicate the surface-level
|
||||
* assertions to confirm index.ts still re-exports the same behavior.
|
||||
*
|
||||
* When to unskip the skipped tests:
|
||||
* Run `bun install` in the worktree so js-yaml and cli-table3 are available,
|
||||
* then remove the `.skip` and implement full spawn mocks with mock.module().
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// ── Surface re-export verification ────────────────────────────────────────────
|
||||
// Confirm that readOptionValue / hasGitLabTokenLoginFlag / CCS_FLAGS / filterCcsFlags
|
||||
// behave correctly. These are re-exported from index.ts; we import from arg-parser
|
||||
// directly here because index.ts transitively loads js-yaml / cli-table3 native
|
||||
// packages that are not installed in this worktree (no bun install run).
|
||||
// The re-export contract is verified structurally by TypeScript (the export block
|
||||
// in index.ts would fail tsc if the symbols were missing from arg-parser.ts).
|
||||
|
||||
import { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from '../arg-parser';
|
||||
|
||||
describe('index.ts re-export surface (backwards compatibility)', () => {
|
||||
// ── readOptionValue ────────────────────────────────────────────────────────
|
||||
|
||||
describe('readOptionValue', () => {
|
||||
it('parses split-token form: --flag value', () => {
|
||||
expect(
|
||||
readOptionValue(
|
||||
['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'],
|
||||
'--kiro-idc-start-url'
|
||||
)
|
||||
).toEqual({ present: true, value: 'https://d-123.awsapps.com/start', missingValue: false });
|
||||
});
|
||||
|
||||
it('parses equals form: --flag=value', () => {
|
||||
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
|
||||
present: true,
|
||||
value: 'device',
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns missingValue=true for bare flag with no value', () => {
|
||||
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns missingValue=true for empty equals form', () => {
|
||||
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns present=false when flag absent', () => {
|
||||
expect(readOptionValue(['--other'], '--kiro-idc-region')).toEqual({
|
||||
present: false,
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasGitLabTokenLoginFlag ────────────────────────────────────────────────
|
||||
|
||||
describe('hasGitLabTokenLoginFlag', () => {
|
||||
it('detects --gitlab-token-login', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true);
|
||||
});
|
||||
|
||||
it('detects --token-login', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when neither flag present', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CCS_FLAGS + filterCcsFlags ─────────────────────────────────────────────
|
||||
|
||||
describe('CCS_FLAGS', () => {
|
||||
it('is a non-empty readonly array', () => {
|
||||
expect(Array.isArray(CCS_FLAGS)).toBe(true);
|
||||
expect(CCS_FLAGS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('contains the core CCS flags', () => {
|
||||
const expected = [
|
||||
'--auth',
|
||||
'--accounts',
|
||||
'--use',
|
||||
'--thinking',
|
||||
'--1m',
|
||||
'--no-1m',
|
||||
'--proxy-host',
|
||||
];
|
||||
for (const flag of expected) {
|
||||
expect(CCS_FLAGS).toContain(flag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCcsFlags', () => {
|
||||
it('removes --auth and passes through non-CCS args', () => {
|
||||
expect(filterCcsFlags(['--auth', '--verbose'])).toEqual(['--verbose']);
|
||||
});
|
||||
|
||||
it('removes --use and its value argument', () => {
|
||||
expect(filterCcsFlags(['--use', 'myaccount', '--print'])).toEqual(['--print']);
|
||||
});
|
||||
|
||||
it('removes --kiro-auth-method= inline form', () => {
|
||||
expect(filterCcsFlags(['--kiro-auth-method=aws', '--model', 'claude-3'])).toEqual([
|
||||
'--model',
|
||||
'claude-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes --thinking= inline form', () => {
|
||||
expect(filterCcsFlags(['--thinking=high', '--dangerously-skip-permissions'])).toEqual([
|
||||
'--dangerously-skip-permissions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves non-CCS args', () => {
|
||||
const args = ['--model', 'claude-opus-4-5', '--print', 'hello world'];
|
||||
expect(filterCcsFlags(args)).toEqual(args);
|
||||
});
|
||||
|
||||
it('removes empty args list to empty result', () => {
|
||||
expect(filterCcsFlags([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── execClaudeWithCLIProxy integration scenarios (skipped — native deps) ──────
|
||||
//
|
||||
// These scenarios characterize the end-to-end spawn behavior.
|
||||
// They are skipped because the worktree's bun install has not been run,
|
||||
// so js-yaml and cli-table3 are missing. Enable after `bun install`.
|
||||
//
|
||||
// Mock strategy (for when unskipped):
|
||||
// - mock.module('child_process', ...) to capture spawn args
|
||||
// - mock.module('../auth/auth-handler', ...) to stub isAuthenticated + triggerOAuth
|
||||
// - mock.module('../services/remote-proxy-client', ...) to stub checkRemoteProxy
|
||||
// - mock.module('../binary-manager', ...) to stub ensureCLIProxyBinary
|
||||
// - mock.module('../../config/unified-config-loader', ...) to return minimal config
|
||||
// - Set CCS_HOME to a temp dir to avoid touching ~/.ccs
|
||||
|
||||
describe.skip('execClaudeWithCLIProxy — integration scenarios (requires bun install)', () => {
|
||||
let tmpHome = '';
|
||||
let fakeClaudePath = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-exec-characterization-'));
|
||||
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
|
||||
fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Scenario 1: --accounts listing exits without spawning Claude
|
||||
it('--accounts exits with code 0 and does not spawn Claude', async () => {
|
||||
// TODO: mock child_process.spawn, assert it was NOT called
|
||||
// TODO: mock getProviderAccounts to return []
|
||||
// TODO: assert process.exit called with 0
|
||||
});
|
||||
|
||||
// Scenario 2: invalid kiro flag combination → exits code 1
|
||||
it('invalid kiro flag combination calls process.exit(1)', async () => {
|
||||
// --kiro-auth-method used with non-kiro provider
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
// TODO: import execClaudeWithCLIProxy and call with gemini + --kiro-auth-method=aws
|
||||
// await execClaudeWithCLIProxy(fakeClaudePath, 'gemini', ['--kiro-auth-method=aws'], {});
|
||||
// expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 3: --auth flow exits without spawning Claude
|
||||
it('--auth (forceAuth) exits after OAuth without spawning Claude CLI', async () => {
|
||||
// TODO: mock triggerOAuth to resolve true
|
||||
// TODO: assert spawn not called, process.exit(0) called
|
||||
});
|
||||
|
||||
// Scenario 4: gemini local profile — verify spawn args subset
|
||||
it('gemini local profile — spawn includes ANTHROPIC_BASE_URL pointing to local port', async () => {
|
||||
// TODO: capture spawn(claudeCli, args, opts) call
|
||||
// TODO: assert opts.env.ANTHROPIC_BASE_URL includes 'localhost' or '127.0.0.1'
|
||||
// TODO: assert args does NOT include CCS-specific flags
|
||||
});
|
||||
|
||||
// Scenario 5: codex local with reasoning proxy — spawn ordering
|
||||
it('codex local — codex reasoning proxy started before Claude spawn', async () => {
|
||||
// TODO: assert CodexReasoningProxy.start() called before spawn
|
||||
// TODO: assert env.ANTHROPIC_BASE_URL points to reasoning proxy port
|
||||
});
|
||||
|
||||
// Scenario 6: composite remote https tunnel — tool sanitization started
|
||||
it('composite remote https — tool sanitization proxy and HTTPS tunnel started', async () => {
|
||||
// TODO: mock checkRemoteProxy to return { reachable: true, latencyMs: 5 }
|
||||
// TODO: assert HttpsTunnelProxy.start() called (when shouldStartHttpsTunnel returns true)
|
||||
// TODO: assert ToolSanitizationProxy.start() called
|
||||
});
|
||||
|
||||
// Scenario 7: kiro with --kiro-auth-method — validation guard passes
|
||||
it('kiro provider + --kiro-auth-method=aws — validation passes and proceeds to auth', async () => {
|
||||
// TODO: mock triggerOAuth, assert called with { kiroMethod: 'aws' }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for model-warnings.ts — Phase 08
|
||||
*
|
||||
* Verifies that warnBrokenModels emits warnings for broken models and is
|
||||
* silent for healthy ones, covering both simple and composite providers.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
|
||||
// ── Stubs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// getCurrentModel: return whatever the test configures
|
||||
const mockGetCurrentModel = jest.fn<string | undefined, [string, string | undefined]>();
|
||||
// isModelBroken: return false by default
|
||||
const mockIsModelBroken = jest.fn<boolean, [string, string]>().mockReturnValue(false);
|
||||
const mockGetModelIssueUrl = jest
|
||||
.fn<string | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
const mockFindModel = jest
|
||||
.fn<{ name: string } | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
const mockGetSuggestedReplacementModel = jest
|
||||
.fn<string | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
|
||||
jest.mock('../model-warnings', () => {
|
||||
// We test the real implementation — only mock its dependencies
|
||||
return jest.requireActual('../model-warnings');
|
||||
});
|
||||
|
||||
jest.mock('../../config/model-config', () => ({
|
||||
getCurrentModel: mockGetCurrentModel,
|
||||
}));
|
||||
|
||||
jest.mock('../../cliproxy/model-catalog', () => ({
|
||||
isModelBroken: mockIsModelBroken,
|
||||
getModelIssueUrl: mockGetModelIssueUrl,
|
||||
findModel: mockFindModel,
|
||||
getSuggestedReplacementModel: mockGetSuggestedReplacementModel,
|
||||
}));
|
||||
|
||||
// We import from real path after jest.mock so actual module is used
|
||||
import { warnBrokenModels } from '../model-warnings';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSimpleCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
isComposite: false,
|
||||
...overrides,
|
||||
} as ExecutorConfig;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('warnBrokenModels', () => {
|
||||
let errorSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetCurrentModel.mockReset();
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
mockGetModelIssueUrl.mockReturnValue(undefined);
|
||||
mockFindModel.mockReturnValue(undefined);
|
||||
mockGetSuggestedReplacementModel.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not warn when no model is configured', () => {
|
||||
mockGetCurrentModel.mockReturnValue(undefined);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not warn when model is healthy', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-2.5-pro');
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when model is broken (no replacement, no issue url)', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
mockFindModel.mockReturnValue({ name: 'Gemini Old' } as { name: string });
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('has known issues with Claude Code'))).toBe(true);
|
||||
expect(calls.some((m) => m.includes('Tool calls will fail'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes replacement model suggestion when available', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
mockGetSuggestedReplacementModel.mockReturnValue('gemini-2.5-pro');
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('gemini-2.5-pro'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes tracking URL when issue url is available', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
mockGetModelIssueUrl.mockReturnValue('https://github.com/issues/123');
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('https://github.com/issues/123'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes remote proxy note when skipLocalAuth=true', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: true,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('remote proxy'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes --config suggestion when skipLocalAuth=false', () => {
|
||||
mockGetCurrentModel.mockReturnValue('gemini-old');
|
||||
mockIsModelBroken.mockReturnValue(true);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeSimpleCfg(),
|
||||
compositeProviders: [],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('--config'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('composite variants', () => {
|
||||
function makeCompositeCfg(): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
isComposite: true,
|
||||
compositeTiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-opus-old' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-sonnet-ok' },
|
||||
haiku: { provider: 'gemini', model: 'gemini-haiku-ok' },
|
||||
},
|
||||
} as unknown as ExecutorConfig;
|
||||
}
|
||||
|
||||
it('warns for broken composite tier', () => {
|
||||
mockIsModelBroken.mockImplementation((_p, m) => m === 'gemini-opus-old');
|
||||
mockFindModel.mockReturnValue({ name: 'Gemini Opus Old' } as { name: string });
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeCompositeCfg(),
|
||||
compositeProviders: ['gemini'],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((m) => m.includes('opus tier'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not warn for healthy composite tiers', () => {
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
|
||||
warnBrokenModels({
|
||||
provider: 'gemini',
|
||||
cfg: makeCompositeCfg(),
|
||||
compositeProviders: ['gemini'],
|
||||
skipLocalAuth: false,
|
||||
});
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Unit tests for proxy-chain-builder.ts (Phase 07)
|
||||
*
|
||||
* Mocking strategy: proxy constructors are injected via the _ToolSanitizationProxy
|
||||
* and _CodexReasoningProxy fields on ProxyChainContext (dependency-injection escape
|
||||
* hatch added for testability). This avoids Bun module-cache limitations with
|
||||
* mock.module / jest.mock and never spins up real HTTP servers.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Tool sanitization proxy started when ANTHROPIC_BASE_URL is set
|
||||
* - Tool sanitization proxy skipped when ANTHROPIC_BASE_URL is absent
|
||||
* - Tool sanitization proxy start failure swallowed (null returned, verbose warn)
|
||||
* - Codex reasoning proxy started for single-provider codex
|
||||
* - Codex reasoning proxy skipped for composite codex (cfg.isComposite true)
|
||||
* - Codex reasoning proxy skipped for non-codex provider
|
||||
* - Codex reasoning proxy uses post-sanitization URL when tool-san is active
|
||||
* - Codex reasoning proxy start failure swallowed (null returned)
|
||||
* - All results null when ANTHROPIC_BASE_URL absent
|
||||
*/
|
||||
|
||||
import { describe, expect, it, jest, beforeEach } from 'bun:test';
|
||||
import { buildProxyChain } from '../proxy-chain-builder';
|
||||
import type { ProxyChainContext } from '../proxy-chain-builder';
|
||||
import type { ToolSanitizationProxy } from '../../proxy/tool-sanitization-proxy';
|
||||
import type { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
|
||||
|
||||
// ── Stub factory ──────────────────────────────────────────────────────────────
|
||||
|
||||
type ProxyStub = { start: jest.Mock; stop: jest.Mock; _port?: number };
|
||||
|
||||
function makeStubCtor(resolvePort: number | (() => Promise<number> | number)): {
|
||||
ctor: jest.Mock;
|
||||
instance: ProxyStub;
|
||||
} {
|
||||
const instance: ProxyStub = {
|
||||
start: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
typeof resolvePort === 'function' ? resolvePort : () => Promise.resolve(resolvePort)
|
||||
),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
const ctor = jest.fn().mockReturnValue(instance);
|
||||
return { ctor, instance };
|
||||
}
|
||||
|
||||
function makeFailCtor(message: string): { ctor: jest.Mock; instance: ProxyStub } {
|
||||
const instance: ProxyStub = {
|
||||
start: jest.fn().mockRejectedValue(new Error(message)),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
const ctor = jest.fn().mockReturnValue(instance);
|
||||
return { ctor, instance };
|
||||
}
|
||||
|
||||
// ── Context helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeThinkingCfg() {
|
||||
return { mode: 'auto' as const, override: undefined };
|
||||
}
|
||||
|
||||
function makeProxyConfig() {
|
||||
return {
|
||||
mode: 'local' as const,
|
||||
port: 8317,
|
||||
protocol: 'http' as const,
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: true,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCfg(overrides: Partial<{ isComposite: boolean }> = {}) {
|
||||
return { port: 8317, timeout: 5000, verbose: false, pollInterval: 100, ...overrides };
|
||||
}
|
||||
|
||||
// Stub ctors shared across tests, reset in beforeEach
|
||||
let defaultToolSan: ReturnType<typeof makeStubCtor>;
|
||||
let defaultCodex: ReturnType<typeof makeStubCtor>;
|
||||
|
||||
beforeEach(() => {
|
||||
defaultToolSan = makeStubCtor(19001);
|
||||
defaultCodex = makeStubCtor(19002);
|
||||
});
|
||||
|
||||
function baseCtx(overrides: Partial<ProxyChainContext> = {}): ProxyChainContext {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
useRemoteProxy: false,
|
||||
proxyConfig: makeProxyConfig(),
|
||||
cfg: makeCfg(),
|
||||
initialEnvVars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' },
|
||||
thinkingOverride: undefined,
|
||||
thinkingCfg: makeThinkingCfg(),
|
||||
verbose: false,
|
||||
log: () => {},
|
||||
_ToolSanitizationProxy: defaultToolSan.ctor as unknown as new (
|
||||
cfg: object
|
||||
) => ToolSanitizationProxy,
|
||||
_CodexReasoningProxy: defaultCodex.ctor as unknown as new (cfg: object) => CodexReasoningProxy,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tool sanitization: started when ANTHROPIC_BASE_URL set ───────────────────
|
||||
|
||||
describe('buildProxyChain — tool sanitization: started when ANTHROPIC_BASE_URL set', () => {
|
||||
it('starts proxy and returns instance + port', async () => {
|
||||
const { ctor, instance } = makeStubCtor(13001);
|
||||
|
||||
const result = await buildProxyChain(baseCtx({ _ToolSanitizationProxy: ctor as never }));
|
||||
|
||||
expect(ctor).toHaveBeenCalledTimes(1);
|
||||
expect(result.toolSanitizationProxy).toBe(instance);
|
||||
expect(result.toolSanitizationPort).toBe(13001);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool sanitization: skipped when ANTHROPIC_BASE_URL absent ────────────────
|
||||
|
||||
describe('buildProxyChain — tool sanitization: skipped when no ANTHROPIC_BASE_URL', () => {
|
||||
it('returns null proxy and port without constructing', async () => {
|
||||
const { ctor } = makeStubCtor(13001);
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({ initialEnvVars: {}, _ToolSanitizationProxy: ctor as never })
|
||||
);
|
||||
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
expect(result.toolSanitizationProxy).toBeNull();
|
||||
expect(result.toolSanitizationPort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool sanitization: start failure swallowed ────────────────────────────────
|
||||
|
||||
describe('buildProxyChain — tool sanitization: start failure swallowed', () => {
|
||||
it('returns null and emits verbose warn without throwing', async () => {
|
||||
const { ctor } = makeFailCtor('port in use');
|
||||
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({ verbose: true, _ToolSanitizationProxy: ctor as never })
|
||||
);
|
||||
|
||||
expect(result.toolSanitizationProxy).toBeNull();
|
||||
expect(result.toolSanitizationPort).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('port in use'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: started for single-provider codex ───────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: started for single-provider codex', () => {
|
||||
it('starts codex proxy and returns instance + port', async () => {
|
||||
const sanCtorPair = makeStubCtor(13010);
|
||||
const codexCtorPair = makeStubCtor(13011);
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: false }),
|
||||
_ToolSanitizationProxy: sanCtorPair.ctor as never,
|
||||
_CodexReasoningProxy: codexCtorPair.ctor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(codexCtorPair.ctor).toHaveBeenCalledTimes(1);
|
||||
expect(result.codexReasoningProxy).toBe(codexCtorPair.instance);
|
||||
expect(result.codexReasoningPort).toBe(13011);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: skipped for composite codex ─────────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: skipped for composite codex', () => {
|
||||
it('does not construct CodexReasoningProxy when cfg.isComposite is true', async () => {
|
||||
const { ctor } = makeStubCtor(13020);
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: true }),
|
||||
_CodexReasoningProxy: ctor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
expect(result.codexReasoningProxy).toBeNull();
|
||||
expect(result.codexReasoningPort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: skipped for non-codex provider ──────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: skipped for non-codex provider', () => {
|
||||
it('does not construct CodexReasoningProxy for gemini', async () => {
|
||||
const { ctor } = makeStubCtor(13030);
|
||||
|
||||
await buildProxyChain(baseCtx({ provider: 'gemini', _CodexReasoningProxy: ctor as never }));
|
||||
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: uses post-sanitization URL ───────────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: uses post-sanitization base URL', () => {
|
||||
it('passes http://127.0.0.1:<sanPort> as upstreamBaseUrl', async () => {
|
||||
const sanCtorPair = makeStubCtor(13040);
|
||||
const codexCtorPair = makeStubCtor(13041);
|
||||
|
||||
await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: false }),
|
||||
_ToolSanitizationProxy: sanCtorPair.ctor as never,
|
||||
_CodexReasoningProxy: codexCtorPair.ctor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(codexCtorPair.ctor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ upstreamBaseUrl: 'http://127.0.0.1:13040' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: start failure swallowed ─────────────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: start failure swallowed', () => {
|
||||
it('returns null codex proxy/port without throwing', async () => {
|
||||
const sanCtorPair = makeStubCtor(13050);
|
||||
const { ctor: codexFailCtor } = makeFailCtor('bind failed');
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: false }),
|
||||
_ToolSanitizationProxy: sanCtorPair.ctor as never,
|
||||
_CodexReasoningProxy: codexFailCtor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.codexReasoningProxy).toBeNull();
|
||||
expect(result.codexReasoningPort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── All results null when no ANTHROPIC_BASE_URL ───────────────────────────────
|
||||
|
||||
describe('buildProxyChain — all results null when no ANTHROPIC_BASE_URL', () => {
|
||||
it('returns four nulls for gemini without base URL', async () => {
|
||||
const sanCtor = jest.fn();
|
||||
const codexCtor = jest.fn();
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
initialEnvVars: {},
|
||||
_ToolSanitizationProxy: sanCtor as never,
|
||||
_CodexReasoningProxy: codexCtor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(sanCtor).not.toHaveBeenCalled();
|
||||
expect(codexCtor).not.toHaveBeenCalled();
|
||||
expect(result.toolSanitizationProxy).toBeNull();
|
||||
expect(result.toolSanitizationPort).toBeNull();
|
||||
expect(result.codexReasoningProxy).toBeNull();
|
||||
expect(result.codexReasoningPort).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Unit tests for proxy-resolver.ts (Phase 03 extraction)
|
||||
*
|
||||
* Tests cover the proxy resolution + remote reachability + binary acquisition
|
||||
* logic extracted from executor/index.ts.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import type { ResolveExecutorProxyContext } from '../proxy-resolver';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
import type { UnifiedConfig } from '../../../config/schemas/unified-config';
|
||||
|
||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy');
|
||||
const mockGetConfiguredBackend = jest.fn().mockReturnValue('original');
|
||||
const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable');
|
||||
|
||||
jest.mock('../../binary-manager', () => ({
|
||||
ensureCLIProxyBinary: mockEnsureCLIProxyBinary,
|
||||
getConfiguredBackend: mockGetConfiguredBackend,
|
||||
getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage,
|
||||
}));
|
||||
|
||||
const mockCheckRemoteProxy = jest.fn();
|
||||
jest.mock('../../services/remote-proxy-client', () => ({
|
||||
checkRemoteProxy: mockCheckRemoteProxy,
|
||||
}));
|
||||
|
||||
jest.mock('../retry-handler', () => ({
|
||||
isNetworkError: jest.fn().mockReturnValue(false),
|
||||
handleNetworkError: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockResolveProxyConfig = jest.fn();
|
||||
jest.mock('../../proxy/proxy-config-resolver', () => ({
|
||||
resolveProxyConfig: mockResolveProxyConfig,
|
||||
}));
|
||||
|
||||
jest.mock('../../config/config-generator', () => ({
|
||||
CLIPROXY_DEFAULT_PORT: 8317,
|
||||
validatePort: jest.fn((port: number | undefined) => port ?? 8317),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { resolveExecutorProxy } = await import('../proxy-resolver');
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMinimalUnifiedConfig(): UnifiedConfig {
|
||||
return {
|
||||
cliproxy_server: undefined,
|
||||
} as unknown as UnifiedConfig;
|
||||
}
|
||||
|
||||
function makeBaseCfg(): ExecutorConfig {
|
||||
return {
|
||||
port: 8317,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
overrides: Partial<ResolveExecutorProxyContext> = {}
|
||||
): ResolveExecutorProxyContext {
|
||||
return {
|
||||
unifiedConfig: makeMinimalUnifiedConfig(),
|
||||
allProviders: ['gemini'],
|
||||
verbose: false,
|
||||
cfg: makeBaseCfg(),
|
||||
log: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock resolveProxyConfig to return a local-mode config */
|
||||
function mockLocalProxyConfig(remainingArgs: string[] = []): void {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'local',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: false,
|
||||
forceLocal: true,
|
||||
},
|
||||
remainingArgs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mock resolveProxyConfig to return a remote-mode config */
|
||||
function mockRemoteProxyConfig(remainingArgs: string[] = []): void {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
|
||||
mockGetConfiguredBackend.mockReturnValue('original');
|
||||
});
|
||||
|
||||
describe('resolveExecutorProxy — local mode', () => {
|
||||
it('returns useRemoteProxy=false and correct binary for local mode', async () => {
|
||||
mockLocalProxyConfig(['--verbose']);
|
||||
const result = await resolveExecutorProxy(['--verbose'], makeContext());
|
||||
|
||||
expect(result.useRemoteProxy).toBe(false);
|
||||
expect(result.localBackend).toBe('original');
|
||||
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
|
||||
expect(result.argsWithoutProxy).toEqual(['--verbose']);
|
||||
});
|
||||
|
||||
it('strips proxy flags and passes remainingArgs through', async () => {
|
||||
mockLocalProxyConfig(['clean-arg']);
|
||||
const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext());
|
||||
|
||||
expect(result.argsWithoutProxy).toEqual(['clean-arg']);
|
||||
expect(result.useRemoteProxy).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call checkRemoteProxy in local mode', async () => {
|
||||
mockLocalProxyConfig();
|
||||
await resolveExecutorProxy([], makeContext());
|
||||
|
||||
expect(mockCheckRemoteProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveExecutorProxy — remote mode reachable', () => {
|
||||
it('returns useRemoteProxy=true when remote proxy is reachable', async () => {
|
||||
mockRemoteProxyConfig();
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined });
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
|
||||
expect(result.useRemoteProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('skips binary acquisition when remote proxy is reachable', async () => {
|
||||
mockRemoteProxyConfig();
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined });
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
|
||||
expect(result.binaryPath).toBeUndefined();
|
||||
expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveExecutorProxy — remote mode unreachable', () => {
|
||||
it('throws expected message when remoteOnly=true and remote is unreachable', async () => {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: true,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs: [],
|
||||
});
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' });
|
||||
|
||||
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
|
||||
'Remote proxy unreachable and --remote-only specified'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when fallback disabled and remote is unreachable', async () => {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs: [],
|
||||
});
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
|
||||
|
||||
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
|
||||
'Remote proxy unreachable and fallback disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to local and acquires binary when autoStartLocal=true', async () => {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: true,
|
||||
autoStartLocal: true,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs: [],
|
||||
});
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
|
||||
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
|
||||
expect(result.useRemoteProxy).toBe(false);
|
||||
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
|
||||
expect(mockEnsureCLIProxyBinary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
|
||||
it('returns the resolved proxyConfig object', async () => {
|
||||
mockLocalProxyConfig();
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
|
||||
expect(result.proxyConfig).toBeDefined();
|
||||
expect(result.proxyConfig.mode).toBe('local');
|
||||
expect(result.proxyConfig.port).toBe(8317);
|
||||
});
|
||||
|
||||
it('returns mutated cfg with validated port', async () => {
|
||||
mockLocalProxyConfig();
|
||||
const ctx = makeContext();
|
||||
|
||||
const result = await resolveExecutorProxy([], ctx);
|
||||
|
||||
// cfg is mutated in place and also returned
|
||||
expect(result.cfg).toBe(ctx.cfg);
|
||||
expect(result.cfg.port).toBe(8317);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Account Resolution — Executor-level account management
|
||||
*
|
||||
* Extracted from executor/index.ts (Phase 05).
|
||||
* Handles:
|
||||
* - --accounts listing (early exit)
|
||||
* - --use <account> switching
|
||||
* - --nickname rename
|
||||
* - Default account touch (lastUsedAt update)
|
||||
* - Account safety guards (cross-provider isolation, ban risk, stale pauses)
|
||||
* - Runtime quota monitor provider resolution
|
||||
*/
|
||||
|
||||
import { ok, fail, info } from '../../utils/ui';
|
||||
import {
|
||||
findAccountByQuery,
|
||||
getProviderAccounts,
|
||||
setDefaultAccount,
|
||||
touchAccount,
|
||||
renameAccount,
|
||||
getDefaultAccount,
|
||||
} from '../accounts/account-manager';
|
||||
import { formatAccountDisplayName } from '../accounts/email-account-identity';
|
||||
import { getProviderConfig } from '../config/config-generator';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager';
|
||||
import {
|
||||
warnCrossProviderDuplicates,
|
||||
warnOAuthBanRisk,
|
||||
cleanupStaleAutoPauses,
|
||||
enforceProviderIsolation,
|
||||
restoreAutoPausedAccounts,
|
||||
} from '../accounts/account-safety';
|
||||
|
||||
// ── Quota provider resolution ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine which managed quota providers need runtime monitoring.
|
||||
* For composite variants, checks all tier providers; otherwise checks the
|
||||
* single active provider.
|
||||
*/
|
||||
export function resolveRuntimeQuotaMonitorProviders(
|
||||
provider: CLIProxyProvider,
|
||||
compositeProviders: CLIProxyProvider[]
|
||||
): ManagedQuotaProvider[] {
|
||||
const candidates = compositeProviders.length > 0 ? compositeProviders : [provider];
|
||||
const resolved: ManagedQuotaProvider[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) &&
|
||||
!resolved.includes(candidate as ManagedQuotaProvider)
|
||||
) {
|
||||
resolved.push(candidate as ManagedQuotaProvider);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ── Account safety guards ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply account safety guards: stale auto-pause cleanup, provider isolation,
|
||||
* cross-provider duplicate warnings, and OAuth ban risk warnings.
|
||||
*
|
||||
* Registers process.on('exit') restore handler when isolation is enforced.
|
||||
*/
|
||||
export function applyAccountSafetyGuards(
|
||||
provider: CLIProxyProvider,
|
||||
_compositeProviders: CLIProxyProvider[]
|
||||
): void {
|
||||
cleanupStaleAutoPauses();
|
||||
const isolated = enforceProviderIsolation(provider);
|
||||
if (isolated === 0) {
|
||||
// No enforcement — still warn about duplicates for awareness
|
||||
warnCrossProviderDuplicates(provider);
|
||||
} else {
|
||||
// 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs
|
||||
process.on('exit', () => {
|
||||
restoreAutoPausedAccounts(provider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context / Result types ────────────────────────────────────────────────────
|
||||
|
||||
export interface AccountResolutionContext {
|
||||
provider: CLIProxyProvider;
|
||||
/** True when running in composite variant mode */
|
||||
showAccounts: boolean;
|
||||
useAccount: string | undefined;
|
||||
setNickname: string | undefined;
|
||||
addAccount: boolean;
|
||||
}
|
||||
|
||||
export interface AccountResolutionResult {
|
||||
/** true if --accounts listing was shown (caller should return early) */
|
||||
earlyExit: boolean;
|
||||
}
|
||||
|
||||
// ── Main account resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle account management CLI flags:
|
||||
* --accounts (list + early exit), --use (switch), --nickname (rename).
|
||||
*
|
||||
* Also calls warnOAuthBanRisk for the active provider.
|
||||
*/
|
||||
export async function resolveAccounts(
|
||||
ctx: AccountResolutionContext
|
||||
): Promise<AccountResolutionResult> {
|
||||
const { provider, showAccounts, useAccount, setNickname, addAccount } = ctx;
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
|
||||
// Warn about OAuth ban risk for this provider (always)
|
||||
warnOAuthBanRisk(provider);
|
||||
|
||||
// Handle --accounts
|
||||
if (showAccounts) {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
if (accounts.length === 0) {
|
||||
console.log(info(`No accounts registered for ${providerConfig.displayName}`));
|
||||
console.log(` Run "ccs ${provider} --auth" to add an account`);
|
||||
} else {
|
||||
console.log(`\n${providerConfig.displayName} Accounts:\n`);
|
||||
for (const acct of accounts) {
|
||||
const defaultMark = acct.isDefault ? ' (default)' : '';
|
||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
||||
console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`);
|
||||
}
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
|
||||
}
|
||||
process.exit(0);
|
||||
return { earlyExit: true };
|
||||
}
|
||||
|
||||
// Handle --use
|
||||
if (useAccount) {
|
||||
const account = findAccountByQuery(provider, useAccount);
|
||||
if (!account) {
|
||||
console.error(fail(`Account not found: "${useAccount}"`));
|
||||
const accounts = getProviderAccounts(provider);
|
||||
if (accounts.length > 0) {
|
||||
console.error(` Available accounts:`);
|
||||
for (const acct of accounts) {
|
||||
const displayName = formatAccountDisplayName(acct);
|
||||
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
|
||||
console.error(` - ${label}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
setDefaultAccount(provider, account.id);
|
||||
touchAccount(provider, account.id);
|
||||
const switchedLabel = account.nickname
|
||||
? `${account.nickname} (${formatAccountDisplayName(account)})`
|
||||
: formatAccountDisplayName(account);
|
||||
console.log(ok(`Switched to account: ${switchedLabel}`));
|
||||
}
|
||||
|
||||
// Handle --nickname (rename account) — only when not in --auth flow
|
||||
if (setNickname && !addAccount) {
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
if (!defaultAccount) {
|
||||
console.error(fail(`No account found for ${providerConfig.displayName}`));
|
||||
console.error(` Run "ccs ${provider} --auth" to add an account first`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const success = renameAccount(provider, defaultAccount.id, setNickname);
|
||||
if (success) {
|
||||
console.log(ok(`Renamed account to: ${setNickname}`));
|
||||
} else {
|
||||
console.error(fail('Failed to rename account'));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(err instanceof Error ? err.message : 'Failed to rename account'));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return { earlyExit: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch the default account's lastUsedAt timestamp.
|
||||
* Called after authentication succeeds and before proxy spawn.
|
||||
*/
|
||||
export function touchDefaultAccount(provider: CLIProxyProvider): void {
|
||||
const usedAccount = getDefaultAccount(provider);
|
||||
if (usedAccount) {
|
||||
touchAccount(provider, usedAccount.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* CLIProxy Executor Arg Parser
|
||||
*
|
||||
* Extracted from index.ts (Concern A):
|
||||
* - readOptionValue
|
||||
* - hasGitLabTokenLoginFlag / getGitLabTokenLoginFlagName
|
||||
* - CCS_FLAGS constant + filterCcsFlags()
|
||||
* - parseExecutorFlags() — flag extraction block (lines ~411-639 in original)
|
||||
* - validateFlagCombinations() — cross-flag guard block (lines ~531-585)
|
||||
*
|
||||
* IMPORTANT: process.exit semantics are kept identical to original index.ts.
|
||||
* All console.error messages are byte-identical.
|
||||
*/
|
||||
|
||||
import { fail } from '../../utils/ui/indicators';
|
||||
import {
|
||||
isKiroAuthMethod,
|
||||
isKiroIDCFlow,
|
||||
type KiroAuthMethod,
|
||||
type KiroIDCFlow,
|
||||
normalizeKiroAuthMethod,
|
||||
normalizeKiroIDCFlow,
|
||||
} from '../auth/auth-types';
|
||||
import type { UnifiedConfig } from '../../config/unified-config-types';
|
||||
import { PROXY_CLI_FLAGS } from '../proxy/proxy-config-resolver';
|
||||
import { parseThinkingOverride } from './thinking-arg-parser';
|
||||
|
||||
// Inlined from antigravity-responsibility.ts to avoid pulling in unified-config-loader
|
||||
// (which requires js-yaml at runtime). Keep in sync if ANTIGRAVITY_ACCEPT_RISK_FLAGS changes.
|
||||
const ANTIGRAVITY_ACCEPT_RISK_FLAGS_LOCAL = [
|
||||
'--accept-agr-risk',
|
||||
'--accept-antigravity-risk',
|
||||
] as const;
|
||||
|
||||
function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean {
|
||||
return args.some((arg) =>
|
||||
(ANTIGRAVITY_ACCEPT_RISK_FLAGS_LOCAL as readonly string[]).includes(arg)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Simple Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a flag value from args supporting both `--flag value` and `--flag=value` forms.
|
||||
* Returns the shape used throughout the executor:
|
||||
* { present, value?, missingValue }
|
||||
*/
|
||||
export function readOptionValue(
|
||||
args: string[],
|
||||
flag: string
|
||||
): { present: boolean; value?: string; missingValue: boolean } {
|
||||
const inlinePrefix = `${flag}=`;
|
||||
const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix));
|
||||
if (inlineArg !== undefined) {
|
||||
const value = inlineArg.slice(inlinePrefix.length).trim();
|
||||
return {
|
||||
present: true,
|
||||
value: value.length > 0 ? value : undefined,
|
||||
missingValue: value.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const index = args.indexOf(flag);
|
||||
if (index === -1) {
|
||||
return { present: false, missingValue: false };
|
||||
}
|
||||
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith('-')) {
|
||||
return { present: true, missingValue: true };
|
||||
}
|
||||
|
||||
return { present: true, value: next.trim(), missingValue: false };
|
||||
}
|
||||
|
||||
/** Returns true if args contain a GitLab token-login flag. */
|
||||
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
|
||||
return args.includes('--gitlab-token-login') || args.includes('--token-login');
|
||||
}
|
||||
|
||||
/** Returns the specific flag name present in args (used for error messages). */
|
||||
export function getGitLabTokenLoginFlagName(
|
||||
args: string[]
|
||||
): '--gitlab-token-login' | '--token-login' {
|
||||
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
|
||||
}
|
||||
|
||||
// ── CCS Flags Filter ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CCS-specific flags that must not be forwarded to the underlying Claude CLI.
|
||||
* The list is kept here as the single source of truth (previously inlined in index.ts).
|
||||
*/
|
||||
export const CCS_FLAGS: readonly string[] = [
|
||||
'--auth',
|
||||
'--paste-callback',
|
||||
'--port-forward',
|
||||
'--headless',
|
||||
'--logout',
|
||||
'--config',
|
||||
'--add',
|
||||
'--accounts',
|
||||
'--use',
|
||||
'--nickname',
|
||||
'--kiro-auth-method',
|
||||
'--kiro-idc-start-url',
|
||||
'--kiro-idc-region',
|
||||
'--kiro-idc-flow',
|
||||
'--thinking',
|
||||
'--effort',
|
||||
'--1m',
|
||||
'--no-1m',
|
||||
'--incognito',
|
||||
'--no-incognito',
|
||||
'--import',
|
||||
'--accept-agr-risk',
|
||||
'--accept-antigravity-risk',
|
||||
'--settings',
|
||||
...PROXY_CLI_FLAGS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Filter all CCS-specific flags (and their value arguments) from args
|
||||
* before forwarding to the Claude CLI.
|
||||
* Mirrors the filter logic from index.ts lines ~1328-1349.
|
||||
*/
|
||||
export function filterCcsFlags(args: string[]): string[] {
|
||||
return args.filter((arg, idx) => {
|
||||
if (CCS_FLAGS.includes(arg)) return false;
|
||||
if (arg.startsWith('--kiro-auth-method=')) return false;
|
||||
if (arg.startsWith('--kiro-idc-start-url=')) return false;
|
||||
if (arg.startsWith('--kiro-idc-region=')) return false;
|
||||
if (arg.startsWith('--kiro-idc-flow=')) return false;
|
||||
if (arg.startsWith('--thinking=')) return false;
|
||||
if (arg.startsWith('--effort=')) return false;
|
||||
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
|
||||
if (
|
||||
args[idx - 1] === '--use' ||
|
||||
args[idx - 1] === '--nickname' ||
|
||||
args[idx - 1] === '--kiro-auth-method' ||
|
||||
args[idx - 1] === '--kiro-idc-start-url' ||
|
||||
args[idx - 1] === '--kiro-idc-region' ||
|
||||
args[idx - 1] === '--kiro-idc-flow' ||
|
||||
args[idx - 1] === '--thinking' ||
|
||||
args[idx - 1] === '--effort'
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── ParsedExecutorFlags ───────────────────────────────────────────────────────
|
||||
|
||||
/** Result of parsing CCS executor flags from args. */
|
||||
export interface ParsedExecutorFlags {
|
||||
forceAuth: boolean;
|
||||
pasteCallback: boolean;
|
||||
portForward: boolean;
|
||||
forceHeadless: boolean;
|
||||
forceLogout: boolean;
|
||||
forceConfig: boolean;
|
||||
addAccount: boolean;
|
||||
showAccounts: boolean;
|
||||
forceImport: boolean;
|
||||
gitlabTokenLogin: boolean;
|
||||
acceptAgyRisk: boolean;
|
||||
incognitoFlag: boolean;
|
||||
noIncognitoFlag: boolean;
|
||||
noIncognito: boolean;
|
||||
useAccount: string | undefined;
|
||||
setNickname: string | undefined;
|
||||
kiroAuthMethod: KiroAuthMethod | undefined;
|
||||
kiroIDCStartUrl: string | undefined;
|
||||
kiroIDCRegion: string | undefined;
|
||||
kiroIDCFlow: KiroIDCFlow | undefined;
|
||||
gitlabBaseUrl: string | undefined;
|
||||
extendedContextOverride: boolean | undefined;
|
||||
thinkingParse: ReturnType<typeof parseThinkingOverride>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all CCS executor flags from args.
|
||||
*
|
||||
* Exits with code 1 (process.exitCode = 1 + return) on invalid flag values.
|
||||
* Exits with process.exit(1) on conflicting flag combinations — identical to
|
||||
* the original index.ts behavior.
|
||||
*
|
||||
* @param args args AFTER proxy flags have been stripped (argsWithoutProxy)
|
||||
* @param context provider context needed for kiro/incognito defaults
|
||||
*/
|
||||
export function parseExecutorFlags(
|
||||
args: string[],
|
||||
context: {
|
||||
provider: string;
|
||||
compositeProviders: string[];
|
||||
unifiedConfig: UnifiedConfig;
|
||||
}
|
||||
): ParsedExecutorFlags {
|
||||
const { provider, unifiedConfig } = context;
|
||||
|
||||
const forceAuth = args.includes('--auth');
|
||||
const pasteCallback = args.includes('--paste-callback');
|
||||
const portForward = args.includes('--port-forward');
|
||||
const forceHeadless = args.includes('--headless');
|
||||
|
||||
if (pasteCallback && portForward) {
|
||||
console.error(fail('Cannot use --paste-callback with --port-forward'));
|
||||
console.error(' --paste-callback: Manually paste OAuth redirect URL');
|
||||
console.error(' --port-forward: Use SSH port forwarding for callback');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const forceLogout = args.includes('--logout');
|
||||
const forceConfig = args.includes('--config');
|
||||
const addAccount = args.includes('--add');
|
||||
const showAccounts = args.includes('--accounts');
|
||||
const forceImport = args.includes('--import');
|
||||
const gitlabTokenLogin = hasGitLabTokenLoginFlag(args);
|
||||
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(args);
|
||||
|
||||
const incognitoFlag = args.includes('--incognito');
|
||||
const noIncognitoFlag = args.includes('--no-incognito');
|
||||
const kiroNoIncognitoConfig =
|
||||
provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false;
|
||||
const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig;
|
||||
|
||||
// Parse --use flag
|
||||
let useAccount: string | undefined;
|
||||
const useIdx = args.indexOf('--use');
|
||||
if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) {
|
||||
useAccount = args[useIdx + 1];
|
||||
}
|
||||
|
||||
// Parse --nickname flag
|
||||
let setNickname: string | undefined;
|
||||
const nicknameIdx = args.indexOf('--nickname');
|
||||
if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) {
|
||||
setNickname = args[nicknameIdx + 1];
|
||||
}
|
||||
|
||||
// Parse --kiro-auth-method flag
|
||||
let kiroAuthMethod: KiroAuthMethod | undefined;
|
||||
const kiroMethodValue = readOptionValue(args, '--kiro-auth-method');
|
||||
if (kiroMethodValue.present) {
|
||||
const rawMethod = kiroMethodValue.value;
|
||||
if (kiroMethodValue.missingValue || !rawMethod) {
|
||||
console.error(fail('--kiro-auth-method requires a value'));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
process.exitCode = 1;
|
||||
// Caller must check process.exitCode = 1 and bail — matching original return behavior
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod: undefined,
|
||||
kiroIDCStartUrl: undefined,
|
||||
kiroIDCRegion: undefined,
|
||||
kiroIDCFlow: undefined,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
const normalized = rawMethod.trim().toLowerCase();
|
||||
if (!isKiroAuthMethod(normalized)) {
|
||||
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod: undefined,
|
||||
kiroIDCStartUrl: undefined,
|
||||
kiroIDCRegion: undefined,
|
||||
kiroIDCFlow: undefined,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
|
||||
}
|
||||
|
||||
let kiroIDCStartUrl: string | undefined;
|
||||
const kiroIDCStartUrlValue = readOptionValue(args, '--kiro-idc-start-url');
|
||||
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
|
||||
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
|
||||
} else if (kiroIDCStartUrlValue.present) {
|
||||
console.error(fail('--kiro-idc-start-url requires a value'));
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl: undefined,
|
||||
kiroIDCRegion: undefined,
|
||||
kiroIDCFlow: undefined,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
|
||||
let kiroIDCRegion: string | undefined;
|
||||
const kiroIDCRegionValue = readOptionValue(args, '--kiro-idc-region');
|
||||
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
|
||||
kiroIDCRegion = kiroIDCRegionValue.value;
|
||||
} else if (kiroIDCRegionValue.present) {
|
||||
console.error(fail('--kiro-idc-region requires a value'));
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl,
|
||||
kiroIDCRegion: undefined,
|
||||
kiroIDCFlow: undefined,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
|
||||
let kiroIDCFlow: KiroIDCFlow | undefined;
|
||||
const kiroIDCFlowValue = readOptionValue(args, '--kiro-idc-flow');
|
||||
if (kiroIDCFlowValue.present) {
|
||||
const rawFlow = kiroIDCFlowValue.value;
|
||||
if (kiroIDCFlowValue.missingValue || !rawFlow) {
|
||||
console.error(fail('--kiro-idc-flow requires a value'));
|
||||
console.error(' Supported values: authcode, device');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl,
|
||||
kiroIDCRegion,
|
||||
kiroIDCFlow: undefined,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
const normalized = rawFlow.trim().toLowerCase();
|
||||
if (!isKiroIDCFlow(normalized)) {
|
||||
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
|
||||
console.error(' Supported values: authcode, device');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl,
|
||||
kiroIDCRegion,
|
||||
kiroIDCFlow: undefined,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
|
||||
}
|
||||
|
||||
let gitlabBaseUrl: string | undefined;
|
||||
const gitlabBaseUrlValue = readOptionValue(args, '--gitlab-url');
|
||||
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
|
||||
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
|
||||
} else if (gitlabBaseUrlValue.present) {
|
||||
console.error(fail('--gitlab-url requires a value'));
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl,
|
||||
kiroIDCRegion,
|
||||
kiroIDCFlow,
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse --thinking / --effort flags (aliases; first occurrence wins)
|
||||
const thinkingParse = parseThinkingOverride(args);
|
||||
if (thinkingParse.error) {
|
||||
const { flag } = thinkingParse.error;
|
||||
console.error(fail(`${flag} requires a value`));
|
||||
|
||||
if (provider === 'codex') {
|
||||
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium');
|
||||
console.error(' Alias: --thinking xhigh (same behavior)');
|
||||
} else {
|
||||
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
||||
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse --1m / --no-1m flags for extended context (1M token window)
|
||||
let extendedContextOverride: boolean | undefined;
|
||||
const has1mFlag = args.includes('--1m') || args.some((arg) => arg.startsWith('--1m='));
|
||||
const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m='));
|
||||
|
||||
if (has1mFlag && hasNo1mFlag) {
|
||||
console.error(fail('Cannot use both --1m and --no-1m flags'));
|
||||
process.exit(1);
|
||||
} else if (has1mFlag) {
|
||||
extendedContextOverride = true;
|
||||
} else if (hasNo1mFlag) {
|
||||
extendedContextOverride = false;
|
||||
}
|
||||
|
||||
// Auto-set kiroAuthMethod = 'idc' if IDC sub-flags present without explicit method
|
||||
if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) {
|
||||
kiroAuthMethod = 'idc';
|
||||
}
|
||||
|
||||
return {
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
portForward,
|
||||
forceHeadless,
|
||||
forceLogout,
|
||||
forceConfig,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
forceImport,
|
||||
gitlabTokenLogin,
|
||||
acceptAgyRisk,
|
||||
incognitoFlag,
|
||||
noIncognitoFlag,
|
||||
noIncognito,
|
||||
useAccount,
|
||||
setNickname,
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl,
|
||||
kiroIDCRegion,
|
||||
kiroIDCFlow,
|
||||
gitlabBaseUrl,
|
||||
extendedContextOverride,
|
||||
thinkingParse,
|
||||
};
|
||||
}
|
||||
|
||||
/** Internal helper — builds a ParsedExecutorFlags from raw fields (avoids repeating the full struct). */
|
||||
function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags {
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ── Cross-Flag Validation ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate flag combinations that are mutually exclusive or provider-scoped.
|
||||
* Calls process.exit(1) on any violation — identical to original index.ts.
|
||||
* Call AFTER parseExecutorFlags() and only if process.exitCode is still 0.
|
||||
*
|
||||
* @param parsed Result of parseExecutorFlags()
|
||||
* @param context Provider context (provider string + compositeProviders list)
|
||||
* @param args Raw argsWithoutProxy (needed for getGitLabTokenLoginFlagName)
|
||||
*/
|
||||
export function validateFlagCombinations(
|
||||
parsed: ParsedExecutorFlags,
|
||||
context: { provider: string; compositeProviders: string[] },
|
||||
args: string[]
|
||||
): void {
|
||||
const { provider, compositeProviders } = context;
|
||||
const {
|
||||
kiroAuthMethod,
|
||||
kiroIDCStartUrl,
|
||||
kiroIDCRegion,
|
||||
kiroIDCFlow,
|
||||
gitlabTokenLogin,
|
||||
gitlabBaseUrl,
|
||||
} = parsed;
|
||||
|
||||
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
||||
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) &&
|
||||
provider !== 'kiro' &&
|
||||
!compositeProviders.includes('kiro')
|
||||
) {
|
||||
console.error(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
|
||||
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
|
||||
console.error(
|
||||
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
kiroAuthMethod &&
|
||||
kiroAuthMethod !== 'idc' &&
|
||||
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
|
||||
) {
|
||||
console.error(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
|
||||
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
|
||||
console.error(fail(`${flagName} is only valid for ccs gitlab`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Browser Launch Setup — Executor-level browser initialization
|
||||
*
|
||||
* Extracted from executor/index.ts (Phase 04).
|
||||
* Handles:
|
||||
* 1. Browser launch flag resolution and override parsing
|
||||
* 2. Browser attach config + exposure resolution + blocked-override warning
|
||||
* 3. Optional browser attach runtime resolution (devtools WebSocket)
|
||||
* 4. Browser MCP ensure + sync-to-config-dir
|
||||
*/
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import {
|
||||
type BrowserLaunchOverride,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getBlockedBrowserOverrideWarning,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from '../../utils/browser';
|
||||
import { getBrowserConfig } from '../../config/config-loader-facade';
|
||||
|
||||
export interface BrowserLaunchSetupResult {
|
||||
/** CLI override flag if --browser-launch / --no-browser-launch was passed */
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
/** args list with --browser-launch* flags removed */
|
||||
argsWithoutBrowserFlags: string[];
|
||||
/** Devtools WebSocket env vars if browser attach runtime is active */
|
||||
browserRuntimeEnv: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 — resolve browser CLI flags and attach config.
|
||||
* Call this immediately after resolveExecutorProxy so that
|
||||
* argsWithoutBrowserFlags is available for downstream parsing.
|
||||
*
|
||||
* @returns partial setup result (no async work yet)
|
||||
*/
|
||||
export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
argsWithoutBrowserFlags: string[];
|
||||
} {
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
let argsWithoutBrowserFlags = argsWithoutProxy;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(warn((error as Error).message));
|
||||
process.exit(1);
|
||||
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags };
|
||||
}
|
||||
|
||||
const browserConfig = getBrowserConfig();
|
||||
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
||||
const claudeBrowserExposure = resolveBrowserExposure(
|
||||
{
|
||||
enabled: browserAttachConfig.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
},
|
||||
browserLaunchOverride
|
||||
);
|
||||
const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning(
|
||||
'Claude Browser Attach',
|
||||
claudeBrowserExposure
|
||||
);
|
||||
if (blockedBrowserOverrideWarning) {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
}
|
||||
|
||||
return { browserLaunchOverride, argsWithoutBrowserFlags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 — resolve async browser attach runtime and MCP setup.
|
||||
* Must be called AFTER phase-1 and AFTER ensureWebSearchMcpOrThrow().
|
||||
*/
|
||||
export async function resolveBrowserRuntime(
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined,
|
||||
inheritedClaudeConfigDir: string | undefined
|
||||
): Promise<Pick<BrowserLaunchSetupResult, 'browserRuntimeEnv'>> {
|
||||
const browserConfig = getBrowserConfig();
|
||||
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
||||
const claudeBrowserExposure = resolveBrowserExposure(
|
||||
{
|
||||
enabled: browserAttachConfig.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
},
|
||||
browserLaunchOverride
|
||||
);
|
||||
|
||||
const browserAttachRuntime =
|
||||
browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch
|
||||
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
|
||||
: undefined;
|
||||
|
||||
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
|
||||
if (browserAttachRuntime?.warning) {
|
||||
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
||||
}
|
||||
if (browserRuntimeEnv) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
|
||||
// Sync browser MCP config into inherited Claude instance if browser is active
|
||||
if (browserRuntimeEnv && inheritedClaudeConfigDir) {
|
||||
if (!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)) {
|
||||
throw new Error(
|
||||
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { browserRuntimeEnv };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Claude Launcher — Concern H
|
||||
*
|
||||
* Handles final argument assembly, trace context injection, Windows shell
|
||||
* escaping, spawning the Claude CLI process, starting the runtime quota
|
||||
* monitor, and wiring up cleanup handlers.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||
import { getProviderSettingsPath } from '../config/config-generator';
|
||||
import {
|
||||
ensureWebSearchMcpOrThrow as _ensureWebSearchMcpOrThrow,
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
} from '../../utils/websearch-manager';
|
||||
import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis';
|
||||
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||
import { getDefaultAccount } from '../accounts/account-manager';
|
||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||
import { setupCleanupHandlers } from './session-bridge';
|
||||
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||
|
||||
export interface ClaudeLaunchContext {
|
||||
/** Path to the Claude CLI executable */
|
||||
claudeCli: string;
|
||||
/** Pre-filtered Claude args (CCS flags already stripped) */
|
||||
claudeArgs: string[];
|
||||
/** Fully assembled environment variables (without trace additions) */
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** Resolved executor config */
|
||||
cfg: ExecutorConfig;
|
||||
/** Active CLIProxy provider */
|
||||
provider: CLIProxyProvider;
|
||||
/** Providers derived from composite tiers (empty for simple providers) */
|
||||
compositeProviders: CLIProxyProvider[];
|
||||
/** Whether local OAuth was skipped (remote proxy auth in use) */
|
||||
skipLocalAuth: boolean;
|
||||
/** Session ID for cleanup tracking */
|
||||
sessionId: string | undefined;
|
||||
/** Whether image analysis MCP is ready */
|
||||
imageAnalysisMcpReady: boolean;
|
||||
/** Browser runtime environment variables (undefined if browser not active) */
|
||||
browserRuntimeEnv: NodeJS.ProcessEnv | undefined;
|
||||
/** Inherited Claude config dir for continuity */
|
||||
inheritedClaudeConfigDir: string | undefined;
|
||||
/** Active Codex reasoning proxy (if any) */
|
||||
codexReasoningProxy: CodexReasoningProxy | null;
|
||||
/** Active tool sanitization proxy (if any) */
|
||||
toolSanitizationProxy: ToolSanitizationProxy | null;
|
||||
/** Active HTTPS tunnel proxy (if any) */
|
||||
httpsTunnel: HttpsTunnelProxy | null;
|
||||
/** Whether verbose logging is enabled */
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble final Claude CLI arguments, inject trace context, spawn the process,
|
||||
* start the runtime quota monitor, and wire cleanup handlers.
|
||||
*
|
||||
* @returns The spawned ChildProcess for the Claude CLI.
|
||||
*/
|
||||
export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildProcess> {
|
||||
const {
|
||||
claudeCli,
|
||||
claudeArgs,
|
||||
env,
|
||||
cfg,
|
||||
provider,
|
||||
compositeProviders,
|
||||
skipLocalAuth,
|
||||
sessionId,
|
||||
imageAnalysisMcpReady,
|
||||
browserRuntimeEnv,
|
||||
inheritedClaudeConfigDir,
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose,
|
||||
} = context;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
const settingsPath = cfg.customSettingsPath
|
||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
|
||||
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||
: claudeArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
|
||||
// Inject web search trace context into env
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'cliproxy.executor',
|
||||
args: launchArgs,
|
||||
profile: cfg.profileName || provider,
|
||||
profileType: 'cliproxy',
|
||||
settingsPath,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
const tracedEnv = { ...env, ...traceEnv };
|
||||
|
||||
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: tracedEnv,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(claudeCli, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: tracedEnv,
|
||||
});
|
||||
}
|
||||
|
||||
// Start runtime quota monitor (adaptive polling during session)
|
||||
if (!skipLocalAuth) {
|
||||
const { startQuotaMonitor } = await import('../quota/quota-manager');
|
||||
for (const monitorProvider of resolveRuntimeQuotaMonitorProviders(
|
||||
provider,
|
||||
compositeProviders
|
||||
)) {
|
||||
const monitorAccount = getDefaultAccount(monitorProvider);
|
||||
if (monitorAccount) {
|
||||
startQuotaMonitor(monitorProvider, monitorAccount.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
sessionId,
|
||||
cfg.port,
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose
|
||||
);
|
||||
|
||||
return claude;
|
||||
}
|
||||
+137
-1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Model Warnings — Concern G
|
||||
*
|
||||
* Emits console warnings when the active model (or any tier model in composite
|
||||
* variants) is flagged as broken in the model catalog.
|
||||
*/
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getCurrentModel } from '../config/model-config';
|
||||
import {
|
||||
isModelBroken,
|
||||
getModelIssueUrl,
|
||||
findModel,
|
||||
getSuggestedReplacementModel,
|
||||
} from '../model-catalog';
|
||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||
|
||||
export interface ModelWarningsContext {
|
||||
provider: CLIProxyProvider;
|
||||
cfg: ExecutorConfig;
|
||||
compositeProviders: CLIProxyProvider[];
|
||||
skipLocalAuth: boolean;
|
||||
customSettingsPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all active models for known issues and emit warnings.
|
||||
*
|
||||
* For composite variants, checks every tier model.
|
||||
* For simple providers, checks the currently configured model.
|
||||
*/
|
||||
export function warnBrokenModels(context: ModelWarningsContext): void {
|
||||
const { provider, cfg, skipLocalAuth } = context;
|
||||
|
||||
if (cfg.isComposite && cfg.compositeTiers) {
|
||||
// Check all tier models in composite variant
|
||||
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||
for (const tier of tiers) {
|
||||
const tierConfig = cfg.compositeTiers[tier];
|
||||
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
||||
)
|
||||
);
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||
const modelEntry = findModel(provider, currentModel);
|
||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
||||
console.error('');
|
||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
if (replacementModel) {
|
||||
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
||||
} else {
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
}
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
if (skipLocalAuth) {
|
||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||
} else {
|
||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Proxy Chain Builder (Concern F — tool-sanitization + codex-reasoning layers)
|
||||
*
|
||||
* Orchestrates the two env-dependent proxy layers that sit between the
|
||||
* Claude CLI process and the upstream CLIProxy / remote endpoint:
|
||||
*
|
||||
* 1. ToolSanitizationProxy — wraps ANTHROPIC_BASE_URL to sanitize tool names/schemas
|
||||
* 2. CodexReasoningProxy — only for single-provider codex, wraps tool-san URL
|
||||
*
|
||||
* Placement in the execution sequence
|
||||
* ─────────────────────────────────────
|
||||
* The HTTPS tunnel (HttpsTunnelProxy) is started BEFORE this function is called,
|
||||
* because its tunnelPort is needed by both imageAnalysisResolution and the
|
||||
* first-pass buildClaudeEnvironment. This function receives the result of that
|
||||
* first-pass env as `initialEnvVars`, from which it extracts ANTHROPIC_BASE_URL
|
||||
* to wire up the tool-sanitization proxy.
|
||||
*
|
||||
* Lifecycle: proxies are started (spawned) but NOT stopped here. The caller
|
||||
* is responsible for registering cleanup handlers (setupCleanupHandlers).
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import {
|
||||
ToolSanitizationProxy,
|
||||
type ToolSanitizationProxyConfig,
|
||||
} from '../proxy/tool-sanitization-proxy';
|
||||
import {
|
||||
CodexReasoningProxy,
|
||||
type CodexReasoningProxyConfig,
|
||||
} from '../ai-providers/codex-reasoning-proxy';
|
||||
import { shouldDisableCodexReasoning } from './thinking-override-resolver';
|
||||
import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types';
|
||||
import type { ThinkingConfig } from '../../config/unified-config-types';
|
||||
|
||||
// ── Proxy constructor types (for dependency injection in tests) ───────────────
|
||||
|
||||
type ToolSanitizationProxyCtor = new (config: ToolSanitizationProxyConfig) => ToolSanitizationProxy;
|
||||
type CodexReasoningProxyCtor = new (config: CodexReasoningProxyConfig) => CodexReasoningProxy;
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProxyChainContext {
|
||||
/** Provider being executed */
|
||||
provider: CLIProxyProvider;
|
||||
/** True when routing to a remote proxy host */
|
||||
useRemoteProxy: boolean;
|
||||
/** Resolved proxy configuration (host, port, protocol, auth, …) */
|
||||
proxyConfig: ResolvedProxyConfig;
|
||||
/** Executor configuration */
|
||||
cfg: ExecutorConfig;
|
||||
/**
|
||||
* Initial environment from first-pass buildClaudeEnvironment.
|
||||
* ANTHROPIC_BASE_URL and ANTHROPIC_MODEL* keys are consumed here.
|
||||
*/
|
||||
initialEnvVars: Partial<Record<string, string>>;
|
||||
/** Thinking mode override (value or undefined for default) */
|
||||
thinkingOverride: string | number | undefined;
|
||||
/** Thinking configuration from unified config */
|
||||
thinkingCfg: ThinkingConfig;
|
||||
verbose: boolean;
|
||||
log: (msg: string) => void;
|
||||
/**
|
||||
* Optional constructor overrides for unit testing.
|
||||
* Production code omits these; tests inject stubs to avoid real HTTP servers.
|
||||
*/
|
||||
_ToolSanitizationProxy?: ToolSanitizationProxyCtor;
|
||||
_CodexReasoningProxy?: CodexReasoningProxyCtor;
|
||||
}
|
||||
|
||||
export interface ProxyChainResult {
|
||||
toolSanitizationProxy: ToolSanitizationProxy | null;
|
||||
toolSanitizationPort: number | null;
|
||||
codexReasoningProxy: CodexReasoningProxy | null;
|
||||
codexReasoningPort: number | null;
|
||||
}
|
||||
|
||||
// ── Implementation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build and start the two env-dependent proxy layers (tool-sanitization and
|
||||
* codex-reasoning). Each layer is started independently; a failed start is
|
||||
* swallowed with a verbose warning so the session can continue degraded.
|
||||
*
|
||||
* Note: HttpsTunnelProxy is started inline in the orchestrator (index.ts)
|
||||
* before this function is called, because tunnelPort is required for
|
||||
* imageAnalysisResolution and the first-pass buildClaudeEnvironment.
|
||||
*/
|
||||
export async function buildProxyChain(context: ProxyChainContext): Promise<ProxyChainResult> {
|
||||
const {
|
||||
provider,
|
||||
useRemoteProxy,
|
||||
proxyConfig,
|
||||
cfg,
|
||||
initialEnvVars,
|
||||
thinkingOverride,
|
||||
thinkingCfg,
|
||||
verbose,
|
||||
log,
|
||||
// Allow test injection of proxy constructors so tests never spin up real servers
|
||||
_ToolSanitizationProxy: ToolSanitizationProxyImpl = ToolSanitizationProxy,
|
||||
_CodexReasoningProxy: CodexReasoningProxyImpl = CodexReasoningProxy,
|
||||
} = context;
|
||||
|
||||
// ── Step 1: Tool sanitization proxy ────────────────────────────────────────
|
||||
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
||||
let toolSanitizationPort: number | null = null;
|
||||
|
||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||
try {
|
||||
toolSanitizationProxy = new ToolSanitizationProxyImpl({
|
||||
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
||||
verbose,
|
||||
warnOnSanitize: true,
|
||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||
});
|
||||
toolSanitizationPort = await toolSanitizationProxy.start();
|
||||
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
toolSanitizationProxy = null;
|
||||
toolSanitizationPort = null;
|
||||
if (verbose) {
|
||||
console.error(warn(`Tool sanitization proxy disabled: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Codex reasoning proxy ──────────────────────────────────────────
|
||||
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
||||
let codexReasoningPort: number | null = null;
|
||||
|
||||
// Composite variants require root model-routed endpoints, never provider-pinned codex endpoints.
|
||||
if (provider === 'codex' && !cfg.isComposite) {
|
||||
const postSanitizationBaseUrl = toolSanitizationPort
|
||||
? `http://127.0.0.1:${toolSanitizationPort}`
|
||||
: initialEnvVars.ANTHROPIC_BASE_URL;
|
||||
|
||||
if (!postSanitizationBaseUrl) {
|
||||
log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled');
|
||||
} else {
|
||||
try {
|
||||
const traceEnabled =
|
||||
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
|
||||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
|
||||
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
||||
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
|
||||
codexReasoningProxy = new CodexReasoningProxyImpl({
|
||||
upstreamBaseUrl: postSanitizationBaseUrl,
|
||||
verbose,
|
||||
defaultEffort: 'medium',
|
||||
disableEffort: codexThinkingOff,
|
||||
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
|
||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||
modelMap: {
|
||||
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
|
||||
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
},
|
||||
stripPathPrefix,
|
||||
});
|
||||
codexReasoningPort = await codexReasoningProxy.start();
|
||||
log(
|
||||
`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex`
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
codexReasoningProxy = null;
|
||||
codexReasoningPort = null;
|
||||
if (verbose) {
|
||||
console.error(warn(`Codex reasoning proxy disabled: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolSanitizationProxy,
|
||||
toolSanitizationPort,
|
||||
codexReasoningProxy,
|
||||
codexReasoningPort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Proxy Resolver — Concern D
|
||||
*
|
||||
* Handles proxy configuration resolution, remote proxy reachability check,
|
||||
* local-backend selection, and CLIProxy binary acquisition.
|
||||
*
|
||||
* Extracted from executor/index.ts to isolate the proxy-resolution concern.
|
||||
* All log messages, error messages, and exit semantics are byte-identical to
|
||||
* the original implementation.
|
||||
*/
|
||||
|
||||
import { ProgressIndicator } from '../../utils/progress-indicator';
|
||||
import { ok, fail, info, warn } from '../../utils/ui';
|
||||
import {
|
||||
ensureCLIProxyBinary,
|
||||
getConfiguredBackend,
|
||||
getPlusBackendUnavailableMessage,
|
||||
} from '../binary-manager';
|
||||
import { checkRemoteProxy } from '../services/remote-proxy-client';
|
||||
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
|
||||
import { resolveProxyConfig } from '../proxy/proxy-config-resolver';
|
||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../config/config-generator';
|
||||
import type { ResolvedProxyConfig } from '../types';
|
||||
import type { UnifiedConfig } from '../../config/schemas/unified-config';
|
||||
import { isNetworkError, handleNetworkError } from './retry-handler';
|
||||
|
||||
/** Result returned from resolveExecutorProxy */
|
||||
export interface ResolvedProxy {
|
||||
/** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */
|
||||
proxyConfig: ResolvedProxyConfig;
|
||||
/** Whether to use the remote proxy (vs spawning a local one) */
|
||||
useRemoteProxy: boolean;
|
||||
/** Which local backend binary to use ('original' | 'plus') */
|
||||
localBackend: CLIProxyBackend;
|
||||
/** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */
|
||||
binaryPath: string | undefined;
|
||||
/** Args after proxy-related flags are stripped out */
|
||||
argsWithoutProxy: string[];
|
||||
/** Mutated executor config (port resolved and validated) */
|
||||
cfg: ExecutorConfig;
|
||||
}
|
||||
|
||||
/** Dependencies injected by the orchestrator */
|
||||
export interface ResolveExecutorProxyContext {
|
||||
unifiedConfig: UnifiedConfig;
|
||||
allProviders: CLIProxyProvider[];
|
||||
verbose: boolean;
|
||||
cfg: ExecutorConfig;
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves proxy configuration, checks remote reachability, selects the local
|
||||
* backend, and ensures the CLIProxy binary is present when running locally.
|
||||
*
|
||||
* Mutates `context.cfg.port` in-place (same as original orchestrator behaviour).
|
||||
*/
|
||||
export async function resolveExecutorProxy(
|
||||
args: string[],
|
||||
context: ResolveExecutorProxyContext
|
||||
): Promise<ResolvedProxy> {
|
||||
const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context;
|
||||
|
||||
// Resolve proxy config from CLI flags > ENV > config.yaml > defaults
|
||||
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
|
||||
remote: cliproxyServerConfig?.remote
|
||||
? {
|
||||
enabled: cliproxyServerConfig.remote.enabled,
|
||||
host: cliproxyServerConfig.remote.host,
|
||||
port: cliproxyServerConfig.remote.port,
|
||||
protocol: cliproxyServerConfig.remote.protocol,
|
||||
auth_token: cliproxyServerConfig.remote.auth_token,
|
||||
management_key: cliproxyServerConfig.remote.management_key,
|
||||
timeout: cliproxyServerConfig.remote.timeout,
|
||||
}
|
||||
: undefined,
|
||||
local: cliproxyServerConfig?.local
|
||||
? {
|
||||
port: cliproxyServerConfig.local.port,
|
||||
auto_start: cliproxyServerConfig.local.auto_start,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Port resolution and validation (mutates cfg in-place)
|
||||
if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
cfg.port = proxyConfig.port;
|
||||
}
|
||||
} else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
cfg.port = proxyConfig.port;
|
||||
}
|
||||
cfg.port = validatePort(cfg.port);
|
||||
|
||||
log(`Proxy mode: ${proxyConfig.mode}`);
|
||||
if (proxyConfig.mode === 'remote') {
|
||||
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
|
||||
}
|
||||
|
||||
// Check remote proxy reachability
|
||||
let useRemoteProxy = false;
|
||||
let localBackend: CLIProxyBackend = 'original';
|
||||
|
||||
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
|
||||
const status = await checkRemoteProxy({
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
timeout: proxyConfig.timeout ?? 2000,
|
||||
allowSelfSigned: proxyConfig.allowSelfSigned ?? false,
|
||||
});
|
||||
|
||||
if (status.reachable) {
|
||||
useRemoteProxy = true;
|
||||
console.log(
|
||||
ok(
|
||||
`Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(warn(`Remote proxy unreachable: ${status.error}`));
|
||||
|
||||
if (proxyConfig.remoteOnly) {
|
||||
throw new Error('Remote proxy unreachable and --remote-only specified');
|
||||
}
|
||||
|
||||
if (proxyConfig.fallbackEnabled) {
|
||||
if (proxyConfig.autoStartLocal) {
|
||||
console.log(info('Falling back to local proxy...'));
|
||||
} else {
|
||||
if (process.stdin.isTTY) {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question('Start local proxy instead? [Y/n] ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() === 'n') {
|
||||
throw new Error('Remote proxy unreachable and user declined fallback');
|
||||
}
|
||||
}
|
||||
console.log(info('Starting local proxy...'));
|
||||
}
|
||||
} else {
|
||||
throw new Error('Remote proxy unreachable and fallback disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local backend selection (only when not using remote proxy)
|
||||
if (!useRemoteProxy) {
|
||||
localBackend = getConfiguredBackend({ notifyOnPlus: true });
|
||||
|
||||
for (const p of allProviders) {
|
||||
if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
|
||||
console.error('');
|
||||
console.error(fail(getPlusBackendUnavailableMessage(p)));
|
||||
console.error('');
|
||||
throw new Error(`Provider ${p} requires local CLIProxy Plus backend`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Binary acquisition — skipped when using remote proxy
|
||||
let binaryPath: string | undefined;
|
||||
|
||||
if (!useRemoteProxy) {
|
||||
const spinner = new ProgressIndicator('Preparing CLIProxy');
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(_verbose, { skipAutoUpdate: true });
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
const err = error as Error;
|
||||
|
||||
if (isNetworkError(err)) {
|
||||
handleNetworkError(err);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
proxyConfig,
|
||||
useRemoteProxy,
|
||||
localBackend,
|
||||
binaryPath,
|
||||
argsWithoutProxy,
|
||||
cfg,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user