mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
Merge pull request #784 from kaitranntt/kai/fix/780-circular-symlink-external-config
fix(shared-manager): allow external claude symlink chains
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
): 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 mergeCliproxyAuthField(
|
||||
currentValue: string | undefined,
|
||||
nextAuth: NonNullable<UnifiedConfig['cliproxy']['auth']>,
|
||||
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>
|
||||
): 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<UnifiedConfig>;
|
||||
|
||||
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<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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user