fix(config-backup): origin is a stored random id, Settings refills after a settings restore, clis.json reloads

Review round 2 on #661: the hostname in the origin orphaned every snapshot when a Docker container was recreated, so the origin is a random id kept in <data dir>/config-backup-id; after restoring settings.json the Settings form is re-read from /api/settings so Save cannot write the old values back; the restore confirm names the files and the credential ones; clis.json is reloaded (reloadCliRegistry) and users.json needs no restart; API reference and route docs corrected.
This commit is contained in:
Devvyn committed 2026-10-20 18:01:42 +08:00
1 parent 247740fc77
commit 8b27e4beb6
10 files changed
+186 -23

No files matched your search

+30 -5
View File
@@ -21,7 +21,7 @@
* @module config-backup
*/
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import {
chmodSync,
existsSync,
@@ -34,7 +34,7 @@ import {
statSync,
writeFileSync,
} from 'node:fs';
import { homedir, hostname } from 'node:os';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve, sep } from 'node:path';
/**
@@ -96,7 +96,7 @@ 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. */
/** Id of the data dir that made it (see {@link configBackupOrigin}), so instances sharing one folder keep to their own snapshots. */
origin?: string;
files: ConfigBackupFile[];
}
@@ -156,8 +156,33 @@ 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 ORIGIN_FILE = 'config-backup-id';
const ORIGIN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* Who owns a snapshot: a random id made once and kept in the data dir. A hostname would not do (a
* Docker container gets a new one on every recreate, which orphaned every earlier snapshot), nor
* would the path alone (two machines can share a `$HOME` path). It is a file of its own, not the
* hook secret, and is not tracked or restored. If it cannot be written the id is not persisted, so
* snapshots made meanwhile are not listed later; the data dir being unwritable breaks far more.
*/
export function configBackupOrigin(dataDir: string): string {
const path = join(dataDir, ORIGIN_FILE);
try {
const existing = readFileSync(path, 'utf8').trim();
if (ORIGIN_RE.test(existing)) return existing;
} catch {
/* missing: made below */
}
const id = randomUUID();
try {
mkdirSync(dataDir, { recursive: true });
writeFileSync(path, id + '\n', { mode: 0o600 });
} catch {
/* see above */
}
return id;
}
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
+11 -2
View File
@@ -1482,7 +1482,11 @@ Object.assign(CodemanApp.prototype, {
},
async restoreConfigBackup(id) {
if (!confirm('Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.')) return;
const names = ((this._configBackups || []).find((b) => b.id === id)?.files || []).map((f) => f.name);
const secret = names.filter((n) => ['.env', 'users.json', 'push-keys.json', 'custom-model-hosts.json', 'webhook.json'].includes(n));
const list = names.length ? `\n\nFiles replaced: ${names.join(', ')}` : '';
const warn = secret.length ? `\n\nThis rolls back credentials and accounts (${secret.join(', ')}): passwords, disabled accounts and keys return to how they were then.` : '';
if (!confirm(`Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.${list}${warn}`)) return;
const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
@@ -1492,7 +1496,12 @@ Object.assign(CodemanApp.prototype, {
}
const d = body.data;
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}. Reload this page to see settings.${restart}`, 'success');
// The form still holds the pre-restore values, and Save would PUT them over the restored file.
if (d.restored.includes('settings.json')) {
await this.loadAppSettingsFromServer();
this.openAppSettings();
}
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
this.loadConfigBackups();
},
+8 -4
View File
@@ -7,9 +7,9 @@
* GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the
* running config (`current`, the default for `to`).
*
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all four
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json` and any key that looks
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
* may have cached: `restartRequired` says which ones only apply after a restart.
*/
@@ -18,6 +18,7 @@ import { createRequire } from 'node:module';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { reloadCliRegistry } from '../../config/cli-registry/registry.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { getDataDir } from '../../config/instance.js';
import { intentStore } from '../../intent-store.js';
@@ -53,8 +54,9 @@ 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', 'settings.json']);
/** Files whose running-process copy only reloads on restart (the rest are read per request; `users.json`
* is re-read within a second, `clis.json` is reloaded below, `intents.json` by `reload()`). */
const RESTART_REQUIRED = new Set<string>(['.env', 'push-keys.json', 'settings.json']);
export interface ConfigBackupStatus {
settings: ConfigBackupSettings;
@@ -203,6 +205,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
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();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (out.restored.includes('clis.json')) reloadCliRegistry();
return {
success: true,
data: {