mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
commit
3b6818449e
27 files changed
+3299
-6
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user