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

+15
View File
@@ -1171,6 +1171,21 @@ Claude Code reports the main conversation's prompt-cache state on its statusline
`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, no push is sent for them and the run summary does not record them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`.
## Config backups
Timestamped snapshots of the files that hold what a user has customized (`src/config-backup.ts`). On by default; a snapshot is taken at startup and whenever the tracked files' content changes (checked every 5 minutes), so there is one entry per real change. All four routes are admin only in multi-user mode (`403`), because the files include credentials. Responses carry file names and sizes; only the diff returns values, and never a credential's.
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 = `<data dir>/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 |
| ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. |
| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. |
| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. |
| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. |
## Webhook notifications
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
+31 -1
View File
@@ -117,7 +117,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different
Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden.
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()`: mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`.
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()` (the one exception is a config-backup restore of `clis.json`, which writes it atomically and calls `reloadCliRegistry()`): mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`.
## Session data and lifecycle
@@ -721,6 +721,36 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
### Config backups
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again.
⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened.
⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock.
⚠️ **One walk of the folder per pass.** `runConfigBackupPass()` reads the folder once and hands the names to the temp sweep and the manifests to the retention fallback, the change check and the prune (with the snapshot it just made prepended: it is always the newest, by the date floor above). Each of the three used to list the folder itself, so every manifest was read three times per pass, every five minutes, on what may be a network folder; restore lists once too. `test/routes/config-backup-one-walk.test.ts` counts the reads.
⚠️ **Restore verifies everything before it writes anything.** Every file's SHA-256 against the manifest, and the id must match the name pattern (no traversal); a mismatch is a 409 with nothing changed. It also refuses a snapshot folder or file that is a symlink or has a group/world bit: this module only ever writes 0700/0600 real files, so such a thing was planted or loosened later, and rewriting it as 0600 would turn it into trusted config (`clis.json` selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). The mode test is skipped where the filesystem keeps no modes (vfat/exFAT, CIFS, WSL `/mnt/d` without `metadata` report 0755/0777 for everything, and Restore would refuse every backup there): `folderKeepsModes()` decides by creating a 0600 probe file with a random name and `wx`, because the earlier fixed `.mode-probe-<pid>` name let a planted symlink be truncated and chmodded by one Compare click. Compare applies the same checks to the snapshots it reads.
⚠️ **The undo copy comes first, and a partial restore is reported, never hidden.** A `pre-restore` snapshot of the live files precedes the writes; it is not forced, so when nothing changed since the newest backup that backup is the undo copy and is the one reported (a forced copy spent a retention slot on a duplicate). It records the retention in force, so it cannot hide that retention from the next pass. Each file is replaced atomically (temp + rename, 0600). A file that cannot be written (a full disk, a directory in its place) does not stop the others: `ConfigBackupPartialRestoreError` carries `restored`, `failed`, `untouched` and `safetySnapshot`, and the route answers 500 with the error envelope plus those fields (and `restartRequired`) in `data`, the shape the tab-layout 409 uses. The bare 500 it replaced named neither the files already put back nor the undo copy.
⚠️ **What a restore wrote must reach the running server**, on the full and the partial path alike (`afterRestore()` in the route): `intentStore.reload()` (the store caches `intents.json` and rewrote it whole on the next captured prompt, reverting the restore), `reloadCliRegistry()` (a restore of `clis.json` is the one write that bypasses `mutateRegistryFile()`, see CLI registry), and in multi-user mode a `config_backup_restore` admin-audit entry (`users.json` holds accounts, password hashes, roles and bypass grants). `users.json` itself is re-read within a second by the user store. `.env`, `push-keys.json` and `settings.json` (whose watcher toggles only `PUT /api/settings` runs) apply after a restart: `RESTART_REQUIRED`, returned as `restartRequired`. Settings re-reads `/api/settings` into the form after any restore that wrote `settings.json`, partial ones included, because the form still holds the old values and the next Save would PUT them over the restored file.
⚠️ **Compare never returns a credential.** `REDACT_ALL_FILES` (`.env`, `users.json`, `push-keys.json`, `webhook.json` whose URL is a bearer secret, `intents.json` whose prompts carry secrets) report changed key names only; anywhere else a key matching `SECRET_KEY_RE` (token, secret, password, credential, auth, `*key`) is reported as changed without its value, and URL values lose userinfo, query and fragment. Listing returns file names and sizes only.
⚠️ **Admin only in multi-user mode**: all four routes answer 403 for a non-admin and the Settings group is hidden for them (`_applyDoctorAdminGate()`), because the files include credentials.
Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/config-backup-restore-ui.test.ts` (vm harness over the real `settings-ui.js`), `test/routes/config-backup-routes.test.ts`, `test/routes/config-backup-unreachable.test.ts`, `test/routes/config-backup-one-walk.test.ts`.
## Frontend
### Command palette and shortcut registry
+256
View File
@@ -0,0 +1,256 @@
# Config Backups
Codeman keeps timestamped copies of the files that hold what you have customized, so a bad
write, a hand edit or a botched restart can be undone. It is on by default, needs no setup,
and everything about it (where the copies go, how many are kept, how long) is a setting.
## What is backed up
A fixed list of files from the data directory (`~/.codeman/`, or the directory of the
[instance](Core-Concepts#instances) you run):
| File | What it holds |
| ---- | ------------- |
| `settings.json` | Your preferences that sync across devices, including model and CLI settings. |
| `clis.json` | CLI enable/disable and custom CLI entries. |
| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** |
| `webhook.json` | Webhook notification target. |
| `intents.json` | Read My Mind per-case profiles. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. |
| `webviews.json` | Saved dashboard URLs. |
| `push-keys.json` | Web-push keys (regenerating them drops every device's subscription). |
| `users.json` | Multi-user accounts. **Holds password hashes.** |
| `.env` | Credentials the TUI client reads. **Holds a password.** |
Only files that exist are included. This is an allowlist: nothing else in the data
directory is ever copied.
**Not backed up:** `state.json` (sessions, and also cron jobs and respawn presets: it is rewritten
with every session event, so it would bury the real changes), logs, `mux-sessions.json`, the hook
secret, Docker seeds and caches. Back `state.json` up with your normal file backups if you rely on
cron jobs.
## When a backup is made
- **At startup**, so the state the server found is always on record.
- **Whenever the content changes.** The files are read and hashed every five minutes; a new
backup is written only if the set differs from the latest backup. An idle server writes
nothing, and the history is one entry per real change rather than one per timer tick.
A field a file rewrites on its own is not a change, and the comparison looks through it (the
snapshot still stores the file exactly as written, and a restore puts those fields back as they
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
`lastOpenedAt` (opening a web tab). Without that, every
tick looked like an edit and 20 copies covered only about 100 minutes. So Compare shows
`intents.json` as unchanged when only prompts differ, yet a restore drops the prompts captured
since.
- **On demand**, with **Back up now** (always writes one, even if nothing changed).
- **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced (unless
nothing changed since the newest backup).
## Where they go and what they are called
The default folder is `<data dir>/backups/config` (`~/.codeman/backups/config`). You can point
it anywhere with an absolute path (a leading `~` is expanded). A path that is not absolute, or
that is the data directory itself, falls back to the default.
Each backup is one folder named for the moment it was made, in the server's local time:
```
~/.codeman/backups/config/
cfg-20261011-120305/
manifest.json id, createdAt (epoch ms), reason, appVersion, hash, origin,
keepCount and keepDays in force, SHA-256 + size per file
settings.json the original file names, unchanged
clis.json
custom-model-hosts.json
...
cfg-20261011-123012/
cfg-20261011-123012-2/ a second backup in the same second gets -2, -3, ...
```
| Part | Meaning |
| ---- | ------- |
| `cfg-YYYYMMDD-HHMMSS` | Date and time (server local time) the backup was made. A collision in the same second gets `-2`, `-3`, … |
| `manifest.json` | The record of the backup: `reason`, `appVersion`, the change-check `hash`, the `origin` id, the retention in force (`keepCount`, `keepDays`), and a SHA-256 and size for every file. |
| `reason` | `startup`, `auto` (a change was detected), `manual` (Back up now) or `pre-restore`. It is in the manifest and the Settings list, not in the folder name. |
| `.tmp-…` | A backup being written. It is renamed into place when complete and is never listed. |
A folder counts as a backup only if its name matches that pattern **and** it has a valid
`manifest.json`. That is what makes it safe to point the folder at a directory that also holds
other things.
## Retention
Two limits, applied after every backup:
| Setting | Range | Default | Meaning |
| ------- | ----- | ------- | ------- |
| **Keep this many backups** (`configBackupKeepCount`) | 2 – 500 | 20 | Keep the newest N; older ones are removed. (1 is not offered: every change would delete the state before it.) |
| **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove a backup once it has been replaced for longer than this. `0` means no age limit. |
Both apply together. Four guarantees:
- **The newest backup is never removed**, whatever the numbers say, so there is always one to
go back to.
- **Only folders Codeman made are ever removed**, never anything else that happens to live in a
custom folder.
- **Age counts from when a backup was replaced**, not from when it was made. Backups are made on
change, so a config that stays the same for months has one backup whose own date keeps
ageing; it is the copy you need the day something goes wrong, so it stays until a newer one
has existed for longer than the limit.
- Because backups are made on change, 20 backups reach back across 20 *changes*, however long
that took, not 20 timer ticks.
## Settings → System → Config backups
| Control | Setting | Default |
| ------- | ------- | ------- |
| Back up my configuration | `configBackupEnabled` | On (absent means on) |
| Backup folder | `configBackupDir` | empty = `<data dir>/backups/config` |
| Keep this many backups | `configBackupKeepCount` | 20 |
| Delete backups older than (days) | `configBackupKeepDays` | 30 |
| **Back up now** | | |
| The list, with **Restore**, **Compare with current** and **Changes in this backup** per entry | | |
| **Compare** … with … **Show changes** | | |
The settings are saved server-side and read fresh, so a change applies on the next pass. The
list and **Back up now** use the **saved** folder and retention: apply your edits first.
## Restoring
From the list, press **Restore** on a backup and confirm. Codeman then:
1. **Verifies** every file in the backup against the SHA-256 in its manifest. If any file is
missing or does not match, nothing is changed and you get an error. It also refuses a backup
folder or file that is a symlink or that group or others can read or write (Codeman never
makes one like that, and writing it back as `0600` would turn it into trusted config). The
permission test is skipped when the backup folder's filesystem does not keep file modes
(FAT/exFAT drives, SMB shares, WSL's `/mnt/d` without the `metadata` option report the same
mode for every file, so it would refuse every backup there). The symlink test is not. Compare
applies the same checks to the backups it reads.
2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself
be undone. When nothing changed since the newest backup (the same comparison an automatic
backup makes), no copy is made and the response names that backup instead (`safetySnapshot`).
3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`.
Only the tracked names are ever written, whatever a manifest lists.
If a file cannot be written (a full disk, a folder where the file should be), the restore still
writes the others and then reports an error that names the files it restored, the ones it could
not write, and the backup that holds the files as they were before. The restored files are in
effect as after a full restore (Settings re-reads `settings.json` into the form), so fix the cause
and restore again, or restore that backup to go back.
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 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
read once per process: **restart Codeman** for those to take effect (the response lists which).
`clis.json` is reloaded by the restore itself and `users.json` is re-read within a second. Restoring `settings.json` also does not re-run the switches
that saving in Settings applies at once (file watchers and the like), so those apply after a
restart, and `settings.json` is listed with the restart-required files. A restore does not touch
running sessions.
When the backup folder is shared (a synced folder used by two machines, or prod and a beta),
each snapshot records a random id that the instance made once and keeps in its data directory
(`config-backup-id`; not the hostname, which a Docker container changes on every recreate). Every
instance only snapshots, lists, prunes, compares and restores its own. Deleting that file makes
the instance a new owner, and its earlier snapshots stop being listed (they stay on disk). The
same happens after a reinstall of the data directory. To see them again, copy the `origin` value
from any of their `manifest.json` files into `<data dir>/config-backup-id`.
You can also restore by hand: the files in a backup folder are the originals, so copying one back
over the file in the data directory is equivalent (stop Codeman first if you are replacing
`clis.json` or `users.json`).
## Comparing backups
Before you restore, or to find out what changed and when, use **Compare** in the list:
- **Compare with current** on a backup shows what differs between that backup and the config
running now. A restore puts back the files the backup holds; files created since are left as
they are, and the restore response names them (`untouched`).
- **Changes in this backup** shows what differs between the backup before it and this one.
- The **Compare … with … Show changes** pickers compare any two backups, or a backup with the
current config.
Each file that differs is listed with its changed keys (`theme.mode`, `tags[1]`, a variable name
in `.env`) as before → after, plus files that appeared or vanished between the two states. Files
that are the same are listed on one line. A file that is not JSON shows only that it differs, and
a file with more than 200 changes is cut short, so use the files themselves for a full diff. The
self-rewritten fields listed under "When a backup is made" are not counted as changes.
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the
URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
credential or key is shown as "changed" without its value. URL values lose any `user:password@`,
query and fragment.
## Security
- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`,
`push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default
location exposes nothing the data directory does not already.
- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a
network share copies those credentials there, with whatever permissions that location has.
Choose a private location, or turn the feature off if you cannot.
- **Retention survives a bad `settings.json`.** If it does not parse, a pass still takes its
snapshot but deletes nothing. If a retention setting is missing (a reset file), the pass
prunes with the retention the newest backup that recorded one holds, not the 20 copies and 30
days defaults, and Settings shows those values. Saving sends a retention value only if it was
saved before or you edited it there, so saving other settings (or re-entering a custom folder
after a reset) does not write the displayed 20 and 30 over it; the first pass in the re-entered
folder finds the retention its own backups recorded. While `settings.json` does not
parse, a pass reads it as empty, so its snapshot goes to the default folder even if a custom
folder is set or backups are switched off; nothing is widened (same modes, inside the data
directory).
- **Anyone who can write to a custom backup folder can plant a backup that Restore applies.**
Restore refuses symlinks and files others can write, but a file of your own user's making
passes, so keep the folder private.
- In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now,
Restore and Compare, and each restore is written to the admin audit log
(`config_backup_restore`).
- Listing returns file names and sizes, never contents. A comparison returns changed values but
never a credential's (see [Comparing backups](#comparing-backups)).
## HTTP API
```bash
API=http://localhost:3000
curl -s "$API/api/config-backups" | jq # settings, folder, list
curl -s -X POST "$API/api/config-backups" | jq # back up now
curl -s -X POST "$API/api/config-backups/cfg-20261011-120305/restore" | jq
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305" | jq # that backup vs the running config
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305&to=cfg-20261012-090000" | jq
```
See [HTTP API](HTTP-API) and the endpoint reference in `docs/api-reference.md`.
## Troubleshooting
- **The list is empty.** None of the tracked files existed yet, or backups are turned off. Press
**Back up now**.
- **"Backups are not available here".** You are not an admin in multi-user mode.
- **No new backup after I changed a setting.** Changes are picked up within five minutes; **Back up
now** takes one immediately.
- **A restore said a file does not match its checksum.** The backup was altered or damaged on
disk. Nothing was changed; restore an earlier one.
- **The backups folder is growing.** Lower **Keep this many backups**. Each backup is a few
kilobytes, but `custom-model-hosts.json` and `intents.json` can be larger.
- **I want my own off-machine copy.** Point the folder at a location your backup tool already
covers, mind the Security note above, and keep **Keep this many backups** high enough that
your tool sees each one.
## Read next
- [Settings Reference](Settings-Reference) - where the controls live.
- [Core Concepts](Core-Concepts) - what is on disk.
- [Security](Security) - what is protected and how.
- [Troubleshooting](Troubleshooting) - "my settings were reset".
+1
View File
@@ -138,6 +138,7 @@ Everything Codeman knows lives under `~/.codeman/`:
| `users.json` | Multi-user accounts, mode 0600. |
| `push-*.json` | Web push keys and subscriptions. |
| `certs/` | Self-signed TLS for `--https`. |
| `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). |
None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets
Codeman to a fresh install without touching your code.
+4
View File
@@ -173,6 +173,10 @@ curl -s "$API/api/subagents" | jq # background agents
curl -s "$API/api/search?q=deploy" | jq # cross-session search
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
curl -s "$API/api/config-backups" | jq # config snapshots: folder, retention, list (admin only in multi-user mode)
curl -s -X POST "$API/api/config-backups" | jq # back up the customized config files now
curl -s -X POST "$API/api/config-backups/cfg-20261011-120000/restore" | jq # put a snapshot back (after a pre-restore copy)
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120000" | jq # what differs from the running config (`to=` another snapshot id to compare two)
# with ID set to a session id:
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
+1
View File
@@ -113,6 +113,7 @@ codeman web # then open http://localhost:3000
| Page | What it answers |
| --------------------------------------------- | -------------------------------------------------- |
| [Running As A Service](Running-As-A-Service) | How do I keep it up across reboots, and update it? |
| [Config Backups](Config-Backups) | How do I get my settings back after they were changed or lost? |
| [Troubleshooting](Troubleshooting) | Why is it doing that? |
| [FAQ](FAQ) | The questions that keep coming up. |
| [Contributing](Contributing) | How do I send a fix? |
+5
View File
@@ -114,6 +114,11 @@ never logged and redirects are refused. Turn off **Plan Usage Limits** in App Se
Two features send data outward, both off by default and both stated where they appear: voice
dictation through your Claude login, and the Read My Mind prediction call.
[Config Backups](Config-Backups) copies some credential-bearing files (`.env`, `users.json`,
`custom-model-hosts.json`, `push-keys.json`, `webhook.json`) into a backup folder. The default folder is inside
the data directory, and every backup is mode `0700`/`0600`, so nothing new is exposed. Only a
folder you choose yourself can move them somewhere less private; admin only in multi-user mode.
## Reporting a vulnerability
**Never in a public issue.**
+9
View File
@@ -190,6 +190,14 @@ Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group r
tools with their versions and install hints (admin only in multi-user mode). In multi-user
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). 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).
## Session Options
Per session, from the tab.
@@ -244,3 +252,4 @@ Some things are configured before the server starts, not in the UI:
- [The Dashboard](The-Dashboard) - what each control does once visible.
- [Keeping Agents Running](Keeping-Agents-Running) - the Respawn panel in depth.
- [Agent CLIs](Agent-CLIs) - model, effort, and permission modes.
- [Config Backups](Config-Backups) - copies of your settings, and how to restore them.
+26
View File
@@ -131,6 +131,32 @@ That prompt comes from Claude Code, not Codeman. Codeman's default is to start w
permission prompts skipped, which is what the security model is built around. If you would
rather it prompted, change **App Settings → Agents & CLIs → Claude → Startup Mode**.
### My settings were reset, or a CLI I enabled is gone
Something rewrote a file in `~/.codeman/` (a hand edit, a restart that loaded a bad file, a
test run pointed at the real data directory). If [Config Backups](Config-Backups) was on,
which is the default, you have copies:
1. Open **Settings → System → Config backups** and pick the newest backup from before the
change. The list shows when each was made and what it holds.
2. **Restore** it, reload the page, and restart Codeman if the response's `restartRequired`
list is not empty.
A reset `settings.json` also drops the backup folder setting. If you use a custom folder, enter
it again and save before looking for the list; until then the list shows the default folder.
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/`.
Not covered: `state.json` (cron jobs and respawn presets live there). A backup only exists
from the moment the feature first ran, so a reset that happened before that cannot be undone.
### Sessions vanished after a reboot
Expected. tmux does not survive a reboot, so the sessions are gone. Conversations are not:
+1
View File
@@ -51,6 +51,7 @@
**Operating it**
- [Running As A Service](Running-As-A-Service)
- [Config Backups](Config-Backups)
- [Troubleshooting](Troubleshooting)
- [FAQ](FAQ)
- [Contributing](Contributing)