From 8a4ff775fbb9f4fc56cbda4b477616a851d5a10c Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:47:20 +0800 Subject: [PATCH] fix(config-backup): probe a custom folder before the retention fallback reads it, test that Save omits a displayed-only retention --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Config-Backups.md | 6 +- docs/wiki/Settings-Reference.md | 3 +- docs/wiki/Troubleshooting.md | 4 + src/web/public/settings-ui.js | 28 +++++-- src/web/routes/config-backup-routes.ts | 31 +++++--- test/config-backup-restore-ui.test.ts | 46 +++++++++++ test/routes/config-backup-unreachable.test.ts | 76 +++++++++++++++++++ 9 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 test/routes/config-backup-unreachable.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bcdefb3d..357c1db4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 2-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 2-500 default 20, `configBackupKeepDays` 0-3650 default 30 (an ABSENT key means what the newest backup in the folder recorded, `withRecordedRetention`); `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index e5e27151..ae5e881c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1158,7 +1158,7 @@ Timestamped snapshots of the files that hold what a user has customized (`src/co Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. -Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). A key absent from `settings.json` means the retention the newest backup in the folder recorded, and the default only when none did. Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. | Method | Path | Body | Notes | | ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 62736e73..5dabfd24 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -139,9 +139,9 @@ Only the tracked names are ever written, whatever a manifest lists. Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many days) at their current values, so a restore from before a folder change does not point the list -at the old folder and hide the `pre-restore` copy it just made. The exception is a live -`settings.json` that has none of the four (a reset file): the restore then keeps the backup's own -how-many and how-many-days, so the next pass does not fall back to 20 copies and 30 days. The +at the old folder and hide the `pre-restore` copy it just made. The exception is the two +retention values: where the live `settings.json` lacks one (a reset file), the restore keeps the +backup's own, key by key, so the next pass does not fall back to 20 copies and 30 days. The `pre-restore` copy records the retention in force too. Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index b61ab68e..a5287f9b 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -193,7 +193,8 @@ mode, the **Users** administration entry is injected here. The **Config backups** group controls timestamped copies of your configuration files: an on/off switch (on by default), the backup folder (default `~/.codeman/backups/config`), how many backups to keep (2 to 500, default 20) and how many days to keep them (0 to 3650, default -30; 0 means no age limit), plus **Back up now** and a list with **Restore**. These are +30; 0 means no age limit). A value that is not saved in `settings.json` (a reset file) is whatever +the newest backup in that folder recorded, and the default only when none did, plus **Back up now** and a list with **Restore**. These are server-side settings, read fresh. Details, naming and restore behaviour are on [Config Backups](Config-Backups). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 5c34528b..c6332686 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -147,6 +147,10 @@ it again and save before looking for the list; until then the list shows the def After a reinstall of the data directory the old backups are unlisted because the instance has a new id; see [Config Backups](Config-Backups) for how to make them visible again. +A backup is never dated earlier than the newest one, so if the clock was once set to a far +future date, every later backup keeps that date. Delete the future-dated backup folder to +return to normal dating. + Without the UI: the backups are plain folders in `~/.codeman/backups/config/` (`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`. diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 99662c23..c40dcd75 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1374,6 +1374,25 @@ Object.assign(CodemanApp.prototype, { // ── 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'); @@ -2887,14 +2906,7 @@ Object.assign(CodemanApp.prototype, { mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked, configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), - // Sent only once saved before or edited here: a value merely displayed from the server (after a reset) - // would otherwise be written back and replace the retention the backups recorded. - ...(_prev.configBackupKeepCount !== undefined || document.getElementById('appSettingsConfigBackupKeepCount').dataset.touched - ? { configBackupKeepCount: Math.min(500, Math.max(2, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)) } - : {}), - ...(_prev.configBackupKeepDays !== undefined || document.getElementById('appSettingsConfigBackupKeepDays').dataset.touched - ? { configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))) } - : {}), + ...this._configBackupRetentionPayload(_prev), readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 602e0673..e4ecea1f 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -70,12 +70,23 @@ export interface ConfigBackupStatus { backups: ConfigBackupSummary[]; } -export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> { +/** + * 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>(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. - return { settings: withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())), dir }; + 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). */ @@ -158,16 +169,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { app.get('/api/config-backups', async (req, reply): Promise> => { const denied = deny(req, reply); if (denied) return denied; - const { settings, dir } = await readEffectiveBackupSettings(); - // Read-only: a listing must not create config-backup-id, and without one nothing can be ours. - const origin = readConfigBackupOrigin(getDataDir()); - let present: boolean; + let eff: Awaited>; try { - present = await probeBackupDir(settings, dir); + 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: { @@ -201,8 +212,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")'); } try { - const { settings, dir } = await readEffectiveBackupSettings(); - await probeBackupDir(settings, dir); + const { dir } = await readEffectiveBackupSettings(); const data = diffConfigBackups({ dataDir: getDataDir(), backupDir: dir, @@ -229,8 +239,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { if (denied) return denied; const { id } = req.params as { id: string }; try { - const { settings, dir } = await readEffectiveBackupSettings(); - await probeBackupDir(settings, dir); + const { dir } = await readEffectiveBackupSettings(); 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(); diff --git a/test/config-backup-restore-ui.test.ts b/test/config-backup-restore-ui.test.ts index 256e4479..840ee567 100644 --- a/test/config-backup-restore-ui.test.ts +++ b/test/config-backup-restore-ui.test.ts @@ -106,3 +106,49 @@ describe('loadConfigBackups (retention fields)', () => { expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it }); }); + +describe('Save and the displayed retention', () => { + function payload( + prev: Record, + touched: { count?: boolean; days?: boolean }, + count = '100', + days = '0' + ) { + const inputs: Record }> = { + appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} }, + appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} }, + }; + function CodemanApp(this: unknown) {} + const ctx = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: (id: string) => inputs[id] }, + }); + vm.runInContext(SRC, ctx); + const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record; + return (app._configBackupRetentionPayload as (p: Record) => Record).call( + app, + prev + ); + } + + it('leaves out a retention value that was only displayed (a reset settings.json)', () => { + expect(payload({}, {})).toEqual({}); + }); + + it('sends a value that was saved before, or edited here, and only that one', () => { + expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 }); + expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 }); + expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({ + configBackupKeepCount: 5, + configBackupKeepDays: 7, + }); + }); + + it('clamps like the form always did', () => { + expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({ + configBackupKeepCount: 2, + configBackupKeepDays: 3650, + }); + }); +}); diff --git a/test/routes/config-backup-unreachable.test.ts b/test/routes/config-backup-unreachable.test.ts new file mode 100644 index 00000000..d1d99700 --- /dev/null +++ b/test/routes/config-backup-unreachable.test.ts @@ -0,0 +1,76 @@ +/** + * @fileoverview A custom backup folder that stops answering (a network mount) must never be listed or + * read before the bounded probe says it is there: GET, diff and restore answer 503 without touching it. + * With only `configBackupDir` in settings.json the recorded-retention fallback would otherwise + * `readdirSync` the folder first (found in review of #661). + * + * ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (inject()). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const dead = vi.hoisted(() => ({ dir: '', touched: [] as string[] })); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + const record = unknown>(name: string, real: F): F => + ((path: unknown, ...rest: unknown[]) => { + if (dead.dir && String(path).startsWith(dead.dir)) dead.touched.push(`${name} ${String(path)}`); + return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest); + }) as unknown as F; + const wrapped = { + ...actual, + readdirSync: record('readdirSync', actual.readdirSync), + readFileSync: record('readFileSync', actual.readFileSync), + statSync: record('statSync', actual.statSync), + lstatSync: record('lstatSync', actual.lstatSync), + existsSync: record('existsSync', actual.existsSync), + }; + return { ...wrapped, default: wrapped }; +}); + +vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probePathKind: async (path: string, options?: { pastCap?: boolean }) => + dead.dir && path.startsWith(dead.dir) ? 'unknown' : actual.probePathKind(path, options), + }; +}); + +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-routes.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { configBackupOrigin } from '../../src/config-backup.js'; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'cfg-backup-dead-')); + dead.dir = join(root, 'mount'); + dead.touched.length = 0; + configBackupOrigin(getDataDir()); // an instance that already has an id: the fallback has something to look for + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + // Only the folder: no retention keys, the shape that makes the recorded-retention fallback want to read it. + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dead.dir })); +}); +afterEach(() => { + dead.dir = ''; + rmSync(root, { recursive: true, force: true }); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('a custom backup folder that does not answer', () => { + it.each([ + ['GET', '/api/config-backups'], + ['GET', '/api/config-backups/diff?from=cfg-20261001-120000'], + ['POST', '/api/config-backups/cfg-20261001-120000/restore'], + ] as const)('%s %s answers 503 without reading the folder', async (method, url) => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const res = await app.inject({ method, url }); + expect(res.statusCode).toBe(503); + expect(dead.touched).toEqual([]); + }); +});