fix(config-backup): redact webhook and intents, bounded folder probe, per-instance origin, reload intents on restore

Review round on #661: webhook.json and intents.json are redacted in compare and URL values lose userinfo/query; a custom folder is probed (bounded, pastCap) before each pass and in each route and only created when its parent exists; manifests record an origin and change check, prune, list, restore and diff are scoped to it; a restore reloads IntentStore, lists untouched files and treats settings.json as restart-required; remote-cases.json is tracked; stale .tmp-cfg-* folders are swept and a failed restore unlinks its temp; the group is hidden from non-admins; an empty days field saves 30.
This commit is contained in:
Devvyn committed 2026-10-20 18:01:42 +08:00
1 parent a2e49eb26d
commit 247740fc77
8 files changed
+240 -37

No files matched your search

+6 -5
View File
@@ -1364,13 +1364,13 @@ Object.assign(CodemanApp.prototype, {
* wired to `codeman:me` for the same late-resolving role as the groups above.
*/
_applyDoctorAdminGate() {
const group = document.getElementById('doctorGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
for (const id of ['doctorGroup', 'configBackupGroup']) {
const group = document.getElementById(id);
if (group) group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
}
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
// ── Config backups ──────────────────────────────────────────────────────────
// Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save.
@@ -1496,6 +1496,7 @@ Object.assign(CodemanApp.prototype, {
this.loadConfigBackups();
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html, hint = '') => {
@@ -2871,7 +2872,7 @@ Object.assign(CodemanApp.prototype, {
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
configBackupKeepCount: Math.min(500, Math.max(1, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)),
configBackupKeepDays: Math.min(3650, Math.max(0, parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10) || 0)),
configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))),
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
+54 -8
View File
@@ -20,6 +20,9 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse }
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { getDataDir } from '../../config/instance.js';
import { intentStore } from '../../intent-store.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { dirname } from 'node:path';
import {
CONFIG_BACKUP_FILES,
CONFIG_DIFF_CURRENT,
@@ -33,6 +36,8 @@ import {
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
configBackupOrigin,
sweepConfigBackupTemps,
type ConfigBackupResult,
type ConfigBackupSettings,
type ConfigBackupSummary,
@@ -49,7 +54,7 @@ const APP_VERSION: string = (() => {
})();
/** Files whose running-process copy only reloads on restart (the rest are read per request). */
const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json']);
const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json', 'settings.json']);
export interface ConfigBackupStatus {
settings: ConfigBackupSettings;
@@ -65,6 +70,29 @@ export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigB
return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) };
}
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
export class BackupDirUnavailableError extends Error {}
/**
* Probe a custom backup folder without blocking the event loop on a dead mount. The default folder
* needs no probe. Returns whether the folder exists; throws when it did not answer, or is not a
* folder. A missing custom folder is only creatable when its parent is present.
*/
export async function probeBackupDir(settings: ConfigBackupSettings, dir: string): Promise<boolean> {
if (!settings.dir || dir === defaultConfigBackupDir(getDataDir())) return true;
const kind = await probePathKind(dir, { pastCap: true });
if (kind === 'unknown')
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dir, { pastCap: true }));
if (kind === 'file') throw new BackupDirUnavailableError(`Backup folder is a file, not a folder: ${dir}`);
if (kind === 'directory') return true;
const parent = await probePathKind(dirname(dir), { pastCap: true });
if (parent === 'unknown') {
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dirname(dir), { pastCap: true }));
}
if (parent !== 'directory') throw new BackupDirUnavailableError(`Backup folder's parent is missing: ${dirname(dir)}`);
return false;
}
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
export async function runConfigBackup(
reason: string,
@@ -72,6 +100,8 @@ export async function runConfigBackup(
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
const { settings, dir } = await readEffectiveBackupSettings();
if (!settings.enabled && !opts.force) return null;
await probeBackupDir(settings, dir);
sweepConfigBackupTemps(dir);
const result = createConfigBackup({
dataDir: getDataDir(),
backupDir: dir,
@@ -79,7 +109,11 @@ export async function runConfigBackup(
appVersion: APP_VERSION,
force: opts.force,
});
const removed = pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays });
const removed = pruneConfigBackups(dir, {
keepCount: settings.keepCount,
keepDays: settings.keepDays,
origin: configBackupOrigin(getDataDir()),
});
return { result, removed };
}
@@ -96,6 +130,13 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
const denied = deny(req, reply);
if (denied) return denied;
const { settings, dir } = await readEffectiveBackupSettings();
let present: boolean;
try {
present = await probeBackupDir(settings, dir);
} catch (err) {
reply.code(503);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return {
success: true,
data: {
@@ -103,7 +144,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
dir,
defaultDir: defaultConfigBackupDir(getDataDir()),
tracked: CONFIG_BACKUP_FILES,
backups: listConfigBackups(dir),
backups: present ? listConfigBackups(dir, configBackupOrigin(getDataDir())) : [],
},
};
});
@@ -115,7 +156,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
const out = await runConfigBackup('manual', { force: true });
return { success: true, data: { ...out!.result, removed: out!.removed } };
} catch (err) {
reply.code(500);
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
}
});
@@ -129,7 +170,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
}
try {
const { dir } = await readEffectiveBackupSettings();
const { settings, dir } = await readEffectiveBackupSettings();
await probeBackupDir(settings, dir);
const data = diffConfigBackups({
dataDir: getDataDir(),
backupDir: dir,
@@ -146,7 +188,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
}
});
@@ -156,12 +198,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
if (denied) return denied;
const { id } = req.params as { id: string };
try {
const { dir } = await readEffectiveBackupSettings();
const { settings, dir } = await readEffectiveBackupSettings();
await probeBackupDir(settings, dir);
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
if (out.restored.includes('intents.json')) intentStore.reload();
return {
success: true,
data: {
restored: out.restored,
untouched: out.untouched,
safetySnapshot: out.safetySnapshot,
restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)),
},
@@ -175,7 +221,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
}
reply.code(500);
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
}
});