fix(config): harden dashboard config and rollback paths

This commit is contained in:
Tam Nhu Tran
2026-03-24 14:38:40 -04:00
parent c923f51cf5
commit 37251cd1f8
4 changed files with 724 additions and 16 deletions
+53 -4
View File
@@ -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<MigrationResult> {
*/
export async function rollback(backupPath: string): Promise<boolean> {
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<boolean> {
}
// 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;
+356 -6
View File
@@ -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>
): 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>
): 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>
): 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<UnifiedConfig>;
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<void> => {
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 });
+53 -5
View File
@@ -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);
});
});
@@ -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<Response> {
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<void>((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 });
});
});