mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-18 06:25:36 +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';
|
} from '../model-catalog';
|
||||||
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-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 {
|
import {
|
||||||
ensureWebSearchMcpOrThrow,
|
ensureWebSearchMcpOrThrow,
|
||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
@@ -57,26 +48,20 @@ import {
|
|||||||
syncImageAnalysisMcpToConfigDir,
|
syncImageAnalysisMcpToConfigDir,
|
||||||
appendThirdPartyImageAnalysisToolArgs,
|
appendThirdPartyImageAnalysisToolArgs,
|
||||||
} from '../../utils/image-analysis';
|
} from '../../utils/image-analysis';
|
||||||
import {
|
import { getDefaultAccount } from '../accounts/account-manager';
|
||||||
appendBrowserToolArgs,
|
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||||
type BrowserLaunchOverride,
|
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||||
ensureBrowserMcpOrThrow,
|
|
||||||
getBlockedBrowserOverrideWarning,
|
|
||||||
getEffectiveClaudeBrowserAttachConfig,
|
|
||||||
resolveBrowserExposure,
|
|
||||||
resolveBrowserLaunchFlagResolution,
|
|
||||||
resolveOptionalBrowserAttachRuntime,
|
|
||||||
syncBrowserMcpToConfigDir,
|
|
||||||
} from '../../utils/browser';
|
|
||||||
import {
|
|
||||||
getBrowserConfig,
|
|
||||||
loadOrCreateUnifiedConfig,
|
|
||||||
getThinkingConfig,
|
|
||||||
} from '../../config/unified-config-loader';
|
|
||||||
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
|
||||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||||
|
|
||||||
// Import modular components
|
// 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 { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
||||||
import {
|
import {
|
||||||
buildClaudeEnvironment,
|
buildClaudeEnvironment,
|
||||||
@@ -84,15 +69,8 @@ import {
|
|||||||
resolveCliproxyImageAnalysisEnv,
|
resolveCliproxyImageAnalysisEnv,
|
||||||
} from './env-resolver';
|
} from './env-resolver';
|
||||||
import { handleTokenExpiration, handleQuotaCheck } from './retry-handler';
|
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 { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||||
import {
|
|
||||||
warnCrossProviderDuplicates,
|
|
||||||
warnOAuthBanRisk,
|
|
||||||
cleanupStaleAutoPauses,
|
|
||||||
enforceProviderIsolation,
|
|
||||||
restoreAutoPausedAccounts,
|
|
||||||
} from '../accounts/account-safety';
|
|
||||||
import {
|
import {
|
||||||
ensureCliAntigravityResponsibility,
|
ensureCliAntigravityResponsibility,
|
||||||
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
|
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
|
||||||
@@ -107,24 +85,8 @@ import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
|||||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||||
import { resolveExecutorProxy } from './proxy-resolver';
|
import { resolveExecutorProxy } from './proxy-resolver';
|
||||||
|
|
||||||
function resolveRuntimeQuotaMonitorProviders(
|
/** Local alias so internal call sites need no change */
|
||||||
provider: CLIProxyProvider,
|
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -193,53 +155,16 @@ export async function execClaudeWithCLIProxy(
|
|||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
const { browserLaunchOverride, argsWithoutBrowserFlags } =
|
||||||
let argsWithoutBrowserFlags = argsWithoutProxy;
|
resolveBrowserLaunchFlags(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup first-class CCS WebSearch runtime
|
// Setup first-class CCS WebSearch runtime
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
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();
|
displayWebSearchStatus();
|
||||||
|
|
||||||
const providerConfig = getProviderConfig(provider);
|
const providerConfig = getProviderConfig(provider);
|
||||||
log(`Provider: ${providerConfig.displayName}`);
|
log(`Provider: ${providerConfig.displayName}`);
|
||||||
warnOAuthBanRisk(provider);
|
|
||||||
|
|
||||||
// Variables for local proxy mode
|
// Variables for local proxy mode
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
@@ -300,70 +225,8 @@ export async function execClaudeWithCLIProxy(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle --accounts
|
// Handle --accounts / --use / --nickname (warnOAuthBanRisk emitted inside)
|
||||||
if (showAccounts) {
|
await resolveAccounts({ provider, showAccounts, useAccount, setNickname, addAccount });
|
||||||
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 --config
|
// Handle --config
|
||||||
if (forceConfig && supportsModelConfig(provider)) {
|
if (forceConfig && supportsModelConfig(provider)) {
|
||||||
@@ -559,10 +422,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3a-1. Update lastUsedAt
|
// 3a-1. Update lastUsedAt
|
||||||
const usedAccount = getDefaultAccount(provider);
|
touchDefaultAccount(provider);
|
||||||
if (usedAccount) {
|
|
||||||
touchAccount(provider, usedAccount.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3b. Preflight quota check (providers with quota-based rotation)
|
// 3b. Preflight quota check (providers with quota-based rotation)
|
||||||
@@ -581,17 +441,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// 3c. Account safety: enforce cross-provider isolation
|
// 3c. Account safety: enforce cross-provider isolation
|
||||||
if (!skipLocalAuth) {
|
if (!skipLocalAuth) {
|
||||||
cleanupStaleAutoPauses();
|
applyAccountSafetyGuards(provider, compositeProviders);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. First-run model configuration
|
// 4. First-run model configuration
|
||||||
@@ -784,15 +634,12 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
if (
|
|
||||||
browserRuntimeEnv &&
|
// Resolve browser attach runtime and sync browser MCP (needs inheritedClaudeConfigDir)
|
||||||
inheritedClaudeConfigDir &&
|
const { browserRuntimeEnv } = await resolveBrowserRuntime(
|
||||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
browserLaunchOverride,
|
||||||
) {
|
inheritedClaudeConfigDir
|
||||||
throw new Error(
|
);
|
||||||
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build initial env vars to get ANTHROPIC_BASE_URL
|
// Build initial env vars to get ANTHROPIC_BASE_URL
|
||||||
const initialEnvVars = buildClaudeEnvironment({
|
const initialEnvVars = buildClaudeEnvironment({
|
||||||
@@ -1038,6 +885,9 @@ export { isPortAvailable, findAvailablePort } from './lifecycle-manager';
|
|||||||
// import from index or directly from ./arg-parser)
|
// import from index or directly from ./arg-parser)
|
||||||
export { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } 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 = {
|
export const __testExports = {
|
||||||
resolveRuntimeQuotaMonitorProviders,
|
resolveRuntimeQuotaMonitorProviders,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user