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

+76 -16
View File
@@ -25,6 +25,7 @@ import { createHash } from 'node:crypto';
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
@@ -33,7 +34,7 @@ import {
statSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { homedir, hostname } from 'node:os';
import { isAbsolute, join, resolve, sep } from 'node:path';
/**
@@ -55,6 +56,7 @@ export const CONFIG_BACKUP_FILES = [
'intents.json',
'linked-cases.json',
'remote-hosts.json',
'remote-cases.json',
'docker-hosts.json',
'docker-cases.json',
'webviews.json',
@@ -94,6 +96,8 @@ export interface ConfigBackupManifest {
appVersion: string;
/** Digest of the set of files, so an unchanged state is not snapshotted twice. */
hash: string;
/** Host and data dir that made it, so instances sharing one folder keep to their own snapshots. */
origin?: string;
files: ConfigBackupFile[];
}
@@ -152,6 +156,9 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir:
return dir;
}
/** Who owns a snapshot: this machine's name plus the data dir (prod and a beta differ by the latter). */
export const configBackupOrigin = (dataDir: string): string => `${hostname()}:${resolve(dataDir)}`;
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
/**
@@ -196,21 +203,25 @@ function stamp(now: number): string {
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
function readManifest(dir: string, id: string): ConfigBackupManifest | null {
function readManifest(dir: string, id: string, origin?: string): ConfigBackupManifest | null {
if (!SNAPSHOT_ID_RE.test(id)) return null;
try {
const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8')) as ConfigBackupManifest;
if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') {
return null;
}
if (origin !== undefined && m.origin !== origin) return null;
return m;
} catch {
return null;
}
}
/** Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored. */
export function listConfigBackups(dir: string): ConfigBackupSummary[] {
/**
* Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so
* are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}).
*/
export function listConfigBackups(dir: string, origin?: string): ConfigBackupSummary[] {
let names: string[];
try {
names = readdirSync(dir);
@@ -219,7 +230,7 @@ export function listConfigBackups(dir: string): ConfigBackupSummary[] {
}
const out: ConfigBackupSummary[] = [];
for (const id of names) {
const m = readManifest(dir, id);
const m = readManifest(dir, id, origin);
if (!m) continue;
const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes }));
out.push({
@@ -267,9 +278,10 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku
if (present.length === 0) return { status: 'empty' };
const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n'));
const existing = listConfigBackups(opts.backupDir);
const origin = configBackupOrigin(opts.dataDir);
const existing = listConfigBackups(opts.backupDir, origin);
if (!opts.force && existing[0]) {
const latest = readManifest(opts.backupDir, existing[0].id);
const latest = readManifest(opts.backupDir, existing[0].id, origin);
if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id };
}
@@ -288,6 +300,7 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku
reason: opts.reason,
appVersion: opts.appVersion ?? '',
hash,
origin,
files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })),
};
for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 });
@@ -305,9 +318,26 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku
return { status: 'created', id, files: present.length, bytes: present.reduce((n, f) => n + f.data.length, 0) };
}
/** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */
export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): void {
let names: string[];
try {
names = readdirSync(dir);
} catch {
return;
}
for (const n of names) {
if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n)) continue;
const st = lstatSync(join(dir, n), { throwIfNoEntry: false });
if (st && now - st.mtimeMs > 3_600_000) rmSync(join(dir, n), { recursive: true, force: true });
}
}
export interface PruneOptions {
keepCount: number;
keepDays: number;
/** Only this instance's snapshots are pruned; omitted = every snapshot in the folder. */
origin?: string;
now?: number;
}
@@ -317,7 +347,7 @@ export interface PruneOptions {
*/
export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
const now = opts.now ?? Date.now();
const all = listConfigBackups(dir);
const all = listConfigBackups(dir, opts.origin);
const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null;
const removed: string[] = [];
all.forEach((b, index) => {
@@ -343,6 +373,8 @@ export class ConfigBackupCorruptError extends Error {}
export interface RestoreResult {
restored: string[];
/** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */
untouched: string[];
/** The snapshot taken of the live files before they were overwritten. */
safetySnapshot: string | null;
}
@@ -359,7 +391,7 @@ export function restoreConfigBackup(opts: {
appVersion?: string;
now?: number;
}): RestoreResult {
const manifest = readManifest(opts.backupDir, opts.id);
const manifest = readManifest(opts.backupDir, opts.id, configBackupOrigin(opts.dataDir));
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
@@ -392,11 +424,20 @@ export function restoreConfigBackup(opts: {
for (const f of loaded) {
const target = join(opts.dataDir, f.name);
const tmp = `${target}.restore-${process.pid}`;
writeFileSync(tmp, f.data, { mode: 0o600 });
renameSync(tmp, target);
try {
writeFileSync(tmp, f.data, { mode: 0o600 });
renameSync(tmp, target);
} catch (err) {
rmSync(tmp, { force: true });
throw err;
}
restored.push(f.name);
}
return { restored, safetySnapshot: safety.status === 'created' ? safety.id : null };
// A restore never deletes: tracked files the snapshot did not hold stay as they are.
const untouched = CONFIG_BACKUP_FILES.filter(
(n) => !restored.includes(n) && lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile()
);
return { restored, untouched, safetySnapshot: safety.status === 'created' ? safety.id : null };
}
/** The live files in the data dir, as a side of a diff. */
@@ -436,7 +477,13 @@ const MAX_DIFF_CHANGES = 200;
const MAX_DIFF_VALUE_CHARS = 200;
/** Files whose every value is a credential or key: only names are ever shown. */
const REDACT_ALL_FILES: ReadonlySet<string> = new Set(['.env', 'users.json', 'push-keys.json']);
const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
'.env',
'users.json',
'push-keys.json',
'webhook.json',
'intents.json',
]);
/** Elsewhere a value is hidden when its own key looks like a credential. */
const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i;
@@ -478,6 +525,19 @@ function leavesOf(name: string, data: Buffer): Leaves | null {
}
}
/** A URL value loses its userinfo, query and fragment: they carry passwords and tokens. */
function scrubUrl(jsonText: string): string {
try {
const v = JSON.parse(jsonText) as unknown;
if (typeof v !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return jsonText;
const u = new URL(v);
if (!u.username && !u.password && !u.search && !u.hash) return jsonText;
return JSON.stringify(`${u.protocol}//${u.host}${u.pathname}`);
} catch {
return jsonText;
}
}
const clip = (s: string): string => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s);
function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffChange[]; truncated: boolean } {
@@ -494,8 +554,8 @@ function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffCh
changes.push({
path,
kind,
...(before !== undefined && { before: clip(before) }),
...(after !== undefined && { after: clip(after) }),
...(before !== undefined && { before: clip(scrubUrl(before)) }),
...(after !== undefined && { after: clip(scrubUrl(after)) }),
});
}
}
@@ -516,7 +576,7 @@ function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<str
}
return out;
}
const manifest = readManifest(backupDir, side);
const manifest = readManifest(backupDir, side, configBackupOrigin(dataDir));
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`);
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
for (const f of manifest.files) {
+5
View File
@@ -108,6 +108,11 @@ export class IntentStore {
// ----- Public API -----
/** Drop the in-memory copy so the next call re-reads intents.json (after a config restore replaced it). */
reload(): void {
this.profiles = null;
}
/**
* The profile for a session's case. Never persists on read: an absent
* profile returns an empty transient one (`updatedAt: 0`).
+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)}`);
}
});