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:
Tam Nhu Tran
2026-05-02 21:47:54 -04:00
parent 968681f261
commit bc48613bbd
5 changed files with 957 additions and 178 deletions
@@ -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);
});
});
+197
View File
@@ -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
View File
@@ -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,
};