Merge pull request #661: feat(config): timestamped config backups with a configurable folder and retention

Conflicts in CLAUDE.md, README.md, docs/api-reference.md and
src/web/server.ts were additions beside #662's Copilot quota read and
#666's keep-warm sections; both sides kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:54:40 +02:00
commit 3b6818449e
27 files changed
+3299 -6

No files matched your search

+42
View File
@@ -3258,6 +3258,48 @@
</div>
</div>
<div class="set-group" id="configBackupGroup">
<div class="set-group-head"><h4>Config backups</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="config backup snapshot settings clis restore retention keep">
<div class="set-row-text">
<span class="set-row-label">Back up my configuration</span>
<span class="set-row-desc">Keeps timestamped copies of your settings, CLI overrides, custom endpoints, webhook, linked cases and (in multi-user mode) accounts. A copy is made at startup and whenever one of them changes. Some of these files hold credentials, so each copy is private to this user (mode 0700/0600).</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsConfigBackup"><span class="slider"></span></label>
</div>
<div class="set-row has-field" data-search="config backup folder location path directory">
<div class="set-row-text">
<span class="set-row-label">Backup folder</span>
<span class="set-row-desc">An absolute path (a leading <code>~</code> is expanded). Leave empty for the default shown. Only folders Codeman created there are ever pruned.</span>
</div>
<input type="text" id="appSettingsConfigBackupDir" class="set-input" placeholder="default: ~/.codeman/backups/config" data-i18n-skip>
</div>
<div class="set-row has-field" data-search="config backup retention keep count snapshots">
<div class="set-row-text">
<span class="set-row-label">Keep this many backups</span>
<span class="set-row-desc">2 to 500 (with 1, every change would delete the state before it). The newest backup is always kept.</span>
</div>
<input type="number" id="appSettingsConfigBackupKeepCount" oninput="this.dataset.touched=1" class="set-input" min="2" max="500" step="1" placeholder="20">
</div>
<div class="set-row has-field" data-search="config backup retention keep days age">
<div class="set-row-text">
<span class="set-row-label">Delete backups older than (days)</span>
<span class="set-row-desc">A backup is removed once a newer one has existed this many days. 0 keeps backups until the count above pushes them out.</span>
</div>
<input type="number" id="appSettingsConfigBackupKeepDays" oninput="this.dataset.touched=1" class="set-input" min="0" max="3650" step="1" placeholder="30">
</div>
<div class="set-row" data-search="config backup now restore list">
<div class="set-row-text">
<span class="set-row-label">Backups</span>
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first. Compare shows what changed between two backups, or between a backup and the running config; credential values are never shown.</span>
</div>
<button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button>
</div>
<div id="configBackupList" class="set-note" data-i18n-skip>Loading…</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Automation</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
+180 -3
View File
@@ -555,6 +555,12 @@ Object.assign(CodemanApp.prototype, {
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
// Config backups: absent means ON, 20 copies, 30 days.
document.getElementById('appSettingsConfigBackup').checked = settings.configBackupEnabled !== false;
document.getElementById('appSettingsConfigBackupDir').value = settings.configBackupDir || '';
document.getElementById('appSettingsConfigBackupKeepCount').value = settings.configBackupKeepCount ?? 20;
document.getElementById('appSettingsConfigBackupKeepDays').value = settings.configBackupKeepDays ?? 30;
this.loadConfigBackups();
this._applyDoctorAdminGate();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
@@ -1365,10 +1371,178 @@ 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' : '';
}
},
// ── Config backups ──────────────────────────────────────────────────────────
// Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save.
/**
* The retention keys a Save sends: a value only once it was saved before or edited here. After a reset
* the form merely DISPLAYS what the passes use (the retention the backups recorded), and writing that back
* would replace it with whatever a different folder or an older default says.
*/
_configBackupRetentionPayload(prev) {
const count = document.getElementById('appSettingsConfigBackupKeepCount');
const days = document.getElementById('appSettingsConfigBackupKeepDays');
const out = {};
if (prev.configBackupKeepCount !== undefined || count.dataset.touched) {
out.configBackupKeepCount = Math.min(500, Math.max(2, parseInt(count.value, 10) || 20));
}
if (prev.configBackupKeepDays !== undefined || days.dataset.touched) {
const n = parseInt(days.value, 10);
out.configBackupKeepDays = Math.min(3650, Math.max(0, Number.isNaN(n) ? 30 : n));
}
return out;
},
/** Fill the backup list, and show the default folder as the input's placeholder. */
async loadConfigBackups() {
const out = this.$('configBackupList');
if (!out) return;
const res = await this._api('/api/config-backups');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
out.textContent = body?.error || 'Backups are not available here (admin only in multi-user mode).';
return;
}
const data = body.data;
const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir');
if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`;
// A reset settings.json has none of the four keys, but the passes keep the retention the newest backup
// recorded: show that, or a Save would write 20 / 30 over it.
const saved = this.loadAppSettingsFromStorage();
const countInput = document.getElementById('appSettingsConfigBackupKeepCount');
const daysInput = document.getElementById('appSettingsConfigBackupKeepDays');
if (countInput && saved.configBackupKeepCount === undefined && data.settings) countInput.value = data.settings.keepCount;
if (daysInput && saved.configBackupKeepDays === undefined && data.settings) daysInput.value = data.settings.keepDays;
if (!data.backups.length) {
out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`;
return;
}
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
this._configBackups = data.backups;
const rows = data.backups.map((b, i) => {
const when = new Date(b.createdAt).toLocaleString();
const names = b.files.map((f) => escapeHtml(f.name)).join(', ');
const id = escapeHtml(b.id);
// The list is newest first, so the next row is the backup this one followed.
const prev = data.backups[i + 1];
const vsPrev = prev ? `<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${escapeHtml(prev.id)}','${id}')">Changes in this backup</button>` : '';
return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span>
<button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${id}')">Restore</button>
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${id}','current')">Compare with current</button>
${vsPrev}
<div class="set-row-desc">${names}</div></li>`;
});
const options = (sel) => [`<option value="current"${sel === 'current' ? ' selected' : ''}>Current config</option>`]
.concat(data.backups.map((b) => `<option value="${escapeHtml(b.id)}"${sel === b.id ? ' selected' : ''}>${escapeHtml(new Date(b.createdAt).toLocaleString())} (${escapeHtml(b.reason)})</option>`))
.join('');
const picker = data.backups.length
? `<div class="set-row-desc">Compare <select id="configDiffFrom">${options(data.backups[0].id)}</select> with <select id="configDiffTo">${options('current')}</select>
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff(app.$('configDiffFrom').value, app.$('configDiffTo').value)">Show changes</button></div>`
: '';
out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div>${picker}<ul>${rows.join('')}</ul><div id="configBackupDiff"></div>`;
},
/** What differs between two states of the config files: a backup id or `current` (the running config). */
async showConfigDiff(from, to) {
const out = this.$('configBackupDiff');
if (!out) return;
out.textContent = 'Comparing…';
const res = await this._api(`/api/config-backups/diff?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
out.textContent = body?.error || 'Could not compare these';
return;
}
const label = (id) => {
if (id === 'current') return 'the current config';
const b = (this._configBackups || []).find((x) => x.id === id);
return b ? new Date(b.createdAt).toLocaleString() : id;
};
const d = body.data;
const verb = { added: 'added', removed: 'removed', changed: 'changed' };
const files = d.files.filter((f) => f.status !== 'unchanged').map((f) => {
let detail;
if (f.detail === 'bytes') {
detail = '<div class="set-row-desc">Differs (not a key/value file, so no detail).</div>';
} else {
const rows = f.changes.map((c) => {
const val = (v) => `<code>${escapeHtml(v ?? '')}</code>`;
const text = c.redacted ? `${verb[c.kind]} <span class="set-row-desc">(value hidden: credential)</span>`
: c.kind === 'added' ? `added ${val(c.after)}`
: c.kind === 'removed' ? `removed (was ${val(c.before)})`
: `${val(c.before)} → ${val(c.after)}`;
return `<li><code>${escapeHtml(c.path)}</code>: ${text}</li>`;
});
const more = f.truncated ? '<li class="set-row-desc">More changes not shown.</li>' : '';
detail = f.changes.length ? `<ul>${rows.join('')}${more}</ul>` : '';
}
const whole = f.status === 'added' ? ' (new file)' : f.status === 'removed' ? ' (file gone)' : '';
return `<div><b>${escapeHtml(f.name)}</b>${whole}${detail}</div>`;
});
const same = d.files.filter((f) => f.status === 'unchanged').map((f) => escapeHtml(f.name));
out.innerHTML = `<div><b>${escapeHtml(label(d.from))}</b> → <b>${escapeHtml(label(d.to))}</b>: ${
d.changed ? `${d.changed} file${d.changed === 1 ? '' : 's'} differ` : 'no differences'}</div>${files.join('')}${
same.length ? `<div class="set-row-desc">Unchanged: ${same.join(', ')}</div>` : ''}`;
},
async backupConfigNow() {
const btn = this.$('configBackupNowBtn');
if (btn) btn.disabled = true;
try {
const res = await this._apiPost('/api/config-backups', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this.showToast(body?.error || 'Backup failed', 'error');
} else if (body.data.status === 'empty') {
this.showToast('Nothing to back up yet', 'warning');
} else {
this.showToast(`Backed up ${body.data.files} file${body.data.files === 1 ? '' : 's'}`, 'success');
}
} finally {
if (btn) btn.disabled = false;
this.loadConfigBackups();
}
},
async restoreConfigBackup(id) {
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 */ }
const ok = res && res.ok && body && body.success !== false;
const d = body?.data;
// A restore that failed partway still answers with what it wrote (`restored`, `failed`, `safetySnapshot`):
// those files are the backup's now and need the same refill as a full restore.
if (!ok && !Array.isArray(d?.restored)) {
this.showToast(body?.error || 'Restore failed', 'error');
return;
}
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
// 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();
}
if (ok) {
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
} else {
this.showToast(`${body.error || 'Restore failed partway'}.${restart}`, 'error');
}
this.loadConfigBackups();
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
@@ -2744,6 +2918,9 @@ Object.assign(CodemanApp.prototype, {
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
...this._configBackupRetentionPayload(_prev),
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
+298
View File
@@ -0,0 +1,298 @@
/**
* @fileoverview Config backups (src/config-backup.ts).
*
* GET /api/config-backups — settings in effect, the folder, and the snapshots in it.
* POST /api/config-backups — snapshot now (a manual request always makes one).
* POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot).
* 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 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`, `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. A restore that could not
* write every file answers 500 with the error envelope plus `data` (`restored`, `failed`, `untouched`,
* `safetySnapshot`, `restartRequired`): the files it wrote stay restored and are reloaded like a full one.
*/
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { appendAdminAudit } from '../admin-audit.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';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { dirname } from 'node:path';
import {
CONFIG_BACKUP_FILES,
CONFIG_DIFF_CURRENT,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
runConfigBackupPass,
withRecordedRetention,
readConfigBackupOrigin,
type ConfigBackupResult,
type ConfigBackupSettings,
type ConfigBackupSummary,
type ConfigDiff,
} from '../../config-backup.js';
// Only recorded in each manifest; a missing package.json just leaves it blank.
const APP_VERSION: string = (() => {
try {
return (createRequire(import.meta.url)('../../../package.json') as { version?: string }).version ?? '';
} catch {
return '';
}
})();
/** 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;
dir: string;
defaultDir: string;
tracked: readonly string[];
backups: ConfigBackupSummary[];
}
/**
* The settings in effect and the folder they name. A custom folder is probed (bounded, #516) BEFORE
* the recorded-retention fallback reads it, so this throws `BackupDirUnavailableError` for a folder
* that does not answer and never lists it; `present` is false for a custom folder that does not exist yet.
*/
export async function readEffectiveBackupSettings(): Promise<{
settings: ConfigBackupSettings;
dir: string;
present: boolean;
}> {
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const base = readConfigBackupSettings(raw);
const dir = resolveConfigBackupDir(base, getDataDir());
const present = await probeBackupDir(base, dir);
// The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0.
const settings = present ? withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())) : base;
return { settings, dir, present };
}
/** 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;
}
async function readSettingsForBackup(): Promise<Record<string, unknown> | 'unreadable'> {
try {
const parsed: unknown = JSON.parse(await readFile(SETTINGS_PATH, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 'unreadable';
} catch (err) {
return (err as NodeJS.ErrnoException).code === 'ENOENT' ? {} : 'unreadable';
}
}
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
export async function runConfigBackup(
reason: string,
opts: { force?: boolean } = {}
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read
// that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30
// days) and drop the longer history the user asked to keep.
const raw = await readSettingsForBackup();
const unreadable = raw === 'unreadable';
const fromFile = readConfigBackupSettings(unreadable ? {} : raw);
if (!fromFile.enabled && !opts.force) return null;
const dir = resolveConfigBackupDir(fromFile, getDataDir());
await probeBackupDir(fromFile, dir);
return runConfigBackupPass({
dataDir: getDataDir(),
backupDir: dir,
reason,
appVersion: APP_VERSION,
force: opts.force,
settings: fromFile,
raw: unreadable ? {} : raw,
// An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so.
prune: !unreadable,
});
}
export function registerConfigBackupRoutes(app: FastifyInstance): void {
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => {
const denied = deny(req, reply);
if (denied) return denied;
let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>;
try {
eff = await readEffectiveBackupSettings();
} catch (err) {
reply.code(503);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
const { settings, dir, present } = eff;
// Read-only: a listing must not create config-backup-id, and without one nothing can be ours.
const origin = readConfigBackupOrigin(getDataDir());
return {
success: true,
data: {
settings,
dir,
defaultDir: defaultConfigBackupDir(getDataDir()),
tracked: CONFIG_BACKUP_FILES,
backups: present && origin ? listConfigBackups(dir, origin) : [],
},
};
});
app.post('/api/config-backups', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
try {
const out = await runConfigBackup('manual', { force: true });
return { success: true, data: { ...out!.result, removed: out!.removed } };
} catch (err) {
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
}
});
app.get('/api/config-backups/diff', async (req, reply): Promise<ApiResponse<ConfigDiff>> => {
const denied = deny(req, reply);
if (denied) return denied;
const q = req.query as { from?: string; to?: string };
if (typeof q.from !== 'string' || !q.from) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
}
try {
const { dir } = await readEffectiveBackupSettings();
const data = diffConfigBackups({
dataDir: getDataDir(),
backupDir: dir,
from: q.from,
to: typeof q.to === 'string' && q.to ? q.to : CONFIG_DIFF_CURRENT,
});
return { success: true, data };
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
}
});
/**
* What a restore that wrote files must do, whether or not every file made it: reload what the running
* server caches, audit it, and name the files that only apply after a restart.
*/
const afterRestore = (req: FastifyRequest, id: string, restored: string[], failed: string[] = []): string[] => {
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
if (restored.includes('intents.json')) intentStore.reload();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (restored.includes('clis.json')) reloadCliRegistry();
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
admin: getAuthUser(req).username,
action: 'config_backup_restore',
target: id,
ip: req.ip,
detail: { restored, ...(failed.length > 0 && { failed }) },
});
}
return restored.filter((n) => RESTART_REQUIRED.has(n));
};
app.post('/api/config-backups/:id/restore', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
try {
const { dir } = await readEffectiveBackupSettings();
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
return {
success: true,
data: {
restored: out.restored,
untouched: out.untouched,
safetySnapshot: out.safetySnapshot,
restartRequired: afterRestore(req, id, out.restored),
},
};
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
}
if (err instanceof ConfigBackupPartialRestoreError) {
// Some files are the backup's now: the client must learn which (Settings re-reads a restored
// settings.json, or its next Save writes the old values back) and where the undo copy is.
const failedNames = err.failed.map((f) => f.name);
const restartRequired = afterRestore(req, id, err.restored, failedNames);
reply.code(500);
return {
...createErrorResponse(ApiErrorCode.OPERATION_FAILED, err.message),
data: {
restored: err.restored,
failed: err.failed,
untouched: err.untouched,
safetySnapshot: err.safetySnapshot,
restartRequired,
},
};
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
}
});
}
+1
View File
@@ -32,6 +32,7 @@ export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
+12
View File
@@ -1525,6 +1525,18 @@ export const SettingsUpdateSchema = z
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Config backups (src/config-backup.ts): timestamped snapshots of the customized config files,
* taken at startup and whenever their content changes. SYNCED. Absent means ON (the feature
* protects against an event nobody schedules); the folder defaults to `<data dir>/backups/config`.
*/
configBackupEnabled: z.boolean().optional(),
/** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */
configBackupDir: z.string().max(1024).optional(),
/** Keep at most this many snapshots. */
configBackupKeepCount: z.number().int().min(2).max(500).optional(),
/** Also drop snapshots older than this many days; 0 = no age limit. */
configBackupKeepDays: z.number().int().min(0).max(3650).optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
+19
View File
@@ -223,6 +223,8 @@ import {
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerConfigBackupRoutes,
runConfigBackup,
registerWebhookRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
@@ -246,6 +248,10 @@ const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
// Copilot's quota is read over the network from GitHub, and a monthly figure moves slowly.
const COPILOT_USAGE_POLL_INTERVAL_MS = 10 * 60_000;
// Config backups: one cheap read-and-hash of a handful of small files; a snapshot is written only when
// their content changed since the latest one, so this is one entry per real change, not per tick.
const CONFIG_BACKUP_INTERVAL_MS = 5 * 60_000;
const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000;
// Much shorter than the model-LIST refresh above on purpose: this catches an actual
// eviction (a session's model no longer loaded, silently swapped out by another
@@ -1243,6 +1249,7 @@ export class WebServer extends EventEmitter {
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerConfigBackupRoutes(this.app);
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
@@ -3164,6 +3171,18 @@ export class WebServer extends EventEmitter {
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
getHookSecret();
// Config backups: a snapshot at start (the state the process found) and then on change.
if (!this.testMode) {
const backup = (reason: string) =>
void runConfigBackup(reason).catch((err) =>
console.warn('[config-backup] snapshot failed:', err instanceof Error ? err.message : err)
);
backup('startup');
this.cleanup.setInterval(() => backup('auto'), CONFIG_BACKUP_INTERVAL_MS, {
description: 'Config backup',
});
}
// Main Codex subscription limits come from the signed-in local CLI. Keep
// this read-only and host-scoped; multi-user SSE routing makes it admin-only.
if (!this.testMode) {