Merge pull request #1176 from kaitranntt/dev

feat: promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-03 23:21:00 -04:00
committed by GitHub
199 changed files with 10879 additions and 4471 deletions
+2 -1
View File
@@ -119,7 +119,8 @@ config. Deep dive:
Manage OAuth-backed providers, quota visibility, and proxy-wide routing from one place. CCS now
surfaces round-robin vs fill-first natively in both CLI and dashboard flows instead of hiding that
choice inside raw upstream controls. The original CLIProxyAPI backend remains the default; the
community-maintained CLIProxyAPIPlus fork is opt-in for plus-only providers.
community-maintained CLIProxyAPIPlus fork is opt-in for plus-only providers. When Plus is selected,
CCS points the embedded management panel at the maintained CPAMC dashboard fork by default.
Deep dive:
[CLIProxy API](https://docs.ccs.kaitran.ca/features/proxy/cliproxy-api).
+1
View File
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes.
- **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases.
- **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account.
- **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`.
@@ -16,6 +16,8 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with
CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, GitHub Copilot, Cursor, GitLab, CodeBuddy, and Kilo. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers.
Generated local CLIProxy configs also keep the management dashboard aligned with the selected backend. `backend: original` uses upstream CPAMC (`router-for-me/Cli-Proxy-API-Management-Center`), while `backend: plus` uses the CCS-maintained dashboard fork (`kaitranntt/Cli-Proxy-API-Management-Center`). Advanced users can override the generated `remote-management.panel-github-repository` value by setting `cliproxy.management_panel_repository` in `~/.ccs/config.yaml`; CCS will regenerate stale local CLIProxy configs when the expected dashboard repository changes.
```
+===========================================================================+
| CLIProxyAPI Integration |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.76.0",
"version": "7.76.0-dev.6",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+8 -2
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager';
import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader';
@@ -11,7 +11,7 @@ import {
mapExternalProviderName,
} from '../../cliproxy/provider-capabilities';
import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { TargetType } from '../../targets/target-adapter';
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
@@ -21,6 +21,12 @@ import type {
ModelMapping,
ResolvedCliproxyBridgeProfile,
} from './profile-types';
import {
getCcsDir,
isUnifiedMode,
loadConfigSafe,
loadOrCreateUnifiedConfig,
} from '../../config/config-loader-facade';
const DEFAULT_PROFILE_SUFFIX = '-api';
+1 -1
View File
@@ -5,7 +5,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { getCcsDir } from '../../config/config-loader-facade';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models';
function getCacheFile() {
@@ -9,12 +9,12 @@ import * as path from 'path';
import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { getConfigPath } from '../../utils/config-manager';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader';
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
@@ -26,6 +26,12 @@ import type {
ImportApiProfileResult,
RegisterApiProfileOrphansResult,
} from './profile-types';
import {
getCcsDir,
isUnifiedMode,
loadConfigSafe,
mutateConfig,
} from '../../config/config-loader-facade';
const SETTINGS_FILE_SUFFIX = '.settings.json';
const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__';
@@ -62,7 +68,7 @@ function writeJsonObjectAtomically(filePath: string, value: unknown): void {
function registerApiProfileInConfig(name: string, target: TargetType, force = false): void {
if (isUnifiedMode()) {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
+6 -2
View File
@@ -6,14 +6,18 @@
*/
import * as fs from 'fs';
import { loadConfigSafe } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { expandPath } from '../../utils/helpers';
import type { TargetType } from '../../targets/target-adapter';
import { isPersistedTargetType } from '../../targets/target-metadata';
import type { Settings } from '../../types/config';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge';
import {
isUnifiedMode,
loadConfigSafe,
loadOrCreateUnifiedConfig,
} from '../../config/config-loader-facade';
function sanitizeTarget(target: unknown): TargetType {
if (isPersistedTargetType(target)) {
+11 -5
View File
@@ -4,10 +4,10 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { getConfigPath } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import type { TargetType } from '../../targets/target-adapter';
@@ -31,6 +31,12 @@ import {
resolveCliproxyBridgeMetadata,
resolveCliproxyBridgeProfile,
} from './cliproxy-profile-bridge';
import {
getCcsDir,
isUnifiedMode,
loadConfigSafe,
mutateConfig,
} from '../../config/config-loader-facade';
/** Check if URL is an OpenRouter endpoint */
function isOpenRouterUrl(baseUrl: string): boolean {
@@ -232,7 +238,7 @@ function createApiProfileUnified(
throw error;
}
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${settingsFile}`,
@@ -343,7 +349,7 @@ export function updateApiProfileTarget(
): UpdateApiProfileTargetResult {
try {
if (isUnifiedMode()) {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.profiles[name]) {
throw new Error(`API profile not found: ${name}`);
}
@@ -392,7 +398,7 @@ export function updateApiProfileTarget(
/** Remove API profile from unified config */
function removeApiProfileUnified(name: string): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
const profile = config.profiles[name];
if (!profile) {
+2 -1
View File
@@ -12,7 +12,7 @@ import {
getWindowsEscapedCommandShell,
stripClaudeCodeEnv,
} from '../../utils/shell-executor';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { ProfileMetadata } from '../../types';
import {
resolveCreateAccountContext,
@@ -25,6 +25,7 @@ import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
import { isUnifiedMode } from '../../config/config-loader-facade';
function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
+2 -1
View File
@@ -5,10 +5,11 @@
*/
import { initUI, color, dim, ok, fail } from '../../utils/ui';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types';
import { isUnifiedMode } from '../../config/config-loader-facade';
/**
* Handle the default command (set default profile)
+2 -1
View File
@@ -8,10 +8,11 @@ import * as fs from 'fs';
import * as path from 'path';
import { initUI, color, ok, fail, info } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types';
import { isUnifiedMode } from '../../config/config-loader-facade';
/**
* Handle the remove command
+6 -5
View File
@@ -1,15 +1,16 @@
import * as fs from 'fs';
import {
getConfigJsonPath,
getContinuityInheritanceMap,
isUnifiedMode,
} from '../config/unified-config-loader';
import { warn } from '../utils/ui';
import InstanceManager from '../management/instance-manager';
import ProfileRegistry from './profile-registry';
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
import type { ProfileType } from '../types/profile';
import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat';
import {
getConfigJsonPath,
getContinuityInheritanceMap,
isUnifiedMode,
} from '../config/config-loader-facade';
export interface ProfileContinuityInheritanceInput {
profileName: string;
+7 -2
View File
@@ -21,8 +21,7 @@ import {
CompositeVariantConfig,
CompositeTierConfig,
} from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode, getCursorConfig } from '../config/unified-config-loader';
import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
@@ -30,6 +29,12 @@ import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile';
import {
getCcsDir,
getCursorConfig,
isUnifiedMode,
loadUnifiedConfig,
} from '../config/config-loader-facade';
export type { ProfileType } from '../types/profile';
/** CLIProxy profile names (OAuth-based, zero config) */
+14 -12
View File
@@ -1,15 +1,17 @@
import * as fs from 'fs';
import * as path from 'path';
import { ProfileMetadata } from '../types';
import {
loadOrCreateUnifiedConfig,
mutateUnifiedConfig,
isUnifiedMode,
} from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
import { createLogger } from '../services/logging';
import {
getCcsDir,
isUnifiedMode,
loadOrCreateUnifiedConfig,
mutateConfig,
} from '../config/config-loader-facade';
const logger = createLogger('auth:profile-registry');
@@ -323,7 +325,7 @@ export class ProfileRegistry {
* Create account in unified config (config.yaml)
*/
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
}
@@ -342,7 +344,7 @@ export class ProfileRegistry {
* Update account metadata in unified config
*/
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
@@ -357,7 +359,7 @@ export class ProfileRegistry {
* Remove account from unified config
*/
removeAccountUnified(name: string): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
@@ -372,7 +374,7 @@ export class ProfileRegistry {
* Set default profile in unified config
*/
setDefaultUnified(name: string): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
const exists =
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
if (!exists) {
@@ -386,7 +388,7 @@ export class ProfileRegistry {
* Clear default profile in unified config (restore original CCS behavior)
*/
clearDefaultUnified(): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
config.default = undefined;
});
}
@@ -426,7 +428,7 @@ export class ProfileRegistry {
* Update account last_used in unified config
*/
touchAccountUnified(name: string): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
+2 -1
View File
@@ -1,11 +1,12 @@
import * as fs from 'fs';
import * as path from 'path';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import { getCcsDir } from '../utils/config-manager';
import InstanceManager from '../management/instance-manager';
import ProfileDetector from './profile-detector';
import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance';
import type { ProfileType } from '../types/profile';
import { getCcsDir } from '../config/config-loader-facade';
export type ResumeLaneKind =
| 'native'
+31 -1636
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
/**
* Official Channels IDs (leaf module, no runtime dependencies)
*
* Pure helpers extracted from `official-channels-runtime` so that
* config-loader code can use them without transitively pulling in
* `claude-detector` / `shell-executor` / `websearch-manager`.
*
* The full channel definitions (with display names, plugin specs, env keys,
* etc.) live in `official-channels-runtime`. This module only owns:
* - the canonical ordered list of channel IDs
* - the type-narrowing predicate
* - the order-preserving normalizer
* - the legacy-discord shim
*/
import type { OfficialChannelId } from '../config/unified-config-types';
/** Canonical, ordered list of official channel IDs. */
export const OFFICIAL_CHANNEL_IDS: readonly OfficialChannelId[] = [
'telegram',
'discord',
'imessage',
] as const;
const OFFICIAL_CHANNEL_ID_SET = new Set<string>(OFFICIAL_CHANNEL_IDS);
export function isOfficialChannelId(value: string): value is OfficialChannelId {
return OFFICIAL_CHANNEL_ID_SET.has(value);
}
export function normalizeOfficialChannelIds(values: readonly string[]): OfficialChannelId[] {
const seen = new Set<OfficialChannelId>();
const normalized: OfficialChannelId[] = [];
for (const channelId of OFFICIAL_CHANNEL_IDS) {
if (!values.includes(channelId) || seen.has(channelId)) {
continue;
}
seen.add(channelId);
normalized.push(channelId);
}
return normalized;
}
export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] {
return enabled ? ['discord'] : [];
}
+14 -25
View File
@@ -64,7 +64,20 @@ export const OFFICIAL_CHANNELS: Record<OfficialChannelId, OfficialChannelDefinit
},
};
export const OFFICIAL_CHANNEL_IDS = Object.keys(OFFICIAL_CHANNELS) as OfficialChannelId[];
// Re-export from leaf module so config-loader can use these without pulling
// in this file's claude-detector / shell-executor dep chain.
import {
OFFICIAL_CHANNEL_IDS,
isOfficialChannelId,
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from './official-channels-ids';
export {
OFFICIAL_CHANNEL_IDS,
isOfficialChannelId,
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
};
export const MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION = '2.1.80';
export interface OfficialChannelsVersionSummary {
@@ -163,26 +176,6 @@ export function isDiscordChannelsSessionSupported(
return target === 'claude' && (profileType === 'default' || profileType === 'account');
}
export function isOfficialChannelId(value: string): value is OfficialChannelId {
return value in OFFICIAL_CHANNELS;
}
export function normalizeOfficialChannelIds(values: readonly string[]): OfficialChannelId[] {
const seen = new Set<OfficialChannelId>();
const normalized: OfficialChannelId[] = [];
for (const channelId of OFFICIAL_CHANNEL_IDS) {
if (!values.includes(channelId) || seen.has(channelId)) {
continue;
}
seen.add(channelId);
normalized.push(channelId);
}
return normalized;
}
export function hasExplicitChannelsFlag(args: string[]): boolean {
return args.some((arg) => arg === '--channels' || arg.startsWith('--channels='));
}
@@ -757,10 +750,6 @@ export function isOfficialChannelSelectionValid(selection: string): boolean {
);
}
export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] {
return enabled ? ['discord'] : [];
}
export function getOfficialChannelsSupportedProfiles(): string[] {
return ['default', 'account'];
}
+2 -1
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import type { OfficialChannelId } from '../config/unified-config-types';
import {
@@ -10,6 +10,7 @@ import {
getOfficialChannelTokenIds,
isOfficialChannelTokenRequired,
} from './official-channels-runtime';
import { getCcsDir } from '../config/config-loader-facade';
export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing';
+2 -2
View File
@@ -14,7 +14,7 @@ import * as path from 'path';
import { warn, info } from '../../utils/ui';
import { CLIProxyProvider } from '../types';
import { loadAccountsRegistry, pauseAccount, resumeAccount } from './registry';
import { getCcsDir } from '../../utils/config-manager';
import { getCcsDir } from '../../config/config-loader-facade';
const ISSUE_509_URL = 'https://github.com/kaitranntt/ccs/issues/509';
@@ -690,7 +690,7 @@ export async function handleQuotaExhaustion(
// Dynamic imports to avoid circular dependencies
const { applyCooldown, findHealthyAccount } = await import('../quota/quota-manager');
const { setDefaultAccount, touchAccount } = await import('./account-manager');
const { loadOrCreateUnifiedConfig } = await import('../../config/unified-config-loader');
const { loadOrCreateUnifiedConfig } = await import('../../config/config-loader-facade');
const config = loadOrCreateUnifiedConfig();
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
@@ -10,7 +10,7 @@
import { createInterface, Interface } from 'readline';
import { fail, info, ok, warn } from '../../utils/ui';
import { getCliproxySafetyConfig } from '../../config/unified-config-loader';
import { getCliproxySafetyConfig } from '../../config/config-loader-facade';
export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509';
export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2';
+6 -5
View File
@@ -8,8 +8,9 @@
*/
import { randomBytes } from 'crypto';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from '../config/generator';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
/**
* Generate a cryptographically secure token.
@@ -87,7 +88,7 @@ export function getEffectiveManagementSecret(): string {
* @param apiKey - New API key (or undefined to reset to default)
*/
export function setGlobalApiKey(apiKey: string | undefined): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
@@ -107,7 +108,7 @@ export function setGlobalApiKey(apiKey: string | undefined): void {
* @param secret - New management secret (or undefined to reset to default)
*/
export function setGlobalManagementSecret(secret: string | undefined): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
@@ -128,7 +129,7 @@ export function setGlobalManagementSecret(secret: string | undefined): void {
* @param apiKey - New API key (or undefined to remove override)
*/
export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
const variant = config.cliproxy.variants[variantName];
if (!variant) {
@@ -155,7 +156,7 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined
* Removes cliproxy.auth and all variant auth overrides.
*/
export function resetAuthToDefaults(): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
delete config.cliproxy.auth;
for (const variantName of Object.keys(config.cliproxy.variants)) {
+1 -1
View File
@@ -5,8 +5,8 @@
* Returns null if disabled or not configured.
*/
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { TokenRefreshSettings } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
/**
* Get token refresh configuration from unified config
+6 -3
View File
@@ -8,7 +8,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config/config-generator';
import { getBinDir } from './config/config-generator';
import { resolveLifecyclePort } from './config/port-manager';
import { BinaryInfo, BinaryManagerConfig } from './types';
import {
BACKEND_CONFIG,
@@ -18,7 +19,7 @@ import {
} from './binary/platform-detector';
import { stopProxy } from './services/proxy-lifecycle-service';
import { waitForPortFree } from '../utils/port-utils';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import {
UpdateCheckResult,
checkForUpdates,
@@ -39,6 +40,7 @@ import {
import type { CLIProxyBackend } from './types';
import { getVersionListCachePath } from './binary/version-cache';
import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade';
export const CLIPROXY_DELETED_PLUS_REPO = 'router-for-me/CLIProxyAPIPlus';
export const CLIPROXY_PLUS_FALLBACK_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062';
@@ -339,8 +341,9 @@ export async function installCliproxyVersion(
if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...'));
const result = await stopProxyFn();
if (result.stopped) {
const stoppedPort = result.port ?? resolveLifecyclePort();
// Wait for port to be fully released
const portFree = await waitForPortFreeFn(CLIPROXY_DEFAULT_PORT, 5000);
const portFree = await waitForPortFreeFn(stoppedPort, 5000);
if (!portFree && verbose) {
console.log(formatWarn('Port did not free up in time, proceeding anyway...'));
}
@@ -176,6 +176,32 @@ describe('installCliproxyVersion', () => {
expect(calls.ensureBinary).toBe(1);
});
it('waits for the port that was actually stopped before continuing install', async () => {
let waitedPort: number | undefined;
const binaryManager = await import(
`../../binary-manager?binary-manager-stopped-port=${Date.now()}`
);
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
createManager: () => ({
isBinaryInstalled: () => false,
deleteBinary: () => undefined,
ensureBinary: async () => '/tmp/ccs-bin/plus/cliproxy',
}),
stopProxyFn: async () => ({ stopped: true, port: 8317 }),
waitForPortFreeFn: async (port: number) => {
waitedPort = port;
return true;
},
formatInfo: (message: string) => message,
formatWarn: (message: string) => message,
getInstalledVersion: () => '6.6.80',
});
expect(waitedPort).toBe(8317);
});
it('fails fast when runtime startup forbids installing a missing binary', async () => {
const binaryManager = await import(`../../binary-manager?binary-manager-runtime=${Date.now()}`);
+2 -2
View File
@@ -14,7 +14,7 @@ import {
import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer';
import { info, warn } from '../../utils/ui';
import { isCliproxyRunning } from '../services/stats-fetcher';
import { CLIPROXY_DEFAULT_PORT } from '../config/config-generator';
import { resolveLifecyclePort } from '../config/port-manager';
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
@@ -82,7 +82,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean):
return;
}
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
const proxyRunning = await isCliproxyRunning(resolveLifecyclePort());
const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : '';
const updateMsg = `${backendLabel} update: v${currentVersion} -> v${targetVersion}${latestNote}`;
+2 -2
View File
@@ -19,13 +19,13 @@ export const BACKEND_CONFIG = {
repo: 'router-for-me/CLIProxyAPI',
binaryPrefix: 'CLIProxyAPI',
executable: 'cli-proxy-api',
fallbackVersion: '6.8.2',
fallbackVersion: '6.9.45',
},
plus: {
repo: 'kaitranntt/CLIProxyAPIPlus',
binaryPrefix: 'CLIProxyAPIPlus',
executable: 'cli-proxy-api-plus',
fallbackVersion: '6.9.36-0',
fallbackVersion: '6.9.45-0',
},
} as const;
@@ -585,6 +585,124 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
});
});
describe('management panel repository selection', () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const ORIGINAL_PANEL_REPO = 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center';
const PLUS_PANEL_REPO = 'https://github.com/kaitranntt/Cli-Proxy-API-Management-Center';
let testDir;
let originalCcsHome;
let regenerateConfig;
let configNeedsRegeneration;
function loadGenerator() {
delete require.cache[require.resolve('../../../../dist/cliproxy/config/config-generator')];
delete require.cache[require.resolve('../../../../dist/utils/config-manager')];
delete require.cache[require.resolve('../../../../dist/config/config-loader-facade')];
delete require.cache[require.resolve('../../../../dist/config/unified-config-loader')];
return require('../../../../dist/cliproxy/config/config-generator');
}
function writeUnifiedConfig(cliproxyYaml) {
const ccsDir = path.join(testDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
`version: 999\ncliproxy:\n${cliproxyYaml}`
);
}
function readGeneratedConfig() {
const configPath = path.join(testDir, '.ccs', 'cliproxy', 'config.yaml');
return fs.readFileSync(configPath, 'utf-8');
}
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-panel-repo-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = testDir;
const configGenerator = loadGenerator();
regenerateConfig = configGenerator.regenerateConfig;
configNeedsRegeneration = configGenerator.configNeedsRegeneration;
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it('uses the upstream management dashboard for the original backend', () => {
writeUnifiedConfig(' backend: original\n');
regenerateConfig();
const config = readGeneratedConfig();
assert(
config.includes(`panel-github-repository: "${ORIGINAL_PANEL_REPO}"`),
'Original backend should use upstream CPAMC repository'
);
});
it('uses the maintained management dashboard fork for the Plus backend', () => {
writeUnifiedConfig(' backend: plus\n');
regenerateConfig();
const config = readGeneratedConfig();
assert(
config.includes(`panel-github-repository: "${PLUS_PANEL_REPO}"`),
'Plus backend should use the CCS-maintained CPAMC fork'
);
});
it('lets users override the management dashboard repository', () => {
const customRepo = 'https://github.com/example/custom-panel';
writeUnifiedConfig(` backend: plus\n management_panel_repository: "${customRepo}"\n`);
regenerateConfig();
const config = readGeneratedConfig();
assert(
config.includes(`panel-github-repository: "${customRepo}"`),
'Explicit dashboard repository override should win over backend defaults'
);
});
it('marks v18 generated configs stale so the panel repository is backfilled', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
fs.writeFileSync(
path.join(cliproxyDir, 'config.yaml'),
'# CLIProxyAPI config generated by CCS v18\nport: 8317\n'
);
assert.strictEqual(configNeedsRegeneration(), true);
});
it('checks the generated config for the requested local port', () => {
writeUnifiedConfig(' backend: plus\n');
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
fs.writeFileSync(
path.join(cliproxyDir, 'config-8318.yaml'),
`# CLIProxyAPI config generated by CCS v19
port: 8318
remote-management:
panel-github-repository: "${ORIGINAL_PANEL_REPO}"
`
);
assert.strictEqual(configNeedsRegeneration(8318), true);
});
});
describe('oauth-model-alias fork:true', () => {
const fs = require('fs');
const os = require('os');
+2 -1
View File
@@ -7,7 +7,7 @@ import * as fs from 'fs';
import * as path from 'path';
import type { CLIProxyProvider, ProviderModelMapping } from '../types';
import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../config/base-config-loader';
import { getGlobalEnvConfig } from '../../config/unified-config-loader';
import { getEffectiveApiKey } from '../auth/auth-token-manager';
import { expandPath } from '../../utils/helpers';
import { warn } from '../../utils/ui';
@@ -31,6 +31,7 @@ import {
normalizeIFlowLegacyModelAliases,
normalizeModelIdForProvider,
} from '../ai-providers/model-id-normalizer';
import { getGlobalEnvConfig } from '../../config/config-loader-facade';
/** Settings file structure for user overrides */
interface ProviderSettings {
+51 -6
View File
@@ -5,15 +5,16 @@
import * as fs from 'fs';
import * as path from 'path';
import type { CLIProxyProvider, ProviderConfig } from '../types';
import type { CLIProxyBackend, CLIProxyProvider, ProviderConfig } from '../types';
import { getProviderDisplayName } from '../provider-capabilities';
import { getModelMappingFromConfig } from '../config/base-config-loader';
import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth/auth-token-manager';
import { getDeniedModelIdReasonForProvider } from '../ai-providers/model-id-normalizer';
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
/** Internal API key for CCS-managed requests */
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
@@ -41,8 +42,14 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges
* v17: Persist routing.strategy from CCS unified config
* v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config
* v19: Persist backend-aware management panel repository from CCS unified config
*/
export const CLIPROXY_CONFIG_VERSION = 18;
export const CLIPROXY_CONFIG_VERSION = 19;
export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY =
'https://github.com/router-for-me/Cli-Proxy-API-Management-Center';
export const PLUS_MANAGEMENT_PANEL_REPOSITORY =
'https://github.com/kaitranntt/Cli-Proxy-API-Management-Center';
interface RegenerateConfigOptions {
configPath?: string;
@@ -144,6 +151,33 @@ function getSessionAffinityTtl(): string {
return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h';
}
function normalizeManagementPanelRepository(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function getDefaultManagementPanelRepository(backend: CLIProxyBackend | undefined): string {
return backend === 'plus'
? PLUS_MANAGEMENT_PANEL_REPOSITORY
: ORIGINAL_MANAGEMENT_PANEL_REPOSITORY;
}
export function getManagementPanelRepository(): string {
const config = loadOrCreateUnifiedConfig();
return (
normalizeManagementPanelRepository(config.cliproxy?.management_panel_repository) ??
getDefaultManagementPanelRepository(config.cliproxy?.backend)
);
}
function quoteYamlString(value: string): string {
return JSON.stringify(value);
}
function hasPositiveDuration(value: string): boolean {
const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g'));
if (!segments) {
@@ -167,6 +201,11 @@ function sanitizeYamlScalar(rawValue: string): string {
return trimmed;
}
function parseManagementPanelRepository(content: string): string | null {
const match = content.match(/^\s*panel-github-repository:\s*(.+?)\s*$/m);
return match ? sanitizeYamlScalar(match[1]) : null;
}
function normalizeAntigravityAlias(rawAlias: string): string {
const normalized = sanitizeYamlScalar(rawAlias);
if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) {
@@ -578,6 +617,7 @@ function generateUnifiedConfigContent(
const routingStrategy = getRoutingStrategy();
const sessionAffinityEnabled = getSessionAffinityEnabled();
const sessionAffinityTtl = getSessionAffinityTtl();
const managementPanelRepository = getManagementPanelRepository();
// Get effective auth tokens (respects user customization)
const effectiveApiKey = getEffectiveApiKey();
@@ -632,6 +672,7 @@ remote-management:
allow-remote: true
secret-key: "${effectiveSecret}"
disable-control-panel: false
panel-github-repository: ${quoteYamlString(managementPanelRepository)}
# =============================================================================
# Reliability & Quota Management
@@ -857,8 +898,8 @@ export function regenerateConfig(
* Check if config needs regeneration (version mismatch)
* @returns true if config should be regenerated
*/
export function configNeedsRegeneration(): boolean {
const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
export function configNeedsRegeneration(port: number = CLIPROXY_DEFAULT_PORT): boolean {
const configPath = getConfigPathForPort(port);
if (!fs.existsSync(configPath)) {
return false; // Will be created on first use
}
@@ -871,7 +912,11 @@ export function configNeedsRegeneration(): boolean {
if (configVersion === null) {
return true; // No version marker = old config
}
return configVersion < CLIPROXY_CONFIG_VERSION;
if (configVersion < CLIPROXY_CONFIG_VERSION) {
return true;
}
return parseManagementPanelRepository(content) !== getManagementPanelRepository();
} catch {
return true; // Error reading = regenerate
}
+2 -1
View File
@@ -12,8 +12,9 @@ import { getProviderCatalog, supportsModelConfig, ModelEntry } from '../model-ca
import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator';
import { CLIProxyProvider } from '../types';
import { initUI, color, bold, dim, ok, info, header } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
import { normalizeModelIdForProvider } from '../ai-providers/model-id-normalizer';
import { getCcsDir } from '../../config/config-loader-facade';
function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string {
return normalizeModelIdForProvider(model, provider);
+2 -1
View File
@@ -4,9 +4,10 @@
*/
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import type { CLIProxyProvider } from '../types';
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
import { getCcsDir } from '../../config/config-loader-facade';
/**
* Get CLIProxy base directory
+23
View File
@@ -3,6 +3,9 @@
* Handles port number validation and default port resolution
*/
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { UnifiedConfig } from '../../config/unified-config-types';
/** Default CLIProxy port */
export const CLIPROXY_DEFAULT_PORT = 8317;
@@ -57,3 +60,23 @@ export function normalizeProtocol(protocol: string | undefined): 'http' | 'https
// Invalid protocol (e.g., 'ftp') - default to http
return 'http';
}
/**
* Resolve the local CLIProxy lifecycle port from unified config.
* Falls back to default port when unset, invalid, or unreadable.
*/
export function resolveLifecyclePort(
config?: Pick<UnifiedConfig, 'cliproxy_server'>,
loadConfig: () => Pick<UnifiedConfig, 'cliproxy_server'> = loadOrCreateUnifiedConfig
): number {
if (config) {
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
}
try {
const loadedConfig = loadConfig();
return validatePort(loadedConfig.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
} catch {
return CLIPROXY_DEFAULT_PORT;
}
}
+2 -1
View File
@@ -6,11 +6,12 @@
import type { CLIProxyProvider } from '../types';
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
import type { ThinkingConfig } from '../../config/unified-config-types';
import { getThinkingConfig } from '../../config/unified-config-loader';
import { getModelThinkingSupport, supportsThinking } from '../model-catalog';
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
import { normalizeModelIdForProvider } from '../ai-providers/model-id-normalizer';
import { warn } from '../../utils/ui';
import { getThinkingConfig } from '../../config/config-loader-facade';
/** Model tier types for thinking budget defaults */
export type ModelTier = 'opus' | 'sonnet' | 'haiku';
@@ -0,0 +1,430 @@
/**
* Unit tests for account-resolution.ts (Phase 05)
*
* Tests cover:
* - resolveRuntimeQuotaMonitorProviders: single provider, composite, dedup
* - resolveAccounts: --accounts early exit, --use switching, --nickname rename,
* default touch (no --use), warnOAuthBanRisk delegation
* - applyAccountSafetyGuards: delegates to safety functions (isolation + warn)
*
* Strategy: pure unit tests on the exported functions. Account-manager and
* account-safety modules are mocked to avoid file I/O.
*/
import { afterEach, beforeEach, describe, expect, it, jest, mock } from 'bun:test';
// ── resolveRuntimeQuotaMonitorProviders ───────────────────────────────────────
describe('resolveRuntimeQuotaMonitorProviders', () => {
// Import the module under test directly (no heavy deps needed for this fn)
it('returns empty array when provider is not managed', async () => {
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
const result = resolveRuntimeQuotaMonitorProviders('kiro', []);
expect(result).toEqual([]);
});
it('returns [provider] for single managed provider', async () => {
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
const result = resolveRuntimeQuotaMonitorProviders('agy', []);
expect(result).toContain('agy');
expect(result).toHaveLength(1);
});
it('returns composite providers that are managed, deduped', async () => {
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
// agy is managed; kiro is not; duplicate agy should be deduped
const result = resolveRuntimeQuotaMonitorProviders('gemini', ['agy', 'kiro', 'agy']);
expect(result).toContain('agy');
expect(result).not.toContain('kiro');
expect(result.filter((p) => p === 'agy')).toHaveLength(1);
});
it('ignores base provider when compositeProviders is non-empty', async () => {
const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution');
// base provider is gemini (managed), but composite list contains only kiro (not managed)
const result = resolveRuntimeQuotaMonitorProviders('gemini', ['kiro']);
expect(result).toEqual([]);
});
});
// ── resolveAccounts — --accounts early exit ───────────────────────────────────
describe('resolveAccounts — --accounts early exit', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
let logSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('calls process.exit(0) and returns earlyExit=true when showAccounts=true (no accounts)', async () => {
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [],
findAccountByQuery: () => undefined,
setDefaultAccount: () => {},
touchAccount: () => {},
renameAccount: () => true,
getDefaultAccount: () => undefined,
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: (a: { email?: string }) => a.email ?? 'unknown',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
const result = await resolveAccounts({
provider: 'gemini',
showAccounts: true,
useAccount: undefined,
setNickname: undefined,
addAccount: false,
});
expect(exitSpy).toHaveBeenCalledWith(0);
expect(result.earlyExit).toBe(true);
});
it('prints account list when accounts exist', async () => {
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [
{ id: 'acc1', email: 'test@example.com', isDefault: true, nickname: 'main' },
],
findAccountByQuery: () => undefined,
setDefaultAccount: () => {},
touchAccount: () => {},
renameAccount: () => true,
getDefaultAccount: () => undefined,
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: () => 'test@example.com',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
await resolveAccounts({
provider: 'gemini',
showAccounts: true,
useAccount: undefined,
setNickname: undefined,
addAccount: false,
});
const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n');
expect(allOutput).toContain('test@example.com');
expect(allOutput).toContain('(default)');
});
});
// ── resolveAccounts — --use switching ─────────────────────────────────────────
describe('resolveAccounts — --use switching', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
let logSpy: ReturnType<typeof jest.spyOn>;
let errSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
errSpy.mockRestore();
});
it('calls setDefaultAccount + touchAccount and logs success', async () => {
const setDefaultMock = jest.fn();
const touchMock = jest.fn();
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [],
findAccountByQuery: () => ({ id: 'acc1', email: 'user@example.com', nickname: undefined }),
setDefaultAccount: setDefaultMock,
touchAccount: touchMock,
renameAccount: () => true,
getDefaultAccount: () => undefined,
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: () => 'user@example.com',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
await resolveAccounts({
provider: 'gemini',
showAccounts: false,
useAccount: 'user@example.com',
setNickname: undefined,
addAccount: false,
});
expect(setDefaultMock).toHaveBeenCalledWith('gemini', 'acc1');
expect(touchMock).toHaveBeenCalledWith('gemini', 'acc1');
const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n');
expect(allOutput).toContain('Switched to account');
});
it('calls process.exit(1) when account not found', async () => {
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [],
findAccountByQuery: () => undefined,
setDefaultAccount: () => {},
touchAccount: () => {},
renameAccount: () => true,
getDefaultAccount: () => undefined,
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: () => 'x',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
await resolveAccounts({
provider: 'gemini',
showAccounts: false,
useAccount: 'nonexistent',
setNickname: undefined,
addAccount: false,
});
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── resolveAccounts — --nickname rename ───────────────────────────────────────
describe('resolveAccounts — --nickname rename', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
let logSpy: ReturnType<typeof jest.spyOn>;
let errSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
errSpy.mockRestore();
});
it('renames default account and exits 0 on success', async () => {
const renameMock = jest.fn(() => true);
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [],
findAccountByQuery: () => undefined,
setDefaultAccount: () => {},
touchAccount: () => {},
renameAccount: renameMock,
getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }),
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: () => 'user@example.com',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
await resolveAccounts({
provider: 'gemini',
showAccounts: false,
useAccount: undefined,
setNickname: 'work',
addAccount: false,
});
expect(renameMock).toHaveBeenCalledWith('gemini', 'acc1', 'work');
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('exits 1 when no default account found', async () => {
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [],
findAccountByQuery: () => undefined,
setDefaultAccount: () => {},
touchAccount: () => {},
renameAccount: () => false,
getDefaultAccount: () => undefined,
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: () => 'x',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
await resolveAccounts({
provider: 'gemini',
showAccounts: false,
useAccount: undefined,
setNickname: 'work',
addAccount: false,
});
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('skips rename when addAccount=true (--auth flow)', async () => {
const renameMock = jest.fn(() => true);
mock.module('../../accounts/account-manager', () => ({
getProviderAccounts: () => [],
findAccountByQuery: () => undefined,
setDefaultAccount: () => {},
touchAccount: () => {},
renameAccount: renameMock,
getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }),
}));
mock.module('../../accounts/email-account-identity', () => ({
formatAccountDisplayName: () => 'user@example.com',
}));
mock.module('../../config/config-generator', () => ({
getProviderConfig: () => ({ displayName: 'Gemini' }),
}));
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: () => false,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: () => 0,
restoreAutoPausedAccounts: () => {},
}));
const { resolveAccounts } = await import('../account-resolution');
const result = await resolveAccounts({
provider: 'gemini',
showAccounts: false,
useAccount: undefined,
setNickname: 'work',
addAccount: true, // suppresses rename
});
expect(renameMock).not.toHaveBeenCalled();
expect(result.earlyExit).toBe(false);
});
});
// ── applyAccountSafetyGuards — delegation ─────────────────────────────────────
describe('applyAccountSafetyGuards', () => {
it('calls cleanupStaleAutoPauses and enforceProviderIsolation', async () => {
const cleanupMock = jest.fn();
const enforceMock = jest.fn(() => 0);
const warnDupMock = jest.fn(() => false);
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: warnDupMock,
cleanupStaleAutoPauses: cleanupMock,
enforceProviderIsolation: enforceMock,
restoreAutoPausedAccounts: () => {},
}));
const { applyAccountSafetyGuards } = await import('../account-resolution');
applyAccountSafetyGuards('gemini', []);
expect(cleanupMock).toHaveBeenCalledTimes(1);
expect(enforceMock).toHaveBeenCalledWith('gemini');
});
it('calls warnCrossProviderDuplicates when isolation returns 0', async () => {
const warnDupMock = jest.fn(() => false);
const enforceMock = jest.fn(() => 0);
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: warnDupMock,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: enforceMock,
restoreAutoPausedAccounts: () => {},
}));
const { applyAccountSafetyGuards } = await import('../account-resolution');
applyAccountSafetyGuards('gemini', []);
expect(warnDupMock).toHaveBeenCalledWith('gemini');
});
it('does NOT call warnCrossProviderDuplicates when isolation is enforced', async () => {
const warnDupMock = jest.fn(() => false);
const enforceMock = jest.fn(() => 2); // 2 accounts isolated
mock.module('../../accounts/account-safety', () => ({
warnOAuthBanRisk: () => {},
warnCrossProviderDuplicates: warnDupMock,
cleanupStaleAutoPauses: () => {},
enforceProviderIsolation: enforceMock,
restoreAutoPausedAccounts: () => {},
}));
const { applyAccountSafetyGuards } = await import('../account-resolution');
applyAccountSafetyGuards('gemini', []);
expect(warnDupMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,413 @@
/**
* Unit tests for arg-parser.ts (Phase 02)
*
* Tests cover:
* - readOptionValue: --flag value and --flag=value forms
* - hasGitLabTokenLoginFlag
* - filterCcsFlags / CCS_FLAGS
* - parseExecutorFlags: parse output and early-exit behavior
* - validateFlagCombinations: pass and fail cases
*/
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
import {
readOptionValue,
hasGitLabTokenLoginFlag,
CCS_FLAGS,
filterCcsFlags,
parseExecutorFlags,
validateFlagCombinations,
type ParsedExecutorFlags,
} from '../arg-parser';
import type { UnifiedConfig } from '../../../config/unified-config-types';
/** Minimal stub for UnifiedConfig — avoids loading js-yaml via the full loader. */
function makeEmptyUnifiedConfig(): UnifiedConfig {
return {} as UnifiedConfig;
}
// ── readOptionValue ────────────────────────────────────────────────────────────
describe('readOptionValue', () => {
it('parses space-separated form: --flag value', () => {
expect(
readOptionValue(
['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'],
'--kiro-idc-start-url'
)
).toEqual({
present: true,
value: 'https://d-123.awsapps.com/start',
missingValue: false,
});
});
it('parses equals form: --flag=value', () => {
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
present: true,
value: 'device',
missingValue: false,
});
});
it('returns missingValue=true when flag present but no value (next is a flag)', () => {
expect(readOptionValue(['--kiro-idc-region', '--other-flag'], '--kiro-idc-region')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
});
it('returns missingValue=true when flag is last arg', () => {
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
});
it('returns missingValue=true for empty equals form: --flag=', () => {
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
});
it('returns present=false when flag not in args', () => {
expect(readOptionValue(['--other', 'val'], '--kiro-idc-region')).toEqual({
present: false,
missingValue: false,
});
});
it('trims surrounding whitespace from the value', () => {
const result = readOptionValue(
['--gitlab-url', ' https://gitlab.example.com '],
'--gitlab-url'
);
expect(result.value).toBe('https://gitlab.example.com');
});
});
// ── hasGitLabTokenLoginFlag ────────────────────────────────────────────────────
describe('hasGitLabTokenLoginFlag', () => {
it('detects --gitlab-token-login', () => {
expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true);
});
it('detects --token-login', () => {
expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true);
});
it('returns false when neither flag present', () => {
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
expect(hasGitLabTokenLoginFlag([])).toBe(false);
});
});
// ── CCS_FLAGS / filterCcsFlags ─────────────────────────────────────────────────
describe('CCS_FLAGS and filterCcsFlags', () => {
it('CCS_FLAGS includes known CCS-specific flags', () => {
expect(CCS_FLAGS).toContain('--auth');
expect(CCS_FLAGS).toContain('--accounts');
expect(CCS_FLAGS).toContain('--use');
expect(CCS_FLAGS).toContain('--kiro-auth-method');
expect(CCS_FLAGS).toContain('--thinking');
expect(CCS_FLAGS).toContain('--1m');
expect(CCS_FLAGS).toContain('--no-1m');
expect(CCS_FLAGS).toContain('--proxy-host');
});
it('filterCcsFlags strips known flags and their values', () => {
const args = ['--use', 'myaccount', '--print', 'hello'];
expect(filterCcsFlags(args)).toEqual(['--print', 'hello']);
});
it('filterCcsFlags strips --auth (no value)', () => {
expect(filterCcsFlags(['--auth', '--some-claude-flag'])).toEqual(['--some-claude-flag']);
});
it('filterCcsFlags strips equals-form flags', () => {
const args = ['--kiro-auth-method=aws', '--model', 'claude-3'];
expect(filterCcsFlags(args)).toEqual(['--model', 'claude-3']);
});
it('filterCcsFlags strips --thinking=value', () => {
expect(filterCcsFlags(['--thinking=high', '--dangerously-skip-permissions'])).toEqual([
'--dangerously-skip-permissions',
]);
});
it('filterCcsFlags preserves non-CCS args untouched', () => {
const args = ['--model', 'claude-opus-4-5', '--verbose'];
expect(filterCcsFlags(args)).toEqual(args);
});
it('filterCcsFlags strips --effort and its value', () => {
const args = ['--effort', 'xhigh', '--print'];
expect(filterCcsFlags(args)).toEqual(['--print']);
});
it('filterCcsFlags strips --1m= and --no-1m= inline forms', () => {
expect(filterCcsFlags(['--1m=true'])).toEqual([]);
expect(filterCcsFlags(['--no-1m=true'])).toEqual([]);
});
});
// ── parseExecutorFlags ─────────────────────────────────────────────────────────
describe('parseExecutorFlags', () => {
let originalExitCode: number | undefined;
let errorSpy: ReturnType<typeof jest.spyOn>;
let exitSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
});
afterEach(() => {
process.exitCode = originalExitCode;
errorSpy.mockRestore();
exitSpy.mockRestore();
});
function makeCtx(provider = 'gemini', compositeProviders: string[] = []) {
return { provider, compositeProviders, unifiedConfig: makeEmptyUnifiedConfig() };
}
it('returns default false/undefined for bare args', () => {
const result = parseExecutorFlags([], makeCtx());
expect(result.forceAuth).toBe(false);
expect(result.showAccounts).toBe(false);
expect(result.useAccount).toBeUndefined();
expect(result.kiroAuthMethod).toBeUndefined();
expect(result.extendedContextOverride).toBeUndefined();
});
it('detects --auth flag', () => {
const result = parseExecutorFlags(['--auth'], makeCtx());
expect(result.forceAuth).toBe(true);
});
it('detects --accounts flag', () => {
const result = parseExecutorFlags(['--accounts'], makeCtx());
expect(result.showAccounts).toBe(true);
});
it('parses --use value', () => {
const result = parseExecutorFlags(['--use', 'myaccount'], makeCtx());
expect(result.useAccount).toBe('myaccount');
});
it('parses --nickname value', () => {
const result = parseExecutorFlags(['--nickname', 'mynick'], makeCtx());
expect(result.setNickname).toBe('mynick');
});
it('parses --kiro-auth-method=aws for kiro provider', () => {
const result = parseExecutorFlags(['--kiro-auth-method=aws'], makeCtx('kiro'));
expect(result.kiroAuthMethod).toBe('aws');
});
it('sets process.exitCode=1 and returns on invalid --kiro-auth-method value', () => {
parseExecutorFlags(['--kiro-auth-method=invalid-method'], makeCtx('kiro'));
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalled();
});
it('sets process.exitCode=1 and returns on missing --kiro-auth-method value', () => {
parseExecutorFlags(['--kiro-auth-method'], makeCtx('kiro'));
expect(process.exitCode).toBe(1);
});
it('parses --kiro-idc-start-url', () => {
const result = parseExecutorFlags(
['--kiro-auth-method=idc', '--kiro-idc-start-url', 'https://d-xxx.awsapps.com/start'],
makeCtx('kiro')
);
expect(result.kiroIDCStartUrl).toBe('https://d-xxx.awsapps.com/start');
expect(result.kiroAuthMethod).toBe('idc');
});
it('auto-sets kiroAuthMethod=idc when IDC sub-flags present', () => {
const result = parseExecutorFlags(
['--kiro-idc-start-url', 'https://d-xxx.awsapps.com/start'],
makeCtx('kiro')
);
expect(result.kiroAuthMethod).toBe('idc');
});
it('parses --1m → extendedContextOverride=true', () => {
expect(parseExecutorFlags(['--1m'], makeCtx()).extendedContextOverride).toBe(true);
});
it('parses --no-1m → extendedContextOverride=false', () => {
expect(parseExecutorFlags(['--no-1m'], makeCtx()).extendedContextOverride).toBe(false);
});
it('calls process.exit(1) when --1m and --no-1m both present', () => {
parseExecutorFlags(['--1m', '--no-1m'], makeCtx());
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('calls process.exit(1) when --paste-callback and --port-forward both present', () => {
parseExecutorFlags(['--paste-callback', '--port-forward'], makeCtx());
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('kiro noIncognito defaults to true when provider=kiro and no override', () => {
const result = parseExecutorFlags([], makeCtx('kiro'));
expect(result.noIncognito).toBe(true);
});
it('--incognito overrides kiro default noIncognito', () => {
const result = parseExecutorFlags(['--incognito'], makeCtx('kiro'));
expect(result.noIncognito).toBe(false);
});
it('parses gitlabTokenLogin correctly', () => {
expect(parseExecutorFlags(['--gitlab-token-login'], makeCtx('gitlab')).gitlabTokenLogin).toBe(
true
);
expect(parseExecutorFlags(['--token-login'], makeCtx('gitlab')).gitlabTokenLogin).toBe(true);
});
});
// ── validateFlagCombinations ───────────────────────────────────────────────────
describe('validateFlagCombinations', () => {
let originalExitCode: number | undefined;
let errorSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
process.exitCode = originalExitCode;
errorSpy.mockRestore();
});
function baseFlags() {
return {
forceAuth: false,
pasteCallback: false,
portForward: false,
forceHeadless: false,
forceLogout: false,
forceConfig: false,
addAccount: false,
showAccounts: false,
forceImport: false,
gitlabTokenLogin: false,
acceptAgyRisk: false,
incognitoFlag: false,
noIncognitoFlag: false,
noIncognito: false,
useAccount: undefined as string | undefined,
setNickname: undefined as string | undefined,
kiroAuthMethod: undefined as ReturnType<typeof parseExecutorFlags>['kiroAuthMethod'],
kiroIDCStartUrl: undefined as string | undefined,
kiroIDCRegion: undefined as string | undefined,
kiroIDCFlow: undefined as ReturnType<typeof parseExecutorFlags>['kiroIDCFlow'],
gitlabBaseUrl: undefined as string | undefined,
extendedContextOverride: undefined as boolean | undefined,
thinkingParse: {
value: null,
error: null,
sourceFlag: null,
sourceDisplay: null,
duplicateDisplays: [],
} as unknown as ReturnType<typeof parseExecutorFlags>['thinkingParse'],
};
}
it('passes validation for clean gemini flags', () => {
validateFlagCombinations(baseFlags(), { provider: 'gemini', compositeProviders: [] }, []);
expect(process.exitCode).toBe(0);
expect(errorSpy).not.toHaveBeenCalled();
});
it('sets exitCode=1 when --kiro-auth-method used with non-kiro provider', () => {
const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const };
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, [
'--kiro-auth-method=aws',
]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalled();
});
it('passes when kiro-auth-method used with kiro provider', () => {
const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const };
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, [
'--kiro-auth-method=aws',
]);
expect(process.exitCode).toBe(0);
});
it('sets exitCode=1 when IDC sub-flags used without kiro provider', () => {
const flags = { ...baseFlags(), kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start' };
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, []);
expect(process.exitCode).toBe(1);
});
it('sets exitCode=1 when kiro IDC method missing --kiro-idc-start-url', () => {
const flags = { ...baseFlags(), kiroAuthMethod: 'idc' as const };
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--kiro-idc-start-url'));
});
it('passes when IDC method has start-url', () => {
const flags = {
...baseFlags(),
kiroAuthMethod: 'idc' as const,
kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start',
};
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []);
expect(process.exitCode).toBe(0);
});
it('sets exitCode=1 when non-idc method used with IDC sub-flags', () => {
const flags = {
...baseFlags(),
kiroAuthMethod: 'aws' as const,
kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start',
};
validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []);
expect(process.exitCode).toBe(1);
});
it('sets exitCode=1 when --gitlab-token-login used with non-gitlab provider', () => {
const flags = { ...baseFlags(), gitlabTokenLogin: true };
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, [
'--gitlab-token-login',
]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('gitlab'));
});
it('passes --gitlab-token-login with gitlab provider', () => {
const flags = { ...baseFlags(), gitlabTokenLogin: true };
validateFlagCombinations(flags, { provider: 'gitlab', compositeProviders: [] }, [
'--gitlab-token-login',
]);
expect(process.exitCode).toBe(0);
});
it('passes kiro flags when kiro is a composite provider', () => {
const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const };
validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: ['kiro'] }, []);
expect(process.exitCode).toBe(0);
});
});
@@ -0,0 +1,487 @@
/**
* Auth Coordinator Tests (Phase 06)
*
* Tests for auth-coordinator.ts exported functions.
*
* Strategy:
* - jest.mock() at top level to intercept all imports (static + dynamic)
* before the module-under-test is loaded
* - process.exit spied in beforeEach to prevent runner exit
* - Dynamic-import paths (e.g. '../auth/auth-handler') are mocked via
* jest.mock() from the coordinator's perspective (relative to executor/)
*/
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
import type { ExecutorConfig } from '../../types';
import type { UnifiedConfig } from '../../../config/schemas/unified-config';
import type { AuthCoordinationContext } from '../auth-coordinator';
import type { ParsedExecutorFlags } from '../arg-parser';
// ── Module mocks (must be top-level, before any import of subject) ────────────
const mockIsAuthenticated = jest.fn(() => false);
const mockClearAuth = jest.fn(() => true);
const mockTriggerOAuth = jest.fn(async () => true);
jest.mock('../../auth/auth-handler', () => ({
isAuthenticated: (...args: unknown[]) => mockIsAuthenticated(...args),
clearAuth: (...args: unknown[]) => mockClearAuth(...args),
triggerOAuth: (...args: unknown[]) => mockTriggerOAuth(...args),
}));
const mockEnsureAntigravity = jest.fn(async () => true);
const MOCK_ANTIGRAVITY_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk'];
jest.mock('../../auth/antigravity-responsibility', () => ({
ensureCliAntigravityResponsibility: (...args: unknown[]) => mockEnsureAntigravity(...args),
ANTIGRAVITY_ACCEPT_RISK_FLAGS: MOCK_ANTIGRAVITY_FLAGS,
}));
const mockHandleTokenExpiration = jest.fn(async () => {});
const mockHandleQuotaCheck = jest.fn(async () => {});
jest.mock('../retry-handler', () => ({
handleTokenExpiration: (...args: unknown[]) => mockHandleTokenExpiration(...args),
handleQuotaCheck: (...args: unknown[]) => mockHandleQuotaCheck(...args),
}));
const mockApplyAccountSafetyGuards = jest.fn(() => {});
const mockTouchDefaultAccount = jest.fn(() => {});
jest.mock('../account-resolution', () => ({
applyAccountSafetyGuards: (...args: unknown[]) => mockApplyAccountSafetyGuards(...args),
touchDefaultAccount: (...args: unknown[]) => mockTouchDefaultAccount(...args),
}));
const mockConfigureProviderModel = jest.fn(async () => {});
const mockGetCurrentModel = jest.fn(() => 'claude-opus');
jest.mock('../../config/model-config', () => ({
configureProviderModel: (...args: unknown[]) => mockConfigureProviderModel(...args),
getCurrentModel: (...args: unknown[]) => mockGetCurrentModel(...args),
}));
const mockReconcileCodexModel = jest.fn(async () => {});
jest.mock('../../ai-providers/codex-plan-compatibility', () => ({
reconcileCodexModelForActivePlan: (...args: unknown[]) => mockReconcileCodexModel(...args),
}));
// Do NOT mock quota-manager, model-catalog, config-generator — pure modules
// with no I/O side effects; mocking them would strip exports needed by the
// wider module graph and cause "Export not found" errors.
// ── Import subject AFTER mocks ─────────────────────────────────────────────────
const {
resolveSkipLocalAuth,
handleLogout,
handleImport,
runAntigravityGate,
ensureProviderAuthentication,
runPreflightQuotaCheck,
runAccountSafetyGuards,
ensureModelConfiguration,
} = await import('../auth-coordinator');
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFlags(overrides: Partial<Record<string, unknown>> = {}): ParsedExecutorFlags {
return {
forceAuth: false,
pasteCallback: false,
portForward: false,
forceHeadless: false,
forceLogout: false,
forceConfig: false,
addAccount: false,
showAccounts: false,
forceImport: false,
gitlabTokenLogin: false,
acceptAgyRisk: false,
incognitoFlag: false,
noIncognitoFlag: false,
noIncognito: false,
useAccount: undefined,
setNickname: undefined,
kiroAuthMethod: undefined,
kiroIDCStartUrl: undefined,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: {
value: undefined,
sourceFlag: undefined,
sourceDisplay: 'none',
duplicateDisplays: [],
} as ParsedExecutorFlags['thinkingParse'],
...overrides,
} as unknown as ParsedExecutorFlags;
}
function makeCtx(overrides: Partial<AuthCoordinationContext> = {}): AuthCoordinationContext {
return {
provider: 'gemini',
compositeProviders: [],
parsedFlags: makeFlags(),
cfg: { port: 8090, timeout: 5000, verbose: false, pollInterval: 100 } as ExecutorConfig,
unifiedConfig: {} as UnifiedConfig,
verbose: false,
log: () => {},
...overrides,
};
}
// ── resolveSkipLocalAuth ──────────────────────────────────────────────────────
describe('resolveSkipLocalAuth', () => {
it('returns false when useRemoteProxy=false', () => {
expect(resolveSkipLocalAuth('tok', false)).toBe(false);
});
it('returns false when token is whitespace only', () => {
expect(resolveSkipLocalAuth(' ', true)).toBe(false);
});
it('returns false when token is undefined', () => {
expect(resolveSkipLocalAuth(undefined, true)).toBe(false);
});
it('returns true when useRemoteProxy=true and token non-empty', () => {
expect(resolveSkipLocalAuth('tok123', true)).toBe(true);
});
});
// ── handleLogout ──────────────────────────────────────────────────────────────
describe('handleLogout', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
});
it('returns false and does not exit when forceLogout=false', async () => {
const result = await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: false }) }));
expect(result).toBe(false);
expect(exitSpy).not.toHaveBeenCalled();
});
it('exits 0 when forceLogout=true and clearAuth succeeds', async () => {
mockClearAuth.mockReturnValue(true);
await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: true }) }));
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('exits 0 even when no auth found (clearAuth returns false)', async () => {
mockClearAuth.mockReturnValue(false);
await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: true }) }));
expect(exitSpy).toHaveBeenCalledWith(0);
});
});
// ── handleImport ──────────────────────────────────────────────────────────────
describe('handleImport', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
mockTriggerOAuth.mockResolvedValue(true);
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
});
it('returns false when forceImport=false', async () => {
const result = await handleImport(makeCtx({ parsedFlags: makeFlags({ forceImport: false }) }));
expect(result).toBe(false);
expect(exitSpy).not.toHaveBeenCalled();
});
it('exits 1 for non-kiro provider', async () => {
await handleImport(
makeCtx({ provider: 'gemini', parsedFlags: makeFlags({ forceImport: true }) })
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('exits 1 when --import + --auth combined', async () => {
await handleImport(
makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true, forceAuth: true }) })
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('exits 1 when --import + --logout combined', async () => {
await handleImport(
makeCtx({
provider: 'kiro',
parsedFlags: makeFlags({ forceImport: true, forceLogout: true }),
})
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('exits 0 on successful kiro import', async () => {
mockTriggerOAuth.mockResolvedValue(true);
await handleImport(
makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true }) })
);
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('exits 1 when triggerOAuth returns false', async () => {
mockTriggerOAuth.mockResolvedValue(false);
await handleImport(
makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true }) })
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── runAntigravityGate ────────────────────────────────────────────────────────
describe('runAntigravityGate', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
mockEnsureAntigravity.mockResolvedValue(true);
mockIsAuthenticated.mockReturnValue(false);
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
});
it('returns earlyReturn=false for non-agy provider without calling gate', async () => {
const result = await runAntigravityGate(makeCtx({ provider: 'gemini' }), false);
expect(result.earlyReturn).toBe(false);
expect(mockEnsureAntigravity).not.toHaveBeenCalled();
});
it('agy + forceAuth + skipLocalAuth + acknowledged → earlyReturn=true', async () => {
mockEnsureAntigravity.mockResolvedValue(true);
const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: true }) });
const result = await runAntigravityGate(ctx, true);
expect(result.earlyReturn).toBe(true);
expect(mockEnsureAntigravity).toHaveBeenCalledWith(
expect.objectContaining({ context: 'oauth' })
);
});
it('agy + forceAuth + skipLocalAuth + refused → throws', async () => {
mockEnsureAntigravity.mockResolvedValue(false);
const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: true }) });
await expect(runAntigravityGate(ctx, true)).rejects.toThrow('Antigravity auth blocked');
});
it('agy + no forceAuth + skipLocalAuth + acknowledged → earlyReturn=false, no exit', async () => {
mockEnsureAntigravity.mockResolvedValue(true);
mockIsAuthenticated.mockReturnValue(true); // already authenticated → run context triggers
const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: false }) });
const result = await runAntigravityGate(ctx, true);
expect(result.earlyReturn).toBe(false);
expect(exitSpy).not.toHaveBeenCalled();
expect(mockEnsureAntigravity).toHaveBeenCalledWith(expect.objectContaining({ context: 'run' }));
});
it('agy + no forceAuth + skipLocalAuth + refused → process.exit(1)', async () => {
mockEnsureAntigravity.mockResolvedValue(false);
mockIsAuthenticated.mockReturnValue(true);
const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: false }) });
await runAntigravityGate(ctx, true);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── ensureProviderAuthentication ──────────────────────────────────────────────
describe('ensureProviderAuthentication', () => {
let exitSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
mockIsAuthenticated.mockReturnValue(false);
mockTriggerOAuth.mockResolvedValue(true);
mockHandleTokenExpiration.mockResolvedValue(undefined);
mockTouchDefaultAccount.mockImplementation(() => {});
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
});
it('already authenticated → no OAuth trigger, no exit', async () => {
mockIsAuthenticated.mockReturnValue(true);
await ensureProviderAuthentication(makeCtx({ provider: 'gemini' }));
expect(mockTriggerOAuth).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
it('not authenticated → triggerOAuth called', async () => {
mockIsAuthenticated.mockReturnValue(false);
await ensureProviderAuthentication(makeCtx({ provider: 'gemini' }));
expect(mockTriggerOAuth).toHaveBeenCalledWith('gemini', expect.any(Object));
});
it('forceAuth=true → exits 0 after successful OAuth', async () => {
await ensureProviderAuthentication(
makeCtx({ provider: 'gemini', parsedFlags: makeFlags({ forceAuth: true }) })
);
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('OAuth fails → throws Authentication required error', async () => {
mockTriggerOAuth.mockResolvedValue(false);
await expect(ensureProviderAuthentication(makeCtx({ provider: 'gemini' }))).rejects.toThrow(
'Authentication required'
);
});
it('calls touchDefaultAccount after successful single-provider auth', async () => {
mockIsAuthenticated.mockReturnValue(true);
await ensureProviderAuthentication(makeCtx({ provider: 'gemini' }));
expect(mockTouchDefaultAccount).toHaveBeenCalledWith('gemini');
});
it('runs token refresh after single-provider auth', async () => {
mockIsAuthenticated.mockReturnValue(true);
await ensureProviderAuthentication(makeCtx({ provider: 'gemini' }));
expect(mockHandleTokenExpiration).toHaveBeenCalledWith('gemini', false);
});
it('composite + forceAuth: all succeed → exit(0)', async () => {
mockTriggerOAuth.mockResolvedValue(true);
const ctx = makeCtx({
provider: 'agy',
compositeProviders: ['gemini', 'codex'],
parsedFlags: makeFlags({ forceAuth: true }),
});
await ensureProviderAuthentication(ctx);
expect(exitSpy).toHaveBeenCalledWith(0);
expect(mockTriggerOAuth).toHaveBeenCalledTimes(2);
});
it('composite + forceAuth: partial failure → exit(1)', async () => {
let call = 0;
mockTriggerOAuth.mockImplementation(async () => ++call === 1);
const ctx = makeCtx({
provider: 'agy',
compositeProviders: ['gemini', 'codex'],
parsedFlags: makeFlags({ forceAuth: true }),
});
await ensureProviderAuthentication(ctx);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('composite no forceAuth + unauthenticated → exit(1)', async () => {
mockIsAuthenticated.mockReturnValue(false);
const ctx = makeCtx({
provider: 'agy',
compositeProviders: ['gemini', 'codex'],
parsedFlags: makeFlags({ forceAuth: false }),
});
await ensureProviderAuthentication(ctx);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('composite all authenticated → token refresh for each provider', async () => {
mockIsAuthenticated.mockReturnValue(true);
const ctx = makeCtx({
provider: 'agy',
compositeProviders: ['gemini', 'codex'],
parsedFlags: makeFlags({ forceAuth: false }),
});
await ensureProviderAuthentication(ctx);
expect(exitSpy).not.toHaveBeenCalled();
expect(mockHandleTokenExpiration).toHaveBeenCalledTimes(2);
});
});
// ── runPreflightQuotaCheck ────────────────────────────────────────────────────
describe('runPreflightQuotaCheck', () => {
beforeEach(() => jest.clearAllMocks());
it('single provider → handleQuotaCheck called once with that provider', async () => {
await runPreflightQuotaCheck('gemini', []);
expect(mockHandleQuotaCheck).toHaveBeenCalledWith('gemini');
expect(mockHandleQuotaCheck).toHaveBeenCalledTimes(1);
});
it('composite list → checks only managed providers from the list', async () => {
// Real MANAGED_QUOTA_PROVIDERS = ['agy','claude','codex','gemini','ghcp']
// 'gemini' and 'codex' are both managed; 'kiro' is not
await runPreflightQuotaCheck('agy', ['gemini', 'kiro']);
expect(mockHandleQuotaCheck).toHaveBeenCalledWith('gemini');
expect(mockHandleQuotaCheck).not.toHaveBeenCalledWith('kiro');
});
});
// ── runAccountSafetyGuards ────────────────────────────────────────────────────
describe('runAccountSafetyGuards', () => {
beforeEach(() => jest.clearAllMocks());
it('delegates to applyAccountSafetyGuards with correct args', () => {
runAccountSafetyGuards('gemini', ['codex']);
expect(mockApplyAccountSafetyGuards).toHaveBeenCalledWith('gemini', ['codex']);
});
});
// ── ensureModelConfiguration ──────────────────────────────────────────────────
describe('ensureModelConfiguration', () => {
beforeEach(() => jest.clearAllMocks());
it('non-composite provider with model support → configureProviderModel called', async () => {
const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig;
await ensureModelConfiguration('gemini', cfg, false);
expect(mockConfigureProviderModel).toHaveBeenCalledWith('gemini', false, undefined);
});
it('composite variant → configureProviderModel NOT called', async () => {
const cfg = { isComposite: true } as ExecutorConfig;
await ensureModelConfiguration('gemini', cfg, false);
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
});
it('agy (has model support) → configureProviderModel IS called', async () => {
// agy IS in MODEL_CATALOG so supportsModelConfig returns true
const cfg = { isComposite: false } as ExecutorConfig;
await ensureModelConfiguration('agy', cfg, false);
expect(mockConfigureProviderModel).toHaveBeenCalled();
});
it('codex non-composite → reconcileCodexModelForActivePlan called', async () => {
const cfg = { isComposite: false } as ExecutorConfig;
await ensureModelConfiguration('codex', cfg, false);
expect(mockReconcileCodexModel).toHaveBeenCalled();
});
it('codex composite → reconcileCodexModelForActivePlan NOT called', async () => {
const cfg = { isComposite: true } as ExecutorConfig;
await ensureModelConfiguration('codex', cfg, false);
expect(mockReconcileCodexModel).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,184 @@
/**
* Unit tests for browser-launch-setup.ts (Phase 04)
*
* Tests cover:
* - resolveBrowserLaunchFlags: no flags (default), --browser-launch override,
* blocked override warning emitted, process.exit on parse error
* - resolveBrowserRuntime: no attach (disabled), active runtime env, MCP sync
* error propagation
*
* Strategy: mock the utils/browser and unified-config-loader modules so that
* no real browser detection or file I/O occurs.
*/
import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test';
// ── helpers ──────────────────────────────────────────────────────────────────
/** Minimal BrowserConfig stub */
function makeBrowserConfig(enabled = false, policy: 'auto' | 'always' | 'never' = 'auto'): object {
return {
claude: { enabled, policy, user_data_dir: '', devtools_port: 9222 },
codex: { enabled: false, policy: 'auto' },
};
}
// ── resolveBrowserLaunchFlags — no flags ──────────────────────────────────────
describe('resolveBrowserLaunchFlags — no browser flags', () => {
it('returns undefined override and passes args through unchanged', async () => {
mock.module('../../utils/browser', () => ({
appendBrowserToolArgs: (a: string[]) => a,
resolveBrowserLaunchFlagResolution: (_args: string[]) => ({
override: undefined,
argsWithoutFlags: _args,
}),
getBlockedBrowserOverrideWarning: () => null,
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }),
resolveBrowserExposure: () => ({ exposeForLaunch: false }),
ensureBrowserMcpOrThrow: () => true,
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
syncBrowserMcpToConfigDir: () => true,
}));
mock.module('../../../config/unified-config-loader', () => ({
getBrowserConfig: () => makeBrowserConfig(false),
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup');
const args = ['--model', 'claude-opus-4-5'];
const result = resolveBrowserLaunchFlags(args);
expect(result.browserLaunchOverride).toBeUndefined();
expect(result.argsWithoutBrowserFlags).toEqual(args);
});
});
// ── resolveBrowserLaunchFlags — --browser-launch override ─────────────────────
describe('resolveBrowserLaunchFlags — with browser-launch override', () => {
it('returns override and strips the browser flag from args', async () => {
mock.module('../../utils/browser', () => ({
appendBrowserToolArgs: (a: string[]) => a,
resolveBrowserLaunchFlagResolution: (_args: string[]) => ({
override: 'force-enable' as const,
argsWithoutFlags: ['--model', 'claude-opus-4-5'],
}),
getBlockedBrowserOverrideWarning: () => null,
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }),
resolveBrowserExposure: () => ({ exposeForLaunch: true }),
ensureBrowserMcpOrThrow: () => true,
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
syncBrowserMcpToConfigDir: () => true,
}));
mock.module('../../../config/unified-config-loader', () => ({
getBrowserConfig: () => makeBrowserConfig(true, 'auto'),
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup');
const result = resolveBrowserLaunchFlags(['--browser-launch', '--model', 'claude-opus-4-5']);
expect(result.browserLaunchOverride).toBe('force-enable');
expect(result.argsWithoutBrowserFlags).toEqual(['--model', 'claude-opus-4-5']);
});
});
// ── resolveBrowserLaunchFlags — blocked override warning emitted ──────────────
describe('resolveBrowserLaunchFlags — blocked override warning', () => {
let stderrSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
stderrSpy.mockRestore();
});
it('emits warn() when getBlockedBrowserOverrideWarning returns a message', async () => {
mock.module('../../utils/browser', () => ({
appendBrowserToolArgs: (a: string[]) => a,
resolveBrowserLaunchFlagResolution: (args: string[]) => ({
override: 'force-enable' as const,
argsWithoutFlags: args,
}),
getBlockedBrowserOverrideWarning: () => 'Browser override is blocked by policy',
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }),
resolveBrowserExposure: () => ({ exposeForLaunch: false }),
ensureBrowserMcpOrThrow: () => true,
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
syncBrowserMcpToConfigDir: () => true,
}));
mock.module('../../../config/unified-config-loader', () => ({
getBrowserConfig: () => makeBrowserConfig(false, 'never'),
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup');
resolveBrowserLaunchFlags(['--model', 'claude-opus-4-5']);
expect(stderrSpy).toHaveBeenCalledTimes(1);
expect(stderrSpy.mock.calls[0][0]).toContain('Browser override is blocked by policy');
});
});
// ── resolveBrowserRuntime — attach disabled ───────────────────────────────────
describe('resolveBrowserRuntime — attach disabled', () => {
it('returns undefined browserRuntimeEnv when browser attach is disabled', async () => {
mock.module('../../utils/browser', () => ({
appendBrowserToolArgs: (a: string[]) => a,
resolveBrowserLaunchFlagResolution: (a: string[]) => ({
override: undefined,
argsWithoutFlags: a,
}),
getBlockedBrowserOverrideWarning: () => null,
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }),
resolveBrowserExposure: () => ({ exposeForLaunch: false }),
ensureBrowserMcpOrThrow: () => true,
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
syncBrowserMcpToConfigDir: () => true,
}));
mock.module('../../../config/unified-config-loader', () => ({
getBrowserConfig: () => makeBrowserConfig(false),
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
const { resolveBrowserRuntime } = await import('../browser-launch-setup');
const result = await resolveBrowserRuntime(undefined, undefined);
expect(result.browserRuntimeEnv).toBeUndefined();
});
});
// ── resolveBrowserRuntime — active runtime env ────────────────────────────────
describe('resolveBrowserRuntime — active runtime env', () => {
it('returns runtimeEnv when browser attach resolves successfully', async () => {
const fakeRuntimeEnv = { CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1:9222/json' };
mock.module('../../utils/browser', () => ({
appendBrowserToolArgs: (a: string[]) => a,
resolveBrowserLaunchFlagResolution: (a: string[]) => ({
override: 'force-enable' as const,
argsWithoutFlags: a,
}),
getBlockedBrowserOverrideWarning: () => null,
getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }),
resolveBrowserExposure: () => ({ exposeForLaunch: true }),
ensureBrowserMcpOrThrow: () => true,
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: fakeRuntimeEnv }),
syncBrowserMcpToConfigDir: () => true,
}));
mock.module('../../../config/unified-config-loader', () => ({
getBrowserConfig: () => makeBrowserConfig(true, 'always'),
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
const { resolveBrowserRuntime } = await import('../browser-launch-setup');
const result = await resolveBrowserRuntime('force-enable', undefined);
expect(result.browserRuntimeEnv).toEqual(fakeRuntimeEnv);
});
});
@@ -0,0 +1,217 @@
/**
* Tests for claude-launcher.ts Phase 09
*
* Verifies:
* - spawn called with expected args and env
* - Windows shell escaping path
* - Cleanup handlers registered (setupCleanupHandlers called)
*
* Strategy: mock child_process.spawn and all side-effectful dependencies so
* no real processes are started.
*/
import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test';
import type { ChildProcess } from 'child_process';
import type { ExecutorConfig } from '../../types';
// ── Spawn mock ────────────────────────────────────────────────────────────────
const mockSpawnResult = {
pid: 42,
on: jest.fn(),
stdout: null,
stderr: null,
} as unknown as ChildProcess;
const mockSpawn = jest.fn().mockReturnValue(mockSpawnResult);
mock.module('child_process', () => ({
spawn: mockSpawn,
}));
// ── Dependency mocks ──────────────────────────────────────────────────────────
const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`);
const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe');
mock.module('../../utils/shell-executor', () => ({
escapeShellArg: mockEscapeShellArg,
getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell,
}));
mock.module('../../config/config-generator', () => ({
getProviderSettingsPath: jest.fn().mockReturnValue('/fake/.ccs/settings/gemini.json'),
CLIPROXY_DEFAULT_PORT: 8317,
generateConfig: jest.fn(),
getProviderConfig: jest.fn(),
}));
mock.module('../../utils/websearch-manager', () => ({
appendThirdPartyWebSearchToolArgs: (args: string[]) => args,
createWebSearchTraceContext: jest.fn().mockReturnValue({}),
ensureWebSearchMcpOrThrow: jest.fn(),
displayWebSearchStatus: jest.fn(),
getWebSearchHookEnv: jest.fn().mockReturnValue({}),
}));
mock.module('../../utils/image-analysis', () => ({
appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'],
syncImageAnalysisMcpToConfigDir: jest.fn(),
ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true),
}));
mock.module('../../utils/browser', () => ({
appendBrowserToolArgs: (args: string[]) => [...args, '--browser'],
}));
mock.module('../accounts/account-manager', () => ({
getDefaultAccount: jest.fn().mockReturnValue(null),
}));
const mockSetupCleanupHandlers = jest.fn();
mock.module('../session-bridge', () => ({
setupCleanupHandlers: mockSetupCleanupHandlers,
checkOrJoinProxy: jest.fn(),
registerProxySession: jest.fn(),
}));
const mockResolveRuntimeQuotaMonitorProviders = jest.fn().mockReturnValue([]);
mock.module('../account-resolution', () => ({
resolveRuntimeQuotaMonitorProviders: mockResolveRuntimeQuotaMonitorProviders,
resolveAccounts: jest.fn(),
}));
// Dynamic import for quota-manager
mock.module('../quota/quota-manager', () => ({
startQuotaMonitor: jest.fn(),
stopQuotaMonitor: jest.fn(),
}));
// ── Subject under test ────────────────────────────────────────────────────────
import { launchClaude } from '../claude-launcher';
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
return {
port: 8317,
timeout: 5000,
verbose: false,
pollInterval: 100,
...overrides,
} as ExecutorConfig;
}
function baseContext(overrides: object = {}) {
return {
claudeCli: '/usr/local/bin/claude',
claudeArgs: ['chat', '--model', 'gemini-2.5-pro'],
env: { ANTHROPIC_BASE_URL: 'http://localhost:8317' } as NodeJS.ProcessEnv,
cfg: makeCfg(),
provider: 'gemini' as const,
compositeProviders: [] as string[],
skipLocalAuth: true,
sessionId: undefined,
imageAnalysisMcpReady: false,
browserRuntimeEnv: undefined,
inheritedClaudeConfigDir: undefined,
codexReasoningProxy: null,
toolSanitizationProxy: null,
httpsTunnel: null,
verbose: false,
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('launchClaude', () => {
beforeEach(() => {
mockSpawn.mockClear();
mockSetupCleanupHandlers.mockClear();
mockEscapeShellArg.mockClear();
});
it('calls spawn with claudeCli and includes --settings arg', async () => {
const ctx = baseContext();
await launchClaude(ctx);
expect(mockSpawn).toHaveBeenCalledTimes(1);
const [cmd, spawnArgs] = mockSpawn.mock.calls[0] as [string, string[]];
expect(cmd).toBe('/usr/local/bin/claude');
expect(spawnArgs).toContain('--settings');
});
it('inherits process env merged with provided env', async () => {
const ctx = baseContext({ env: { CUSTOM_KEY: 'custom-value' } as NodeJS.ProcessEnv });
await launchClaude(ctx);
const spawnOpts = mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv };
expect(spawnOpts.env?.CUSTOM_KEY).toBe('custom-value');
});
it('passes stdio: inherit', async () => {
await launchClaude(baseContext());
const spawnOpts = mockSpawn.mock.calls[0][2] as { stdio: string };
expect(spawnOpts.stdio).toBe('inherit');
});
it('appends image analysis args when imageAnalysisMcpReady=true', async () => {
await launchClaude(baseContext({ imageAnalysisMcpReady: true }));
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--mcp-image-analysis');
});
it('does not append image analysis args when imageAnalysisMcpReady=false', async () => {
await launchClaude(baseContext({ imageAnalysisMcpReady: false }));
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).not.toContain('--mcp-image-analysis');
});
it('appends browser tool args when browserRuntimeEnv is set', async () => {
await launchClaude(
baseContext({ browserRuntimeEnv: { CCS_BROWSER: '1' } as NodeJS.ProcessEnv })
);
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--browser');
});
it('registers cleanup handlers after spawn', async () => {
await launchClaude(baseContext());
expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1);
// First arg should be the ChildProcess returned by spawn
expect(mockSetupCleanupHandlers.mock.calls[0][0]).toBe(mockSpawnResult);
});
it('returns the ChildProcess from spawn', async () => {
const result = await launchClaude(baseContext());
expect(result).toBe(mockSpawnResult);
});
describe('Windows shell escaping', () => {
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
it('uses shell mode for .cmd executables on Windows', async () => {
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' }));
const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean };
// shell property should be set (cmd.exe from mock)
expect(spawnOpts.shell).toBeTruthy();
});
it('skips shell mode for non-script executables on Windows', async () => {
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.exe' }));
// spawn should be called with separate args array, not a shell cmd string
const [, secondArg] = mockSpawn.mock.calls[0];
expect(Array.isArray(secondArg)).toBe(true);
});
});
});
@@ -0,0 +1,249 @@
/**
* Characterization tests CLIProxy Executor (Phase 01)
*
* Goal: lock in the current observable behavior of the executor's flag-parsing
* and validation pipeline so that subsequent module extractions (Phases 0310)
* can be proven non-behavior-changing.
*
* Approach:
* - Test the re-exported surface of index.ts to verify backwards compat.
* - Full execClaudeWithCLIProxy integration scenarios (spawn-level) require
* mocking the entire module graph including js-yaml / cli-table3 native
* deps not installed on this worktree. Those scenarios are marked it.skip
* with clear rationale; they will be enabled once bun install is run in CI.
* - The meaningful behavioral contracts (flag parsing, CCS flag filtering,
* validation guards) are fully tested via arg-parser.test.ts (Phase 02).
* These characterization tests deliberately duplicate the surface-level
* assertions to confirm index.ts still re-exports the same behavior.
*
* When to unskip the skipped tests:
* Run `bun install` in the worktree so js-yaml and cli-table3 are available,
* then remove the `.skip` and implement full spawn mocks with mock.module().
*/
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// ── Surface re-export verification ────────────────────────────────────────────
// Confirm that readOptionValue / hasGitLabTokenLoginFlag / CCS_FLAGS / filterCcsFlags
// behave correctly. These are re-exported from index.ts; we import from arg-parser
// directly here because index.ts transitively loads js-yaml / cli-table3 native
// packages that are not installed in this worktree (no bun install run).
// The re-export contract is verified structurally by TypeScript (the export block
// in index.ts would fail tsc if the symbols were missing from arg-parser.ts).
import { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from '../arg-parser';
describe('index.ts re-export surface (backwards compatibility)', () => {
// ── readOptionValue ────────────────────────────────────────────────────────
describe('readOptionValue', () => {
it('parses split-token form: --flag value', () => {
expect(
readOptionValue(
['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'],
'--kiro-idc-start-url'
)
).toEqual({ present: true, value: 'https://d-123.awsapps.com/start', missingValue: false });
});
it('parses equals form: --flag=value', () => {
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
present: true,
value: 'device',
missingValue: false,
});
});
it('returns missingValue=true for bare flag with no value', () => {
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
});
it('returns missingValue=true for empty equals form', () => {
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
});
it('returns present=false when flag absent', () => {
expect(readOptionValue(['--other'], '--kiro-idc-region')).toEqual({
present: false,
missingValue: false,
});
});
});
// ── hasGitLabTokenLoginFlag ────────────────────────────────────────────────
describe('hasGitLabTokenLoginFlag', () => {
it('detects --gitlab-token-login', () => {
expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true);
});
it('detects --token-login', () => {
expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true);
});
it('returns false when neither flag present', () => {
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
});
});
// ── CCS_FLAGS + filterCcsFlags ─────────────────────────────────────────────
describe('CCS_FLAGS', () => {
it('is a non-empty readonly array', () => {
expect(Array.isArray(CCS_FLAGS)).toBe(true);
expect(CCS_FLAGS.length).toBeGreaterThan(0);
});
it('contains the core CCS flags', () => {
const expected = [
'--auth',
'--accounts',
'--use',
'--thinking',
'--1m',
'--no-1m',
'--proxy-host',
];
for (const flag of expected) {
expect(CCS_FLAGS).toContain(flag);
}
});
});
describe('filterCcsFlags', () => {
it('removes --auth and passes through non-CCS args', () => {
expect(filterCcsFlags(['--auth', '--verbose'])).toEqual(['--verbose']);
});
it('removes --use and its value argument', () => {
expect(filterCcsFlags(['--use', 'myaccount', '--print'])).toEqual(['--print']);
});
it('removes --kiro-auth-method= inline form', () => {
expect(filterCcsFlags(['--kiro-auth-method=aws', '--model', 'claude-3'])).toEqual([
'--model',
'claude-3',
]);
});
it('removes --thinking= inline form', () => {
expect(filterCcsFlags(['--thinking=high', '--dangerously-skip-permissions'])).toEqual([
'--dangerously-skip-permissions',
]);
});
it('preserves non-CCS args', () => {
const args = ['--model', 'claude-opus-4-5', '--print', 'hello world'];
expect(filterCcsFlags(args)).toEqual(args);
});
it('removes empty args list to empty result', () => {
expect(filterCcsFlags([])).toEqual([]);
});
});
});
// ── execClaudeWithCLIProxy integration scenarios (skipped — native deps) ──────
//
// These scenarios characterize the end-to-end spawn behavior.
// They are skipped because the worktree's bun install has not been run,
// so js-yaml and cli-table3 are missing. Enable after `bun install`.
//
// Mock strategy (for when unskipped):
// - mock.module('child_process', ...) to capture spawn args
// - mock.module('../auth/auth-handler', ...) to stub isAuthenticated + triggerOAuth
// - mock.module('../services/remote-proxy-client', ...) to stub checkRemoteProxy
// - mock.module('../binary-manager', ...) to stub ensureCLIProxyBinary
// - mock.module('../../config/unified-config-loader', ...) to return minimal config
// - Set CCS_HOME to a temp dir to avoid touching ~/.ccs
describe.skip('execClaudeWithCLIProxy — integration scenarios (requires bun install)', () => {
let tmpHome = '';
let fakeClaudePath = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-exec-characterization-'));
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.chmodSync(fakeClaudePath, 0o755);
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
// Scenario 1: --accounts listing exits without spawning Claude
it('--accounts exits with code 0 and does not spawn Claude', async () => {
// TODO: mock child_process.spawn, assert it was NOT called
// TODO: mock getProviderAccounts to return []
// TODO: assert process.exit called with 0
});
// Scenario 2: invalid kiro flag combination → exits code 1
it('invalid kiro flag combination calls process.exit(1)', async () => {
// --kiro-auth-method used with non-kiro provider
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
// TODO: import execClaudeWithCLIProxy and call with gemini + --kiro-auth-method=aws
// await execClaudeWithCLIProxy(fakeClaudePath, 'gemini', ['--kiro-auth-method=aws'], {});
// expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
// Scenario 3: --auth flow exits without spawning Claude
it('--auth (forceAuth) exits after OAuth without spawning Claude CLI', async () => {
// TODO: mock triggerOAuth to resolve true
// TODO: assert spawn not called, process.exit(0) called
});
// Scenario 4: gemini local profile — verify spawn args subset
it('gemini local profile — spawn includes ANTHROPIC_BASE_URL pointing to local port', async () => {
// TODO: capture spawn(claudeCli, args, opts) call
// TODO: assert opts.env.ANTHROPIC_BASE_URL includes 'localhost' or '127.0.0.1'
// TODO: assert args does NOT include CCS-specific flags
});
// Scenario 5: codex local with reasoning proxy — spawn ordering
it('codex local — codex reasoning proxy started before Claude spawn', async () => {
// TODO: assert CodexReasoningProxy.start() called before spawn
// TODO: assert env.ANTHROPIC_BASE_URL points to reasoning proxy port
});
// Scenario 6: composite remote https tunnel — tool sanitization started
it('composite remote https — tool sanitization proxy and HTTPS tunnel started', async () => {
// TODO: mock checkRemoteProxy to return { reachable: true, latencyMs: 5 }
// TODO: assert HttpsTunnelProxy.start() called (when shouldStartHttpsTunnel returns true)
// TODO: assert ToolSanitizationProxy.start() called
});
// Scenario 7: kiro with --kiro-auth-method — validation guard passes
it('kiro provider + --kiro-auth-method=aws — validation passes and proceeds to auth', async () => {
// TODO: mock triggerOAuth, assert called with { kiroMethod: 'aws' }
});
});
@@ -0,0 +1,227 @@
/**
* Tests for model-warnings.ts Phase 08
*
* Verifies that warnBrokenModels emits warnings for broken models and is
* silent for healthy ones, covering both simple and composite providers.
*/
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
// ── Stubs ─────────────────────────────────────────────────────────────────────
// getCurrentModel: return whatever the test configures
const mockGetCurrentModel = jest.fn<string | undefined, [string, string | undefined]>();
// isModelBroken: return false by default
const mockIsModelBroken = jest.fn<boolean, [string, string]>().mockReturnValue(false);
const mockGetModelIssueUrl = jest
.fn<string | undefined, [string, string]>()
.mockReturnValue(undefined);
const mockFindModel = jest
.fn<{ name: string } | undefined, [string, string]>()
.mockReturnValue(undefined);
const mockGetSuggestedReplacementModel = jest
.fn<string | undefined, [string, string]>()
.mockReturnValue(undefined);
jest.mock('../model-warnings', () => {
// We test the real implementation — only mock its dependencies
return jest.requireActual('../model-warnings');
});
jest.mock('../../config/model-config', () => ({
getCurrentModel: mockGetCurrentModel,
}));
jest.mock('../../cliproxy/model-catalog', () => ({
isModelBroken: mockIsModelBroken,
getModelIssueUrl: mockGetModelIssueUrl,
findModel: mockFindModel,
getSuggestedReplacementModel: mockGetSuggestedReplacementModel,
}));
// We import from real path after jest.mock so actual module is used
import { warnBrokenModels } from '../model-warnings';
import type { ExecutorConfig } from '../../types';
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeSimpleCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
return {
port: 8317,
timeout: 5000,
verbose: false,
pollInterval: 100,
isComposite: false,
...overrides,
} as ExecutorConfig;
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe('warnBrokenModels', () => {
let errorSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGetCurrentModel.mockReset();
mockIsModelBroken.mockReturnValue(false);
mockGetModelIssueUrl.mockReturnValue(undefined);
mockFindModel.mockReturnValue(undefined);
mockGetSuggestedReplacementModel.mockReturnValue(undefined);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('does not warn when no model is configured', () => {
mockGetCurrentModel.mockReturnValue(undefined);
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: false,
});
expect(errorSpy).not.toHaveBeenCalled();
});
it('does not warn when model is healthy', () => {
mockGetCurrentModel.mockReturnValue('gemini-2.5-pro');
mockIsModelBroken.mockReturnValue(false);
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: false,
});
expect(errorSpy).not.toHaveBeenCalled();
});
it('warns when model is broken (no replacement, no issue url)', () => {
mockGetCurrentModel.mockReturnValue('gemini-old');
mockIsModelBroken.mockReturnValue(true);
mockFindModel.mockReturnValue({ name: 'Gemini Old' } as { name: string });
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: false,
});
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((m) => m.includes('has known issues with Claude Code'))).toBe(true);
expect(calls.some((m) => m.includes('Tool calls will fail'))).toBe(true);
});
it('includes replacement model suggestion when available', () => {
mockGetCurrentModel.mockReturnValue('gemini-old');
mockIsModelBroken.mockReturnValue(true);
mockGetSuggestedReplacementModel.mockReturnValue('gemini-2.5-pro');
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: false,
});
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((m) => m.includes('gemini-2.5-pro'))).toBe(true);
});
it('includes tracking URL when issue url is available', () => {
mockGetCurrentModel.mockReturnValue('gemini-old');
mockIsModelBroken.mockReturnValue(true);
mockGetModelIssueUrl.mockReturnValue('https://github.com/issues/123');
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: false,
});
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((m) => m.includes('https://github.com/issues/123'))).toBe(true);
});
it('includes remote proxy note when skipLocalAuth=true', () => {
mockGetCurrentModel.mockReturnValue('gemini-old');
mockIsModelBroken.mockReturnValue(true);
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: true,
});
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((m) => m.includes('remote proxy'))).toBe(true);
});
it('includes --config suggestion when skipLocalAuth=false', () => {
mockGetCurrentModel.mockReturnValue('gemini-old');
mockIsModelBroken.mockReturnValue(true);
warnBrokenModels({
provider: 'gemini',
cfg: makeSimpleCfg(),
compositeProviders: [],
skipLocalAuth: false,
});
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((m) => m.includes('--config'))).toBe(true);
});
describe('composite variants', () => {
function makeCompositeCfg(): ExecutorConfig {
return {
port: 8317,
timeout: 5000,
verbose: false,
pollInterval: 100,
isComposite: true,
compositeTiers: {
opus: { provider: 'gemini', model: 'gemini-opus-old' },
sonnet: { provider: 'gemini', model: 'gemini-sonnet-ok' },
haiku: { provider: 'gemini', model: 'gemini-haiku-ok' },
},
} as unknown as ExecutorConfig;
}
it('warns for broken composite tier', () => {
mockIsModelBroken.mockImplementation((_p, m) => m === 'gemini-opus-old');
mockFindModel.mockReturnValue({ name: 'Gemini Opus Old' } as { name: string });
warnBrokenModels({
provider: 'gemini',
cfg: makeCompositeCfg(),
compositeProviders: ['gemini'],
skipLocalAuth: false,
});
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((m) => m.includes('opus tier'))).toBe(true);
});
it('does not warn for healthy composite tiers', () => {
mockIsModelBroken.mockReturnValue(false);
warnBrokenModels({
provider: 'gemini',
cfg: makeCompositeCfg(),
compositeProviders: ['gemini'],
skipLocalAuth: false,
});
expect(errorSpy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,273 @@
/**
* Unit tests for proxy-chain-builder.ts (Phase 07)
*
* Mocking strategy: proxy constructors are injected via the _ToolSanitizationProxy
* and _CodexReasoningProxy fields on ProxyChainContext (dependency-injection escape
* hatch added for testability). This avoids Bun module-cache limitations with
* mock.module / jest.mock and never spins up real HTTP servers.
*
* Tests cover:
* - Tool sanitization proxy started when ANTHROPIC_BASE_URL is set
* - Tool sanitization proxy skipped when ANTHROPIC_BASE_URL is absent
* - Tool sanitization proxy start failure swallowed (null returned, verbose warn)
* - Codex reasoning proxy started for single-provider codex
* - Codex reasoning proxy skipped for composite codex (cfg.isComposite true)
* - Codex reasoning proxy skipped for non-codex provider
* - Codex reasoning proxy uses post-sanitization URL when tool-san is active
* - Codex reasoning proxy start failure swallowed (null returned)
* - All results null when ANTHROPIC_BASE_URL absent
*/
import { describe, expect, it, jest, beforeEach } from 'bun:test';
import { buildProxyChain } from '../proxy-chain-builder';
import type { ProxyChainContext } from '../proxy-chain-builder';
import type { ToolSanitizationProxy } from '../../proxy/tool-sanitization-proxy';
import type { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
// ── Stub factory ──────────────────────────────────────────────────────────────
type ProxyStub = { start: jest.Mock; stop: jest.Mock; _port?: number };
function makeStubCtor(resolvePort: number | (() => Promise<number> | number)): {
ctor: jest.Mock;
instance: ProxyStub;
} {
const instance: ProxyStub = {
start: jest
.fn()
.mockImplementation(
typeof resolvePort === 'function' ? resolvePort : () => Promise.resolve(resolvePort)
),
stop: jest.fn(),
};
const ctor = jest.fn().mockReturnValue(instance);
return { ctor, instance };
}
function makeFailCtor(message: string): { ctor: jest.Mock; instance: ProxyStub } {
const instance: ProxyStub = {
start: jest.fn().mockRejectedValue(new Error(message)),
stop: jest.fn(),
};
const ctor = jest.fn().mockReturnValue(instance);
return { ctor, instance };
}
// ── Context helpers ───────────────────────────────────────────────────────────
function makeThinkingCfg() {
return { mode: 'auto' as const, override: undefined };
}
function makeProxyConfig() {
return {
mode: 'local' as const,
port: 8317,
protocol: 'http' as const,
fallbackEnabled: false,
autoStartLocal: true,
remoteOnly: false,
forceLocal: false,
};
}
function makeCfg(overrides: Partial<{ isComposite: boolean }> = {}) {
return { port: 8317, timeout: 5000, verbose: false, pollInterval: 100, ...overrides };
}
// Stub ctors shared across tests, reset in beforeEach
let defaultToolSan: ReturnType<typeof makeStubCtor>;
let defaultCodex: ReturnType<typeof makeStubCtor>;
beforeEach(() => {
defaultToolSan = makeStubCtor(19001);
defaultCodex = makeStubCtor(19002);
});
function baseCtx(overrides: Partial<ProxyChainContext> = {}): ProxyChainContext {
return {
provider: 'gemini',
useRemoteProxy: false,
proxyConfig: makeProxyConfig(),
cfg: makeCfg(),
initialEnvVars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' },
thinkingOverride: undefined,
thinkingCfg: makeThinkingCfg(),
verbose: false,
log: () => {},
_ToolSanitizationProxy: defaultToolSan.ctor as unknown as new (
cfg: object
) => ToolSanitizationProxy,
_CodexReasoningProxy: defaultCodex.ctor as unknown as new (cfg: object) => CodexReasoningProxy,
...overrides,
};
}
// ── Tool sanitization: started when ANTHROPIC_BASE_URL set ───────────────────
describe('buildProxyChain — tool sanitization: started when ANTHROPIC_BASE_URL set', () => {
it('starts proxy and returns instance + port', async () => {
const { ctor, instance } = makeStubCtor(13001);
const result = await buildProxyChain(baseCtx({ _ToolSanitizationProxy: ctor as never }));
expect(ctor).toHaveBeenCalledTimes(1);
expect(result.toolSanitizationProxy).toBe(instance);
expect(result.toolSanitizationPort).toBe(13001);
});
});
// ── Tool sanitization: skipped when ANTHROPIC_BASE_URL absent ────────────────
describe('buildProxyChain — tool sanitization: skipped when no ANTHROPIC_BASE_URL', () => {
it('returns null proxy and port without constructing', async () => {
const { ctor } = makeStubCtor(13001);
const result = await buildProxyChain(
baseCtx({ initialEnvVars: {}, _ToolSanitizationProxy: ctor as never })
);
expect(ctor).not.toHaveBeenCalled();
expect(result.toolSanitizationProxy).toBeNull();
expect(result.toolSanitizationPort).toBeNull();
});
});
// ── Tool sanitization: start failure swallowed ────────────────────────────────
describe('buildProxyChain — tool sanitization: start failure swallowed', () => {
it('returns null and emits verbose warn without throwing', async () => {
const { ctor } = makeFailCtor('port in use');
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const result = await buildProxyChain(
baseCtx({ verbose: true, _ToolSanitizationProxy: ctor as never })
);
expect(result.toolSanitizationProxy).toBeNull();
expect(result.toolSanitizationPort).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('port in use'));
warnSpy.mockRestore();
});
});
// ── Codex reasoning: started for single-provider codex ───────────────────────
describe('buildProxyChain — codex reasoning: started for single-provider codex', () => {
it('starts codex proxy and returns instance + port', async () => {
const sanCtorPair = makeStubCtor(13010);
const codexCtorPair = makeStubCtor(13011);
const result = await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: false }),
_ToolSanitizationProxy: sanCtorPair.ctor as never,
_CodexReasoningProxy: codexCtorPair.ctor as never,
})
);
expect(codexCtorPair.ctor).toHaveBeenCalledTimes(1);
expect(result.codexReasoningProxy).toBe(codexCtorPair.instance);
expect(result.codexReasoningPort).toBe(13011);
});
});
// ── Codex reasoning: skipped for composite codex ─────────────────────────────
describe('buildProxyChain — codex reasoning: skipped for composite codex', () => {
it('does not construct CodexReasoningProxy when cfg.isComposite is true', async () => {
const { ctor } = makeStubCtor(13020);
const result = await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: true }),
_CodexReasoningProxy: ctor as never,
})
);
expect(ctor).not.toHaveBeenCalled();
expect(result.codexReasoningProxy).toBeNull();
expect(result.codexReasoningPort).toBeNull();
});
});
// ── Codex reasoning: skipped for non-codex provider ──────────────────────────
describe('buildProxyChain — codex reasoning: skipped for non-codex provider', () => {
it('does not construct CodexReasoningProxy for gemini', async () => {
const { ctor } = makeStubCtor(13030);
await buildProxyChain(baseCtx({ provider: 'gemini', _CodexReasoningProxy: ctor as never }));
expect(ctor).not.toHaveBeenCalled();
});
});
// ── Codex reasoning: uses post-sanitization URL ───────────────────────────────
describe('buildProxyChain — codex reasoning: uses post-sanitization base URL', () => {
it('passes http://127.0.0.1:<sanPort> as upstreamBaseUrl', async () => {
const sanCtorPair = makeStubCtor(13040);
const codexCtorPair = makeStubCtor(13041);
await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: false }),
_ToolSanitizationProxy: sanCtorPair.ctor as never,
_CodexReasoningProxy: codexCtorPair.ctor as never,
})
);
expect(codexCtorPair.ctor).toHaveBeenCalledWith(
expect.objectContaining({ upstreamBaseUrl: 'http://127.0.0.1:13040' })
);
});
});
// ── Codex reasoning: start failure swallowed ─────────────────────────────────
describe('buildProxyChain — codex reasoning: start failure swallowed', () => {
it('returns null codex proxy/port without throwing', async () => {
const sanCtorPair = makeStubCtor(13050);
const { ctor: codexFailCtor } = makeFailCtor('bind failed');
const result = await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: false }),
_ToolSanitizationProxy: sanCtorPair.ctor as never,
_CodexReasoningProxy: codexFailCtor as never,
})
);
expect(result.codexReasoningProxy).toBeNull();
expect(result.codexReasoningPort).toBeNull();
});
});
// ── All results null when no ANTHROPIC_BASE_URL ───────────────────────────────
describe('buildProxyChain — all results null when no ANTHROPIC_BASE_URL', () => {
it('returns four nulls for gemini without base URL', async () => {
const sanCtor = jest.fn();
const codexCtor = jest.fn();
const result = await buildProxyChain(
baseCtx({
initialEnvVars: {},
_ToolSanitizationProxy: sanCtor as never,
_CodexReasoningProxy: codexCtor as never,
})
);
expect(sanCtor).not.toHaveBeenCalled();
expect(codexCtor).not.toHaveBeenCalled();
expect(result.toolSanitizationProxy).toBeNull();
expect(result.toolSanitizationPort).toBeNull();
expect(result.codexReasoningProxy).toBeNull();
expect(result.codexReasoningPort).toBeNull();
});
});
@@ -0,0 +1,257 @@
/**
* Unit tests for proxy-resolver.ts (Phase 03 extraction)
*
* Tests cover the proxy resolution + remote reachability + binary acquisition
* logic extracted from executor/index.ts.
*/
import { beforeEach, describe, expect, it, jest } from 'bun:test';
import type { ResolveExecutorProxyContext } from '../proxy-resolver';
import type { ExecutorConfig } from '../../types';
import type { UnifiedConfig } from '../../../config/schemas/unified-config';
// ── Module mocks ──────────────────────────────────────────────────────────────
const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy');
const mockGetConfiguredBackend = jest.fn().mockReturnValue('original');
const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable');
jest.mock('../../binary-manager', () => ({
ensureCLIProxyBinary: mockEnsureCLIProxyBinary,
getConfiguredBackend: mockGetConfiguredBackend,
getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage,
}));
const mockCheckRemoteProxy = jest.fn();
jest.mock('../../services/remote-proxy-client', () => ({
checkRemoteProxy: mockCheckRemoteProxy,
}));
jest.mock('../retry-handler', () => ({
isNetworkError: jest.fn().mockReturnValue(false),
handleNetworkError: jest.fn(),
}));
const mockResolveProxyConfig = jest.fn();
jest.mock('../../proxy/proxy-config-resolver', () => ({
resolveProxyConfig: mockResolveProxyConfig,
}));
jest.mock('../../config/config-generator', () => ({
CLIPROXY_DEFAULT_PORT: 8317,
validatePort: jest.fn((port: number | undefined) => port ?? 8317),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { resolveExecutorProxy } = await import('../proxy-resolver');
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeMinimalUnifiedConfig(): UnifiedConfig {
return {
cliproxy_server: undefined,
} as unknown as UnifiedConfig;
}
function makeBaseCfg(): ExecutorConfig {
return {
port: 8317,
timeout: 5000,
verbose: false,
pollInterval: 100,
};
}
function makeContext(
overrides: Partial<ResolveExecutorProxyContext> = {}
): ResolveExecutorProxyContext {
return {
unifiedConfig: makeMinimalUnifiedConfig(),
allProviders: ['gemini'],
verbose: false,
cfg: makeBaseCfg(),
log: jest.fn(),
...overrides,
};
}
/** Mock resolveProxyConfig to return a local-mode config */
function mockLocalProxyConfig(remainingArgs: string[] = []): void {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'local',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: false,
forceLocal: true,
},
remainingArgs,
});
}
/** Mock resolveProxyConfig to return a remote-mode config */
function mockRemoteProxyConfig(remainingArgs: string[] = []): void {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: false,
forceLocal: false,
},
remainingArgs,
});
}
// ── Tests ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
jest.clearAllMocks();
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
mockGetConfiguredBackend.mockReturnValue('original');
});
describe('resolveExecutorProxy — local mode', () => {
it('returns useRemoteProxy=false and correct binary for local mode', async () => {
mockLocalProxyConfig(['--verbose']);
const result = await resolveExecutorProxy(['--verbose'], makeContext());
expect(result.useRemoteProxy).toBe(false);
expect(result.localBackend).toBe('original');
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
expect(result.argsWithoutProxy).toEqual(['--verbose']);
});
it('strips proxy flags and passes remainingArgs through', async () => {
mockLocalProxyConfig(['clean-arg']);
const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext());
expect(result.argsWithoutProxy).toEqual(['clean-arg']);
expect(result.useRemoteProxy).toBe(false);
});
it('does not call checkRemoteProxy in local mode', async () => {
mockLocalProxyConfig();
await resolveExecutorProxy([], makeContext());
expect(mockCheckRemoteProxy).not.toHaveBeenCalled();
});
});
describe('resolveExecutorProxy — remote mode reachable', () => {
it('returns useRemoteProxy=true when remote proxy is reachable', async () => {
mockRemoteProxyConfig();
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined });
const result = await resolveExecutorProxy([], makeContext());
expect(result.useRemoteProxy).toBe(true);
});
it('skips binary acquisition when remote proxy is reachable', async () => {
mockRemoteProxyConfig();
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined });
const result = await resolveExecutorProxy([], makeContext());
expect(result.binaryPath).toBeUndefined();
expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled();
});
});
describe('resolveExecutorProxy — remote mode unreachable', () => {
it('throws expected message when remoteOnly=true and remote is unreachable', async () => {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: true,
forceLocal: false,
},
remainingArgs: [],
});
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' });
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
'Remote proxy unreachable and --remote-only specified'
);
});
it('throws when fallback disabled and remote is unreachable', async () => {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: false,
forceLocal: false,
},
remainingArgs: [],
});
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
'Remote proxy unreachable and fallback disabled'
);
});
it('falls back to local and acquires binary when autoStartLocal=true', async () => {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: true,
autoStartLocal: true,
remoteOnly: false,
forceLocal: false,
},
remainingArgs: [],
});
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
const result = await resolveExecutorProxy([], makeContext());
expect(result.useRemoteProxy).toBe(false);
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
expect(mockEnsureCLIProxyBinary).toHaveBeenCalled();
});
});
describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
it('returns the resolved proxyConfig object', async () => {
mockLocalProxyConfig();
const result = await resolveExecutorProxy([], makeContext());
expect(result.proxyConfig).toBeDefined();
expect(result.proxyConfig.mode).toBe('local');
expect(result.proxyConfig.port).toBe(8317);
});
it('returns mutated cfg with validated port', async () => {
mockLocalProxyConfig();
const ctx = makeContext();
const result = await resolveExecutorProxy([], ctx);
// cfg is mutated in place and also returned
expect(result.cfg).toBe(ctx.cfg);
expect(result.cfg.port).toBe(8317);
});
});
+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);
}
}
+624
View File
@@ -0,0 +1,624 @@
/**
* CLIProxy Executor Arg Parser
*
* Extracted from index.ts (Concern A):
* - readOptionValue
* - hasGitLabTokenLoginFlag / getGitLabTokenLoginFlagName
* - CCS_FLAGS constant + filterCcsFlags()
* - parseExecutorFlags() flag extraction block (lines ~411-639 in original)
* - validateFlagCombinations() cross-flag guard block (lines ~531-585)
*
* IMPORTANT: process.exit semantics are kept identical to original index.ts.
* All console.error messages are byte-identical.
*/
import { fail } from '../../utils/ui/indicators';
import {
isKiroAuthMethod,
isKiroIDCFlow,
type KiroAuthMethod,
type KiroIDCFlow,
normalizeKiroAuthMethod,
normalizeKiroIDCFlow,
} from '../auth/auth-types';
import type { UnifiedConfig } from '../../config/unified-config-types';
import { PROXY_CLI_FLAGS } from '../proxy/proxy-config-resolver';
import { parseThinkingOverride } from './thinking-arg-parser';
// Inlined from antigravity-responsibility.ts to avoid pulling in unified-config-loader
// (which requires js-yaml at runtime). Keep in sync if ANTIGRAVITY_ACCEPT_RISK_FLAGS changes.
const ANTIGRAVITY_ACCEPT_RISK_FLAGS_LOCAL = [
'--accept-agr-risk',
'--accept-antigravity-risk',
] as const;
function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean {
return args.some((arg) =>
(ANTIGRAVITY_ACCEPT_RISK_FLAGS_LOCAL as readonly string[]).includes(arg)
);
}
// ── Simple Helpers ────────────────────────────────────────────────────────────
/**
* Parse a flag value from args supporting both `--flag value` and `--flag=value` forms.
* Returns the shape used throughout the executor:
* { present, value?, missingValue }
*/
export function readOptionValue(
args: string[],
flag: string
): { present: boolean; value?: string; missingValue: boolean } {
const inlinePrefix = `${flag}=`;
const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix));
if (inlineArg !== undefined) {
const value = inlineArg.slice(inlinePrefix.length).trim();
return {
present: true,
value: value.length > 0 ? value : undefined,
missingValue: value.length === 0,
};
}
const index = args.indexOf(flag);
if (index === -1) {
return { present: false, missingValue: false };
}
const next = args[index + 1];
if (!next || next.startsWith('-')) {
return { present: true, missingValue: true };
}
return { present: true, value: next.trim(), missingValue: false };
}
/** Returns true if args contain a GitLab token-login flag. */
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
return args.includes('--gitlab-token-login') || args.includes('--token-login');
}
/** Returns the specific flag name present in args (used for error messages). */
export function getGitLabTokenLoginFlagName(
args: string[]
): '--gitlab-token-login' | '--token-login' {
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
}
// ── CCS Flags Filter ──────────────────────────────────────────────────────────
/**
* CCS-specific flags that must not be forwarded to the underlying Claude CLI.
* The list is kept here as the single source of truth (previously inlined in index.ts).
*/
export const CCS_FLAGS: readonly string[] = [
'--auth',
'--paste-callback',
'--port-forward',
'--headless',
'--logout',
'--config',
'--add',
'--accounts',
'--use',
'--nickname',
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--thinking',
'--effort',
'--1m',
'--no-1m',
'--incognito',
'--no-incognito',
'--import',
'--accept-agr-risk',
'--accept-antigravity-risk',
'--settings',
...PROXY_CLI_FLAGS,
] as const;
/**
* Filter all CCS-specific flags (and their value arguments) from args
* before forwarding to the Claude CLI.
* Mirrors the filter logic from index.ts lines ~1328-1349.
*/
export function filterCcsFlags(args: string[]): string[] {
return args.filter((arg, idx) => {
if (CCS_FLAGS.includes(arg)) return false;
if (arg.startsWith('--kiro-auth-method=')) return false;
if (arg.startsWith('--kiro-idc-start-url=')) return false;
if (arg.startsWith('--kiro-idc-region=')) return false;
if (arg.startsWith('--kiro-idc-flow=')) return false;
if (arg.startsWith('--thinking=')) return false;
if (arg.startsWith('--effort=')) return false;
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
if (
args[idx - 1] === '--use' ||
args[idx - 1] === '--nickname' ||
args[idx - 1] === '--kiro-auth-method' ||
args[idx - 1] === '--kiro-idc-start-url' ||
args[idx - 1] === '--kiro-idc-region' ||
args[idx - 1] === '--kiro-idc-flow' ||
args[idx - 1] === '--thinking' ||
args[idx - 1] === '--effort'
)
return false;
return true;
});
}
// ── ParsedExecutorFlags ───────────────────────────────────────────────────────
/** Result of parsing CCS executor flags from args. */
export interface ParsedExecutorFlags {
forceAuth: boolean;
pasteCallback: boolean;
portForward: boolean;
forceHeadless: boolean;
forceLogout: boolean;
forceConfig: boolean;
addAccount: boolean;
showAccounts: boolean;
forceImport: boolean;
gitlabTokenLogin: boolean;
acceptAgyRisk: boolean;
incognitoFlag: boolean;
noIncognitoFlag: boolean;
noIncognito: boolean;
useAccount: string | undefined;
setNickname: string | undefined;
kiroAuthMethod: KiroAuthMethod | undefined;
kiroIDCStartUrl: string | undefined;
kiroIDCRegion: string | undefined;
kiroIDCFlow: KiroIDCFlow | undefined;
gitlabBaseUrl: string | undefined;
extendedContextOverride: boolean | undefined;
thinkingParse: ReturnType<typeof parseThinkingOverride>;
}
/**
* Parse all CCS executor flags from args.
*
* Exits with code 1 (process.exitCode = 1 + return) on invalid flag values.
* Exits with process.exit(1) on conflicting flag combinations identical to
* the original index.ts behavior.
*
* @param args args AFTER proxy flags have been stripped (argsWithoutProxy)
* @param context provider context needed for kiro/incognito defaults
*/
export function parseExecutorFlags(
args: string[],
context: {
provider: string;
compositeProviders: string[];
unifiedConfig: UnifiedConfig;
}
): ParsedExecutorFlags {
const { provider, unifiedConfig } = context;
const forceAuth = args.includes('--auth');
const pasteCallback = args.includes('--paste-callback');
const portForward = args.includes('--port-forward');
const forceHeadless = args.includes('--headless');
if (pasteCallback && portForward) {
console.error(fail('Cannot use --paste-callback with --port-forward'));
console.error(' --paste-callback: Manually paste OAuth redirect URL');
console.error(' --port-forward: Use SSH port forwarding for callback');
process.exit(1);
}
const forceLogout = args.includes('--logout');
const forceConfig = args.includes('--config');
const addAccount = args.includes('--add');
const showAccounts = args.includes('--accounts');
const forceImport = args.includes('--import');
const gitlabTokenLogin = hasGitLabTokenLoginFlag(args);
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(args);
const incognitoFlag = args.includes('--incognito');
const noIncognitoFlag = args.includes('--no-incognito');
const kiroNoIncognitoConfig =
provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false;
const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig;
// Parse --use flag
let useAccount: string | undefined;
const useIdx = args.indexOf('--use');
if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) {
useAccount = args[useIdx + 1];
}
// Parse --nickname flag
let setNickname: string | undefined;
const nicknameIdx = args.indexOf('--nickname');
if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) {
setNickname = args[nicknameIdx + 1];
}
// Parse --kiro-auth-method flag
let kiroAuthMethod: KiroAuthMethod | undefined;
const kiroMethodValue = readOptionValue(args, '--kiro-auth-method');
if (kiroMethodValue.present) {
const rawMethod = kiroMethodValue.value;
if (kiroMethodValue.missingValue || !rawMethod) {
console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
// Caller must check process.exitCode = 1 and bail — matching original return behavior
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod: undefined,
kiroIDCStartUrl: undefined,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
const normalized = rawMethod.trim().toLowerCase();
if (!isKiroAuthMethod(normalized)) {
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod: undefined,
kiroIDCStartUrl: undefined,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
}
let kiroIDCStartUrl: string | undefined;
const kiroIDCStartUrlValue = readOptionValue(args, '--kiro-idc-start-url');
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
} else if (kiroIDCStartUrlValue.present) {
console.error(fail('--kiro-idc-start-url requires a value'));
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod,
kiroIDCStartUrl: undefined,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
let kiroIDCRegion: string | undefined;
const kiroIDCRegionValue = readOptionValue(args, '--kiro-idc-region');
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
kiroIDCRegion = kiroIDCRegionValue.value;
} else if (kiroIDCRegionValue.present) {
console.error(fail('--kiro-idc-region requires a value'));
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
let kiroIDCFlow: KiroIDCFlow | undefined;
const kiroIDCFlowValue = readOptionValue(args, '--kiro-idc-flow');
if (kiroIDCFlowValue.present) {
const rawFlow = kiroIDCFlowValue.value;
if (kiroIDCFlowValue.missingValue || !rawFlow) {
console.error(fail('--kiro-idc-flow requires a value'));
console.error(' Supported values: authcode, device');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
const normalized = rawFlow.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) {
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
console.error(' Supported values: authcode, device');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow: undefined,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
let gitlabBaseUrl: string | undefined;
const gitlabBaseUrlValue = readOptionValue(args, '--gitlab-url');
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value'));
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow,
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
});
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(args);
if (thinkingParse.error) {
const { flag } = thinkingParse.error;
console.error(fail(`${flag} requires a value`));
if (provider === 'codex') {
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium');
console.error(' Alias: --thinking xhigh (same behavior)');
} else {
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
}
process.exit(1);
}
// Parse --1m / --no-1m flags for extended context (1M token window)
let extendedContextOverride: boolean | undefined;
const has1mFlag = args.includes('--1m') || args.some((arg) => arg.startsWith('--1m='));
const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m='));
if (has1mFlag && hasNo1mFlag) {
console.error(fail('Cannot use both --1m and --no-1m flags'));
process.exit(1);
} else if (has1mFlag) {
extendedContextOverride = true;
} else if (hasNo1mFlag) {
extendedContextOverride = false;
}
// Auto-set kiroAuthMethod = 'idc' if IDC sub-flags present without explicit method
if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) {
kiroAuthMethod = 'idc';
}
return {
forceAuth,
pasteCallback,
portForward,
forceHeadless,
forceLogout,
forceConfig,
addAccount,
showAccounts,
forceImport,
gitlabTokenLogin,
acceptAgyRisk,
incognitoFlag,
noIncognitoFlag,
noIncognito,
useAccount,
setNickname,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow,
gitlabBaseUrl,
extendedContextOverride,
thinkingParse,
};
}
/** Internal helper — builds a ParsedExecutorFlags from raw fields (avoids repeating the full struct). */
function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags {
return fields;
}
// ── Cross-Flag Validation ─────────────────────────────────────────────────────
/**
* Validate flag combinations that are mutually exclusive or provider-scoped.
* Calls process.exit(1) on any violation identical to original index.ts.
* Call AFTER parseExecutorFlags() and only if process.exitCode is still 0.
*
* @param parsed Result of parseExecutorFlags()
* @param context Provider context (provider string + compositeProviders list)
* @param args Raw argsWithoutProxy (needed for getGitLabTokenLoginFlagName)
*/
export function validateFlagCombinations(
parsed: ParsedExecutorFlags,
context: { provider: string; compositeProviders: string[] },
args: string[]
): void {
const { provider, compositeProviders } = context;
const {
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow,
gitlabTokenLogin,
gitlabBaseUrl,
} = parsed;
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
return;
}
if (
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) &&
provider !== 'kiro' &&
!compositeProviders.includes('kiro')
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
);
process.exitCode = 1;
return;
}
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
console.error(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
);
process.exitCode = 1;
return;
}
if (
kiroAuthMethod &&
kiroAuthMethod !== 'idc' &&
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
);
process.exitCode = 1;
return;
}
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1;
return;
}
}
+397
View File
@@ -0,0 +1,397 @@
/**
* Auth Coordinator Executor-level authentication handoff
*
* Extracted from executor/index.ts (Phase 06).
* Handles:
* - --logout early exit
* - --import token early exit (Kiro only)
* - --auth / forceAuth OAuth flow (single + composite providers)
* - Antigravity responsibility gate (run and oauth contexts)
* - isAuthenticated checks + automatic OAuth trigger
* - Proactive token refresh (multi-provider for composite)
* - lastUsedAt touch via touchDefaultAccount
* - Preflight quota check
* - Account safety guards (cross-provider isolation)
* - First-run model configuration
*
* ORDERING (load-bearing do not change):
* 1. logout/import/forceAuth early exits
* 2. Remote-proxy auth skip detection
* 3. Antigravity gate (oauth context: remote+forceAuth | run context: else branch)
* 4. OAuth check / trigger (single or composite)
* 5. Token refresh
* 6. lastUsedAt touch
* 7. Preflight quota check
* 8. Account safety guards
* 9. First-run model configuration
*/
import { ok, fail, info } from '../../utils/ui';
import { isAuthenticated } from '../auth/auth-handler';
import { CLIProxyProvider, ExecutorConfig } from '../types';
import { getProviderConfig, ensureProviderSettings } from '../config/config-generator';
import { configureProviderModel, getCurrentModel } from '../config/model-config';
import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility';
import { supportsModelConfig } from '../model-catalog';
import {
ensureCliAntigravityResponsibility,
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
} from '../auth/antigravity-responsibility';
import { handleTokenExpiration, handleQuotaCheck } from './retry-handler';
import { applyAccountSafetyGuards, touchDefaultAccount } from './account-resolution';
import { MANAGED_QUOTA_PROVIDERS } from '../quota/quota-manager';
import type { ParsedExecutorFlags } from './arg-parser';
import type { UnifiedConfig } from '../../config/schemas/unified-config';
// ── Context / Result types ─────────────────────────────────────────────────────
export interface AuthCoordinationContext {
provider: CLIProxyProvider;
compositeProviders: CLIProxyProvider[];
parsedFlags: ParsedExecutorFlags;
cfg: ExecutorConfig;
unifiedConfig: UnifiedConfig;
verbose: boolean;
log: (msg: string) => void;
}
// ── 1. Special early-exit auth flags (logout / import) ────────────────────────
/**
* Handle --logout: clear auth and exit 0.
* Returns true if early exit occurred (caller should return immediately).
*/
export async function handleLogout(context: AuthCoordinationContext): Promise<boolean> {
const { provider, parsedFlags } = context;
if (!parsedFlags.forceLogout) return false;
const providerConfig = getProviderConfig(provider);
const { clearAuth } = await import('../auth/auth-handler');
if (clearAuth(provider)) {
console.log(ok(`Logged out from ${providerConfig.displayName}`));
} else {
console.log(info(`No authentication found for ${providerConfig.displayName}`));
}
process.exit(0);
}
/**
* Handle --import: Kiro-only token import flow, exits when done.
* Returns true if early exit occurred.
*/
export async function handleImport(context: AuthCoordinationContext): Promise<boolean> {
const { provider, parsedFlags, verbose } = context;
const {
forceImport,
forceAuth,
forceLogout,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow,
setNickname,
} = parsedFlags;
if (!forceImport) return false;
if (provider !== 'kiro') {
console.error(fail('--import is only available for Kiro'));
console.error(` Run "ccs ${provider} --auth" to authenticate`);
process.exit(1);
}
if (forceAuth) {
console.error(fail('Cannot use --import with --auth'));
console.error(' --import: Import existing token from Kiro IDE');
console.error(' --auth: Trigger new OAuth flow in browser');
process.exit(1);
}
if (forceLogout) {
console.error(fail('Cannot use --import with --logout'));
process.exit(1);
}
const { triggerOAuth } = await import('../auth/auth-handler');
const authSuccess = await triggerOAuth(provider, {
verbose,
import: true,
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
console.error(fail('Failed to import Kiro token from IDE'));
console.error(' Make sure you are logged into Kiro IDE first');
process.exit(1);
}
process.exit(0);
}
// ── 2. Remote proxy auth skip ──────────────────────────────────────────────────
/**
* Returns true when a remote proxy with an authToken is active
* local OAuth is not needed in this case.
*/
export function resolveSkipLocalAuth(
remoteAuthToken: string | undefined,
useRemoteProxy: boolean
): boolean {
return useRemoteProxy && !!remoteAuthToken?.trim();
}
// ── 3. Antigravity responsibility gate ────────────────────────────────────────
/**
* Runs the Antigravity responsibility gate for the relevant code path.
*
* Two scenarios (must match original ordering in index.ts):
* A. provider=agy + forceAuth + skipLocalAuth oauth-context gate; if refused, log and return
* (caller must return early this function returns { earlyReturn: true })
* B. provider=agy + !forceAuth + (skipLocalAuth || !requiresAuthNow) run-context gate;
* if refused, log and process.exit(1)
*
* Returns { earlyReturn: true } when the caller should return immediately (case A refused).
*/
export async function runAntigravityGate(
context: AuthCoordinationContext,
skipLocalAuth: boolean
): Promise<{ earlyReturn: boolean }> {
const { provider, parsedFlags } = context;
const { forceAuth, acceptAgyRisk } = parsedFlags;
if (provider !== 'agy') return { earlyReturn: false };
const providerConfig = getProviderConfig(provider);
const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider);
if (forceAuth && skipLocalAuth) {
// Case A: remote proxy + forceAuth — gate in oauth context, skip local OAuth
const acknowledged = await ensureCliAntigravityResponsibility({
context: 'oauth',
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
throw new Error(
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
);
}
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.'));
return { earlyReturn: true };
}
if (!forceAuth) {
// Case B: run-context gate (only when auth not immediately required)
if (skipLocalAuth || !requiresAuthNow) {
const acknowledged = await ensureCliAntigravityResponsibility({
context: 'run',
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
console.error(
fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
)
);
process.exit(1);
}
}
}
return { earlyReturn: false };
}
// ── 46. OAuth check / trigger + token refresh + account touch ─────────────────
/**
* Ensure provider(s) are authenticated; trigger OAuth if needed.
* Handles both composite (multi-provider) and single-provider flows.
* Also runs proactive token refresh and touches lastUsedAt.
*
* Only called when providerConfig.requiresOAuth && !skipLocalAuth.
*/
export async function ensureProviderAuthentication(
context: AuthCoordinationContext
): Promise<void> {
const { provider, compositeProviders, parsedFlags, verbose, log } = context;
const {
forceAuth,
addAccount,
acceptAgyRisk,
kiroAuthMethod,
kiroIDCStartUrl,
kiroIDCRegion,
kiroIDCFlow,
gitlabTokenLogin,
gitlabBaseUrl,
forceHeadless,
setNickname,
noIncognito,
pasteCallback,
portForward,
} = parsedFlags;
log(`Checking authentication for ${provider}`);
// Multi-provider path (composite variants)
if (compositeProviders.length > 0) {
if (forceAuth) {
const { triggerOAuth } = await import('../auth/auth-handler');
const failures: string[] = [];
for (const p of compositeProviders) {
const authSuccess = await triggerOAuth(p, {
verbose,
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
...(pasteCallback ? { pasteCallback: true } : {}),
...(portForward ? { portForward: true } : {}),
});
if (!authSuccess) {
failures.push(p);
}
}
if (failures.length > 0) {
const succeeded = compositeProviders.filter((p) => !failures.includes(p));
console.error(fail(`Auth failed for: ${failures.join(', ')}`));
if (succeeded.length > 0) {
console.error(info(`Succeeded: ${succeeded.join(', ')}`));
}
process.exit(1);
}
process.exit(0);
}
// Check for unauthenticated providers
const unauthenticatedProviders: string[] = [];
for (const p of compositeProviders) {
if (!isAuthenticated(p)) {
unauthenticatedProviders.push(p);
}
}
if (unauthenticatedProviders.length > 0) {
console.error(fail('Composite variant requires authentication for multiple providers:'));
for (const p of unauthenticatedProviders) {
console.error(` - ${p} (run "ccs ${p} --auth")`);
}
process.exit(1);
}
} else {
// Single-provider path
if (forceAuth || !isAuthenticated(provider)) {
const { triggerOAuth } = await import('../auth/auth-handler');
const providerConfig = getProviderConfig(provider);
const authSuccess = await triggerOAuth(provider, {
verbose,
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
...(pasteCallback ? { pasteCallback: true } : {}),
...(portForward ? { portForward: true } : {}),
});
if (!authSuccess) {
throw new Error(`Authentication required for ${providerConfig.displayName}`);
}
if (forceAuth) {
process.exit(0);
}
} else {
log(`${provider} already authenticated`);
}
}
// 3a. Proactive token refresh (multi-provider for composite)
if (compositeProviders.length > 0) {
for (const p of compositeProviders) {
await handleTokenExpiration(p, verbose);
}
} else {
await handleTokenExpiration(provider, verbose);
}
// 3a-1. Update lastUsedAt
touchDefaultAccount(provider);
}
// ── 7. Preflight quota check ──────────────────────────────────────────────────
/**
* Preflight quota check for providers with quota-based rotation.
* Only runs when !skipLocalAuth.
*/
export async function runPreflightQuotaCheck(
provider: CLIProxyProvider,
compositeProviders: CLIProxyProvider[]
): Promise<void> {
if (compositeProviders.length > 0) {
for (const managedProvider of MANAGED_QUOTA_PROVIDERS) {
if (compositeProviders.includes(managedProvider)) {
await handleQuotaCheck(managedProvider);
}
}
} else {
await handleQuotaCheck(provider);
}
}
// ── 8. Account safety guards ──────────────────────────────────────────────────
/**
* Enforce cross-provider account isolation. Only runs when !skipLocalAuth.
*/
export function runAccountSafetyGuards(
provider: CLIProxyProvider,
compositeProviders: CLIProxyProvider[]
): void {
applyAccountSafetyGuards(provider, compositeProviders);
}
// ── 9. First-run model configuration ─────────────────────────────────────────
/**
* Ensure provider model is configured on first run.
* Skipped for composite variants and remote proxy mode.
* Also reconciles Codex model for active plan.
*/
export async function ensureModelConfiguration(
provider: CLIProxyProvider,
cfg: ExecutorConfig,
verbose: boolean
): Promise<void> {
if (!cfg.isComposite && supportsModelConfig(provider)) {
await configureProviderModel(provider, false, cfg.customSettingsPath);
}
if (provider === 'codex' && !cfg.isComposite) {
await reconcileCodexModelForActivePlan({
currentModel: getCurrentModel(provider, cfg.customSettingsPath),
verbose,
});
}
}
// ── Ensure provider settings file ────────────────────────────────────────────
/**
* Ensure the provider settings file exists.
*/
export function ensureProviderSettingsFile(provider: CLIProxyProvider): void {
ensureProviderSettings(provider);
}
@@ -0,0 +1,118 @@
/**
* Browser Launch Setup Executor-level browser initialization
*
* Extracted from executor/index.ts (Phase 04).
* Handles:
* 1. Browser launch flag resolution and override parsing
* 2. Browser attach config + exposure resolution + blocked-override warning
* 3. Optional browser attach runtime resolution (devtools WebSocket)
* 4. Browser MCP ensure + sync-to-config-dir
*/
import { warn } from '../../utils/ui';
import {
type BrowserLaunchOverride,
ensureBrowserMcpOrThrow,
getBlockedBrowserOverrideWarning,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserExposure,
resolveBrowserLaunchFlagResolution,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { getBrowserConfig } from '../../config/config-loader-facade';
export interface BrowserLaunchSetupResult {
/** CLI override flag if --browser-launch / --no-browser-launch was passed */
browserLaunchOverride: BrowserLaunchOverride | undefined;
/** args list with --browser-launch* flags removed */
argsWithoutBrowserFlags: string[];
/** Devtools WebSocket env vars if browser attach runtime is active */
browserRuntimeEnv: Record<string, string> | undefined;
}
/**
* Phase 1 resolve browser CLI flags and attach config.
* Call this immediately after resolveExecutorProxy so that
* argsWithoutBrowserFlags is available for downstream parsing.
*
* @returns partial setup result (no async work yet)
*/
export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
browserLaunchOverride: BrowserLaunchOverride | undefined;
argsWithoutBrowserFlags: string[];
} {
let browserLaunchOverride: BrowserLaunchOverride | undefined;
let argsWithoutBrowserFlags = argsWithoutProxy;
try {
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy);
browserLaunchOverride = browserLaunchFlags.override;
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
} catch (error) {
console.error(warn((error as Error).message));
process.exit(1);
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags };
}
const browserConfig = getBrowserConfig();
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const claudeBrowserExposure = resolveBrowserExposure(
{
enabled: browserAttachConfig.enabled,
policy: browserConfig.claude.policy,
},
browserLaunchOverride
);
const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning(
'Claude Browser Attach',
claudeBrowserExposure
);
if (blockedBrowserOverrideWarning) {
console.error(warn(blockedBrowserOverrideWarning));
}
return { browserLaunchOverride, argsWithoutBrowserFlags };
}
/**
* Phase 2 resolve async browser attach runtime and MCP setup.
* Must be called AFTER phase-1 and AFTER ensureWebSearchMcpOrThrow().
*/
export async function resolveBrowserRuntime(
browserLaunchOverride: BrowserLaunchOverride | undefined,
inheritedClaudeConfigDir: string | undefined
): Promise<Pick<BrowserLaunchSetupResult, 'browserRuntimeEnv'>> {
const browserConfig = getBrowserConfig();
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const claudeBrowserExposure = resolveBrowserExposure(
{
enabled: browserAttachConfig.enabled,
policy: browserConfig.claude.policy,
},
browserLaunchOverride
);
const browserAttachRuntime =
browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
if (browserRuntimeEnv) {
ensureBrowserMcpOrThrow();
}
// Sync browser MCP config into inherited Claude instance if browser is active
if (browserRuntimeEnv && inheritedClaudeConfigDir) {
if (!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
}
return { browserRuntimeEnv };
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Claude Launcher Concern H
*
* Handles final argument assembly, trace context injection, Windows shell
* escaping, spawning the Claude CLI process, starting the runtime quota
* monitor, and wiring up cleanup handlers.
*/
import { spawn, ChildProcess } from 'child_process';
import * as os from 'os';
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
import { getProviderSettingsPath } from '../config/config-generator';
import {
ensureWebSearchMcpOrThrow as _ensureWebSearchMcpOrThrow,
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
} from '../../utils/websearch-manager';
import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis';
import { appendBrowserToolArgs } from '../../utils/browser';
import { getDefaultAccount } from '../accounts/account-manager';
import { CLIProxyProvider, ExecutorConfig } from '../types';
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
import { setupCleanupHandlers } from './session-bridge';
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
export interface ClaudeLaunchContext {
/** Path to the Claude CLI executable */
claudeCli: string;
/** Pre-filtered Claude args (CCS flags already stripped) */
claudeArgs: string[];
/** Fully assembled environment variables (without trace additions) */
env: NodeJS.ProcessEnv;
/** Resolved executor config */
cfg: ExecutorConfig;
/** Active CLIProxy provider */
provider: CLIProxyProvider;
/** Providers derived from composite tiers (empty for simple providers) */
compositeProviders: CLIProxyProvider[];
/** Whether local OAuth was skipped (remote proxy auth in use) */
skipLocalAuth: boolean;
/** Session ID for cleanup tracking */
sessionId: string | undefined;
/** Whether image analysis MCP is ready */
imageAnalysisMcpReady: boolean;
/** Browser runtime environment variables (undefined if browser not active) */
browserRuntimeEnv: NodeJS.ProcessEnv | undefined;
/** Inherited Claude config dir for continuity */
inheritedClaudeConfigDir: string | undefined;
/** Active Codex reasoning proxy (if any) */
codexReasoningProxy: CodexReasoningProxy | null;
/** Active tool sanitization proxy (if any) */
toolSanitizationProxy: ToolSanitizationProxy | null;
/** Active HTTPS tunnel proxy (if any) */
httpsTunnel: HttpsTunnelProxy | null;
/** Whether verbose logging is enabled */
verbose: boolean;
}
/**
* Assemble final Claude CLI arguments, inject trace context, spawn the process,
* start the runtime quota monitor, and wire cleanup handlers.
*
* @returns The spawned ChildProcess for the Claude CLI.
*/
export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildProcess> {
const {
claudeCli,
claudeArgs,
env,
cfg,
provider,
compositeProviders,
skipLocalAuth,
sessionId,
imageAnalysisMcpReady,
browserRuntimeEnv,
inheritedClaudeConfigDir,
codexReasoningProxy,
toolSanitizationProxy,
httpsTunnel,
verbose,
} = context;
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const settingsPath = cfg.customSettingsPath
? cfg.customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
// Assemble final args: image analysis tools → browser tools → web search tools → settings
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const launchArgs = [
'--settings',
settingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
// Inject web search trace context into env
const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor',
args: launchArgs,
profile: cfg.profileName || provider,
profileType: 'cliproxy',
settingsPath,
claudeConfigDir: inheritedClaudeConfigDir,
});
const tracedEnv = { ...env, ...traceEnv };
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
let claude: ChildProcess;
if (needsShell) {
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: getWindowsEscapedCommandShell(),
env: tracedEnv,
});
} else {
claude = spawn(claudeCli, launchArgs, {
stdio: 'inherit',
windowsHide: true,
env: tracedEnv,
});
}
// Start runtime quota monitor (adaptive polling during session)
if (!skipLocalAuth) {
const { startQuotaMonitor } = await import('../quota/quota-manager');
for (const monitorProvider of resolveRuntimeQuotaMonitorProviders(
provider,
compositeProviders
)) {
const monitorAccount = getDefaultAccount(monitorProvider);
if (monitorAccount) {
startQuotaMonitor(monitorProvider, monitorAccount.id);
}
}
}
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
setupCleanupHandlers(
claude,
sessionId,
cfg.port,
codexReasoningProxy,
toolSanitizationProxy,
httpsTunnel,
verbose
);
return claude;
}
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
/**
* Model Warnings Concern G
*
* Emits console warnings when the active model (or any tier model in composite
* variants) is flagged as broken in the model catalog.
*/
import { warn } from '../../utils/ui';
import { getCurrentModel } from '../config/model-config';
import {
isModelBroken,
getModelIssueUrl,
findModel,
getSuggestedReplacementModel,
} from '../model-catalog';
import { CLIProxyProvider, ExecutorConfig } from '../types';
export interface ModelWarningsContext {
provider: CLIProxyProvider;
cfg: ExecutorConfig;
compositeProviders: CLIProxyProvider[];
skipLocalAuth: boolean;
customSettingsPath?: string;
}
/**
* Check all active models for known issues and emit warnings.
*
* For composite variants, checks every tier model.
* For simple providers, checks the currently configured model.
*/
export function warnBrokenModels(context: ModelWarningsContext): void {
const { provider, cfg, skipLocalAuth } = context;
if (cfg.isComposite && cfg.compositeTiers) {
// Check all tier models in composite variant
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
for (const tier of tiers) {
const tierConfig = cfg.compositeTiers[tier];
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
console.error('');
console.error(
warn(
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
)
);
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
if (issueUrl) {
console.error(` Tracking: ${issueUrl}`);
}
console.error('');
}
}
} else {
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
if (currentModel && isModelBroken(provider, currentModel)) {
const modelEntry = findModel(provider, currentModel);
const issueUrl = getModelIssueUrl(provider, currentModel);
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
console.error('');
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
if (replacementModel) {
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
} else {
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
}
if (issueUrl) {
console.error(` Tracking: ${issueUrl}`);
}
if (skipLocalAuth) {
console.error(' Note: Model may be overridden by remote proxy configuration.');
} else {
console.error(` Run "ccs ${provider} --config" to change model.`);
}
console.error('');
}
}
}
@@ -0,0 +1,185 @@
/**
* Proxy Chain Builder (Concern F tool-sanitization + codex-reasoning layers)
*
* Orchestrates the two env-dependent proxy layers that sit between the
* Claude CLI process and the upstream CLIProxy / remote endpoint:
*
* 1. ToolSanitizationProxy wraps ANTHROPIC_BASE_URL to sanitize tool names/schemas
* 2. CodexReasoningProxy only for single-provider codex, wraps tool-san URL
*
* Placement in the execution sequence
*
* The HTTPS tunnel (HttpsTunnelProxy) is started BEFORE this function is called,
* because its tunnelPort is needed by both imageAnalysisResolution and the
* first-pass buildClaudeEnvironment. This function receives the result of that
* first-pass env as `initialEnvVars`, from which it extracts ANTHROPIC_BASE_URL
* to wire up the tool-sanitization proxy.
*
* Lifecycle: proxies are started (spawned) but NOT stopped here. The caller
* is responsible for registering cleanup handlers (setupCleanupHandlers).
*/
import * as path from 'path';
import { warn } from '../../utils/ui';
import { getCcsDir } from '../../config/config-loader-facade';
import {
ToolSanitizationProxy,
type ToolSanitizationProxyConfig,
} from '../proxy/tool-sanitization-proxy';
import {
CodexReasoningProxy,
type CodexReasoningProxyConfig,
} from '../ai-providers/codex-reasoning-proxy';
import { shouldDisableCodexReasoning } from './thinking-override-resolver';
import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types';
import type { ThinkingConfig } from '../../config/unified-config-types';
// ── Proxy constructor types (for dependency injection in tests) ───────────────
type ToolSanitizationProxyCtor = new (config: ToolSanitizationProxyConfig) => ToolSanitizationProxy;
type CodexReasoningProxyCtor = new (config: CodexReasoningProxyConfig) => CodexReasoningProxy;
// ── Public types ──────────────────────────────────────────────────────────────
export interface ProxyChainContext {
/** Provider being executed */
provider: CLIProxyProvider;
/** True when routing to a remote proxy host */
useRemoteProxy: boolean;
/** Resolved proxy configuration (host, port, protocol, auth, …) */
proxyConfig: ResolvedProxyConfig;
/** Executor configuration */
cfg: ExecutorConfig;
/**
* Initial environment from first-pass buildClaudeEnvironment.
* ANTHROPIC_BASE_URL and ANTHROPIC_MODEL* keys are consumed here.
*/
initialEnvVars: Partial<Record<string, string>>;
/** Thinking mode override (value or undefined for default) */
thinkingOverride: string | number | undefined;
/** Thinking configuration from unified config */
thinkingCfg: ThinkingConfig;
verbose: boolean;
log: (msg: string) => void;
/**
* Optional constructor overrides for unit testing.
* Production code omits these; tests inject stubs to avoid real HTTP servers.
*/
_ToolSanitizationProxy?: ToolSanitizationProxyCtor;
_CodexReasoningProxy?: CodexReasoningProxyCtor;
}
export interface ProxyChainResult {
toolSanitizationProxy: ToolSanitizationProxy | null;
toolSanitizationPort: number | null;
codexReasoningProxy: CodexReasoningProxy | null;
codexReasoningPort: number | null;
}
// ── Implementation ────────────────────────────────────────────────────────────
/**
* Build and start the two env-dependent proxy layers (tool-sanitization and
* codex-reasoning). Each layer is started independently; a failed start is
* swallowed with a verbose warning so the session can continue degraded.
*
* Note: HttpsTunnelProxy is started inline in the orchestrator (index.ts)
* before this function is called, because tunnelPort is required for
* imageAnalysisResolution and the first-pass buildClaudeEnvironment.
*/
export async function buildProxyChain(context: ProxyChainContext): Promise<ProxyChainResult> {
const {
provider,
useRemoteProxy,
proxyConfig,
cfg,
initialEnvVars,
thinkingOverride,
thinkingCfg,
verbose,
log,
// Allow test injection of proxy constructors so tests never spin up real servers
_ToolSanitizationProxy: ToolSanitizationProxyImpl = ToolSanitizationProxy,
_CodexReasoningProxy: CodexReasoningProxyImpl = CodexReasoningProxy,
} = context;
// ── Step 1: Tool sanitization proxy ────────────────────────────────────────
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
let toolSanitizationPort: number | null = null;
if (initialEnvVars.ANTHROPIC_BASE_URL) {
try {
toolSanitizationProxy = new ToolSanitizationProxyImpl({
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
verbose,
warnOnSanitize: true,
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
});
toolSanitizationPort = await toolSanitizationProxy.start();
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
} catch (error) {
const err = error as Error;
toolSanitizationProxy = null;
toolSanitizationPort = null;
if (verbose) {
console.error(warn(`Tool sanitization proxy disabled: ${err.message}`));
}
}
}
// ── Step 2: Codex reasoning proxy ──────────────────────────────────────────
let codexReasoningProxy: CodexReasoningProxy | null = null;
let codexReasoningPort: number | null = null;
// Composite variants require root model-routed endpoints, never provider-pinned codex endpoints.
if (provider === 'codex' && !cfg.isComposite) {
const postSanitizationBaseUrl = toolSanitizationPort
? `http://127.0.0.1:${toolSanitizationPort}`
: initialEnvVars.ANTHROPIC_BASE_URL;
if (!postSanitizationBaseUrl) {
log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled');
} else {
try {
const traceEnabled =
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
codexReasoningProxy = new CodexReasoningProxyImpl({
upstreamBaseUrl: postSanitizationBaseUrl,
verbose,
defaultEffort: 'medium',
disableEffort: codexThinkingOff,
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
modelMap: {
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
},
stripPathPrefix,
});
codexReasoningPort = await codexReasoningProxy.start();
log(
`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex`
);
} catch (error) {
const err = error as Error;
codexReasoningProxy = null;
codexReasoningPort = null;
if (verbose) {
console.error(warn(`Codex reasoning proxy disabled: ${err.message}`));
}
}
}
}
return {
toolSanitizationProxy,
toolSanitizationPort,
codexReasoningProxy,
codexReasoningPort,
};
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Proxy Resolver Concern D
*
* Handles proxy configuration resolution, remote proxy reachability check,
* local-backend selection, and CLIProxy binary acquisition.
*
* Extracted from executor/index.ts to isolate the proxy-resolution concern.
* All log messages, error messages, and exit semantics are byte-identical to
* the original implementation.
*/
import { ProgressIndicator } from '../../utils/progress-indicator';
import { ok, fail, info, warn } from '../../utils/ui';
import {
ensureCLIProxyBinary,
getConfiguredBackend,
getPlusBackendUnavailableMessage,
} from '../binary-manager';
import { checkRemoteProxy } from '../services/remote-proxy-client';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
import { resolveProxyConfig } from '../proxy/proxy-config-resolver';
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../config/config-generator';
import type { ResolvedProxyConfig } from '../types';
import type { UnifiedConfig } from '../../config/schemas/unified-config';
import { isNetworkError, handleNetworkError } from './retry-handler';
/** Result returned from resolveExecutorProxy */
export interface ResolvedProxy {
/** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */
proxyConfig: ResolvedProxyConfig;
/** Whether to use the remote proxy (vs spawning a local one) */
useRemoteProxy: boolean;
/** Which local backend binary to use ('original' | 'plus') */
localBackend: CLIProxyBackend;
/** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */
binaryPath: string | undefined;
/** Args after proxy-related flags are stripped out */
argsWithoutProxy: string[];
/** Mutated executor config (port resolved and validated) */
cfg: ExecutorConfig;
}
/** Dependencies injected by the orchestrator */
export interface ResolveExecutorProxyContext {
unifiedConfig: UnifiedConfig;
allProviders: CLIProxyProvider[];
verbose: boolean;
cfg: ExecutorConfig;
log: (msg: string) => void;
}
/**
* Resolves proxy configuration, checks remote reachability, selects the local
* backend, and ensures the CLIProxy binary is present when running locally.
*
* Mutates `context.cfg.port` in-place (same as original orchestrator behaviour).
*/
export async function resolveExecutorProxy(
args: string[],
context: ResolveExecutorProxyContext
): Promise<ResolvedProxy> {
const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context;
// Resolve proxy config from CLI flags > ENV > config.yaml > defaults
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
remote: cliproxyServerConfig?.remote
? {
enabled: cliproxyServerConfig.remote.enabled,
host: cliproxyServerConfig.remote.host,
port: cliproxyServerConfig.remote.port,
protocol: cliproxyServerConfig.remote.protocol,
auth_token: cliproxyServerConfig.remote.auth_token,
management_key: cliproxyServerConfig.remote.management_key,
timeout: cliproxyServerConfig.remote.timeout,
}
: undefined,
local: cliproxyServerConfig?.local
? {
port: cliproxyServerConfig.local.port,
auto_start: cliproxyServerConfig.local.auto_start,
}
: undefined,
});
// Port resolution and validation (mutates cfg in-place)
if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) {
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
cfg.port = proxyConfig.port;
}
} else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
cfg.port = proxyConfig.port;
}
cfg.port = validatePort(cfg.port);
log(`Proxy mode: ${proxyConfig.mode}`);
if (proxyConfig.mode === 'remote') {
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
}
// Check remote proxy reachability
let useRemoteProxy = false;
let localBackend: CLIProxyBackend = 'original';
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
const status = await checkRemoteProxy({
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
timeout: proxyConfig.timeout ?? 2000,
allowSelfSigned: proxyConfig.allowSelfSigned ?? false,
});
if (status.reachable) {
useRemoteProxy = true;
console.log(
ok(
`Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)`
)
);
} else {
console.error(warn(`Remote proxy unreachable: ${status.error}`));
if (proxyConfig.remoteOnly) {
throw new Error('Remote proxy unreachable and --remote-only specified');
}
if (proxyConfig.fallbackEnabled) {
if (proxyConfig.autoStartLocal) {
console.log(info('Falling back to local proxy...'));
} else {
if (process.stdin.isTTY) {
const readline = await import('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question('Start local proxy instead? [Y/n] ', resolve);
});
rl.close();
if (answer.toLowerCase() === 'n') {
throw new Error('Remote proxy unreachable and user declined fallback');
}
}
console.log(info('Starting local proxy...'));
}
} else {
throw new Error('Remote proxy unreachable and fallback disabled');
}
}
}
// Local backend selection (only when not using remote proxy)
if (!useRemoteProxy) {
localBackend = getConfiguredBackend({ notifyOnPlus: true });
for (const p of allProviders) {
if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
console.error('');
console.error(fail(getPlusBackendUnavailableMessage(p)));
console.error('');
throw new Error(`Provider ${p} requires local CLIProxy Plus backend`);
}
}
}
// Binary acquisition — skipped when using remote proxy
let binaryPath: string | undefined;
if (!useRemoteProxy) {
const spinner = new ProgressIndicator('Preparing CLIProxy');
spinner.start();
try {
binaryPath = await ensureCLIProxyBinary(_verbose, { skipAutoUpdate: true });
spinner.succeed('CLIProxy binary ready');
} catch (error) {
spinner.fail('Failed to prepare CLIProxy');
const err = error as Error;
if (isNetworkError(err)) {
handleNetworkError(err);
}
throw error;
}
}
return {
proxyConfig,
useRemoteProxy,
localBackend,
binaryPath,
argsWithoutProxy,
cfg,
};
}
+3 -2
View File
@@ -5,16 +5,17 @@
* based on unified config. Used by stats-fetcher, auth-routes, and UI.
*/
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { CliproxyServerConfig } from '../../config/unified-config-types';
import {
CLIPROXY_DEFAULT_PORT,
getRemoteDefaultPort,
normalizeProtocol,
validatePort,
validateRemotePort,
} from '../config/port-manager';
import { getProxyEnvVars } from './proxy-config-resolver';
import { getEffectiveManagementSecret } from '../auth/auth-token-manager';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
/** Resolved proxy target for making requests */
export interface ProxyTarget {
@@ -69,7 +70,7 @@ export function getProxyTarget(): ProxyTarget {
};
}
const localPort = config?.local?.port ?? CLIPROXY_DEFAULT_PORT;
const localPort = validatePort(config?.local?.port ?? CLIPROXY_DEFAULT_PORT);
return {
host: '127.0.0.1',
@@ -25,8 +25,9 @@ import {
stripCodexEffortSuffix,
} from '../ai-providers/model-id-normalizer';
import { getModelMaxLevel } from '../model-catalog';
import { getCcsDir } from '../../utils/config-manager';
import { createLogger } from '../../services/logging';
import { getCcsDir } from '../../config/config-loader-facade';
export interface ToolSanitizationProxyConfig {
/** Upstream CLIProxy URL */
@@ -208,4 +208,111 @@ describe('Antigravity quota failure metadata', () => {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('tries the daily loadCodeAssist host before falling back to prod', async () => {
const moduleId = Date.now() + Math.random();
const { fetchAccountQuota } = await import(`../quota-fetcher?agy-daily-host=${moduleId}`);
const { getProviderAuthDir } = await import(
`../../config/config-generator?agy-config=${moduleId}`
);
const fs = await import('node:fs');
const os = await import('node:os');
const path = await import('node:path');
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-daily-host-'));
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
try {
const authDir = getProviderAuthDir('agy');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'antigravity-user@example.com.json'),
JSON.stringify({
type: 'antigravity',
email: 'user@example.com',
access_token: 'token',
})
);
const originalFetch = globalThis.fetch;
const urls: string[] = [];
globalThis.fetch = (async (input, init) => {
const url = String(input);
urls.push(url);
if (url === 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist') {
const bodyText = typeof init?.body === 'string' ? init.body : '';
expect(init?.headers).toMatchObject({
'X-Goog-Api-Client': 'gl-node/22.21.1',
});
expect(bodyText).toBe(
JSON.stringify({
metadata: {
ide_name: 'antigravity',
ide_type: 'ANTIGRAVITY',
ide_version: '1.21.9',
},
})
);
return new Response('daily unavailable', { status: 503 });
}
if (url === 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist') {
return new Response(
JSON.stringify({
cloudaicompanionProject: { id: 'project-x' },
paidTier: { id: 'g1-pro-tier' },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
}
if (url === 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels') {
return new Response(
JSON.stringify({
models: {
'gemini-3-pro-high': {
quotaInfo: {
remainingFraction: 0.75,
resetTime: '2026-05-01T10:00:00Z',
},
},
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
}
return new Response('unexpected url', { status: 500 });
}) as typeof fetch;
try {
const result = await fetchAccountQuota('agy', 'user@example.com');
expect(result.success).toBe(true);
expect(result.tier).toBe('pro');
expect(result.projectId).toBe('project-x');
expect(urls).toEqual([
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
]);
} finally {
globalThis.fetch = originalFetch;
}
} finally {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
});
+48 -11
View File
@@ -82,17 +82,20 @@ export interface QuotaResult {
}
/** Google Cloud Code API endpoints */
const ANTIGRAVITY_DAILY_API_BASE = 'https://daily-cloudcode-pa.googleapis.com';
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
const ANTIGRAVITY_API_VERSION = 'v1internal';
const ANTIGRAVITY_LOADCODEASSIST_BASE_URLS = [
ANTIGRAVITY_DAILY_API_BASE,
ANTIGRAVITY_API_BASE,
] as const;
const MANAGEMENT_API_TIMEOUT_MS = 5000;
/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */
/** Headers for loadCodeAssist (matches current CLIProxyAPIPlus control-plane requests) */
const LOADCODEASSIST_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
'Client-Metadata':
'{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
'User-Agent': 'antigravity/1.21.9 darwin/arm64 google-api-nodejs-client/10.3.0',
'X-Goog-Api-Client': 'gl-node/22.21.1',
};
/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */
@@ -543,6 +546,40 @@ async function performAntigravityRequest(
}
}
async function performAntigravityRequestWithBaseUrlFallback(
accountId: string,
accessToken: string,
baseUrls: readonly string[],
apiPath: string,
headers: Record<string, string>,
body: string
): Promise<ManagedResponse> {
let lastResponse: ManagedResponse | null = null;
for (const baseUrl of baseUrls) {
const response = await performAntigravityRequest(
accountId,
accessToken,
`${baseUrl}/${apiPath}`,
headers,
body
);
if (response.status >= 200 && response.status < 300) {
return response;
}
lastResponse = response;
}
return (
lastResponse ?? {
status: 503,
bodyText: 'No Antigravity API endpoint available',
json: null,
viaManagement: false,
}
);
}
/**
* Read auth data from auth file (access token, project_id, expiry status)
*/
@@ -616,18 +653,18 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier)
*/
async function getProjectId(accountId: string, accessToken: string): Promise<ProjectLookupResult> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
const body = JSON.stringify({
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
ide_name: 'antigravity',
ide_type: 'ANTIGRAVITY',
ide_version: '1.21.9',
},
});
const response = await performAntigravityRequest(
const response = await performAntigravityRequestWithBaseUrlFallback(
accountId,
accessToken,
url,
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
`${ANTIGRAVITY_API_VERSION}:loadCodeAssist`,
LOADCODEASSIST_HEADERS,
body
);
+2 -1
View File
@@ -32,8 +32,9 @@ import {
touchAccount,
type AccountInfo,
} from '../accounts/account-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { RuntimeMonitorConfig } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp';
type ManagedQuotaResult =
+3 -3
View File
@@ -1,4 +1,3 @@
import { mutateUnifiedConfig, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { regenerateConfig } from '../config/generator';
import { getAuthDir, getConfigPathForPort } from '../config/path-resolver';
import {
@@ -7,6 +6,7 @@ import {
getRoutingErrorMessage,
} from './routing-strategy-http';
import type { CliproxyRoutingStrategy } from '../types';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
@@ -223,7 +223,7 @@ export async function applyCliproxyRoutingStrategy(
};
}
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
}
@@ -278,7 +278,7 @@ export async function applyCliproxySessionAffinitySettings(
current.ttl ??
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = {
...config.cliproxy.routing,
+2 -2
View File
@@ -178,7 +178,7 @@ export async function ensureCliproxyService(
// Check if config needs update (even if running)
let configRegenerated = false;
if (configNeedsRegenerationFn()) {
if (configNeedsRegenerationFn(port)) {
log('Config outdated, regenerating...');
regenerateConfig(port);
configRegenerated = true;
@@ -251,7 +251,7 @@ export async function ensureCliproxyService(
// 2. Ensure/regenerate config if needed
let configPath: string;
if (configNeedsRegeneration()) {
if (configNeedsRegeneration(port)) {
log('Config needs regeneration, updating...');
configPath = regenerateConfig(port);
} else {
+1 -1
View File
@@ -22,7 +22,7 @@ import {
} from '../binary-manager';
import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../binary/platform-detector';
import { CLIProxyBackend } from '../types';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
/** Binary status result */
export interface BinaryStatusResult {
+2 -1
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import type { CLIProxyProvider } from '../types';
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from '../model-catalog';
import { MODEL_CATALOG } from '../model-catalog';
@@ -15,6 +15,7 @@ import {
buildProxyUrl,
getProxyTarget,
} from '../proxy/proxy-target-resolver';
import { getCcsDir } from '../../config/config-loader-facade';
const CACHE_FILE_NAME = 'model-catalog-cache.json';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -10,7 +10,7 @@ import {
getProxyStatus as getProxyStatusSession,
} from '../session-tracker';
import { ensureCliproxyService } from '../service-manager';
import { CLIPROXY_DEFAULT_PORT } from '../config/config-generator';
import { resolveLifecyclePort } from '../config/port-manager';
/** Proxy status result */
export interface ProxyStatusResult {
@@ -24,6 +24,7 @@ export interface ProxyStatusResult {
/** Stop proxy result */
export interface StopProxyResult {
stopped: boolean;
port?: number;
pid?: number;
sessionCount?: number;
error?: string;
@@ -41,14 +42,14 @@ export interface StartProxyResult {
/**
* Get current proxy status
*/
export function getProxyStatus(port?: number): ProxyStatusResult {
export function getProxyStatus(port: number = resolveLifecyclePort()): ProxyStatusResult {
return getProxyStatusSession(port);
}
/**
* Stop the running CLIProxy instance
*/
export async function stopProxy(port?: number): Promise<StopProxyResult> {
export async function stopProxy(port: number = resolveLifecyclePort()): Promise<StopProxyResult> {
return stopProxySession(port);
}
@@ -56,7 +57,7 @@ export async function stopProxy(port?: number): Promise<StopProxyResult> {
* Start CLIProxy service (or reuse existing running instance)
*/
export async function startProxy(
port: number = CLIPROXY_DEFAULT_PORT,
port: number = resolveLifecyclePort(),
verbose: boolean = false
): Promise<StartProxyResult> {
return ensureCliproxyService(port, verbose);
@@ -66,7 +67,7 @@ export async function startProxy(
* Check if proxy is currently running
*/
export function isProxyRunning(): boolean {
const status = getProxyStatusSession();
const status = getProxyStatusSession(resolveLifecyclePort());
return status.running;
}
@@ -74,6 +75,6 @@ export function isProxyRunning(): boolean {
* Get active session count
*/
export function getActiveSessionCount(): number {
const status = getProxyStatusSession();
const status = getProxyStatusSession(resolveLifecyclePort());
return status.sessionCount ?? 0;
}
@@ -1,5 +1,5 @@
import * as fs from 'fs';
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { getConfigPath } from '../../utils/config-manager';
import { CLIProxyProvider } from '../types';
import type { TargetType } from '../../targets/target-adapter';
import {
@@ -8,12 +8,14 @@ import {
CompositeTierConfig,
CLIPROXY_SUPPORTED_PROVIDERS,
} from '../../config/unified-config-types';
import {
loadOrCreateUnifiedConfig,
mutateUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../config/config-generator';
import {
isUnifiedMode,
loadConfigSafe,
loadOrCreateUnifiedConfig,
mutateConfig,
} from '../../config/config-loader-facade';
export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1;
export const VARIANT_PORT_MAX_OFFSET = 100;
@@ -171,7 +173,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
}
export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void {
mutateUnifiedConfig((unifiedConfig) => {
mutateConfig((unifiedConfig) => {
if (!unifiedConfig.cliproxy) {
unifiedConfig.cliproxy = {
oauth_accounts: {},
@@ -195,7 +197,7 @@ export function saveVariantUnified(
port?: number,
target: TargetType = 'claude'
): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.cliproxy) {
config.cliproxy = {
oauth_accounts: {},
@@ -266,7 +268,7 @@ export function saveVariantLegacy(
export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null {
let removedVariant: CLIProxyVariantConfig | CompositeVariantConfig | null = null;
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
return;
}
+3 -2
View File
@@ -12,11 +12,11 @@ import { CLIProxyProvider, PLUS_ONLY_PROVIDERS } from '../types';
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
import type { TargetType } from '../../targets/target-adapter';
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config/config-generator';
import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
import { warn } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
import { validateCompositeTiers } from '../config/composite-validator';
import {
canonicalizeModelIdForProvider,
@@ -43,6 +43,7 @@ import {
getNextAvailablePort,
} from './variant-config-adapter';
import { getConfiguredBackend, getPlusBackendUnavailableMessage } from '../binary-manager';
import { getCcsDir, isUnifiedMode } from '../../config/config-loader-facade';
// Re-export VariantConfig from adapter
export type { VariantConfig } from './variant-config-adapter';
+2 -1
View File
@@ -9,7 +9,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config/config-generator';
import { CLIProxyProvider } from '../types';
@@ -24,6 +24,7 @@ import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
import { getEffectiveApiKey } from '../auth/auth-token-manager';
import { warn } from '../../utils/ui';
import { normalizeModelIdForProvider } from '../ai-providers/model-id-normalizer';
import { getCcsDir } from '../../config/config-loader-facade';
/** Environment settings structure */
interface SettingsEnv {
+15 -8
View File
@@ -395,6 +395,7 @@ export function cleanupOrphanedSessions(port: number): void {
*/
export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
stopped: boolean;
port?: number;
pid?: number;
sessionCount?: number;
error?: string;
@@ -406,12 +407,13 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
const portProcess = await getPortProcess(port);
if (!portProcess) {
return { stopped: false, error: 'No active CLIProxy session found' };
return { stopped: false, port, error: 'No active CLIProxy session found' };
}
if (!isCLIProxyProcess(portProcess)) {
return {
stopped: false,
port,
error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`,
};
}
@@ -432,20 +434,25 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
}
}
return { stopped: true, pid: portProcess.pid, sessionCount: 0 };
return { stopped: true, port, pid: portProcess.pid, sessionCount: 0 };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
return { stopped: false, error: 'CLIProxy process already terminated' };
return { stopped: false, port, error: 'CLIProxy process already terminated' };
}
return { stopped: false, pid: portProcess.pid, error: `Failed to stop: ${error.message}` };
return {
stopped: false,
port,
pid: portProcess.pid,
error: `Failed to stop: ${error.message}`,
};
}
}
// Check if proxy is running
if (!isProcessRunning(lock.pid)) {
deleteSessionLockForPort(port);
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
return { stopped: false, port, error: 'CLIProxy was not running (cleaned up stale lock)' };
}
const sessionCount = lock.sessions.length;
@@ -470,15 +477,15 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
// Clean up session lock
deleteSessionLockForPort(port);
return { stopped: true, pid, sessionCount };
return { stopped: true, port, pid, sessionCount };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
// Process already gone
deleteSessionLockForPort(port);
return { stopped: false, error: 'CLIProxy process already terminated' };
return { stopped: false, port, error: 'CLIProxy process already terminated' };
}
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
return { stopped: false, port, pid, error: `Failed to stop: ${error.message}` };
}
}
+2 -2
View File
@@ -7,9 +7,9 @@
import * as chokidar from 'chokidar';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { syncToLocalConfig } from './local-config-sync';
import { getCcsDir, loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
/** Debounce delay in milliseconds */
const DEBOUNCE_MS = 3000;
+2 -1
View File
@@ -6,10 +6,11 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader';
import type { ClaudeKey } from '../management/management-api-types';
import { getCcsDir } from '../../config/config-loader-facade';
/**
* Profile info with settings for sync.
+4 -3
View File
@@ -1,9 +1,10 @@
import * as browserUtils from '../utils/browser';
import { getBrowserConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import type { BrowserToolPolicy } from '../config/unified-config-types';
import { getCcsPathDisplay } from '../utils/config-manager';
import { getNodePlatformKey } from '../utils/browser/platform';
import { color, dim, header, initUI, subheader } from '../utils/ui';
import { getBrowserConfig, mutateConfig } from '../config/config-loader-facade';
type HelpWriter = (line: string) => void;
type BrowserLane = 'claude' | 'codex' | 'all';
@@ -216,7 +217,7 @@ function updateBrowserPolicies(updates: {
claude?: BrowserToolPolicy;
codex?: BrowserToolPolicy;
}): void {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
const current = getBrowserConfig();
config.browser = {
claude: {
@@ -235,7 +236,7 @@ function updateBrowserPolicies(updates: {
function updateBrowserEnabled(subcommand: 'enable' | 'disable', lane: BrowserLane): void {
const nextEnabled = subcommand === 'enable';
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
const current = getBrowserConfig();
config.browser = {
claude: {
@@ -11,7 +11,7 @@
import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector';
import { resolveLifecyclePort } from './resolve-lifecycle-port';
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
export async function handleStart(verbose = false): Promise<void> {
await initUI();
@@ -1,15 +0,0 @@
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { UnifiedConfig } from '../../config/unified-config-types';
type LifecyclePortConfig = Pick<UnifiedConfig, 'cliproxy_server'>;
/**
* Resolve the local CLIProxy lifecycle port from unified config.
* Falls back to default port when unset/invalid.
*/
export function resolveLifecyclePort(
config: LifecyclePortConfig = loadOrCreateUnifiedConfig()
): number {
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
}
+2 -1
View File
@@ -17,7 +17,7 @@ import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-r
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
import type { TargetType } from '../../targets/target-adapter';
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import {
@@ -32,6 +32,7 @@ import {
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
import { isUnifiedMode } from '../../config/config-loader-facade';
interface CliproxyProfileArgs {
name?: string;
+3 -2
View File
@@ -5,8 +5,9 @@
*/
import { InteractivePrompt } from '../../utils/prompt';
import { getDashboardAuthConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { initUI, header, ok, info, warn, dim } from '../../utils/ui';
import { getDashboardAuthConfig, mutateConfig } from '../../config/config-loader-facade';
/**
* Handle disable command - disable auth with confirmation
@@ -53,7 +54,7 @@ export async function handleDisable(): Promise<void> {
}
// Disable auth
mutateUnifiedConfig((fullConfig) => {
mutateConfig((fullConfig) => {
fullConfig.dashboard_auth = {
enabled: false,
username: fullConfig.dashboard_auth?.username ?? '',
+3 -2
View File
@@ -8,9 +8,10 @@
import bcrypt from 'bcrypt';
import { InteractivePrompt } from '../../utils/prompt';
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui';
import type { AuthSetupResult } from './types';
import { mutateConfig } from '../../config/config-loader-facade';
const BCRYPT_ROUNDS = 10;
const MIN_PASSWORD_LENGTH = 8;
@@ -99,7 +100,7 @@ export async function handleSetup(): Promise<AuthSetupResult> {
// Hash password
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const config = mutateUnifiedConfig((currentConfig) => {
const config = mutateConfig((currentConfig) => {
currentConfig.dashboard_auth = {
enabled: true,
username,
+1 -1
View File
@@ -4,9 +4,9 @@
* Display current dashboard authentication status.
*/
import { getDashboardAuthConfig } from '../../config/unified-config-loader';
import { initUI, header, subheader, ok, info, warn, dim, color } from '../../utils/ui';
import type { AuthStatusInfo } from './types';
import { getDashboardAuthConfig } from '../../config/config-loader-facade';
/**
* Get auth status info with ENV override detection
+7 -6
View File
@@ -1,9 +1,5 @@
import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui';
import {
getOfficialChannelsConfig,
loadOrCreateUnifiedConfig,
updateUnifiedConfig,
} from '../config/unified-config-loader';
import type { OfficialChannelId } from '../config/unified-config-types';
import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types';
import {
@@ -42,6 +38,11 @@ import {
isOfficialChannelSelectionValid,
} from '../channels/official-channels-runtime';
import { extractOption, hasAnyFlag } from './arg-extractor';
import {
getOfficialChannelsConfig,
loadOrCreateUnifiedConfig,
updateConfig,
} from '../config/config-loader-facade';
interface ChannelsCommandOptions {
enable: boolean;
@@ -440,7 +441,7 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
try {
if (hasConfigMutation) {
updateUnifiedConfig({ channels: nextConfig });
updateConfig({ channels: nextConfig });
}
if (options.setToken) {
+3 -3
View File
@@ -11,8 +11,7 @@ import open from 'open';
import { startServer } from '../web-server';
import { setupGracefulShutdown } from '../web-server/shutdown';
import { ensureCliproxyService } from '../cliproxy/service-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/config-generator';
import { getDashboardAuthConfig } from '../config/unified-config-loader';
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
import {
@@ -23,6 +22,7 @@ import {
} from './config-dashboard-host';
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
import { createLogger } from '../services/logging';
import { getDashboardAuthConfig } from '../config/config-loader-facade';
const logger = createLogger('command:config');
@@ -137,7 +137,7 @@ export async function handleConfigCommand(
// Ensure CLIProxy service is running for dashboard features
console.log(deps.info('Starting CLIProxy service...'));
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
const cliproxyResult = await deps.ensureCliproxyService(resolveLifecyclePort(), verbose);
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
started: cliproxyResult.started,
alreadyRunning: cliproxyResult.alreadyRunning,
@@ -6,11 +6,7 @@
*/
import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui';
import {
getImageAnalysisConfig,
updateUnifiedConfig,
loadOrCreateUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
import {
CLIPROXY_PROVIDER_IDS,
@@ -19,6 +15,11 @@ import {
} from '../cliproxy/provider-capabilities';
import { extractOption, hasAnyFlag } from './arg-extractor';
import { normalizeImageAnalysisBackendId } from '../utils/hooks';
import {
getImageAnalysisConfig,
loadOrCreateUnifiedConfig,
updateConfig,
} from '../config/config-loader-facade';
interface ImageAnalysisCommandOptions {
enable?: boolean;
@@ -346,7 +347,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
}
if (hasChanges) {
updateUnifiedConfig({ image_analysis: imageConfig });
updateConfig({ image_analysis: imageConfig });
console.log(ok('Configuration updated'));
console.log('');
}
+7 -6
View File
@@ -6,11 +6,7 @@
*/
import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui';
import {
getThinkingConfig,
updateUnifiedConfig,
loadOrCreateUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types';
import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator';
import {
@@ -18,6 +14,11 @@ import {
parseThinkingCommandArgs,
parseThinkingOverrideInput,
} from './config-thinking-parser';
import {
getThinkingConfig,
loadOrCreateUnifiedConfig,
updateConfig,
} from '../config/config-loader-facade';
const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const;
@@ -293,7 +294,7 @@ export async function handleConfigThinkingCommand(args: string[]): Promise<void>
}
if (hasChanges) {
updateUnifiedConfig({ thinking: thinkingConfig });
updateConfig({ thinking: thinkingConfig });
console.log(ok('Configuration updated'));
console.log('');
}
+4 -3
View File
@@ -15,10 +15,11 @@ import {
normalizeCopilotConfigWithWarnings,
} from '../copilot';
import type { CopilotModel } from '../copilot';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { ok, fail, info, color, warn } from '../utils/ui';
import { normalizeCopilotSubcommand } from '../copilot/constants';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../config/config-loader-facade';
/**
* Handle copilot subcommand.
@@ -361,7 +362,7 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.copilot) {
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
}
@@ -383,7 +384,7 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (config.copilot) {
config.copilot.enabled = false;
}
+4 -3
View File
@@ -16,7 +16,7 @@ import {
getDefaultModel,
probeCursorRuntime,
} from '../cursor';
import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types';
import {
renderCursorHelp,
@@ -25,6 +25,7 @@ import {
renderCursorStatus,
} from './cursor-command-display';
import { ok, fail, info } from '../utils/ui';
import { getCursorConfig, mutateConfig } from '../config/config-loader-facade';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
@@ -286,7 +287,7 @@ async function handleStop(): Promise<number> {
*/
async function handleEnable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
}
@@ -310,7 +311,7 @@ async function handleEnable(): Promise<number> {
*/
async function handleDisable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
mutateConfig((config) => {
if (config.cursor) {
config.cursor.enabled = false;
}
+2 -1
View File
@@ -6,12 +6,13 @@
*/
import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
import { CLAUDE_EXTENSION_HOSTS, type ClaudeExtensionHost } from '../shared/claude-extension-hosts';
import {
renderClaudeExtensionSettingsJson,
resolveClaudeExtensionSetup,
} from '../shared/claude-extension-setup';
import { getCcsDir } from '../config/config-loader-facade';
type ShellType = 'bash' | 'fish' | 'powershell';
type OutputFormat = 'openai' | 'anthropic' | 'raw' | 'claude-extension';
+2 -1
View File
@@ -16,8 +16,9 @@ import {
needsMigration,
getBackupDirectories,
} from '../config/migration-manager';
import { hasUnifiedConfig } from '../config/unified-config-loader';
import { initUI, ok, fail, info, warn, infoBox, dim } from '../utils/ui';
import { hasUnifiedConfig } from '../config/config-loader-facade';
export async function handleMigrateCommand(args: string[]): Promise<void> {
await initUI();
+2 -1
View File
@@ -1,5 +1,5 @@
import { detectShell, formatExportLine } from './env-command';
import { getSettingsPath, loadSettings } from '../utils/config-manager';
import { getSettingsPath } from '../utils/config-manager';
import { expandPath } from '../utils/helpers';
import { fail, info, ok } from '../utils/ui';
import {
@@ -10,6 +10,7 @@ import {
startOpenAICompatProxy,
stopOpenAICompatProxy,
} from '../proxy';
import { loadSettings } from '../config/config-loader-facade';
function parseOptionValue(args: string[], key: string): string | undefined {
const exactIndex = args.findIndex((arg) => arg === key);
+9 -7
View File
@@ -16,15 +16,17 @@ import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import { initUI, header, ok, info, warn } from '../utils/ui';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import {
getCcsDir,
hasUnifiedConfig,
loadOrCreateUnifiedConfig,
loadUnifiedConfig,
mutateUnifiedConfig,
hasUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
mutateConfig,
} from '../config/config-loader-facade';
/** Custom error for user cancellation (Ctrl+C) */
class UserCancelledError extends Error {
@@ -377,7 +379,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
console.log(' After creating, edit the settings file to add your API key.');
}
mutateUnifiedConfig((currentConfig) => {
mutateConfig((currentConfig) => {
currentConfig.setup_completed = true;
currentConfig.cliproxy_server = config.cliproxy_server;
});
+2 -1
View File
@@ -7,9 +7,10 @@
import * as path from 'path';
import * as fs from 'fs';
import { initUI, header, subheader, color, warn } from '../utils/ui';
import { getActiveConfigPath, getCcsDir } from '../utils/config-manager';
import { getActiveConfigPath } from '../utils/config-manager';
import { getVersion } from '../utils/version';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { getCcsDir } from '../config/config-loader-facade';
/**
* Handle version command
+339
View File
@@ -0,0 +1,339 @@
/**
* config-getters.ts
*
* Typed sub-config accessor functions extracted from unified-config-loader.ts
* (Phase 5 split issue #1164).
*
* All functions read the loaded config via loadOrCreateUnifiedConfig and
* return typed sub-configs with defaults applied.
*
* No I/O beyond what loadOrCreateUnifiedConfig performs internally.
*/
import {
DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_CURSOR_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_THINKING_CONFIG,
} from '../unified-config-types';
import type {
BrowserConfig,
CLIProxySafetyConfig,
CursorConfig,
DashboardAuthConfig,
GlobalEnvConfig,
ImageAnalysisConfig,
LoggingConfig,
OfficialChannelsConfig,
ThinkingConfig,
} from '../unified-config-types';
import { canonicalizeBrowserConfig } from './normalizers';
import { canonicalizeImageAnalysisConfig } from '../../utils/hooks/image-analysis-backend-resolver';
import { normalizeOfficialChannelIds } from '../../channels/official-channels-ids';
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
// ---------------------------------------------------------------------------
// Circular-import safety: loadOrCreateUnifiedConfig lives in
// unified-config-loader.ts which imports this file. We break the cycle by
// using a lazy require() inside getConfig() so the module is resolved at
// call time (after both modules have finished loading) rather than at import
// time. This also preserves spy/mock compatibility: test spies replace the
// function on the module namespace object, and require() returns that live
// namespace, so the spy is always picked up.
// ---------------------------------------------------------------------------
function getConfig(): import('../unified-config-types').UnifiedConfig {
const loader = require('../unified-config-loader') as {
loadOrCreateUnifiedConfig: () => import('../unified-config-types').UnifiedConfig;
};
return loader.loadOrCreateUnifiedConfig();
}
// ---------------------------------------------------------------------------
// GeminiWebSearchInfo interface
// ---------------------------------------------------------------------------
/**
* Gemini CLI WebSearch configuration
*/
export interface GeminiWebSearchInfo {
enabled: boolean;
model: string;
timeout: number;
}
// ---------------------------------------------------------------------------
// Accessor functions
// ---------------------------------------------------------------------------
/**
* Get websearch configuration.
* Returns defaults if not configured.
* Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks.
*/
export function getWebSearchConfig(): {
enabled: boolean;
providers?: {
exa?: { enabled?: boolean; max_results?: number };
tavily?: { enabled?: boolean; max_results?: number };
brave?: { enabled?: boolean; max_results?: number };
searxng?: { enabled?: boolean; url?: string; max_results?: number };
duckduckgo?: { enabled?: boolean; max_results?: number };
gemini?: GeminiWebSearchInfo;
opencode?: { enabled?: boolean; model?: string; timeout?: number };
grok?: { enabled?: boolean; timeout?: number };
};
// Legacy fields (deprecated)
gemini?: { enabled?: boolean; timeout?: number };
} {
const config = getConfig();
// Build provider configs
const exaConfig = {
enabled: config.websearch?.providers?.exa?.enabled ?? false,
max_results: config.websearch?.providers?.exa?.max_results ?? 5,
};
const tavilyConfig = {
enabled: config.websearch?.providers?.tavily?.enabled ?? false,
max_results: config.websearch?.providers?.tavily?.max_results ?? 5,
};
const duckDuckGoConfig = {
enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true,
max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5,
};
const braveConfig = {
enabled: config.websearch?.providers?.brave?.enabled ?? false,
max_results: config.websearch?.providers?.brave?.max_results ?? 5,
};
const searxngConfig = {
enabled: config.websearch?.providers?.searxng?.enabled ?? false,
url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '',
max_results: config.websearch?.providers?.searxng?.max_results ?? 5,
};
const geminiConfig: GeminiWebSearchInfo = {
enabled:
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false,
model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
};
const opencodeConfig = {
enabled: config.websearch?.providers?.opencode?.enabled ?? false,
model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: config.websearch?.providers?.opencode?.timeout ?? 90,
};
const grokConfig = {
enabled: config.websearch?.providers?.grok?.enabled ?? false,
timeout: config.websearch?.providers?.grok?.timeout ?? 55,
};
// Auto-enable master switch if ANY provider is enabled
const anyProviderEnabled =
exaConfig.enabled ||
tavilyConfig.enabled ||
braveConfig.enabled ||
searxngConfig.enabled ||
duckDuckGoConfig.enabled ||
geminiConfig.enabled ||
opencodeConfig.enabled ||
grokConfig.enabled;
const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true);
return {
enabled,
providers: {
exa: exaConfig,
tavily: tavilyConfig,
brave: braveConfig,
searxng: searxngConfig,
duckduckgo: duckDuckGoConfig,
gemini: geminiConfig,
opencode: opencodeConfig,
grok: grokConfig,
},
// Legacy field for backwards compatibility
gemini: config.websearch?.gemini,
};
}
/**
* Get global_env configuration.
* Returns defaults if not configured.
*/
export function getGlobalEnvConfig(): GlobalEnvConfig {
const config = getConfig();
return {
enabled: config.global_env?.enabled ?? true,
env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
};
}
/**
* Get continuity inheritance mapping.
* Returns empty mapping when not configured.
*/
export function getContinuityInheritanceMap(): Record<string, string> {
const config = getConfig();
return config.continuity?.inherit_from_account ?? {};
}
/**
* Get cliproxy safety configuration.
* Returns defaults if not configured.
*/
export function getCliproxySafetyConfig(): CLIProxySafetyConfig {
const config = getConfig();
return {
antigravity_ack_bypass:
config.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
};
}
/**
* Get thinking configuration.
* Returns defaults if not configured.
*/
export function getThinkingConfig(): ThinkingConfig {
const config = getConfig();
// W2: Check for invalid thinking config (e.g., thinking: true instead of object)
if (config.thinking !== undefined && typeof config.thinking !== 'object') {
console.warn(
`[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.`
);
console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`);
return DEFAULT_THINKING_CONFIG;
}
return {
mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: config.thinking?.override,
tier_defaults: {
opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: config.thinking?.provider_overrides,
show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
};
}
/**
* Get Official Channels configuration.
* Returns defaults if not configured.
*/
export function getOfficialChannelsConfig(): OfficialChannelsConfig {
const config = getConfig();
return {
selected:
config.channels?.selected && config.channels.selected.length > 0
? normalizeOfficialChannelIds(config.channels.selected)
: DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected,
unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended,
};
}
/**
* Check if dashboard auth is enabled.
* Priority: ENV vars > config.yaml > defaults
*/
export function isDashboardAuthEnabled(): boolean {
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
if (envEnabled !== undefined) {
return envEnabled === 'true' || envEnabled === '1';
}
const config = getConfig();
return config.dashboard_auth?.enabled ?? false;
}
/**
* Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults
*/
export function getDashboardAuthConfig(): DashboardAuthConfig {
const config = getConfig();
// ENV vars take precedence
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
const envUsername = process.env.CCS_DASHBOARD_USERNAME;
const envPasswordHash = process.env.CCS_DASHBOARD_PASSWORD_HASH;
return {
enabled:
envEnabled !== undefined
? envEnabled === 'true' || envEnabled === '1'
: (config.dashboard_auth?.enabled ?? false),
username: envUsername ?? config.dashboard_auth?.username ?? '',
password_hash: envPasswordHash ?? config.dashboard_auth?.password_hash ?? '',
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
};
}
/**
* Get browser automation configuration.
* Returns canonicalized defaults if not configured.
*/
export function getBrowserConfig(): BrowserConfig {
const config = getConfig();
return canonicalizeBrowserConfig(config.browser);
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
*/
export function getImageAnalysisConfig(): ImageAnalysisConfig {
const config = getConfig();
return canonicalizeImageAnalysisConfig({
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
fallback_backend:
config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
});
}
/**
* Get logging configuration.
* Returns defaults if not configured.
*/
export function getLoggingConfig(): LoggingConfig {
const config = getConfig();
return {
enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
};
}
/**
* Get cursor configuration.
* Returns defaults if not configured.
*/
export function getCursorConfig(): CursorConfig {
const config = getConfig();
return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG };
}
+335
View File
@@ -0,0 +1,335 @@
/**
* defaults-merger.ts
*
* mergeWithDefaults function extracted from unified-config-loader.ts
* (Phase 4 split issue #1164).
*
* Pure transform: no I/O, no side effects. Merges a partial UnifiedConfig
* with defaults, filling in missing sections.
*
* Circular-import note: this module imports from normalizers.ts (Phase 2)
* and io-locks.ts (Phase 1) is NOT imported here, so there is no cycle.
* io-locks.ts callbacks (mergeWithDefaults, validateCompositeVariants) are
* now replaced with direct imports in unified-config-loader.ts (Phase 6).
*/
import {
createEmptyUnifiedConfig,
DEFAULT_COPILOT_CONFIG,
DEFAULT_CURSOR_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from '../unified-config-types';
import type { UnifiedConfig } from '../unified-config-types';
import { canonicalizeBrowserConfig, normalizeSessionAffinityTtl } from './normalizers';
import { normalizeContinuityConfig, normalizeOfficialChannelsConfig } from './normalizers';
import type { LegacyDiscordChannelsConfig } from './normalizers';
import { canonicalizeImageAnalysisConfig } from '../../utils/hooks/image-analysis-backend-resolver';
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
// ---------------------------------------------------------------------------
// mergeWithDefaults
// ---------------------------------------------------------------------------
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Merge partial config with defaults.
* Preserves existing data while filling in missing sections.
*/
export function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
const defaults = createEmptyUnifiedConfig();
const continuity = normalizeContinuityConfig(partial);
return {
version: partial.version ?? defaults.version,
setup_completed: partial.setup_completed,
default: partial.default ?? defaults.default,
accounts: partial.accounts ?? defaults.accounts,
profiles: partial.profiles ?? defaults.profiles,
cliproxy: {
...partial.cliproxy,
oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts,
providers: defaults.cliproxy.providers, // Always use defaults for providers
variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants,
logging: {
enabled: partial.cliproxy?.logging?.enabled ?? defaults.cliproxy.logging?.enabled ?? false,
request_log:
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
},
safety: {
antigravity_ack_bypass:
partial.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
},
// Kiro browser behavior setting (optional)
kiro_no_incognito: partial.cliproxy?.kiro_no_incognito,
// Auth config - preserve user values, no defaults (uses constants as fallback)
auth: partial.cliproxy?.auth,
// Background token refresh config (optional)
token_refresh: partial.cliproxy?.token_refresh,
// Backend selection - validate and preserve user choice (original vs plus)
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'original' at runtime)
management_panel_repository: normalizeOptionalString(
partial.cliproxy?.management_panel_repository
),
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
strategy:
partial.cliproxy?.routing?.strategy === 'fill-first' ||
partial.cliproxy?.routing?.strategy === 'round-robin'
? partial.cliproxy.routing.strategy
: defaults.cliproxy.routing?.strategy,
session_affinity:
typeof partial.cliproxy?.routing?.session_affinity === 'boolean'
? partial.cliproxy.routing.session_affinity
: defaults.cliproxy.routing?.session_affinity,
session_affinity_ttl: normalizeSessionAffinityTtl(
partial.cliproxy?.routing?.session_affinity_ttl,
defaults.cliproxy.routing?.session_affinity_ttl ?? '1h'
),
},
},
proxy: {
port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
profile_ports: partial.proxy?.profile_ports ?? {
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports,
},
routing: {
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think,
longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext,
webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch,
longContextThreshold:
partial.proxy?.routing?.longContextThreshold ??
defaults.proxy?.routing?.longContextThreshold,
},
},
logging: {
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size:
partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
},
preferences: {
...defaults.preferences,
...partial.preferences,
},
websearch: {
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
providers: {
exa: {
enabled: partial.websearch?.providers?.exa?.enabled ?? false,
max_results: partial.websearch?.providers?.exa?.max_results ?? 5,
},
tavily: {
enabled: partial.websearch?.providers?.tavily?.enabled ?? false,
max_results: partial.websearch?.providers?.tavily?.max_results ?? 5,
},
brave: {
enabled: partial.websearch?.providers?.brave?.enabled ?? false,
max_results: partial.websearch?.providers?.brave?.max_results ?? 5,
},
searxng: {
enabled: partial.websearch?.providers?.searxng?.enabled ?? false,
url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '',
max_results: partial.websearch?.providers?.searxng?.max_results ?? 5,
},
duckduckgo: {
enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true,
max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5,
},
gemini: {
enabled:
partial.websearch?.providers?.gemini?.enabled ??
partial.websearch?.gemini?.enabled ?? // Legacy fallback
false,
model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
partial.websearch?.providers?.gemini?.timeout ??
partial.websearch?.gemini?.timeout ?? // Legacy fallback
55,
},
opencode: {
enabled: partial.websearch?.providers?.opencode?.enabled ?? false,
model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: partial.websearch?.providers?.opencode?.timeout ?? 90,
},
grok: {
enabled: partial.websearch?.providers?.grok?.enabled ?? false,
timeout: partial.websearch?.providers?.grok?.timeout ?? 55,
},
},
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
// Copilot config - strictly opt-in, merge with defaults
copilot: {
enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit,
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
},
// Cursor config - disabled by default, merge with defaults
cursor: {
enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model: partial.cursor?.opus_model,
sonnet_model: partial.cursor?.sonnet_model,
haiku_model: partial.cursor?.haiku_model,
},
// Global env - injected into all non-Claude subscription profiles
global_env: {
enabled: partial.global_env?.enabled ?? true,
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
},
continuity,
// CLIProxy server config - remote/local CLIProxyAPI settings
cliproxy_server: {
remote: {
enabled:
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
// Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP)
port: partial.cliproxy_server?.remote?.port,
protocol:
partial.cliproxy_server?.remote?.protocol ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol,
auth_token:
partial.cliproxy_server?.remote?.auth_token ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
// management_key is optional - falls back to auth_token when not set
management_key: partial.cliproxy_server?.remote?.management_key,
},
fallback: {
enabled:
partial.cliproxy_server?.fallback?.enabled ??
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled,
auto_start:
partial.cliproxy_server?.fallback?.auto_start ??
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start,
},
local: {
port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port,
auto_start:
partial.cliproxy_server?.local?.auto_start ??
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
},
},
// Quota management config - hybrid auto+manual account selection
quota_management: {
mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode,
auto: {
preflight_check:
partial.quota_management?.auto?.preflight_check ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check,
exhaustion_threshold:
partial.quota_management?.auto?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold,
tier_priority:
partial.quota_management?.auto?.tier_priority ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority,
cooldown_minutes:
partial.quota_management?.auto?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes,
},
manual: {
paused_accounts:
partial.quota_management?.manual?.paused_accounts ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts,
forced_default:
partial.quota_management?.manual?.forced_default ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default,
tier_lock:
partial.quota_management?.manual?.tier_lock ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
},
runtime_monitor: {
enabled:
partial.quota_management?.runtime_monitor?.enabled ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled,
normal_interval_seconds:
partial.quota_management?.runtime_monitor?.normal_interval_seconds ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds,
critical_interval_seconds:
partial.quota_management?.runtime_monitor?.critical_interval_seconds ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds,
warn_threshold:
partial.quota_management?.runtime_monitor?.warn_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold,
exhaustion_threshold:
partial.quota_management?.runtime_monitor?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold,
cooldown_minutes:
partial.quota_management?.runtime_monitor?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes,
},
},
// Thinking config - auto/manual/off control for reasoning budget
thinking: {
mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: partial.thinking?.override,
tier_defaults: {
opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku:
partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: partial.thinking?.provider_overrides,
show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
},
channels: normalizeOfficialChannelsConfig(
partial as Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
),
// Dashboard auth config - disabled by default
dashboard_auth: {
enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled,
username: partial.dashboard_auth?.username ?? DEFAULT_DASHBOARD_AUTH_CONFIG.username,
password_hash:
partial.dashboard_auth?.password_hash ?? DEFAULT_DASHBOARD_AUTH_CONFIG.password_hash,
session_timeout_hours:
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
browser: canonicalizeBrowserConfig(partial.browser),
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: canonicalizeImageAnalysisConfig({
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
fallback_backend:
partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
}),
};
}
+301
View File
@@ -0,0 +1,301 @@
/**
* io-locks.ts
*
* Path constants, lockfile management, and config I/O helpers extracted from
* unified-config-loader.ts (Phase 1 split issue #1164).
*
* Forward-reference note: loadUnifiedConfigWithLockHeld and
* writeUnifiedConfigWithLockHeld depend on mergeWithDefaults,
* validateCompositeVariants, generateYamlHeader, and generateYamlWithComments
* which still live in unified-config-loader.ts (extracted in later phases).
* Those are passed as callbacks to avoid a circular import. Once Phases 23
* land in separate modules with no dependency on this file, they can be
* imported directly.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../../utils/config-manager';
import {
isUnifiedConfig,
createEmptyUnifiedConfig,
UNIFIED_CONFIG_VERSION,
} from '../unified-config-types';
import type { UnifiedConfig } from '../unified-config-types';
// ---------------------------------------------------------------------------
// Path constants
// ---------------------------------------------------------------------------
export const CONFIG_YAML = 'config.yaml';
export const CONFIG_JSON = 'config.json';
export const CONFIG_LOCK = 'config.yaml.lock';
/** Lock is stale after this many milliseconds */
export const LOCK_STALE_MS = 5000;
export const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`;
export const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
/**
* Get path to unified config.yaml
*/
export function getConfigYamlPath(): string {
return path.join(getCcsDir(), CONFIG_YAML);
}
/**
* Get path to legacy config.json
*/
export function getConfigJsonPath(): string {
return path.join(getCcsDir(), CONFIG_JSON);
}
/**
* Get path to config lockfile (internal)
*/
function getLockFilePath(): string {
return path.join(getCcsDir(), CONFIG_LOCK);
}
// ---------------------------------------------------------------------------
// Process check
// ---------------------------------------------------------------------------
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Lockfile primitives
// ---------------------------------------------------------------------------
/**
* Acquire lockfile for config write operations.
* Returns a lock token if acquired, null if already locked by another process.
* Cleans up stale locks (older than LOCK_STALE_MS).
*/
export function acquireLock(): string | null {
const lockPath = getLockFilePath();
const lockDir = path.dirname(lockPath);
const lockToken = crypto.randomUUID();
const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`;
try {
if (!fs.existsSync(lockDir)) {
fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
}
// Check if lock exists
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8');
const [pidStr, timestampStr] = content.trim().split('\n');
const pid = Number.parseInt(pidStr, 10);
const timestamp = Number.parseInt(timestampStr, 10);
const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid);
const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS;
if (hasLiveOwner) {
return null;
}
if (isStale || !hasLiveOwner) {
fs.unlinkSync(lockPath);
}
}
// Acquire lock
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
return lockToken;
} catch (error) {
// EEXIST means another process acquired the lock between our check and write
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return null;
}
return null;
}
}
/**
* Release lockfile after config write operation.
*/
export function releaseLock(lockToken: string): void {
const lockPath = getLockFilePath();
try {
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8');
const fileToken = content.trim().split('\n')[2];
if (fileToken === lockToken) {
fs.unlinkSync(lockPath);
}
}
} catch {
// Ignore cleanup errors
}
}
// ---------------------------------------------------------------------------
// Config format detection
// ---------------------------------------------------------------------------
/**
* Check if unified config.yaml exists
*/
export function hasUnifiedConfig(): boolean {
return fs.existsSync(getConfigYamlPath());
}
/**
* Check if legacy config.json exists
*/
export function hasLegacyConfig(): boolean {
return fs.existsSync(getConfigJsonPath());
}
// ---------------------------------------------------------------------------
// Sync sleep
// ---------------------------------------------------------------------------
/**
* Sync sleep helper for lock retry loops.
* Uses Atomics.wait when available to avoid CPU-intensive busy-wait.
*/
export function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const end = Date.now() + ms;
while (Date.now() < end) {
/* busy-wait */
}
}
}
// ---------------------------------------------------------------------------
// Lock-aware execution wrapper
// ---------------------------------------------------------------------------
/**
* Execute a callback while holding the config lock.
*/
export function withConfigWriteLock<T>(callback: () => T): T {
// Acquire lock (retry for up to 1 second)
const maxRetries = 10;
const retryDelayMs = 100;
let lockToken: string | null = null;
for (let i = 0; i < maxRetries; i++) {
const acquiredToken = acquireLock();
if (acquiredToken) {
lockToken = acquiredToken;
break;
}
sleepSync(retryDelayMs);
}
if (!lockToken) {
throw new Error('Config file is locked by another process. Wait a moment and try again.');
}
try {
return callback();
} finally {
// Always release lock
releaseLock(lockToken);
}
}
// ---------------------------------------------------------------------------
// Lock-held read/write helpers
// ---------------------------------------------------------------------------
/**
* Load unified config directly from disk while lock is already held.
* Falls back to empty config when file doesn't exist.
*
* Forward-reference: mergeWithDefaults and validateCompositeVariants are
* passed as callbacks to avoid a circular dependency. They will be imported
* directly once all phases are complete.
*/
export function loadUnifiedConfigWithLockHeld(
mergeWithDefaults: (partial: Partial<UnifiedConfig>) => UnifiedConfig,
validateCompositeVariants: (config: UnifiedConfig) => void
): UnifiedConfig {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) {
return createEmptyUnifiedConfig();
}
const content = fs.readFileSync(yamlPath, 'utf8');
const parsed = yaml.load(content);
if (!isUnifiedConfig(parsed)) {
throw new Error(`Invalid config format in ${yamlPath}`);
}
const merged = mergeWithDefaults(parsed);
validateCompositeVariants(merged);
return merged;
}
/**
* Write unified config to disk while lock is already held.
* Uses atomic write (temp file + rename) to prevent corruption.
*
* Forward-reference: generateYamlHeader and generateYamlWithComments are
* passed as callbacks to avoid a circular dependency. They will be imported
* directly once all phases are complete.
*/
export function writeUnifiedConfigWithLockHeld(
config: UnifiedConfig,
generateYamlHeader: () => string,
generateYamlWithComments: (config: UnifiedConfig) => string
): void {
const yamlPath = getConfigYamlPath();
const dir = path.dirname(yamlPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Ensure version is set
config.version = UNIFIED_CONFIG_VERSION;
// Generate YAML with section comments
const yamlContent = generateYamlWithComments(config);
const content = generateYamlHeader() + yamlContent;
// Atomic write: write to temp file, then rename
const tempPath = `${yamlPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, yamlPath);
} catch (error) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
// Classify filesystem errors
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOSPC') {
throw new Error('Disk full - cannot save config. Free up space and try again.');
} else if (err.code === 'EROFS' || err.code === 'EACCES') {
throw new Error(`Cannot write config - check file permissions: ${err.message}`);
}
throw error;
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* normalizers.ts
*
* Input normalization and validation helpers extracted from
* unified-config-loader.ts (Phase 2 split issue #1164).
*
* Contains: browser config normalizers, session affinity TTL normalizer,
* composite variant validator, continuity config normalizer, and official
* channels config normalizer.
*/
import { DEFAULT_BROWSER_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../unified-config-types';
import type {
UnifiedConfig,
BrowserConfig,
BrowserEvalMode,
BrowserToolPolicy,
OfficialChannelsConfig,
OfficialChannelId,
ContinuityConfig,
} from '../unified-config-types';
import { validateCompositeTiers } from '../../cliproxy/config/composite-validator';
import {
isOfficialChannelId,
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from '../../channels/official-channels-ids';
import { getRecommendedBrowserUserDataDir } from '../../utils/browser/browser-settings';
import { GO_DURATION_PATTERN, GO_DURATION_SEGMENT } from './io-locks';
// ---------------------------------------------------------------------------
// Browser normalizers
// ---------------------------------------------------------------------------
export function normalizeBrowserDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
return port;
}
export function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy {
return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy;
}
export function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode {
if (value === 'disabled' || value === 'readonly' || value === 'readwrite') {
return value;
}
return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly';
}
export function canonicalizeBrowserConfig(
config?: BrowserConfig,
fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG
): BrowserConfig {
const claudeUserDataDir =
config?.claude?.user_data_dir === undefined
? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir()
: config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir();
return {
claude: {
enabled: config?.claude?.enabled ?? fallback.claude.enabled,
policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy),
user_data_dir: claudeUserDataDir,
devtools_port: normalizeBrowserDevtoolsPort(
config?.claude?.devtools_port ?? fallback.claude.devtools_port
),
eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode),
},
codex: {
enabled: config?.codex?.enabled ?? fallback.codex.enabled,
policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy),
eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode),
},
};
}
// ---------------------------------------------------------------------------
// Session affinity TTL normalizer
// ---------------------------------------------------------------------------
export function normalizeSessionAffinityTtl(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback;
}
const trimmed = value.trim();
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) {
return fallback;
}
return trimmed;
}
export function hasPositiveDuration(value: string): boolean {
const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g'));
if (!segments) {
return false;
}
return segments.some((segment) => {
const numeric = parseFloat(segment);
return Number.isFinite(numeric) && numeric > 0;
});
}
// ---------------------------------------------------------------------------
// Composite variant validator
// ---------------------------------------------------------------------------
/**
* Validate composite variant provider strings.
* Warns about invalid providers in composite variant configurations.
*/
export function validateCompositeVariants(config: UnifiedConfig): void {
const variants = config.cliproxy?.variants;
if (!variants) return;
for (const [name, variant] of Object.entries(variants)) {
if ('type' in variant && variant.type === 'composite') {
const error = validateCompositeTiers(variant.tiers, {
defaultTier: variant.default_tier,
requireAllTiers: true,
});
if (error) {
console.warn(`[!] Variant '${name}': invalid composite config (${error})`);
}
}
}
}
// ---------------------------------------------------------------------------
// Continuity config normalizer
// ---------------------------------------------------------------------------
/**
* Normalize continuity inheritance mapping payload.
* Keeps only non-empty string keys and values.
*/
export function normalizeContinuityInheritanceMap(
value: unknown
): Record<string, string> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [profileName, accountName] of Object.entries(value as Record<string, unknown>)) {
const normalizedProfile = profileName.trim();
const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : '';
if (!normalizedProfile || !normalizedAccount) {
continue;
}
normalized[normalizedProfile] = normalizedAccount;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
/**
* Normalize continuity section.
* Supports legacy root key: continuity_inherit_from_account.
*/
export function normalizeContinuityConfig(
partial: Partial<UnifiedConfig>
): ContinuityConfig | undefined {
const legacyMap = normalizeContinuityInheritanceMap(
(partial as Partial<UnifiedConfig> & { continuity_inherit_from_account?: unknown })
.continuity_inherit_from_account
);
const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account);
if (!legacyMap && !continuityMap) {
return undefined;
}
return {
inherit_from_account: {
...(legacyMap ?? {}),
...(continuityMap ?? {}),
},
};
}
// ---------------------------------------------------------------------------
// Official channels config normalizer
// ---------------------------------------------------------------------------
export interface LegacyDiscordChannelsConfig {
enabled?: boolean;
unattended?: boolean;
}
export function normalizeOfficialChannelsConfig(
partial: Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
): OfficialChannelsConfig {
const hasCanonicalChannelsSection = partial.channels !== undefined;
const hasExplicitSelectedField =
hasCanonicalChannelsSection &&
Object.prototype.hasOwnProperty.call(partial.channels, 'selected');
const rawSelected =
hasExplicitSelectedField && Array.isArray(partial.channels?.selected)
? partial.channels.selected.filter((value): value is OfficialChannelId =>
isOfficialChannelId(value)
)
: [];
return {
selected: hasCanonicalChannelsSection
? normalizeOfficialChannelIds(rawSelected)
: resolveLegacyDiscordSelection(partial.discord_channels?.enabled),
unattended:
partial.channels?.unattended ??
partial.discord_channels?.unattended ??
DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended,
};
}
+352
View File
@@ -0,0 +1,352 @@
/**
* yaml-serializer.ts
*
* YAML generation helpers extracted from unified-config-loader.ts
* (Phase 3 split issue #1164).
*
* Contains: generateYamlHeader, generateYamlWithComments.
* These produce the commented YAML written to config.yaml on every save.
*/
import * as yaml from 'js-yaml';
import type { UnifiedConfig } from '../unified-config-types';
/**
* Generate YAML header with helpful comments.
*/
export function generateYamlHeader(): string {
return `# CCS Unified Configuration
# Docs: https://github.com/kaitranntt/ccs
`;
}
/**
* Generate YAML content with section comments for better readability.
*/
export function generateYamlWithComments(config: UnifiedConfig): string {
const lines: string[] = [];
// Version
lines.push(`version: ${config.version}`);
if (config.setup_completed !== undefined) {
lines.push(`setup_completed: ${config.setup_completed}`);
}
lines.push('');
// Default
if (config.default) {
lines.push(`# Default profile used when running 'ccs' without arguments`);
lines.push(`default: "${config.default}"`);
lines.push('');
}
// Accounts section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Accounts: Isolated Claude instances (each with separate auth/sessions)');
lines.push('# Manage with: ccs auth add <name>, ccs auth list, ccs auth remove <name>');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ accounts: config.accounts }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
// Profiles section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Profiles: API-based providers (GLM, Kimi, custom endpoints)');
lines.push('# Each profile points to a *.settings.json file containing env vars.');
lines.push('# Edit the settings file directly to customize (ANTHROPIC_MAX_TOKENS, etc.)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ profiles: config.profiles }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
// CLIProxy section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)');
lines.push('# Each variant can reference a *.settings.json file for custom env vars.');
lines.push('# Edit the settings file directly to customize model or other settings.');
lines.push(
'# Optional: cliproxy.management_panel_repository overrides the generated CPAMC repo.'
);
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
if (config.proxy?.routing) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules');
lines.push('# Use profile:model selectors to force a target profile and upstream model.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
if (config.logging) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Logging: CCS-owned structured runtime logs');
lines.push('# Current file: ~/.ccs/logs/current.jsonl');
lines.push('# Archives rotate automatically and are pruned by retain_days.');
lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// CLIProxy Server section (remote proxy configuration) - placed right after cliproxy
if (config.cliproxy_server) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# CLIProxy Server: Remote proxy connection settings');
lines.push('# Configure via Dashboard (`ccs config`) > Proxy tab.');
lines.push('#');
lines.push('# remote: Connect to a remote CLIProxyAPI instance');
lines.push('# fallback: Use local proxy if remote is unreachable');
lines.push('# local: Local proxy settings (port, auto-start)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ cliproxy_server: config.cliproxy_server },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
// Preferences section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Preferences: User settings');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ preferences: config.preferences }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
// WebSearch section
if (config.websearch) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# WebSearch: real search backends for third-party profiles');
lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.');
lines.push('#');
lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to');
lines.push("# Anthropic's WebSearch tool. CCS intercepts that tool and runs local search.");
lines.push('#');
lines.push(
'# Priority: Exa -> Tavily -> Brave -> DuckDuckGo -> optional legacy AI CLI fallbacks'
);
lines.push('#');
lines.push('# Exa requires EXA_API_KEY in your environment.');
lines.push('# Tavily requires TAVILY_API_KEY in your environment.');
lines.push('# Brave requires BRAVE_API_KEY in your environment.');
lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.');
lines.push('#');
lines.push('# Legacy LLM fallbacks remain optional if you still want them:');
lines.push('# gemini: npm i -g @google/gemini-cli');
lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash');
lines.push('# grok: npm i -g @vibe-kit/grok-cli');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Copilot section (GitHub Copilot proxy)
if (config.copilot) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)');
lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.');
lines.push('#');
lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!');
lines.push('# This uses an UNOFFICIAL reverse-engineered API.');
lines.push('# Excessive usage may trigger GitHub account restrictions.');
lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.');
lines.push('#');
lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)');
lines.push('# Usage: ccs copilot (switch to copilot profile)');
lines.push('#');
lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro');
lines.push('# Account types: individual, business, enterprise');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Cursor section (Cursor IDE proxy daemon)
if (config.cursor) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Cursor: Cursor IDE proxy daemon');
lines.push('# Enables Cursor IDE integration via local proxy daemon.');
lines.push('#');
lines.push('# enabled: Enable/disable Cursor integration (default: false)');
lines.push('# port: Port for cursor proxy daemon (default: 20129)');
lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)');
lines.push('# ghost_mode: Disable telemetry for privacy (default: true)');
lines.push('# model: Default model ID (used for ANTHROPIC_MODEL)');
lines.push('# opus_model/sonnet_model/haiku_model: Optional tier model mapping');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Global env section
if (config.global_env) {
lines.push('# ----------------------------------------------------------------------------');
lines.push(
'# Global Environment Variables: Injected into all non-Claude subscription profiles'
);
lines.push('# These env vars disable telemetry/reporting for third-party providers.');
lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.');
lines.push('#');
lines.push('# Default variables:');
lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)');
lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic');
lines.push('# DISABLE_TELEMETRY: Disables usage telemetry');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Continuity inheritance section
if (config.continuity?.inherit_from_account) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles');
lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).');
lines.push('# Applies to Claude target only; credentials remain profile-specific.');
lines.push('# Example: continuity.inherit_from_account.glm: pro');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Thinking section (extended thinking/reasoning configuration)
if (config.thinking) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Thinking: Extended thinking/reasoning budget configuration');
lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).');
lines.push('#');
lines.push(
'# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)'
);
lines.push(
'# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto'
);
lines.push('# Override: Set global override value (number or level name)');
lines.push('# Provider overrides: Per-provider tier defaults');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ thinking: config.thinking }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Official Channels section
if (config.channels) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Official Channels: Runtime auto-enable for Anthropic official channel plugins');
lines.push('# Supported channels: telegram, discord, imessage');
lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.');
lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.');
lines.push('# Use selected: [telegram, discord, imessage] to choose channels.');
lines.push(
'# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.'
);
lines.push('# Compatible sessions: native Claude default/account profiles only.');
lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Dashboard auth section (only if configured)
if (config.dashboard_auth?.enabled) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Dashboard Auth: Optional login protection for CCS dashboard');
lines.push('# Generate password hash: npx bcrypt-cli hash "your-password"');
lines.push(
'# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH'
);
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ dashboard_auth: config.dashboard_auth },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
// Browser automation section
if (config.browser) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Browser Automation: Claude browser attach and Codex browser tooling');
lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.');
lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.');
lines.push('#');
lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the');
lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.');
lines.push('# Configure via: Settings > Browser or `ccs browser ...`.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Image analysis section
if (config.image_analysis) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Image Analysis: Vision-based analysis for images and PDFs');
lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.');
lines.push('#');
lines.push('# When enabled: Image files trigger vision analysis instead of raw file read');
lines.push('# Provider models: Vision model used for each CLIProxy provider');
lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)');
lines.push('#');
lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf');
lines.push('# Configure via: ccs config image-analysis');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ image_analysis: config.image_analysis },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
return lines.join('\n');
}
+2
View File
@@ -128,6 +128,8 @@ export interface CLIProxyRoutingConfig {
export interface CLIProxyConfig {
/** Backend selection: 'original' or 'plus' (default: 'original') */
backend?: 'original' | 'plus';
/** Optional CPAMC dashboard GitHub repository override for generated CLIProxy config */
management_panel_repository?: string;
/** Nickname to email mapping for OAuth accounts */
oauth_accounts: OAuthAccounts;
/** Built-in providers (read-only, for reference) */
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,10 +11,11 @@ import * as path from 'path';
import * as http from 'http';
import { CopilotDaemonStatus } from './types';
import { CopilotConfig, DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager';
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
import { createLogger } from '../services/logging';
import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade';
const logger = createLogger('copilot:daemon');
+4 -3
View File
@@ -7,10 +7,10 @@
import { spawn } from 'child_process';
import { CopilotConfig } from '../config/unified-config-types';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { ensureCliproxyService } from '../cliproxy';
import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
import { isDaemonRunning, startDaemon } from './copilot-daemon';
import { ensureCopilotApi } from './copilot-package-manager';
@@ -36,6 +36,7 @@ import {
} from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { createLogger } from '../services/logging';
import { getGlobalEnvConfig } from '../config/config-loader-facade';
const logger = createLogger('copilot:executor');
@@ -142,7 +143,7 @@ export async function resolveCopilotImageAnalysisEnv(
if (status.proxyReadiness === 'stopped') {
const ensureServiceResult = await resolvedDeps.ensureCliproxyService(
CLIPROXY_DEFAULT_PORT,
resolveLifecyclePort(),
verbose
);
if (!ensureServiceResult.started) {
+1 -1
View File
@@ -15,7 +15,7 @@ import * as path from 'path';
import { spawn, spawnSync } from 'child_process';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, info } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
import { getCcsDir } from '../config/config-loader-facade';
/** Cache duration for version check (1 hour in milliseconds) */
const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
+1 -1
View File
@@ -19,7 +19,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types';
import { getCcsDir } from '../utils/config-manager';
import { getCcsDir } from '../config/config-loader-facade';
const ACCESS_TOKEN_KEYS = ['cursorAuth/accessToken', 'cursorAuth/token'] as const;
const MACHINE_ID_KEYS = [
+1 -1
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
import { getCcsDir } from '../config/config-loader-facade';
function getCursorDir(): string {
return path.join(getCcsDir(), 'cursor');
+21 -6
View File
@@ -1,9 +1,9 @@
import { spawn } from 'child_process';
import type { CursorConfig } from '../config/unified-config-types';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { ensureCliproxyService } from '../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
import { fail, info, ok } from '../utils/ui';
import {
appendThirdPartyWebSearchToolArgs,
@@ -15,12 +15,20 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u
import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getGlobalEnvConfig } from '../config/config-loader-facade';
interface CursorImageAnalysisResolution {
env: Record<string, string>;
warning: string | null;
}
interface CursorImageAnalysisDeps {
getImageAnalysisHookEnv?: typeof getImageAnalysisHookEnv;
resolveImageAnalysisRuntimeStatus?: typeof resolveImageAnalysisRuntimeStatus;
ensureCliproxyService?: typeof ensureCliproxyService;
resolveLifecyclePort?: typeof resolveLifecyclePort;
}
export function generateCursorEnv(
config: CursorConfig,
claudeConfigDir?: string
@@ -44,9 +52,16 @@ export function generateCursorEnv(
}
export async function resolveCursorImageAnalysisEnv(
verbose = false
verbose = false,
deps: CursorImageAnalysisDeps = {}
): Promise<CursorImageAnalysisResolution> {
const env = getImageAnalysisHookEnv({
const getImageAnalysisHookEnvFn = deps.getImageAnalysisHookEnv ?? getImageAnalysisHookEnv;
const resolveImageAnalysisRuntimeStatusFn =
deps.resolveImageAnalysisRuntimeStatus ?? resolveImageAnalysisRuntimeStatus;
const ensureCliproxyServiceFn = deps.ensureCliproxyService ?? ensureCliproxyService;
const resolveLifecyclePortFn = deps.resolveLifecyclePort ?? resolveLifecyclePort;
const env = getImageAnalysisHookEnvFn({
profileName: 'cursor',
profileType: 'cursor',
});
@@ -55,7 +70,7 @@ export async function resolveCursorImageAnalysisEnv(
return { env, warning: null };
}
const status = await resolveImageAnalysisRuntimeStatus({
const status = await resolveImageAnalysisRuntimeStatusFn({
profileName: 'cursor',
profileType: 'cursor',
});
@@ -72,7 +87,7 @@ export async function resolveCursorImageAnalysisEnv(
}
if (status.proxyReadiness === 'stopped') {
const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
const ensureServiceResult = await ensureCliproxyServiceFn(resolveLifecyclePortFn(), verbose);
if (!ensureServiceResult.started) {
return {
env: {

Some files were not shown because too many files have changed in this diff Show More