diff --git a/src/api/services/index.ts b/src/api/services/index.ts index 8b213e17..f4e00c66 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -5,7 +5,13 @@ */ // Validation services -export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service'; +export { + validateApiName, + validateApiNameSyntax, + validateUrl, + getUrlWarning, + sanitizeBaseUrl, +} from './validation-service'; // Profile types export { diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 39c6cf82..a9cf061d 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -9,14 +9,19 @@ 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 { ConfigError, ProfileError } from '../../errors/error-types'; 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 { + canOverwriteGrandfatheredReservedProfileName, + isGrandfatheredReservedProfileName, + isReservedName, +} from '../../config/reserved-names'; -import { validateApiName } from './validation-service'; -import { listApiProfiles } from './profile-reader'; +import { validateApiName, validateApiNameSyntax } from './validation-service'; +import { apiProfileExists, listApiProfiles } from './profile-reader'; import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; import type { ApiProfileExportBundle, @@ -44,7 +49,7 @@ function parseTargetValue(value: unknown): TargetType | null { } function validateProfileNameForPath(name: string, label: string): string | null { - const validationError = validateApiName(name); + const validationError = validateApiNameSyntax(name); if (validationError) { return `Invalid ${label} profile name "${name}": ${validationError}`; } @@ -70,7 +75,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa if (isUnifiedMode()) { mutateConfig((config) => { if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); + throw new ProfileError(`API profile already exists: ${name}`, name); } config.profiles[name] = { @@ -85,7 +90,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa const configPath = getConfigPath(); const config = loadConfigSafe() as Config; if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); + throw new ProfileError(`API profile already exists: ${name}`, name); } config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`; @@ -124,7 +129,7 @@ function readJsonObject(filePath: string): Record { const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Settings file must contain a JSON object.'); + throw new ConfigError('Settings file must contain a JSON object.', filePath); } return parsed as Record; } @@ -158,7 +163,10 @@ export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult { .filter((file) => !registeredSettings.has(file)) .filter((file) => !file.startsWith('base-')) .filter((file) => !ignoredNames.has(file)) - .filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length))) + .filter((file) => { + const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); + return !isReservedName(name) || isGrandfatheredReservedProfileName(name); + }) .map((file) => { const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); const settingsPath = path.join(ccsDir, file); @@ -206,7 +214,7 @@ export function registerApiProfileOrphans(options?: { const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] }; for (const orphan of selected) { - const nameError = validateApiName(orphan.name); + const nameError = validateApiNameSyntax(orphan.name); if (nameError) { result.skipped.push({ name: orphan.name, @@ -247,7 +255,16 @@ export function copyApiProfile( if (sourceError) return { success: false, error: sourceError }; const destinationError = validateApiName(destination); - if (destinationError) return { success: false, error: destinationError }; + const canOverwriteGrandfatheredDestination = canOverwriteGrandfatheredReservedProfileName( + destination, + { + force: options?.force === true, + exists: apiProfileExists(destination), + } + ); + if (destinationError && !canOverwriteGrandfatheredDestination) { + return { success: false, error: destinationError }; + } const sourceSettingsPath = getProfileSettingsPath(source); if (!fs.existsSync(sourceSettingsPath)) { @@ -363,7 +380,13 @@ export function importApiProfileBundle( const name = options?.name || input.profile.name; const nameError = validateApiName(name); - if (nameError) return { success: false, error: nameError }; + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force: options?.force === true, + exists: apiProfileExists(name), + }); + if (nameError && !canOverwriteGrandfatheredProfile) { + return { success: false, error: nameError }; + } const bundleTarget = parseTargetValue(input.profile.target); if (input.profile.target !== undefined && bundleTarget === null) { diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index dc366eae..2683d170 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,7 +12,10 @@ import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; -import { isReservedName } from '../../config/reserved-names'; +import { + canOverwriteGrandfatheredReservedProfileName, + isReservedName, +} from '../../config/reserved-names'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname, @@ -266,8 +269,18 @@ export function createApiProfile( models: ModelMapping, target: TargetType = 'claude', provider?: string, - extraModels?: string[] + extraModels?: string[], + options?: { force?: boolean } ): CreateApiProfileResult { + const nameError = validateApiName(name); + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force: options?.force === true, + exists: apiProfileExists(name), + }); + if (nameError && !canOverwriteGrandfatheredProfile) { + return { success: false, settingsFile: '', error: nameError }; + } + try { const deniedReason = getDeniedModelReason(baseUrl, models); if (deniedReason) { @@ -304,10 +317,17 @@ export function createCliproxyBridgeProfile( const providedName = options.name?.trim(); if (providedName) { const nameError = validateApiName(providedName); - if (nameError) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + providedName, + { + force: options.force === true, + exists: apiProfileExists(providedName), + } + ); + if (nameError && !canOverwriteGrandfatheredProfile) { return { success: false, settingsFile: '', error: nameError }; } - if (isReservedName(providedName)) { + if (isReservedName(providedName) && !canOverwriteGrandfatheredProfile) { return { success: false, settingsFile: '', @@ -332,7 +352,9 @@ export function createCliproxyBridgeProfile( resolved.apiKey, resolved.models, resolved.target, - provider + provider, + undefined, + { force: options.force } ); const detectedBridge = resolveCliproxyBridgeMetadata({ env: { diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts index e9b830ce..1487a85c 100644 --- a/src/api/services/validation-service.ts +++ b/src/api/services/validation-service.ts @@ -8,10 +8,13 @@ import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; /** - * Validate API profile name + * Validate API profile name syntax for an existing profile reference. + * + * Existing profiles may use a name that became reserved after they were + * created, so this validator intentionally excludes built-in-name policy. * @returns Error message if invalid, null if valid */ -export function validateApiName(name: string): string | null { +export function validateApiNameSyntax(name: string): string | null { if (!name) { return 'API name is required'; } @@ -21,15 +24,27 @@ export function validateApiName(name: string): string | null { if (name.length > 32) { return 'API name must be 32 characters or less'; } - if (isReservedName(name)) { - return `'${name}' is a reserved name`; - } if (isWindowsReservedName(name)) { return `'${name}' is a Windows reserved device name and cannot be used`; } return null; } +/** + * Validate a name for creation of a new API profile. + * @returns Error message if invalid or reserved, null if valid + */ +export function validateApiName(name: string): string | null { + const syntaxError = validateApiNameSyntax(name); + if (syntaxError) { + return syntaxError; + } + if (isReservedName(name)) { + return `'${name}' is a reserved name`; + } + return null; +} + /** * Validate URL format * @returns Error message if invalid, null if valid diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index 52ce2eb8..6c030b4c 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -5,6 +5,8 @@ * project workspace context with other accounts in the same context group. */ +import { isReservedName } from '../config/reserved-names'; + export type AccountContextMode = 'isolated' | 'shared'; export type AccountContinuityMode = 'standard' | 'deeper'; @@ -36,7 +38,6 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard'; export const MAX_CONTEXT_GROUP_LENGTH = 64; export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; -const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']); const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -58,10 +59,7 @@ export function isValidContextGroupName(value: string): boolean { * Validate account profile naming constraints. */ export function isValidAccountProfileName(value: string): boolean { - return ( - ACCOUNT_PROFILE_NAME_PATTERN.test(value) && - !RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase()) - ); + return ACCOUNT_PROFILE_NAME_PATTERN.test(value) && !isReservedName(value); } /** diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index ee78ffba..dd958b99 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -34,6 +34,7 @@ import { maybeShowPoolOnboardingHint, countNativeClaudeProfiles, } from '../../cliproxy/routing/pool-onboarding-hint'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); @@ -63,7 +64,19 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } - if (!isValidAccountProfileName(profileName)) { + // Check exact account ownership before applying the narrow grandfathered + // repair exception. API profiles and case variants do not qualify. + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + profileName, + { + force: force === true, + exists: existsLegacy || existsUnified, + } + ); + + if (!isValidAccountProfileName(profileName) && !canOverwriteGrandfatheredProfile) { exitWithError( 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.', ExitCode.PROFILE_ERROR @@ -71,8 +84,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } // Check if profile already exists (check both legacy and unified) - const existsLegacy = ctx.registry.hasProfile(profileName); - const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!force && (existsLegacy || existsUnified)) { // Keep the --force hint in the exitWithError message so it appears in the single output line exitWithError( diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index dbac96b3..e596a0cc 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -26,8 +26,11 @@ import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profi import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, + isCLIProxyProvider, resolveCLIProxyProviderShortcut, } from '../cliproxy/provider-capabilities'; +import { isGrandfatheredReservedProfileName } from '../config/reserved-names'; +import { ConfigError } from '../errors/error-types'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; @@ -85,6 +88,50 @@ export interface ProfileNotFoundError extends Error { availableProfiles: string; } +function hasOwnEntry(value: unknown, key: string): boolean { + return ( + typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, key) + ); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isResolvableCompositeVariant(value: unknown): value is CompositeVariantConfig { + if (!isObjectRecord(value) || value.type !== 'composite') { + return false; + } + if ( + value.default_tier !== 'opus' && + value.default_tier !== 'sonnet' && + value.default_tier !== 'haiku' + ) { + return false; + } + if (!isObjectRecord(value.tiers)) { + return false; + } + const tiers = value.tiers; + + return (['opus', 'sonnet', 'haiku'] as const).every((tierName) => { + const tier = tiers[tierName]; + return ( + isObjectRecord(tier) && + typeof tier.provider === 'string' && + isCLIProxyProvider(tier.provider) && + typeof tier.model === 'string' && + tier.model.trim().length > 0 + ); + }); +} + +function isResolvableAccountProfile(value: unknown): value is ProfileMetadata { + return ( + isObjectRecord(value) && typeof value.created === 'string' && value.created.trim().length > 0 + ); +} + /** * Profile Detector Class */ @@ -133,28 +180,22 @@ class ProfileDetector { config: UnifiedConfig ): ProfileDetectionResult | null { // Check CLIProxy variants first - if (config.cliproxy?.variants?.[profileName]) { - const variant = config.cliproxy.variants[profileName]; + if (hasOwnEntry(config.cliproxy?.variants, profileName)) { + const variant = config.cliproxy?.variants?.[profileName]; + if (!isObjectRecord(variant)) { + return null; + } // Handle composite variants if ('type' in variant && variant.type === 'composite') { - const composite = variant as CompositeVariantConfig; - - // Defensive: check for missing tiers or default_tier - if (!composite.tiers || !composite.default_tier) { + if (!isResolvableCompositeVariant(variant)) { console.warn( - `[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier` + `[!] Warning: Composite variant '${profileName}' has invalid or incomplete tiers/default_tier` ); return null; } - + const composite = variant; const defaultTierConfig = composite.tiers[composite.default_tier]; - if (!defaultTierConfig) { - console.warn( - `[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'` - ); - return null; - } return { type: 'cliproxy', @@ -170,6 +211,9 @@ class ProfileDetector { } const singleVariant = variant as CLIProxyVariantConfig; + if (!isCLIProxyProvider(singleVariant.provider)) { + return null; + } return { type: 'cliproxy', name: profileName, @@ -182,11 +226,18 @@ class ProfileDetector { // Check API profiles (supports compatibility aliases, e.g. km -> kimi) for (const candidate of getProfileLookupCandidates(profileName)) { - if (!config.profiles?.[candidate]) { + if (!hasOwnEntry(config.profiles, candidate)) { continue; } const profile = config.profiles[candidate]; + if ( + !isObjectRecord(profile) || + typeof profile.settings !== 'string' || + profile.settings.trim().length === 0 + ) { + return null; + } const settingsPath = profile.settings; const settingsEnv = loadSettingsFromFile(settingsPath); const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate); @@ -204,8 +255,11 @@ class ProfileDetector { } // Check accounts - if (config.accounts?.[profileName]) { + if (hasOwnEntry(config.accounts, profileName)) { const account = config.accounts[profileName]; + if (!isResolvableAccountProfile(account)) { + return null; + } return { type: 'account', name: profileName, @@ -225,6 +279,20 @@ class ProfileDetector { return null; } + private hasUnifiedConfiguredProfile(profileName: string, config: UnifiedConfig): boolean { + return ( + hasOwnEntry(config.cliproxy?.variants, profileName) || + hasOwnEntry(config.profiles, profileName) || + hasOwnEntry(config.accounts, profileName) + ); + } + + private createConfiguredCollisionError(profileName: string, source: string): ConfigError { + return new ConfigError( + `Configured profile "${profileName}" in ${source} is invalid and cannot be resolved as a grandfathered profile.` + ); + } + /** * Read settings-based config (config.json) */ @@ -264,11 +332,12 @@ class ProfileDetector { * * Priority order: * 0. Hardcoded special runtime profiles (copilot, cursor) - * 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...) + * 0.5. Hardcoded CLIProxy profiles (except grandfathered xai/grok collisions) * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] * 3. Settings-based profiles (config.profiles section) [legacy] * 4. Account-based profiles (profiles.json) [legacy] + * 5. Built-in xai/grok shortcut fallback when no configured profile exists */ detectProfileType(profileName: string | null | undefined): ProfileDetectionResult { // Special case: 'default' means use default profile @@ -336,7 +405,8 @@ class ProfileDetector { // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) const builtinProvider = resolveCLIProxyProviderShortcut(profileName); - if (builtinProvider) { + const shouldGrandfatherConfiguredProfile = builtinProvider === 'xai'; + if (builtinProvider && !shouldGrandfatherConfiguredProfile) { return { type: 'cliproxy', name: builtinProvider, @@ -349,6 +419,12 @@ class ProfileDetector { if (unifiedConfig) { const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig); if (result) return result; + if ( + isGrandfatheredReservedProfileName(profileName) && + this.hasUnifiedConfiguredProfile(profileName, unifiedConfig) + ) { + throw this.createConfiguredCollisionError(profileName, 'config.yaml'); + } // Fall through to legacy if not found in unified config } @@ -357,22 +433,36 @@ class ProfileDetector { const legacyTargetMap = (config as { profile_targets?: Record }) .profile_targets; - if (config.cliproxy && config.cliproxy[profileName]) { - const variant = config.cliproxy[profileName]; - return { - type: 'cliproxy', - name: profileName, - target: variant.target, - provider: variant.provider as CLIProxyProfileName, - settingsPath: variant.settings, - port: variant.port, - }; + if (hasOwnEntry(config.cliproxy, profileName)) { + const variant = config.cliproxy?.[profileName]; + if (!isObjectRecord(variant) || !isCLIProxyProvider(variant.provider as string)) { + if (isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'config.json'); + } + } else { + return { + type: 'cliproxy', + name: profileName, + target: variant.target, + provider: variant.provider, + settingsPath: variant.settings, + port: variant.port, + }; + } } // Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK if (config.profiles) { for (const candidate of getProfileLookupCandidates(profileName)) { - if (!config.profiles[candidate]) { + if (!hasOwnEntry(config.profiles, candidate)) { + continue; + } + + const settingsPath = config.profiles[candidate]; + if (typeof settingsPath !== 'string' || settingsPath.trim().length === 0) { + if (candidate === profileName && isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'config.json'); + } continue; } @@ -380,7 +470,7 @@ class ProfileDetector { return { type: 'settings', name: profileName, - settingsPath: config.profiles[candidate], + settingsPath, target: legacyTargetMap?.[candidate], message: viaLegacyAlias ? `Using legacy API profile "${candidate}" for "${profileName}".` @@ -392,11 +482,28 @@ class ProfileDetector { // Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK const profiles = this.readProfiles(); - if (profiles.profiles && profiles.profiles[profileName]) { + if (hasOwnEntry(profiles.profiles, profileName)) { + const profile = profiles.profiles[profileName]; + if (!isResolvableAccountProfile(profile)) { + if (isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'profiles.json'); + } + } else { + return { + type: 'account', + name: profileName, + profile, + }; + } + } + + // xai and grok became reserved when the built-in xAI provider shipped. Existing + // configured profiles keep working, while new installs still get the shortcuts. + if (builtinProvider) { return { - type: 'account', - name: profileName, - profile: profiles.profiles[profileName], + type: 'cliproxy', + name: builtinProvider, + provider: builtinProvider, }; } @@ -426,25 +533,49 @@ class ProfileDetector { if (unifiedConfig?.default) { const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig); if (result) return result; + if ( + isGrandfatheredReservedProfileName(unifiedConfig.default) && + this.hasUnifiedConfiguredProfile(unifiedConfig.default, unifiedConfig) + ) { + throw this.createConfiguredCollisionError(unifiedConfig.default, 'config.yaml'); + } } // Check if account-based default exists (legacy) const profiles = this.readProfiles(); - if (unifiedConfig?.default && profiles.profiles[unifiedConfig.default]) { - return { - type: 'account', - name: unifiedConfig.default, - profile: profiles.profiles[unifiedConfig.default], - }; + if (unifiedConfig?.default && hasOwnEntry(profiles.profiles, unifiedConfig.default)) { + const profile = profiles.profiles[unifiedConfig.default]; + if ( + isGrandfatheredReservedProfileName(unifiedConfig.default) && + !isResolvableAccountProfile(profile) + ) { + throw this.createConfiguredCollisionError(unifiedConfig.default, 'profiles.json'); + } + if (profile) { + return { + type: 'account', + name: unifiedConfig.default, + profile, + }; + } } - if (profiles.default && profiles.profiles[profiles.default]) { - return { - type: 'account', - name: profiles.default, - profile: profiles.profiles[profiles.default], - }; + if (profiles.default && hasOwnEntry(profiles.profiles, profiles.default)) { + const profile = profiles.profiles[profiles.default]; + if ( + isGrandfatheredReservedProfileName(profiles.default) && + !isResolvableAccountProfile(profile) + ) { + throw this.createConfiguredCollisionError(profiles.default, 'profiles.json'); + } + if (profile) { + return { + type: 'account', + name: profiles.default, + profile, + }; + } } // Check if settings-based default exists diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 77b585f3..1dc86cd7 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -22,6 +22,7 @@ import { isCLIProxyProvider, } from '../../cliproxy/provider-capabilities'; import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -63,7 +64,8 @@ function showPresetDeprecationNotice(presetId?: string): void { async function resolveProfileName( providedName: string | undefined, - preset: ProviderPreset | null + preset: ProviderPreset | null, + force: boolean ): Promise { const name = providedName || preset?.defaultProfileName; if (!name) { @@ -73,7 +75,11 @@ async function resolveProfileName( } const error = validateApiName(name); - if (error) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force, + exists: apiProfileExists(name), + }); + if (error && !canOverwriteGrandfatheredProfile) { console.log(fail(error)); process.exit(1); } @@ -83,11 +89,19 @@ async function resolveProfileName( async function resolveCliproxyProfileName( provider: string, providedName: string | undefined, - yes: boolean | undefined + yes: boolean | undefined, + force: boolean ): Promise { if (providedName) { const error = validateApiName(providedName); - if (error) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + providedName, + { + force, + exists: apiProfileExists(providedName), + } + ); + if (error && !canOverwriteGrandfatheredProfile) { console.log(fail(error)); process.exit(1); } @@ -368,7 +382,8 @@ export async function handleApiCreateCommand(args: string[]): Promise { const name = await resolveCliproxyProfileName( cliproxyProvider, parsedArgs.name, - parsedArgs.yes + parsedArgs.yes, + parsedArgs.force === true ); const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes); @@ -456,7 +471,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { showPresetDeprecationNotice(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset); - const name = await resolveProfileName(parsedArgs.name, preset); + const name = await resolveProfileName(parsedArgs.name, preset, parsedArgs.force === true); if (apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); @@ -503,7 +518,8 @@ export async function handleApiCreateCommand(args: string[]): Promise { finalModels, target, undefined, - parsedArgs.extraModels + parsedArgs.extraModels, + { force: parsedArgs.force === true } ); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 2f9fdb11..c979691b 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -31,6 +31,7 @@ import { } from '../../cliproxy/services'; import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector'; import { CompositeTierConfig } from '../../config/unified-config-types'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity'; import { isUnifiedMode } from '../../config/config-loader-facade'; @@ -120,6 +121,24 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs { return result; } +export function validateVariantCreateName( + name: string, + force: boolean, + exists: boolean = variantExists(name) +): string | null { + const error = validateProfileName(name); + if ( + error && + !canOverwriteGrandfatheredReservedProfileName(name, { + force, + exists, + }) + ) { + return error; + } + return null; +} + function formatModelOption(model: ModelEntry): string { const tierBadge = model.tier === 'ultra' @@ -306,7 +325,7 @@ export async function handleCreate( validate: validateProfileName, }); } else { - const error = validateProfileName(name); + const error = validateVariantCreateName(name, parsedArgs.force === true); if (error) { console.log(fail(error)); process.exit(1); diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index dacfb97d..42e7b6d5 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -1,3 +1,5 @@ +import { ConfigError } from '../errors/error-types'; + /** * Reserved profile names that cannot be used for user-defined profiles. * These names are reserved for CLIProxy providers and CLI commands. @@ -6,6 +8,8 @@ export const RESERVED_PROFILE_NAMES = [ // CLIProxy providers (built-in OAuth) 'gemini', 'codex', + 'xai', + 'grok', 'agy', 'qwen', 'iflow', @@ -32,6 +36,14 @@ export const RESERVED_PROFILE_NAMES = [ export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; +/** + * Reserved names that may still identify profiles created before the + * corresponding built-in provider shortcuts shipped. + */ +export const GRANDFATHERED_RESERVED_PROFILE_NAMES = ['xai', 'grok'] as const; +export type GrandfatheredReservedProfileName = + (typeof GRANDFATHERED_RESERVED_PROFILE_NAMES)[number]; + /** * Windows reserved device names - cannot be used as filenames on Windows. * Case-insensitive on Windows filesystem. @@ -70,6 +82,27 @@ export function isReservedName(name: string): boolean { return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); } +/** + * Check whether a reserved name may identify an existing grandfathered profile. + * This does not permit creating a new profile with the name. + */ +export function isGrandfatheredReservedProfileName(name: string): boolean { + return GRANDFATHERED_RESERVED_PROFILE_NAMES.includes( + name.toLowerCase() as GrandfatheredReservedProfileName + ); +} + +/** + * Allow a reserved grandfathered name only for an explicit overwrite of the + * exact profile that already owns it. + */ +export function canOverwriteGrandfatheredReservedProfileName( + name: string, + options: { force: boolean; exists: boolean } +): boolean { + return options.force && options.exists && isGrandfatheredReservedProfileName(name); +} + /** * Check if a name is a Windows reserved device name. * These cause filesystem errors on Windows systems. @@ -89,7 +122,7 @@ export function isWindowsReservedName(name: string): boolean { */ export function validateProfileName(name: string): void { if (isReservedName(name)) { - throw new Error( + throw new ConfigError( `Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}` ); } diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index a5ea70ac..1b0b940a 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -22,7 +22,7 @@ import { apiProfileExists, listCliproxyBridgeProviders, listApiProfiles, - validateApiName, + validateApiNameSyntax, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; import { getPersistedTargetChoices } from '../../targets/target-metadata'; @@ -284,7 +284,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => { } names = payload.names.map((value) => value.trim()); - const invalidName = names.find((name) => validateApiName(name) !== null); + const invalidName = names.find((name) => validateApiNameSyntax(name) !== null); if (invalidName) { res.status(400).json({ error: `Invalid profile name in names: ${invalidName}` }); return; @@ -321,7 +321,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => { } const { name } = req.params; - const sourceNameError = validateApiName(name); + const sourceNameError = validateApiNameSyntax(name); if (sourceNameError) { res.status(400).json({ error: sourceNameError }); return; @@ -359,7 +359,7 @@ router.post('/:name/export', (req: Request, res: Response): void => { } const { name } = req.params; - const profileNameError = validateApiName(name); + const profileNameError = validateApiNameSyntax(name); if (profileNameError) { res.status(400).json({ error: profileNameError }); return; diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index affb01b3..89a4854d 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -21,8 +21,9 @@ describe('account context helpers', () => { }); it('rejects reserved account profile names', () => { - expect(isValidAccountProfileName('default')).toBe(false); - expect(isValidAccountProfileName('Default')).toBe(false); + for (const name of ['default', 'Default', 'xai', 'XAI', 'grok', 'GROK']) { + expect(isValidAccountProfileName(name)).toBe(false); + } }); it('falls back to default shared group for invalid persisted metadata', () => { diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 310d9af5..5d984973 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, runWithScopedConfigDir, @@ -68,7 +69,7 @@ describe('profile lifecycle service', () => { } }); - it('discovers only API profile orphans (skips registered and reserved names)', async () => { + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -101,9 +102,59 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); + for (const profileName of ['xai', 'grok']) { + fs.writeFileSync( + path.join(ccsDir, `${profileName}.settings.json`), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'token', + }, + }, + null, + 2 + ) + '\n' + ); + } const result = await runInScopedCcsDir(() => discoverApiProfileOrphans()); - expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); + expect(result.orphans.map((orphan) => orphan.name).sort()).toEqual(['extra', 'grok', 'xai']); + }); + + it('registers a grandfathered xai orphan without opening general reserved-name creation', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'gemini.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + + const result = await runInScopedCcsDir(() => + registerApiProfileOrphans({ names: ['xai', 'gemini'] }) + ); + const config = await runInScopedCcsDir(() => loadConfigSafe()); + + expect(result.registered).toEqual(['xai']); + expect(result.skipped).toEqual([]); + expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json'); + expect(config.profiles.gemini).toBeUndefined(); }); it('treats explicit empty names list as no-op during orphan registration', async () => { @@ -140,7 +191,10 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => { throw new Error('copy failed'); @@ -168,8 +222,15 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); - fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + 'version: 12\nwebsearch:\n enabled: false\n', + 'utf8' + ); const originalCopyFileSync = fs.copyFileSync.bind(fs); const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => { @@ -195,7 +256,10 @@ describe('profile lifecycle service', () => { const malformedPath = path.join(ccsDir, 'bad.settings.json'); fs.writeFileSync(malformedPath, '{ invalid json', 'utf8'); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['bad'], force: true }) @@ -247,6 +311,173 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid source profile name'); }); + it('copies and exports grandfathered sources but rejects reserved destinations', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const exported = await runInScopedCcsDir(() => exportApiProfile('xai')); + const copied = await runInScopedCcsDir(() => copyApiProfile('xai', 'xai-backup')); + const rejectedDestination = await runInScopedCcsDir(() => copyApiProfile('xai', 'GROK')); + + expect(exported.success).toBe(true); + expect(exported.bundle?.profile.name).toBe('xai'); + expect(copied.success).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'xai-backup.settings.json'))).toBe(true); + expect(rejectedDestination.success).toBe(false); + expect(rejectedDestination.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'GROK.settings.json'))).toBe(false); + }); + + it('allows force copy only onto an exact existing grandfathered destination', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + source: '~/.ccs/source.settings.json', + xai: '~/.ccs/xai.settings.json', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'source.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://source.example.com', + ANTHROPIC_AUTH_TOKEN: 'source-token', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const nonForced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai')); + const forced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai', { force: true })); + const absentForced = await runInScopedCcsDir(() => + copyApiProfile('source', 'grok', { force: true }) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + expect(absentForced.success).toBe(false); + expect(absentForced.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + + const copiedSettings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(copiedSettings.env.ANTHROPIC_BASE_URL).toBe('https://source.example.com'); + expect(copiedSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('source-token'); + }); + + it('rejects an absent reserved name even when profile creation is forced', async () => { + const result = await runInScopedCcsDir(() => + createApiProfile( + 'XAI', + 'https://api.example.com', + 'token', + { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }, + 'claude', + undefined, + undefined, + { force: true } + ) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('reserved name'); + expect(fs.existsSync(path.join(getScopedCcsDir(), 'XAI.settings.json'))).toBe(false); + }); + + it('allows force to repair an exact existing xai API profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const nonForced = await runInScopedCcsDir(() => + createApiProfile('xai', 'https://new.example.com', 'new-token', { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }) + ); + const forced = await runInScopedCcsDir(() => + createApiProfile( + 'xai', + 'https://new.example.com', + 'new-token', + { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }, + 'claude', + undefined, + undefined, + { force: true } + ) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token'); + }); + it('rolls back copied settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -294,6 +525,62 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid bundle profile target'); }); + it('allows force import only for an exact existing grandfathered profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const bundle = { + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'xai', target: 'claude' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://repaired.example.com', + ANTHROPIC_AUTH_TOKEN: 'repaired-token', + }, + }, + }; + + const nonForced = await runInScopedCcsDir(() => importApiProfileBundle(bundle)); + const forced = await runInScopedCcsDir(() => importApiProfileBundle(bundle, { force: true })); + const absentForced = await runInScopedCcsDir(() => + importApiProfileBundle( + { + ...bundle, + profile: { ...bundle.profile, name: 'grok' }, + }, + { force: true } + ) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + expect(absentForced.success).toBe(false); + expect(absentForced.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://repaired.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('repaired-token'); + }); + it('rolls back imported settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts index 2264ed3d..a81b8965 100644 --- a/tests/unit/auth-resources-command.test.ts +++ b/tests/unit/auth-resources-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as childProcess from 'child_process'; import ProfileRegistry from '../../src/auth/profile-registry'; import InstanceManager from '../../src/management/instance-manager'; import { handleResources } from '../../src/auth/commands/resources-command'; @@ -227,6 +228,103 @@ describe('auth resources command', () => { expect(output).toContain('Unknown option(s): "--mode"'); }); + it('rejects reserved xai and grok account profile names on auth create', async () => { + for (const profileName of ['xai', 'GROK']) { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + [profileName] + ) + ); + + expect(output).toContain('Invalid profile name'); + expect(registry.hasAccountUnified(profileName)).toBe(false); + } + }); + + it('rejects --force for an absent reserved account profile', async () => { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['xai', '--force'] + ) + ); + + expect(output).toContain('Invalid profile name'); + expect(registry.hasAccountUnified('xai')).toBe(false); + }); + + it('allows --force to repair an exact existing xai account profile', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' xai:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const claudeDetector = await import('../../src/utils/claude-detector'); + const cliSpy = spyOn(claudeDetector, 'getClaudeCliInfo').mockReturnValue({ + path: '/usr/bin/claude', + isWindows: false, + needsShell: false, + }); + const child = { + on: () => child, + } as unknown as ReturnType; + const spawnSpy = spyOn(childProcess, 'spawn').mockReturnValue(child); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'xai') + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + await handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['xai', '--force'] + ); + + expect(spawnSpy).toHaveBeenCalled(); + expect(registry.hasAccountUnified('xai')).toBe(true); + } finally { + logSpy.mockRestore(); + ensureSpy.mockRestore(); + spawnSpy.mockRestore(); + cliSpy.mockRestore(); + } + }); + it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => { const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index c028453a..66b4f0da 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as os from 'os'; import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector'; import * as unifiedConfigLoader from '../../../src/config/unified-config-loader'; +import { ConfigError } from '../../../src/errors/error-types'; describe('ProfileDetector', () => { const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`); @@ -101,11 +102,186 @@ describe('ProfileDetector', () => { expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); - it('should resolve the grok CLI alias to canonical xai routing', () => { - const result = detector.detectProfileType('grok'); - expect(result.type).toBe('cliproxy'); - expect(result.name).toBe('xai'); - expect(result.provider).toBe('xai'); + it('should resolve xai and grok to built-in xai routing when no configured profile exists', () => { + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + for (const shortcut of ['xai', 'grok']) { + const result = detector.detectProfileType(shortcut); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('xai'); + expect(result.provider).toBe('xai'); + } + } finally { + isUnifiedModeSpy.mockRestore(); + existsSyncSpy.mockRestore(); + } + }); + + it('should preserve configured xai and grok profiles from unified config', () => { + const xaiSettingsPath = path.join(tempDir, 'xai.settings.json'); + const grokSettingsPath = path.join(tempDir, 'grok.settings.json'); + fs.writeFileSync( + xaiSettingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-xai.example' } }) + ); + fs.writeFileSync( + grokSettingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-grok.example' } }) + ); + + const mockUnifiedConfig = { + version: 2, + profiles: { + xai: { settings: xaiSettingsPath, type: 'api' }, + grok: { settings: grokSettingsPath, type: 'api' }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + for (const profileName of ['xai', 'grok']) { + const result = detector.detectProfileType(profileName); + expect(result.type).toBe('settings'); + expect(result.name).toBe(profileName); + expect(result.settingsPath).toBe( + profileName === 'xai' ? xaiSettingsPath : grokSettingsPath + ); + } + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed unified xai composite instead of using the built-in shortcut', () => { + const mockUnifiedConfig = { + version: 12, + profiles: {}, + accounts: {}, + cliproxy: { + variants: { + xai: { + type: 'composite', + default_tier: 'opus', + tiers: {}, + }, + }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => detector.detectProfileType('xai')).toThrow(ConfigError); + expect(() => detector.detectProfileType('xai')).toThrow( + /Configured profile "xai" in config.yaml is invalid/ + ); + } finally { + warningSpy.mockRestore(); + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed legacy grok variant instead of using the built-in shortcut', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {}, cliproxy: { grok: {} } }, null, 2) + ); + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + + try { + const localDetector = new ProfileDetector(); + expect(() => localDetector.detectProfileType('grok')).toThrow(ConfigError); + expect(() => localDetector.detectProfileType('grok')).toThrow( + /Configured profile "grok" in config.json is invalid/ + ); + } finally { + isUnifiedModeSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + + it('should reject a malformed unified xai default instead of using the native default', () => { + const mockUnifiedConfig = { + version: 12, + default: 'xai', + profiles: {}, + accounts: {}, + cliproxy: { + variants: { + xai: { + type: 'composite', + default_tier: 'opus', + tiers: {}, + }, + }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => detector.resolveDefaultProfileResult()).toThrow(ConfigError); + expect(() => detector.resolveDefaultProfileResult()).toThrow( + /Configured profile "xai" in config.yaml is invalid/ + ); + } finally { + warningSpy.mockRestore(); + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed legacy xai account default', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify({ default: 'xai', profiles: { xai: {} } }, null, 2) + ); + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + + try { + const localDetector = new ProfileDetector(); + expect(() => localDetector.resolveDefaultProfileResult()).toThrow(ConfigError); + expect(() => localDetector.resolveDefaultProfileResult()).toThrow( + /Configured profile "xai" in profiles.json is invalid/ + ); + } finally { + isUnifiedModeSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } }); it('should detect settings-based profile from unified config', () => { diff --git a/tests/unit/commands/api-create-grandfathered-force.test.ts b/tests/unit/commands/api-create-grandfathered-force.test.ts new file mode 100644 index 00000000..8795562f --- /dev/null +++ b/tests/unit/commands/api-create-grandfathered-force.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { handleApiCreateCommand } from '../../../src/commands/api-command/create-command'; + +describe('api create grandfathered force policy', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnifiedMode: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-create-grandfathered-')); + originalCcsHome = process.env.CCS_HOME; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_UNIFIED_CONFIG; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('allows ccs api create --force to repair an exact existing xai profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleApiCreateCommand([ + 'xai', + '--force', + '--yes', + '--base-url', + 'https://new.example.com', + '--api-key', + 'new-token', + '--model', + 'new-model', + ]); + } finally { + logSpy.mockRestore(); + } + + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token'); + }); + + it('rejects ccs api create --force when the reserved profile is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + + const originalExit = process.exit; + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleApiCreateCommand([ + 'grok', + '--force', + '--yes', + '--base-url', + 'https://new.example.com', + '--api-key', + 'new-token', + '--model', + 'new-model', + ]) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalExit; + logSpy.mockRestore(); + } + + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + }); +}); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index 5e3f514c..54b5aa75 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand'; +import { + parseProfileArgs, + validateVariantCreateName, +} from '../../../src/commands/cliproxy/variant-subcommand'; describe('cliproxy variant arg parser', () => { test('parses --target value form', () => { @@ -56,3 +59,16 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual([]); }); }); + +describe('cliproxy variant create name policy', () => { + test('allows force repair of an exact existing grandfathered variant', () => { + expect(validateVariantCreateName('xai', true, true)).toBeNull(); + expect(validateVariantCreateName('GROK', true, true)).toBeNull(); + }); + + test('keeps absent, non-force, and other reserved variant names blocked', () => { + expect(validateVariantCreateName('xai', true, false)).toContain('reserved name'); + expect(validateVariantCreateName('xai', false, true)).toContain('reserved name'); + expect(validateVariantCreateName('gemini', true, true)).toContain('reserved name'); + }); +}); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index b8566a28..095195bb 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -44,6 +44,8 @@ describe('reserved-names', () => { expect(RESERVED_PROFILE_NAMES).toContain('agy'); expect(RESERVED_PROFILE_NAMES).toContain('qwen'); expect(RESERVED_PROFILE_NAMES).toContain('iflow'); + expect(RESERVED_PROFILE_NAMES).toContain('xai'); + expect(RESERVED_PROFILE_NAMES).toContain('grok'); }); it('should include CLI commands', () => { @@ -57,12 +59,16 @@ describe('reserved-names', () => { it('should return true for reserved names', () => { expect(isReservedName('gemini')).toBe(true); expect(isReservedName('codex')).toBe(true); + expect(isReservedName('xai')).toBe(true); + expect(isReservedName('grok')).toBe(true); expect(isReservedName('default')).toBe(true); }); it('should be case-insensitive', () => { expect(isReservedName('GEMINI')).toBe(true); expect(isReservedName('Codex')).toBe(true); + expect(isReservedName('XAI')).toBe(true); + expect(isReservedName('GROK')).toBe(true); expect(isReservedName('DEFAULT')).toBe(true); }); @@ -76,6 +82,8 @@ describe('reserved-names', () => { describe('validateProfileName', () => { it('should throw for reserved names', () => { expect(() => validateProfileName('gemini')).toThrow(/reserved/i); + expect(() => validateProfileName('xai')).toThrow(/reserved/i); + expect(() => validateProfileName('grok')).toThrow(/reserved/i); expect(() => validateProfileName('default')).toThrow(/reserved/i); }); diff --git a/tests/unit/web-server/profile-routes-lifecycle.test.ts b/tests/unit/web-server/profile-routes-lifecycle.test.ts index a79ae8e1..d215cf71 100644 --- a/tests/unit/web-server/profile-routes-lifecycle.test.ts +++ b/tests/unit/web-server/profile-routes-lifecycle.test.ts @@ -76,11 +76,17 @@ describe('profile-routes lifecycle endpoints', () => { it('does not register all orphans when names=[] is explicitly passed', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); fs.writeFileSync( path.join(ccsDir, 'lonely.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { @@ -142,4 +148,85 @@ describe('profile-routes lifecycle endpoints', () => { const body = (await response.json()) as { error: string }; expect(body.error).toContain('API name must start with letter'); }); + + it('keeps grandfathered profiles exportable, copyable, and removable', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const exportResponse = await fetch(`${baseUrl}/api/profiles/xai/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(exportResponse.status).toBe(200); + + const copyResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination: 'xai-backup' }), + }); + expect(copyResponse.status).toBe(201); + + const reservedDestinationResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination: 'GROK' }), + }); + expect(reservedDestinationResponse.status).toBe(400); + const reservedDestinationBody = (await reservedDestinationResponse.json()) as { + error: string; + }; + expect(reservedDestinationBody.error).toContain('reserved name'); + + const deleteResponse = await fetch(`${baseUrl}/api/profiles/xai`, { + method: 'DELETE', + }); + expect(deleteResponse.status).toBe(200); + expect(fs.existsSync(path.join(ccsDir, 'xai.settings.json'))).toBe(false); + }); + + it('recovers a grandfathered xai orphan through the route', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names: ['xai'] }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { registered: string[]; skipped: unknown[] }; + expect(body.registered).toEqual(['xai']); + expect(body.skipped).toEqual([]); + + const config = JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8')) as { + profiles: Record; + }; + expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json'); + }); });