mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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? |
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
**Operating it**
|
||||
|
||||
- [Running As A Service](Running-As-A-Service)
|
||||
- [Config Backups](Config-Backups)
|
||||
- [Troubleshooting](Troubleshooting)
|
||||
- [FAQ](FAQ)
|
||||
- [Contributing](Contributing)
|
||||
|
||||
Reference in new issue
Block a user