diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 38659b74..db206d45 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -26,6 +26,53 @@ import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; +function resolveCanonicalPath(targetPath: string): string { + try { + return fs.realpathSync.native(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +function isPathWithinDirectory(candidatePath: string, rootPath: string): boolean { + const normalizedCandidate = path.resolve(candidatePath); + const normalizedRoot = path.resolve(rootPath); + const relative = path.relative(normalizedRoot, normalizedCandidate); + + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +export function resolveManagedBackupPath(backupPath: string): string | null { + const resolvedInput = path.resolve(backupPath); + const baseName = path.basename(resolvedInput); + + if (!baseName.startsWith(BACKUP_DIR_PREFIX)) { + return null; + } + + try { + const stats = fs.lstatSync(resolvedInput); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return null; + } + } catch { + return null; + } + + const canonicalCcsDir = resolveCanonicalPath(getCcsDir()); + const canonicalBackupPath = resolveCanonicalPath(resolvedInput); + + if (!isPathWithinDirectory(canonicalBackupPath, canonicalCcsDir)) { + return null; + } + + if (path.dirname(canonicalBackupPath) !== canonicalCcsDir) { + return null; + } + + return canonicalBackupPath; +} + /** * Migration result with details about what was migrated. */ @@ -112,6 +159,7 @@ export function getBackupDirectories(): string[] { .readdirSync(ccsDir) .filter((name) => name.startsWith(BACKUP_DIR_PREFIX)) .map((name) => path.join(ccsDir, name)) + .filter((backupPath) => resolveManagedBackupPath(backupPath) !== null) .sort() .reverse(); // Most recent first } @@ -327,9 +375,10 @@ export async function migrate(dryRun = false): Promise { */ export async function rollback(backupPath: string): Promise { const ccsDir = getCcsDir(); + const managedBackupPath = resolveManagedBackupPath(backupPath); - if (!fs.existsSync(backupPath)) { - console.error(`[X] Backup not found: ${backupPath}`); + if (!managedBackupPath) { + console.error(`[X] Invalid backup path: ${backupPath}`); return false; } @@ -356,9 +405,9 @@ export async function rollback(backupPath: string): Promise { } // Restore files from backup - const files = fs.readdirSync(backupPath); + const files = fs.readdirSync(managedBackupPath); for (const file of files) { - fs.copyFileSync(path.join(backupPath, file), path.join(ccsDir, file)); + fs.copyFileSync(path.join(managedBackupPath, file), path.join(ccsDir, file)); } return true; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index b4054c22..e468c2d6 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -149,12 +149,7 @@ class SharedManager { /** * Detect circular symlink before creation */ - private detectCircularSymlink(target: string, linkPath: string): boolean { - // Check if target exists and is symlink - if (!fs.existsSync(target)) { - return false; - } - + private detectCircularSymlink(target: string): boolean { try { const stats = fs.lstatSync(target); if (!stats.isSymbolicLink()) { @@ -164,22 +159,31 @@ class SharedManager { // Resolve target's link const targetLink = fs.readlinkSync(target); const resolvedTarget = path.resolve(path.dirname(target), targetLink); + const sharedDirPath = path.resolve(this.sharedDir); - // Check if target points back to our shared dir or link path - const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared')); - const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget); - const canonicalLinkPath = this.resolveCanonicalPath(linkPath); - - if ( - this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir) || - canonicalResolvedTarget === canonicalLinkPath - ) { + // A raw target path pointing back into ~/.ccs/shared is already unsafe. + // Re-pointing ~/.ccs/shared/* to ~/.claude/* would turn it into a real loop, + // even if the current ~/.ccs/shared entry ultimately resolves to an external path. + if (this.isPathWithinDirectory(resolvedTarget, sharedDirPath)) { console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); return true; } - } catch (_err) { - // If can't read, assume not circular - return false; + + // Only treat targets inside the managed shared root as circular. + // Existing shared symlinks may already resolve through ~/.claude/ to an + // external repo, which is a supported upgrade path rather than a loop. + const sharedDir = this.resolveCanonicalPath(sharedDirPath); + const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget); + + if (this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir)) { + console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); + return true; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw err; } return false; @@ -191,13 +195,13 @@ class SharedManager { */ ensureSharedDirectories(): void { // Create ~/.claude/ if missing - if (!fs.existsSync(this.claudeDir)) { + if (!this.getLstatSync(this.claudeDir)) { console.log(info('Creating ~/.claude/ directory structure')); fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 }); } // Create shared directory - if (!fs.existsSync(this.sharedDir)) { + if (!this.getLstatSync(this.sharedDir)) { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); } @@ -209,7 +213,7 @@ class SharedManager { const sharedPath = path.join(this.sharedDir, item.name); // Create in ~/.claude/ if missing - if (!fs.existsSync(claudePath)) { + if (!this.getLstatSync(claudePath)) { if (item.type === 'directory') { fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); } else if (item.type === 'file') { @@ -219,13 +223,13 @@ class SharedManager { } // Check for circular symlink - if (this.detectCircularSymlink(claudePath, sharedPath)) { + if (this.detectCircularSymlink(claudePath)) { console.log(warn(`Skipping ${item.name}: circular symlink detected`)); continue; } // If already a symlink pointing to correct target, skip - if (fs.existsSync(sharedPath)) { + if (this.getLstatSync(sharedPath)) { try { const stats = fs.lstatSync(sharedPath); if (stats.isSymbolicLink()) { @@ -1641,6 +1645,17 @@ class SharedManager { } } + private getLstatSync(targetPath: string): fs.Stats | null { + try { + return fs.lstatSync(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } + } + /** * Copy directory as fallback (Windows without Developer Mode) */ diff --git a/src/utils/sensitive-keys.ts b/src/utils/sensitive-keys.ts index d163f90d..b09ddbf7 100644 --- a/src/utils/sensitive-keys.ts +++ b/src/utils/sensitive-keys.ts @@ -11,10 +11,12 @@ */ export const SENSITIVE_KEY_PATTERNS = [ /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_TOKEN$/, // Keys ending with _TOKEN /_API_KEY$/, // Keys ending with _API_KEY /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN /_SECRET$/, // Keys ending with _SECRET /_SECRET_KEY$/, // Keys ending with _SECRET_KEY + /^TOKEN$/, // Exact match for TOKEN /^API_KEY$/, // Exact match for API_KEY /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN /^SECRET$/, // Exact match for SECRET diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 7b3ed8fd..60b0cc44 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import { hasUnifiedConfig, loadUnifiedConfig, - saveUnifiedConfig, + mutateUnifiedConfig, getConfigFormat, getConfigYamlPath, } from '../../config/unified-config-loader'; @@ -16,15 +16,498 @@ import { migrate, rollback, getBackupDirectories, + resolveManagedBackupPath, } from '../../config/migration-manager'; +import type { UnifiedConfig } from '../../config/unified-config-types'; import { isUnifiedConfig } from '../../config/unified-config-types'; import { DEFAULT_ACCOUNT_CONTINUITY_MODE, isValidContextGroupName, normalizeContextGroupName, } from '../../auth/account-context'; +import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../../config/unified-config-types'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { isSensitiveKey } from '../../utils/sensitive-keys'; const router = Router(); +const LOCAL_CONFIG_ERROR = + 'Local configuration endpoints require localhost access when dashboard auth is disabled.'; +const REDACTED_SECRET_VALUE = '[redacted]'; +const RAW_YAML_REDACTED_PATHS = new Set([ + 'cliproxy.auth.api_key', + 'cliproxy.auth.management_secret', + 'cliproxy_server.remote.auth_token', + 'cliproxy_server.remote.management_key', + 'dashboard_auth.password_hash', +]); + +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, LOCAL_CONFIG_ERROR)) { + next(); + } +}); + +function redactSecretValue(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + return value.length > 0 ? REDACTED_SECRET_VALUE : value; +} + +function sanitizeUnifiedConfigForDashboard(config: UnifiedConfig): UnifiedConfig { + return { + ...config, + global_env: config.global_env + ? { + ...config.global_env, + env: Object.fromEntries( + Object.entries(config.global_env.env).map(([key, value]) => [ + key, + isSensitiveKey(key) && value ? REDACTED_SECRET_VALUE : value, + ]) + ), + } + : config.global_env, + cliproxy: { + ...config.cliproxy, + auth: sanitizeCliproxyAuthConfig(config.cliproxy.auth), + variants: Object.fromEntries( + Object.entries(config.cliproxy.variants).map(([variantName, variantConfig]) => [ + variantName, + sanitizeCliproxyVariantAuth(variantConfig), + ]) + ), + }, + cliproxy_server: config.cliproxy_server + ? { + ...config.cliproxy_server, + remote: { + ...config.cliproxy_server.remote, + auth_token: redactSecretValue(config.cliproxy_server.remote.auth_token) ?? '', + management_key: redactSecretValue(config.cliproxy_server.remote.management_key), + }, + } + : config.cliproxy_server, + dashboard_auth: config.dashboard_auth + ? { + ...config.dashboard_auth, + password_hash: redactSecretValue(config.dashboard_auth.password_hash) ?? '', + } + : config.dashboard_auth, + }; +} + +function sanitizeCliproxyAuthConfig( + auth: UnifiedConfig['cliproxy']['auth'] +): UnifiedConfig['cliproxy']['auth'] { + if (!auth) { + return auth; + } + + return { + ...auth, + api_key: redactSecretValue(auth.api_key), + management_secret: redactSecretValue(auth.management_secret), + }; +} + +function sanitizeCliproxyVariantAuth( + variantConfig: UnifiedConfig['cliproxy']['variants'][string] +): UnifiedConfig['cliproxy']['variants'][string] { + return { + ...variantConfig, + auth: sanitizeCliproxyAuthConfig(variantConfig.auth), + }; +} + +function normalizeYamlKey(rawKey: string): string { + const key = rawKey.trim(); + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { + return key.slice(1, -1); + } + return key; +} + +function splitYamlScalarAndComment(valuePortion: string): { + value: string; + comment: string; +} { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < valuePortion.length; index += 1) { + const char = valuePortion[index]; + + if (char === "'" && !inDoubleQuote) { + if (inSingleQuote && valuePortion[index + 1] === "'") { + index += 1; + continue; + } + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote && valuePortion[index - 1] !== '\\') { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if ( + char === '#' && + !inSingleQuote && + !inDoubleQuote && + (index === 0 || /\s/.test(valuePortion[index - 1])) + ) { + const prefix = valuePortion.slice(0, index); + const spacing = prefix.match(/\s*$/)?.[0] ?? ''; + return { + value: prefix.trimEnd(), + comment: `${spacing}${valuePortion.slice(index)}`, + }; + } + } + + return { + value: valuePortion.trimEnd(), + comment: '', + }; +} + +function redactYamlScalarLine(line: string): string { + const match = line.match(/^(\s*[^:#][^:]*:\s*)(.*)$/); + if (!match) { + return line; + } + + const [, prefix, rawTail] = match; + const leadingSpacing = rawTail.match(/^\s*/)?.[0] ?? ''; + const { value, comment } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length)); + const trimmedValue = value.trim(); + + if (trimmedValue.length === 0 || trimmedValue === '""' || trimmedValue === "''") { + return line; + } + + const quote = + trimmedValue.startsWith('"') && trimmedValue.endsWith('"') + ? '"' + : trimmedValue.startsWith("'") && trimmedValue.endsWith("'") + ? "'" + : ''; + const redactedValue = quote ? `${quote}${REDACTED_SECRET_VALUE}${quote}` : REDACTED_SECRET_VALUE; + + return `${prefix}${leadingSpacing}${redactedValue}${comment}`; +} + +function isYamlBlockScalarIndicator(value: string): boolean { + const trimmedValue = value.trim(); + if (!(trimmedValue.startsWith('|') || trimmedValue.startsWith('>'))) { + return false; + } + + const indicators = trimmedValue.slice(1); + if (indicators.length === 0) { + return true; + } + + if (indicators.length > 2) { + return false; + } + + let sawChompingIndicator = false; + let sawIndentIndicator = false; + + for (const indicator of indicators) { + if ((indicator === '+' || indicator === '-') && !sawChompingIndicator) { + sawChompingIndicator = true; + continue; + } + + if (/[1-9]/.test(indicator) && !sawIndentIndicator) { + sawIndentIndicator = true; + continue; + } + + return false; + } + + return true; +} + +function shouldRedactRawYamlPath(pathKeys: string[]): boolean { + if ( + pathKeys.length === 3 && + pathKeys[0] === 'global_env' && + pathKeys[1] === 'env' && + isSensitiveKey(pathKeys[2]) + ) { + return true; + } + + if ( + pathKeys.length === 3 && + pathKeys[0] === 'cliproxy' && + pathKeys[1] === 'auth' && + (pathKeys[2] === 'api_key' || pathKeys[2] === 'management_secret') + ) { + return true; + } + + if ( + pathKeys.length === 5 && + pathKeys[0] === 'cliproxy' && + pathKeys[1] === 'variants' && + pathKeys[3] === 'auth' && + (pathKeys[4] === 'api_key' || pathKeys[4] === 'management_secret') + ) { + return true; + } + + return RAW_YAML_REDACTED_PATHS.has(pathKeys.join('.')); +} + +function redactRawConfigYamlForDashboard(content: string): string { + const stack: Array<{ indent: number; key: string }> = []; + const redactedLines: string[] = []; + const lines = content.split('\n'); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/); + if (!mappingMatch) { + redactedLines.push(line); + continue; + } + + const [, indentText, rawKey, rawTail] = mappingMatch; + if (rawKey.trimStart().startsWith('-')) { + redactedLines.push(line); + continue; + } + + const indent = indentText.length; + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + + stack.push({ indent, key: normalizeYamlKey(rawKey) }); + const currentPath = stack.map((entry) => entry.key); + + if (!shouldRedactRawYamlPath(currentPath)) { + redactedLines.push(line); + continue; + } + + const redactedLine = redactYamlScalarLine(line); + redactedLines.push(redactedLine); + + const leadingSpacing = rawTail.match(/^\s*/)?.[0] ?? ''; + const { value } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length)); + const trimmedValue = value.trim(); + + if (!isYamlBlockScalarIndicator(trimmedValue)) { + continue; + } + + while (index + 1 < lines.length) { + const nextLine = lines[index + 1]; + if (nextLine.trim().length === 0) { + index += 1; + continue; + } + + const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0; + if (nextIndent <= indent) { + break; + } + + index += 1; + } + } + + return redactedLines.join('\n'); +} + +function restoreRedactedSecretValue( + currentValue: string | undefined, + nextValue: string | undefined +): string | undefined { + if (nextValue === undefined || nextValue === REDACTED_SECRET_VALUE) { + return currentValue; + } + + return nextValue; +} + +function mergeGlobalEnvConfig( + currentConfig: UnifiedConfig, + nextConfig: Partial +): UnifiedConfig['global_env'] { + const nextGlobalEnv = nextConfig.global_env; + if (!nextGlobalEnv) { + return currentConfig.global_env; + } + + const currentEnv = currentConfig.global_env?.env ?? {}; + const nextEnv = nextGlobalEnv.env; + + return { + enabled: nextGlobalEnv.enabled ?? currentConfig.global_env?.enabled ?? true, + env: + nextEnv === undefined + ? currentEnv + : Object.fromEntries( + Object.entries(nextEnv).map(([key, value]) => { + if (value === REDACTED_SECRET_VALUE && isSensitiveKey(key)) { + return [key, currentEnv[key] ?? value]; + } + return [key, value]; + }) + ), + }; +} + +function mergeCliproxyServerConfig( + currentConfig: UnifiedConfig, + nextConfig: Partial +): UnifiedConfig['cliproxy_server'] { + const nextServer = nextConfig.cliproxy_server; + if (!nextServer) { + return currentConfig.cliproxy_server; + } + + const nextRemote = nextServer.remote; + const currentServer = currentConfig.cliproxy_server ?? DEFAULT_CLIPROXY_SERVER_CONFIG; + + return { + remote: + nextRemote === undefined + ? currentServer.remote + : { + ...nextRemote, + auth_token: + restoreRedactedSecretValue(currentServer.remote.auth_token, nextRemote.auth_token) ?? + '', + management_key: restoreRedactedSecretValue( + currentServer.remote.management_key, + nextRemote.management_key + ), + }, + fallback: nextServer.fallback ?? currentServer.fallback, + local: nextServer.local ?? currentServer.local, + }; +} + +function mergeDashboardAuthConfig( + currentConfig: UnifiedConfig, + nextConfig: Partial +): UnifiedConfig['dashboard_auth'] { + const nextAuth = nextConfig.dashboard_auth; + if (!nextAuth) { + return currentConfig.dashboard_auth; + } + + return { + ...nextAuth, + password_hash: + restoreRedactedSecretValue( + currentConfig.dashboard_auth?.password_hash, + nextAuth.password_hash + ) ?? '', + }; +} + +function mergeCliproxyAuthField( + currentValue: string | undefined, + nextAuth: NonNullable, + key: 'api_key' | 'management_secret' +): string | undefined { + if (!Object.prototype.hasOwnProperty.call(nextAuth, key)) { + return undefined; + } + + return restoreRedactedSecretValue(currentValue, nextAuth[key]); +} + +function mergeCliproxyAuthConfig( + currentAuth: UnifiedConfig['cliproxy']['auth'], + nextAuth: UnifiedConfig['cliproxy']['auth'] +): UnifiedConfig['cliproxy']['auth'] { + if (!nextAuth) { + return currentAuth; + } + + const mergedAuth = { ...nextAuth }; + const apiKey = mergeCliproxyAuthField(currentAuth?.api_key, nextAuth, 'api_key'); + const managementSecret = mergeCliproxyAuthField( + currentAuth?.management_secret, + nextAuth, + 'management_secret' + ); + + if (apiKey === undefined) { + delete mergedAuth.api_key; + } else { + mergedAuth.api_key = apiKey; + } + + if (managementSecret === undefined) { + delete mergedAuth.management_secret; + } else { + mergedAuth.management_secret = managementSecret; + } + + return Object.keys(mergedAuth).length > 0 ? mergedAuth : undefined; +} + +function mergeCliproxyVariantConfig( + currentVariant: UnifiedConfig['cliproxy']['variants'][string] | undefined, + nextVariant: UnifiedConfig['cliproxy']['variants'][string] +): UnifiedConfig['cliproxy']['variants'][string] { + const mergedVariant = { ...nextVariant }; + const mergedAuth = mergeCliproxyAuthConfig(currentVariant?.auth, nextVariant.auth); + + if (mergedAuth === undefined) { + delete mergedVariant.auth; + } else { + mergedVariant.auth = mergedAuth; + } + + return mergedVariant; +} + +function mergeCliproxyConfig( + currentConfig: UnifiedConfig, + nextConfig: Partial +): UnifiedConfig['cliproxy'] { + const nextCliproxy = nextConfig.cliproxy; + if (!nextCliproxy) { + return currentConfig.cliproxy; + } + + const mergedCliproxy = { + ...nextCliproxy, + variants: + nextCliproxy.variants === undefined + ? currentConfig.cliproxy.variants + : Object.fromEntries( + Object.entries(nextCliproxy.variants).map(([variantName, nextVariant]) => [ + variantName, + mergeCliproxyVariantConfig(currentConfig.cliproxy.variants[variantName], nextVariant), + ]) + ), + }; + const mergedAuth = mergeCliproxyAuthConfig(currentConfig.cliproxy.auth, nextCliproxy.auth); + + if (mergedAuth === undefined) { + delete mergedCliproxy.auth; + } else { + mergedCliproxy.auth = mergedAuth; + } + + return mergedCliproxy; +} function validateAndNormalizeAccountContextMetadata(config: unknown): string | null { if (typeof config !== 'object' || config === null) { @@ -130,7 +613,7 @@ router.get('/', (_req: Request, res: Response): void => { return; } - res.json(config); + res.json(sanitizeUnifiedConfigForDashboard(config)); }); /** @@ -142,9 +625,10 @@ router.get('/raw', (_req: Request, res: Response): void => { res.status(404).json({ error: 'Config file not found' }); return; } + try { const content = fs.readFileSync(yamlPath, 'utf8'); - res.type('text/plain').send(content); + res.type('text/plain').send(redactRawConfigYamlForDashboard(content)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } @@ -154,7 +638,7 @@ router.get('/raw', (_req: Request, res: Response): void => { * PUT /api/config - Update unified config */ router.put('/', (req: Request, res: Response): void => { - const config = req.body; + const config = req.body as Partial; if (!isUnifiedConfig(config)) { res.status(400).json({ error: 'Invalid config format' }); @@ -168,7 +652,72 @@ router.put('/', (req: Request, res: Response): void => { } try { - saveUnifiedConfig(config); + mutateUnifiedConfig((currentConfig) => { + if ('setup_completed' in config) { + currentConfig.setup_completed = config.setup_completed; + } + + if ('default' in config) { + currentConfig.default = config.default; + } + + if (config.accounts !== undefined) { + currentConfig.accounts = config.accounts; + } + + if (config.profiles !== undefined) { + currentConfig.profiles = config.profiles; + } + + if (config.cliproxy !== undefined) { + currentConfig.cliproxy = mergeCliproxyConfig(currentConfig, config); + } + + if (config.preferences !== undefined) { + currentConfig.preferences = config.preferences; + } + + if (config.websearch !== undefined) { + currentConfig.websearch = config.websearch; + } + + if (config.continuity !== undefined) { + currentConfig.continuity = config.continuity; + } + + if (config.copilot !== undefined) { + currentConfig.copilot = config.copilot; + } + + if (config.cursor !== undefined) { + currentConfig.cursor = config.cursor; + } + + if (config.quota_management !== undefined) { + currentConfig.quota_management = config.quota_management; + } + + if (config.thinking !== undefined) { + currentConfig.thinking = config.thinking; + } + + if (config.image_analysis !== undefined) { + currentConfig.image_analysis = config.image_analysis; + } + + if (config.global_env !== undefined) { + currentConfig.global_env = mergeGlobalEnvConfig(currentConfig, config); + } + + if (config.cliproxy_server !== undefined) { + currentConfig.cliproxy_server = mergeCliproxyServerConfig(currentConfig, config); + } + + if (config.dashboard_auth !== undefined) { + currentConfig.dashboard_auth = mergeDashboardAuthConfig(currentConfig, config); + } + }); + res.json({ success: true }); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -209,7 +758,15 @@ router.post('/rollback', async (req: Request, res: Response): Promise => { return; } - const success = await rollback(backupPath); + const managedBackupPath = resolveManagedBackupPath(backupPath); + if (!managedBackupPath) { + res.status(400).json({ + error: 'Invalid backupPath. Must reference a managed CCS migration backup directory.', + }); + return; + } + + const success = await rollback(managedBackupPath); res.json({ success }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index c05cdb51..92a8ec3a 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager'; +import { + loadMigrationCheckData, + migrate, + resolveManagedBackupPath, + rollback, +} from '../../../src/config/migration-manager'; import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; @@ -61,13 +66,13 @@ describe('migration-manager legacy kimi compatibility', () => { expect(result.success).toBe(true); expect( result.migratedFiles.some((entry) => - entry.includes(`config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})`) + entry.includes( + `config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})` + ) ) ).toBe(true); expect( - result.migratedFiles.some((entry) => - entry.includes(`(settings: ${kimiSettingsPath})`) - ) + result.migratedFiles.some((entry) => entry.includes(`(settings: ${kimiSettingsPath})`)) ).toBe(false); expect( result.warnings.some((warning) => @@ -215,4 +220,47 @@ describe('migration-manager legacy kimi compatibility', () => { expect(unified?.accounts.work.continuity_mode).toBe('standard'); expect(unified?.accounts.broken.continuity_mode).toBe('standard'); }); + + it('resolves only direct managed backup directories under ~/.ccs', () => { + const managedBackupPath = path.join(ccsDir, 'backup-v1-2026-03-24'); + const externalBackupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-backup-external-')); + const symlinkedBackupPath = path.join(ccsDir, 'backup-v1-symlink'); + + fs.mkdirSync(managedBackupPath, { recursive: true }); + fs.symlinkSync(externalBackupPath, symlinkedBackupPath, 'dir'); + + expect(resolveManagedBackupPath(managedBackupPath)).toBe( + fs.realpathSync.native(managedBackupPath) + ); + expect(resolveManagedBackupPath(externalBackupPath)).toBeNull(); + expect(resolveManagedBackupPath(symlinkedBackupPath)).toBeNull(); + + fs.rmSync(externalBackupPath, { recursive: true, force: true }); + }); + + it('rejects rollback from directories outside managed CCS backups', async () => { + const externalBackupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-backup-invalid-')); + + expect(await rollback(externalBackupPath)).toBe(false); + + fs.rmSync(externalBackupPath, { recursive: true, force: true }); + }); + + it('restores files only from managed CCS backup directories', async () => { + const backupPath = path.join(ccsDir, 'backup-v1-2026-03-24'); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync(path.join(backupPath, 'config.json'), JSON.stringify({ restored: true })); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 8\n'); + fs.mkdirSync(path.join(ccsDir, 'cache'), { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'cache', 'usage.json'), '{}'); + + const success = await rollback(backupPath); + + expect(success).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'config.yaml'))).toBe(false); + expect(JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8'))).toEqual({ + restored: true, + }); + expect(fs.existsSync(path.join(ccsDir, 'usage-cache.json'))).toBe(true); + }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 3cd142f8..240769c6 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -154,6 +154,127 @@ describe('SharedManager', () => { }); }); + describe('shared symlink lifecycle', () => { + it('does not rewrite inverse shared symlink chains into a real loop', () => { + const manager = new SharedManager(); + const externalCommandsDir = path.join(tempRoot, 'Documents', 'claude-config', 'commands'); + const claudeCommandsPath = path.join(claudeDir(), 'commands'); + const sharedCommandsPath = path.join(ccsDir(), 'shared', 'commands'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + fs.mkdirSync(externalCommandsDir, { recursive: true }); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + fs.symlinkSync(sharedCommandsPath, claudeCommandsPath, 'dir'); + fs.symlinkSync(externalCommandsDir, sharedCommandsPath, 'dir'); + + manager.ensureSharedDirectories(); + + expect( + logSpy.mock.calls.some(([message]) => + String(message).includes('Skipping commands: circular symlink detected') + ) + ).toBe(true); + expect(fs.lstatSync(claudeCommandsPath).isSymbolicLink()).toBe(true); + expect( + path.resolve(path.dirname(claudeCommandsPath), fs.readlinkSync(claudeCommandsPath)) + ).toBe(sharedCommandsPath); + expect(fs.lstatSync(sharedCommandsPath).isSymbolicLink()).toBe(true); + expect( + path.resolve(path.dirname(sharedCommandsPath), fs.readlinkSync(sharedCommandsPath)) + ).toBe(externalCommandsDir); + }); + + it('preserves external ~/.claude symlinks during upgrade reconciliation', () => { + const manager = new SharedManager(); + const externalCommandsDir = path.join(tempRoot, 'Documents', 'claude-config', 'commands'); + const externalSettingsPath = path.join( + tempRoot, + 'Documents', + 'claude-config', + 'settings.json' + ); + const claudeCommandsPath = path.join(claudeDir(), 'commands'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const sharedCommandsPath = path.join(ccsDir(), 'shared', 'commands'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + fs.mkdirSync(externalCommandsDir, { recursive: true }); + fs.mkdirSync(path.dirname(externalSettingsPath), { recursive: true }); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + fs.writeFileSync(externalSettingsPath, JSON.stringify({ theme: 'dark' }), 'utf8'); + fs.symlinkSync(externalCommandsDir, claudeCommandsPath, 'dir'); + fs.symlinkSync(externalSettingsPath, claudeSettingsPath, 'file'); + fs.symlinkSync(claudeCommandsPath, sharedCommandsPath, 'dir'); + fs.symlinkSync(claudeSettingsPath, sharedSettingsPath, 'file'); + + manager.ensureSharedDirectories(); + + expect( + logSpy.mock.calls.some( + ([message]) => + String(message).includes('Skipping commands: circular symlink detected') || + String(message).includes('Skipping settings.json: circular symlink detected') + ) + ).toBe(false); + expect(fs.lstatSync(sharedCommandsPath).isSymbolicLink()).toBe(true); + expect( + path.resolve(path.dirname(sharedCommandsPath), fs.readlinkSync(sharedCommandsPath)) + ).toBe(claudeCommandsPath); + expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true); + expect( + path.resolve(path.dirname(sharedSettingsPath), fs.readlinkSync(sharedSettingsPath)) + ).toBe(claudeSettingsPath); + }); + + it('still blocks real circular links back into ~/.ccs/shared', () => { + const manager = new SharedManager(); + const claudeCommandsPath = path.join(claudeDir(), 'commands'); + const sharedCommandsPath = path.join(ccsDir(), 'shared', 'commands'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(sharedCommandsPath, { recursive: true }); + fs.symlinkSync(sharedCommandsPath, claudeCommandsPath, 'dir'); + + manager.ensureSharedDirectories(); + + expect( + logSpy.mock.calls.some(([message]) => + String(message).includes('Skipping commands: circular symlink detected') + ) + ).toBe(true); + expect(fs.lstatSync(sharedCommandsPath).isDirectory()).toBe(true); + }); + + it('does not materialize dangling external settings symlinks', () => { + const manager = new SharedManager(); + const externalSettingsPath = path.join( + tempRoot, + 'Documents', + 'claude-config', + 'settings.json' + ); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + + fs.mkdirSync(path.dirname(externalSettingsPath), { recursive: true }); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.symlinkSync(externalSettingsPath, claudeSettingsPath, 'file'); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(claudeSettingsPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(externalSettingsPath)).toBe(false); + expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true); + expect( + path.resolve(path.dirname(sharedSettingsPath), fs.readlinkSync(sharedSettingsPath)) + ).toBe(claudeSettingsPath); + }); + }); + describe('marketplace registry ownership', () => { it('writes global and instance registries with different authoritative install locations', () => { const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); diff --git a/tests/unit/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts index f24328ff..e6d8aa37 100644 --- a/tests/unit/web-server/config-routes-account-context.test.ts +++ b/tests/unit/web-server/config-routes-account-context.test.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import type { Server } from 'http'; import configRoutes from '../../../src/web-server/routes/config-routes'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; -import { loadUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { return fetch(`${baseUrl}${routePath}`, { @@ -31,12 +31,21 @@ async function postJson(baseUrl: string, routePath: string, body?: unknown): Pro describe('web-server config-routes account context validation', () => { let server: Server; let baseUrl = ''; + let forcedRemoteAddress = '127.0.0.1'; let tempHome = ''; let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; beforeAll(async () => { const app = express(); app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); app.use('/api/config', configRoutes); await new Promise((resolve, reject) => { @@ -63,19 +72,48 @@ describe('web-server config-routes account context validation', () => { beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-routes-context-')); originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true }); }); afterEach(() => { if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; else delete process.env.CCS_HOME; + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/config/format`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: + 'Local configuration endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('allows remote access when dashboard auth is enabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + + const response = await fetch(`${baseUrl}/api/config/format`); + + expect(response.status).toBe(200); + }); + it('rejects invalid account context_mode values', async () => { const response = await putJson(baseUrl, '/api/config', { version: 8, @@ -216,6 +254,371 @@ describe('web-server config-routes account context validation', () => { expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper'); }); + it('redacts secrets from config and raw YAML responses', async () => { + const config = createEmptyUnifiedConfig(); + config.global_env = { + enabled: true, + env: { + DEBUG: '1', + GH_TOKEN: 'gh-token-secret', + GITHUB_TOKEN: 'github-token-secret', + OPENAI_API_KEY: 'sk-test-123456', + }, + }; + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; + config.cliproxy_server = { + remote: { + enabled: true, + host: 'proxy.example.com', + protocol: 'https', + auth_token: 'remote-auth-token', + management_key: 'management-secret', + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: true, + }, + }; + config.dashboard_auth = { + enabled: true, + username: 'admin', + password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e', + session_timeout_hours: 24, + }; + saveUnifiedConfig(config); + const yamlPath = path.join(tempHome, '.ccs', 'config.yaml'); + const rawYaml = fs.readFileSync(yamlPath, 'utf8'); + fs.writeFileSync(yamlPath, `# custom comment\n${rawYaml}`); + + const response = await fetch(`${baseUrl}/api/config`); + expect(response.status).toBe(200); + const payload = (await response.json()) as typeof config; + + expect(payload.global_env?.env.DEBUG).toBe('1'); + expect(payload.global_env?.env.GH_TOKEN).toBe('[redacted]'); + expect(payload.global_env?.env.GITHUB_TOKEN).toBe('[redacted]'); + expect(payload.global_env?.env.OPENAI_API_KEY).toBe('[redacted]'); + expect(payload.cliproxy.auth?.api_key).toBe('[redacted]'); + expect(payload.cliproxy.auth?.management_secret).toBe('[redacted]'); + expect(payload.cliproxy.variants.gemini?.auth?.api_key).toBe('[redacted]'); + expect(payload.cliproxy.variants.gemini?.auth?.management_secret).toBe('[redacted]'); + expect(payload.cliproxy_server?.remote.auth_token).toBe('[redacted]'); + expect(payload.cliproxy_server?.remote.management_key).toBe('[redacted]'); + expect(payload.dashboard_auth?.password_hash).toBe('[redacted]'); + + const rawResponse = await fetch(`${baseUrl}/api/config/raw`); + expect(rawResponse.status).toBe(200); + const rawConfig = await rawResponse.text(); + + expect(rawConfig).toContain('# custom comment'); + expect(rawConfig).toContain('DEBUG: "1"'); + expect(rawConfig).toContain('GH_TOKEN: [redacted]'); + expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]'); + expect(rawConfig).toContain('OPENAI_API_KEY: [redacted]'); + expect(rawConfig).toContain('api_key: [redacted]'); + expect(rawConfig).toContain('management_secret: [redacted]'); + expect(rawConfig).not.toContain('sk-test-123456'); + expect(rawConfig).not.toContain('gh-token-secret'); + expect(rawConfig).not.toContain('github-token-secret'); + expect(rawConfig).not.toContain('cliproxy-global-api-key'); + expect(rawConfig).not.toContain('cliproxy-global-management-secret'); + expect(rawConfig).not.toContain('variant-api-key'); + expect(rawConfig).not.toContain('variant-management-secret'); + expect(rawConfig).not.toContain('remote-auth-token'); + expect(rawConfig).not.toContain('management-secret'); + }); + + it('preserves redacted secrets when saving a sanitized config payload', async () => { + const config = createEmptyUnifiedConfig(); + config.global_env = { + enabled: true, + env: { + DEBUG: '1', + OPENAI_API_KEY: 'sk-test-123456', + }, + }; + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; + config.cliproxy_server = { + remote: { + enabled: true, + host: 'proxy.example.com', + protocol: 'https', + auth_token: 'remote-auth-token', + management_key: 'management-secret', + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: true, + }, + }; + config.dashboard_auth = { + enabled: true, + username: 'admin', + password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e', + session_timeout_hours: 24, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + sanitized.cliproxy.kiro_no_incognito = true; + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true); + expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456'); + expect(savedConfig?.cliproxy.auth?.api_key).toBe('cliproxy-global-api-key'); + expect(savedConfig?.cliproxy.auth?.management_secret).toBe( + 'cliproxy-global-management-secret' + ); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key'); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe( + 'variant-management-secret' + ); + expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token'); + expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret'); + expect(savedConfig?.dashboard_auth?.password_hash).toBe( + '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e' + ); + }); + + it('preserves cliproxy auth secrets when hidden auth subtrees are omitted on save', async () => { + const config = createEmptyUnifiedConfig(); + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + delete sanitized.cliproxy.auth; + if (sanitized.cliproxy.variants.gemini) { + delete sanitized.cliproxy.variants.gemini.auth; + } + sanitized.cliproxy.kiro_no_incognito = true; + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true); + expect(savedConfig?.cliproxy.auth?.api_key).toBe('cliproxy-global-api-key'); + expect(savedConfig?.cliproxy.auth?.management_secret).toBe( + 'cliproxy-global-management-secret' + ); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key'); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe( + 'variant-management-secret' + ); + }); + + it('clears cliproxy auth secrets when empty auth objects are saved explicitly', async () => { + const config = createEmptyUnifiedConfig(); + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + sanitized.cliproxy.auth = {}; + if (sanitized.cliproxy.variants.gemini) { + sanitized.cliproxy.variants.gemini.auth = {}; + } + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.cliproxy.auth).toBeUndefined(); + expect(savedConfig?.cliproxy.variants.gemini?.auth).toBeUndefined(); + }); + + it('redacts block-scalar secrets from raw YAML responses', async () => { + const yamlPath = path.join(tempHome, '.ccs', 'config.yaml'); + fs.writeFileSync( + yamlPath, + [ + '# block scalar test', + 'version: 10', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: []', + ' variants:', + ' gemini:', + ' provider: gemini', + ' auth:', + ' api_key: |-', + ' variant-api-secret-line-1', + ' variant-api-secret-line-2', + ' auth:', + ' api_key: >+', + ' cliproxy-global-api-secret', + ' management_secret: |+', + ' cliproxy-global-management-secret', + 'global_env:', + ' enabled: true', + ' env:', + ' GITHUB_TOKEN: |-', + ' github-token-secret-line-1', + ' github-token-secret-line-2', + 'cliproxy_server:', + ' remote:', + ' enabled: true', + ' host: proxy.example.com', + ' protocol: https', + ' auth_token: >-', + ' remote-auth-secret', + 'dashboard_auth:', + ' enabled: true', + ' username: admin', + ' password_hash: >+', + ' dashboard-password-secret', + '', + ].join('\n') + ); + + const rawResponse = await fetch(`${baseUrl}/api/config/raw`); + expect(rawResponse.status).toBe(200); + const rawConfig = await rawResponse.text(); + + expect(rawConfig).toContain('# block scalar test'); + expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]'); + expect(rawConfig).toContain('api_key: [redacted]'); + expect(rawConfig).toContain('management_secret: [redacted]'); + expect(rawConfig).toContain('auth_token: [redacted]'); + expect(rawConfig).toContain('password_hash: [redacted]'); + expect(rawConfig).not.toContain('github-token-secret-line-1'); + expect(rawConfig).not.toContain('cliproxy-global-api-secret'); + expect(rawConfig).not.toContain('cliproxy-global-management-secret'); + expect(rawConfig).not.toContain('variant-api-secret-line-1'); + expect(rawConfig).not.toContain('remote-auth-secret'); + expect(rawConfig).not.toContain('dashboard-password-secret'); + }); + + it('allows deleting visible global env keys while preserving redacted secrets', async () => { + const config = createEmptyUnifiedConfig(); + config.global_env = { + enabled: true, + env: { + DEBUG: '1', + OPENAI_API_KEY: 'sk-test-123456', + }, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + if (sanitized.global_env?.env) { + delete sanitized.global_env.env.DEBUG; + } + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.global_env?.env.DEBUG).toBeUndefined(); + expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456'); + }); + + it('allows deleting visible cliproxy_server and dashboard_auth fields while preserving secrets', async () => { + const config = createEmptyUnifiedConfig(); + config.cliproxy_server = { + remote: { + enabled: true, + host: 'proxy.example.com', + port: 8443, + protocol: 'https', + auth_token: 'remote-auth-token', + management_key: 'management-secret', + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: true, + }, + }; + config.dashboard_auth = { + enabled: true, + username: 'admin', + password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e', + session_timeout_hours: 24, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + if (sanitized.cliproxy_server?.remote) { + delete sanitized.cliproxy_server.remote.port; + } + if (sanitized.dashboard_auth) { + delete sanitized.dashboard_auth.username; + } + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.cliproxy_server?.remote.port).toBeUndefined(); + expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token'); + expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret'); + expect(savedConfig?.dashboard_auth?.username).toBeUndefined(); + expect(savedConfig?.dashboard_auth?.password_hash).toBe( + '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e' + ); + }); + it('returns alreadyMigrated when migration is not needed', async () => { const response = await postJson(baseUrl, '/api/config/migrate'); @@ -231,4 +634,42 @@ describe('web-server config-routes account context validation', () => { expect(payload.warnings).toEqual([]); expect(payload.alreadyMigrated).toBe(true); }); + + it('excludes unmanaged backup directories from format response', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const managedBackup = path.join(ccsDir, 'backup-v1-2026-03-24'); + const externalBackup = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-format-backup-')); + const symlinkBackup = path.join(ccsDir, 'backup-v1-symlink'); + + fs.mkdirSync(managedBackup, { recursive: true }); + fs.symlinkSync(externalBackup, symlinkBackup, 'dir'); + + const response = await fetch(`${baseUrl}/api/config/format`); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + format: string; + migrationNeeded: boolean; + backups: string[]; + }; + expect(payload.backups).toContain(managedBackup); + expect(payload.backups).not.toContain(symlinkBackup); + + fs.rmSync(externalBackup, { recursive: true, force: true }); + }); + + it('rejects rollback paths outside managed CCS backups', async () => { + const externalBackup = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-external-backup-')); + + const response = await postJson(baseUrl, '/api/config/rollback', { + backupPath: externalBackup, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid backupPath. Must reference a managed CCS migration backup directory.', + }); + + fs.rmSync(externalBackup, { recursive: true, force: true }); + }); });