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