mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
refactor(cliproxy/executor): extract browser-setup and account-resolution
Phases 04+05 of #1162. Splits two more concerns out of the orchestrator: - src/cliproxy/executor/browser-launch-setup.ts (118 LOC): resolveBrowserLaunchFlags + resolveBrowserRuntime. Encapsulates browser flag resolution, attach config, blocked-override warning, and runtime setup including MCP sync. - src/cliproxy/executor/account-resolution.ts (197 LOC): resolveRuntimeQuotaMonitorProviders, resolveAccounts (--accounts / --use / --nickname / OAuth ban-risk warn / default touch), applyAccountSafetyGuards, touchDefaultAccount. - New tests: 184 + 430 LOC covering both modules. index.ts: 1045 -> 895 LOC (-150). resolveRuntimeQuotaMonitorProviders re-exported from index.ts for __testExports backwards compat. Behavior unchanged; full suite passes 1824/1824. Refs #1162
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,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,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,118 @@
|
||||
/**
|
||||
* Browser Launch Setup — Executor-level browser initialization
|
||||
*
|
||||
* Extracted from executor/index.ts (Phase 04).
|
||||
* Handles:
|
||||
* 1. Browser launch flag resolution and override parsing
|
||||
* 2. Browser attach config + exposure resolution + blocked-override warning
|
||||
* 3. Optional browser attach runtime resolution (devtools WebSocket)
|
||||
* 4. Browser MCP ensure + sync-to-config-dir
|
||||
*/
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import {
|
||||
type BrowserLaunchOverride,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getBlockedBrowserOverrideWarning,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from '../../utils/browser';
|
||||
import { getBrowserConfig } from '../../config/unified-config-loader';
|
||||
|
||||
export interface BrowserLaunchSetupResult {
|
||||
/** CLI override flag if --browser-launch / --no-browser-launch was passed */
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
/** args list with --browser-launch* flags removed */
|
||||
argsWithoutBrowserFlags: string[];
|
||||
/** Devtools WebSocket env vars if browser attach runtime is active */
|
||||
browserRuntimeEnv: Record<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 };
|
||||
}
|
||||
+28
-178
@@ -37,15 +37,6 @@ import {
|
||||
} from '../model-catalog';
|
||||
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||
import {
|
||||
findAccountByQuery,
|
||||
getProviderAccounts,
|
||||
setDefaultAccount,
|
||||
touchAccount,
|
||||
renameAccount,
|
||||
getDefaultAccount,
|
||||
} from '../accounts/account-manager';
|
||||
import { formatAccountDisplayName } from '../accounts/email-account-identity';
|
||||
import {
|
||||
ensureWebSearchMcpOrThrow,
|
||||
displayWebSearchStatus,
|
||||
@@ -57,26 +48,20 @@ import {
|
||||
syncImageAnalysisMcpToConfigDir,
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
} from '../../utils/image-analysis';
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
type BrowserLaunchOverride,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getBlockedBrowserOverrideWarning,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from '../../utils/browser';
|
||||
import {
|
||||
getBrowserConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
getThinkingConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { getDefaultAccount } from '../accounts/account-manager';
|
||||
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||
|
||||
// Import modular components
|
||||
import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup';
|
||||
import {
|
||||
resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders,
|
||||
applyAccountSafetyGuards,
|
||||
resolveAccounts,
|
||||
touchDefaultAccount,
|
||||
} from './account-resolution';
|
||||
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
||||
import {
|
||||
buildClaudeEnvironment,
|
||||
@@ -84,15 +69,8 @@ import {
|
||||
resolveCliproxyImageAnalysisEnv,
|
||||
} from './env-resolver';
|
||||
import { handleTokenExpiration, handleQuotaCheck } from './retry-handler';
|
||||
import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager';
|
||||
import { MANAGED_QUOTA_PROVIDERS } from '../quota/quota-manager';
|
||||
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||
import {
|
||||
warnCrossProviderDuplicates,
|
||||
warnOAuthBanRisk,
|
||||
cleanupStaleAutoPauses,
|
||||
enforceProviderIsolation,
|
||||
restoreAutoPausedAccounts,
|
||||
} from '../accounts/account-safety';
|
||||
import {
|
||||
ensureCliAntigravityResponsibility,
|
||||
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
|
||||
@@ -107,24 +85,8 @@ import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||
import { resolveExecutorProxy } from './proxy-resolver';
|
||||
|
||||
function resolveRuntimeQuotaMonitorProviders(
|
||||
provider: CLIProxyProvider,
|
||||
compositeProviders: CLIProxyProvider[]
|
||||
): ManagedQuotaProvider[] {
|
||||
const candidates = compositeProviders.length > 0 ? compositeProviders : [provider];
|
||||
const resolved: ManagedQuotaProvider[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) &&
|
||||
!resolved.includes(candidate as ManagedQuotaProvider)
|
||||
) {
|
||||
resolved.push(candidate as ManagedQuotaProvider);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
/** Local alias so internal call sites need no change */
|
||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -193,53 +155,16 @@ export async function execClaudeWithCLIProxy(
|
||||
log,
|
||||
});
|
||||
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
let argsWithoutBrowserFlags = argsWithoutProxy;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const browserConfig = getBrowserConfig();
|
||||
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
||||
const claudeBrowserExposure = resolveBrowserExposure(
|
||||
{
|
||||
enabled: browserAttachConfig.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
},
|
||||
browserLaunchOverride
|
||||
);
|
||||
const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning(
|
||||
'Claude Browser Attach',
|
||||
claudeBrowserExposure
|
||||
);
|
||||
if (blockedBrowserOverrideWarning) {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
}
|
||||
const { browserLaunchOverride, argsWithoutBrowserFlags } =
|
||||
resolveBrowserLaunchFlags(argsWithoutProxy);
|
||||
|
||||
// Setup first-class CCS WebSearch runtime
|
||||
ensureWebSearchMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
const browserAttachRuntime =
|
||||
browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch
|
||||
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
|
||||
: undefined;
|
||||
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
|
||||
if (browserAttachRuntime?.warning) {
|
||||
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
||||
}
|
||||
if (browserRuntimeEnv) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
displayWebSearchStatus();
|
||||
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
warnOAuthBanRisk(provider);
|
||||
|
||||
// Variables for local proxy mode
|
||||
let sessionId: string | undefined;
|
||||
@@ -300,70 +225,8 @@ export async function execClaudeWithCLIProxy(
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --accounts
|
||||
if (showAccounts) {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
if (accounts.length === 0) {
|
||||
console.log(info(`No accounts registered for ${providerConfig.displayName}`));
|
||||
console.log(` Run "ccs ${provider} --auth" to add an account`);
|
||||
} else {
|
||||
console.log(`\n${providerConfig.displayName} Accounts:\n`);
|
||||
for (const acct of accounts) {
|
||||
const defaultMark = acct.isDefault ? ' (default)' : '';
|
||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
||||
console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`);
|
||||
}
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle --use
|
||||
if (useAccount) {
|
||||
const account = findAccountByQuery(provider, useAccount);
|
||||
if (!account) {
|
||||
console.error(fail(`Account not found: "${useAccount}"`));
|
||||
const accounts = getProviderAccounts(provider);
|
||||
if (accounts.length > 0) {
|
||||
console.error(` Available accounts:`);
|
||||
for (const acct of accounts) {
|
||||
const displayName = formatAccountDisplayName(acct);
|
||||
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
|
||||
console.error(` - ${label}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
setDefaultAccount(provider, account.id);
|
||||
touchAccount(provider, account.id);
|
||||
const switchedLabel = account.nickname
|
||||
? `${account.nickname} (${formatAccountDisplayName(account)})`
|
||||
: formatAccountDisplayName(account);
|
||||
console.log(ok(`Switched to account: ${switchedLabel}`));
|
||||
}
|
||||
|
||||
// Handle --nickname (rename account)
|
||||
if (setNickname && !addAccount) {
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
if (!defaultAccount) {
|
||||
console.error(fail(`No account found for ${providerConfig.displayName}`));
|
||||
console.error(` Run "ccs ${provider} --auth" to add an account first`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const success = renameAccount(provider, defaultAccount.id, setNickname);
|
||||
if (success) {
|
||||
console.log(ok(`Renamed account to: ${setNickname}`));
|
||||
} else {
|
||||
console.error(fail('Failed to rename account'));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(err instanceof Error ? err.message : 'Failed to rename account'));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
// Handle --accounts / --use / --nickname (warnOAuthBanRisk emitted inside)
|
||||
await resolveAccounts({ provider, showAccounts, useAccount, setNickname, addAccount });
|
||||
|
||||
// Handle --config
|
||||
if (forceConfig && supportsModelConfig(provider)) {
|
||||
@@ -559,10 +422,7 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 3a-1. Update lastUsedAt
|
||||
const usedAccount = getDefaultAccount(provider);
|
||||
if (usedAccount) {
|
||||
touchAccount(provider, usedAccount.id);
|
||||
}
|
||||
touchDefaultAccount(provider);
|
||||
}
|
||||
|
||||
// 3b. Preflight quota check (providers with quota-based rotation)
|
||||
@@ -581,17 +441,7 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 3c. Account safety: enforce cross-provider isolation
|
||||
if (!skipLocalAuth) {
|
||||
cleanupStaleAutoPauses();
|
||||
const isolated = enforceProviderIsolation(provider);
|
||||
if (isolated === 0) {
|
||||
// No enforcement — still warn about duplicates for awareness
|
||||
warnCrossProviderDuplicates(provider);
|
||||
} else {
|
||||
// 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs
|
||||
process.on('exit', () => {
|
||||
restoreAutoPausedAccounts(provider);
|
||||
});
|
||||
}
|
||||
applyAccountSafetyGuards(provider, compositeProviders);
|
||||
}
|
||||
|
||||
// 4. First-run model configuration
|
||||
@@ -784,15 +634,12 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserRuntimeEnv &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
throw new Error(
|
||||
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve browser attach runtime and sync browser MCP (needs inheritedClaudeConfigDir)
|
||||
const { browserRuntimeEnv } = await resolveBrowserRuntime(
|
||||
browserLaunchOverride,
|
||||
inheritedClaudeConfigDir
|
||||
);
|
||||
|
||||
// Build initial env vars to get ANTHROPIC_BASE_URL
|
||||
const initialEnvVars = buildClaudeEnvironment({
|
||||
@@ -1038,6 +885,9 @@ export { isPortAvailable, findAvailablePort } from './lifecycle-manager';
|
||||
// import from index or directly from ./arg-parser)
|
||||
export { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from './arg-parser';
|
||||
|
||||
// Re-export account-resolution helpers for backwards compat with __testExports consumers
|
||||
export { resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||
|
||||
export const __testExports = {
|
||||
resolveRuntimeQuotaMonitorProviders,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user