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/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 7b3ed8fd..95dead42 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,291 @@ 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_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_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 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 === "''" || + 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 shouldRedactRawYamlPath(pathKeys: string[]): boolean { + if ( + pathKeys.length === 3 && + pathKeys[0] === 'global_env' && + pathKeys[1] === 'env' && + isSensitiveKey(pathKeys[2]) + ) { + return true; + } + + return RAW_YAML_REDACTED_PATHS.has(pathKeys.join('.')); +} + +function redactRawConfigYamlForDashboard(content: string): string { + const stack: Array<{ indent: number; key: string }> = []; + + return content + .split('\n') + .map((line) => { + const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/); + if (!mappingMatch) { + return line; + } + + const [, indentText, rawKey] = mappingMatch; + if (rawKey.trimStart().startsWith('-')) { + return line; + } + + 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); + + return shouldRedactRawYamlPath(currentPath) ? redactYamlScalarLine(line) : line; + }) + .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 validateAndNormalizeAccountContextMetadata(config: unknown): string | null { if (typeof config !== 'object' || config === null) { @@ -130,7 +406,7 @@ router.get('/', (_req: Request, res: Response): void => { return; } - res.json(config); + res.json(sanitizeUnifiedConfigForDashboard(config)); }); /** @@ -142,9 +418,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 +431,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 +445,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 = config.cliproxy; + } + + 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 +551,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/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts index f24328ff..8ccd20fc 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,191 @@ 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', + OPENAI_API_KEY: 'sk-test-123456', + }, + }; + 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.OPENAI_API_KEY).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('OPENAI_API_KEY: [redacted]'); + expect(rawConfig).not.toContain('sk-test-123456'); + 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_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_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('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 +454,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 }); + }); });