From cc2d62db38977fd5a0597388c2882e3600e5e179 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 18:09:09 -0500 Subject: [PATCH] fix(profiles): prevent GLM auth regression from first-time install detection - Check legacy config.json/profiles.json in isFirstTimeInstall() - Use expandPath() for cross-platform path handling in profile-detector - Add pre-flight API key validation for better error messages - Enhance 401 error handling with Z.AI refresh guidance Fixes #195 --- src/auth/profile-detector.ts | 7 +- src/ccs.ts | 27 ++++++ src/commands/setup-command.ts | 87 +++++++++++++----- src/config/migration-manager.ts | 158 +++++++++++++++++++++++++++----- src/glmt/glmt-proxy.ts | 9 +- src/utils/api-key-validator.ts | 115 +++++++++++++++++++++++ 6 files changed, 353 insertions(+), 50 deletions(-) create mode 100644 src/utils/api-key-validator.ts diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 014d5407..328e7406 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { findSimilarStrings } from '../utils/helpers'; +import { findSimilarStrings, expandPath } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; @@ -62,10 +62,11 @@ export interface ProfileNotFoundError extends Error { */ /** * Load env vars from a settings file (*.settings.json). - * Expands ~ to home directory. Returns empty object on error. + * Uses expandPath() for consistent cross-platform path handling. + * Returns empty object on error. */ function loadSettingsFromFile(settingsPath: string): Record { - const expandedPath = settingsPath.replace(/^~/, os.homedir()); + const expandedPath = expandPath(settingsPath); try { if (!fs.existsSync(expandedPath)) return {}; const content = fs.readFileSync(expandedPath, 'utf8'); diff --git a/src/ccs.ts b/src/ccs.ts index ced3fc28..9999428b 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; +import { validateGlmKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { @@ -508,6 +509,32 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); + // Pre-flight validation for GLM/GLMT profiles + if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { + const preflightSettingsPath = getSettingsPath(profileInfo.name); + const preflightSettings = loadSettings(preflightSettingsPath); + const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN']; + + if (apiKey) { + const validation = await validateGlmKey( + apiKey, + preflightSettings.env?.['ANTHROPIC_BASE_URL'] + ); + + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index dc0c5801..5b7acf5a 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -13,6 +13,8 @@ */ 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 { loadOrCreateUnifiedConfig, @@ -21,6 +23,7 @@ import { hasUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; +import { getCcsDir } from '../utils/config-manager'; /** Custom error for user cancellation (Ctrl+C) */ class UserCancelledError extends Error { @@ -112,39 +115,75 @@ async function selectOption( /** * Check if this is a first-time install (config exists but is empty/unconfigured) * Returns true if user should be prompted to run setup wizard + * + * IMPORTANT: Also checks legacy config.json for existing profiles to avoid + * treating users with existing GLM/Kimi setups as "first-time installs" + * (Fix for issue #195 - GLM auth persistence regression) */ export function isFirstTimeInstall(): boolean { - // No config at all → definitely first time - if (!hasUnifiedConfig()) { - return true; - } + // Check unified config first (config.yaml) + if (hasUnifiedConfig()) { + const loaded = loadUnifiedConfig(); - // Try loading config directly to detect corruption - const loaded = loadUnifiedConfig(); - if (loaded === null) { // Config exists but is corrupted/invalid - don't treat as first-time - // User should fix or delete the file, or use --force - console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); - console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); - return false; + if (loaded === null) { + console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); + console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); + return false; + } + + // Check for any meaningful configuration in unified config + const hasProfiles = Object.keys(loaded.profiles || {}).length > 0; + const hasAccounts = Object.keys(loaded.accounts || {}).length > 0; + const hasVariants = Object.keys(loaded.cliproxy?.variants || {}).length > 0; + const hasOAuthAccounts = Object.keys(loaded.cliproxy?.oauth_accounts || {}).length > 0; + const hasRemoteProxy = + loaded.cliproxy_server?.remote?.enabled && loaded.cliproxy_server?.remote?.host; + + // If any of these exist in unified config, user has configured something + if (hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy) { + return false; + } } - // Config exists and is valid - check if it's meaningfully configured - const config = loaded; + // Also check legacy config.json for existing profiles + // This prevents treating users with GLM/Kimi in config.json as "first-time installs" + const ccsDir = getCcsDir(); + const legacyConfigPath = path.join(ccsDir, 'config.json'); - // Check for any meaningful configuration - const hasProfiles = Object.keys(config.profiles || {}).length > 0; - const hasAccounts = Object.keys(config.accounts || {}).length > 0; - const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0; - const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0; - const hasRemoteProxy = - config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host; + if (fs.existsSync(legacyConfigPath)) { + try { + const content = fs.readFileSync(legacyConfigPath, 'utf8'); + const legacyConfig = JSON.parse(content) as { profiles?: Record }; - // If any of these exist, user has configured something - const isConfigured = - hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy; + if (legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0) { + // Has legacy profiles - NOT first time + return false; + } + } catch { + // Legacy config exists but is invalid - ignore and continue + } + } - return !isConfigured; + // Also check profiles.json for existing accounts + const legacyProfilesPath = path.join(ccsDir, 'profiles.json'); + + if (fs.existsSync(legacyProfilesPath)) { + try { + const content = fs.readFileSync(legacyProfilesPath, 'utf8'); + const legacyProfiles = JSON.parse(content) as { profiles?: Record }; + + if (legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0) { + // Has legacy accounts - NOT first time + return false; + } + } catch { + // Legacy profiles exists but is invalid - ignore and continue + } + } + + // No meaningful configuration found anywhere + return true; } /** diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 142c57e6..e45245d7 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -17,7 +17,7 @@ import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types'; import { createEmptyUnifiedConfig } from './unified-config-types'; -import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader'; +import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -44,6 +44,47 @@ export function needsMigration(): boolean { return hasOldConfig && !hasNewConfig; } +/** + * Check if there are legacy profiles that haven't been migrated to config.yaml. + * This catches the case where config.yaml exists but is empty/missing profiles + * that are still in config.json. + * + * Used by autoMigrate() to trigger inline migration when needed. + * (Fix for issue #195 - GLM auth persistence regression) + */ +export function needsProfileMigration(): boolean { + const ccsDir = getCcsDir(); + const configJsonPath = path.join(ccsDir, 'config.json'); + + // No legacy config → nothing to migrate + if (!fs.existsSync(configJsonPath)) { + return false; + } + + const legacyConfig = readJsonSafe(configJsonPath); + const unifiedConfig = loadUnifiedConfig(); + + // No legacy profiles → nothing to migrate + if (!legacyConfig?.profiles || typeof legacyConfig.profiles !== 'object') { + return false; + } + + // No unified config → needs full migration (handled by needsMigration) + if (!unifiedConfig) { + return false; + } + + // Check if any legacy profile is missing from unified config + const legacyProfiles = legacyConfig.profiles as Record; + for (const profileName of Object.keys(legacyProfiles)) { + if (!unifiedConfig.profiles[profileName]) { + return true; // Found unmigrated profile + } + } + + return false; +} + /** * Get list of backup directories. */ @@ -326,6 +367,9 @@ async function performBackup(srcDir: string, backupDir: string): Promise { * Auto-migrate on first run after update. * Silent if already migrated or no config exists. * Shows friendly message with backup location on success. + * + * Also handles inline profile migration when config.yaml exists but is missing + * profiles from config.json (Fix for issue #195). */ export async function autoMigrate(): Promise { // Skip in test environment @@ -333,30 +377,100 @@ export async function autoMigrate(): Promise { return; } - // Skip if no migration needed - if (!needsMigration()) { + // Check if full migration is needed (no config.yaml exists) + if (needsMigration()) { + const result = await migrate(false); + + if (result.success) { + console.log(''); + console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); + console.log(` Backup: ${result.backupPath}`); + console.log(` Items: ${result.migratedFiles.length} migrated`); + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(warn(warning)); + } + } + console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); + console.log(''); + } else { + console.log(''); + console.log(infoBox('Migration failed - using legacy config', 'WARNING')); + console.log(` Error: ${result.error}`); + console.log(' Retry: ccs migrate'); + console.log(''); + } return; } - const result = await migrate(false); - - if (result.success) { - console.log(''); - console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); - console.log(` Backup: ${result.backupPath}`); - console.log(` Items: ${result.migratedFiles.length} migrated`); - if (result.warnings.length > 0) { - for (const warning of result.warnings) { - console.log(warn(warning)); - } + // Check if inline profile migration is needed (config.yaml exists but missing profiles) + if (needsProfileMigration()) { + const result = await migrateProfilesToUnified(); + if (result.success && result.migratedFiles.length > 0) { + console.log(''); + console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS')); + console.log(` Profiles: ${result.migratedFiles.join(', ')}`); + console.log(''); } - console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); - console.log(''); - } else { - console.log(''); - console.log(infoBox('Migration failed - using legacy config', 'WARNING')); - console.log(` Error: ${result.error}`); - console.log(' Retry: ccs migrate'); - console.log(''); + } +} + +/** + * Migrate only profiles from config.json to existing config.yaml. + * Used when config.yaml exists but is missing profiles. + */ +async function migrateProfilesToUnified(): Promise { + const ccsDir = getCcsDir(); + const migratedFiles: string[] = []; + const warnings: string[] = []; + + try { + const oldConfig = readJsonSafe(path.join(ccsDir, 'config.json')); + const unifiedConfig = loadUnifiedConfig(); + + if (!oldConfig?.profiles || !unifiedConfig) { + return { success: true, migratedFiles, warnings }; + } + + let modified = false; + + // Migrate API profiles from config.json + for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + // Skip if already in unified config + if (unifiedConfig.profiles[name]) { + continue; + } + + const pathStr = settingsPath as string; + const expandedPath = expandPath(pathStr); + + // Verify settings file exists + if (!fs.existsSync(expandedPath)) { + warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`); + continue; + } + + // Store reference to settings file + unifiedConfig.profiles[name] = { + type: 'api', + settings: pathStr, + }; + migratedFiles.push(name); + modified = true; + } + + // Save if modified + if (modified) { + saveUnifiedConfig(unifiedConfig); + } + + return { success: true, migratedFiles, warnings }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + migratedFiles, + warnings, + }; } } diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index 9edb832d..d1170f6e 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -587,7 +587,14 @@ export class GlmtProxy { statusCode: 401, type: 'authentication_error', message: - 'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.', + 'API key rejected by Z.AI. This can happen when:\n' + + ' 1. Key expired on Z.AI server (most common after idle periods)\n' + + ' 2. Key was revoked or regenerated\n' + + ' 3. Key is missing or malformed\n\n' + + 'To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM -> Update key', }; } diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts new file mode 100644 index 00000000..f49060f2 --- /dev/null +++ b/src/utils/api-key-validator.ts @@ -0,0 +1,115 @@ +/** + * API Key Pre-flight Validator + * + * Quick validation of API keys before Claude CLI launch. + * Catches expired keys early with actionable error messages. + */ + +import * as https from 'https'; +import { URL } from 'url'; + +export interface ValidationResult { + valid: boolean; + error?: string; + suggestion?: string; +} + +/** Default placeholders that indicate unconfigured keys */ +const DEFAULT_PLACEHOLDERS = [ + 'YOUR_GLM_API_KEY_HERE', + 'YOUR_KIMI_API_KEY_HERE', + 'YOUR_API_KEY_HERE', + 'your-api-key-here', + 'PLACEHOLDER', + '', +]; + +/** + * Validate GLM API key with quick health check + * + * @param apiKey - The ANTHROPIC_AUTH_TOKEN value + * @param baseUrl - Optional base URL (defaults to Z.AI) + * @param timeoutMs - Timeout in milliseconds (default 2000) + */ +export async function validateGlmKey( + apiKey: string, + baseUrl?: string, + timeoutMs = 2000 +): Promise { + // Skip if disabled + if (process.env.CCS_SKIP_PREFLIGHT === '1') { + return { valid: true }; + } + + // Basic format check - detect placeholders + if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) { + return { + valid: false, + error: 'API key not configured', + suggestion: + 'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' + + 'Or run: ccs config -> API Profiles -> GLM', + }; + } + + // Determine validation endpoint + // Z.AI uses /api/anthropic path, we can test with a minimal request + const targetBase = baseUrl || 'https://api.z.ai'; + let url: URL; + try { + url = new URL('/api/anthropic/v1/models', targetBase); + } catch { + // Invalid URL - fail-open + return { valid: true }; + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + // Fail-open on timeout - let Claude CLI handle it + resolve({ valid: true }); + }, timeoutMs); + + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'User-Agent': 'CCS-Preflight/1.0', + }, + }; + + const req = https.request(options, (res) => { + clearTimeout(timeout); + + if (res.statusCode === 200) { + resolve({ valid: true }); + } else if (res.statusCode === 401 || res.statusCode === 403) { + resolve({ + valid: false, + error: 'API key rejected by Z.AI', + suggestion: + 'Your key may have expired. To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM', + }); + } else { + // Other errors (404, 500, etc.) - fail-open, let Claude CLI handle + resolve({ valid: true }); + } + + // Consume response body to free resources + res.resume(); + }); + + req.on('error', () => { + clearTimeout(timeout); + // Network error - fail-open + resolve({ valid: true }); + }); + + req.end(); + }); +}