---
title: Backup Mirror (encrypted off-machine snapshot) (part 2)
---

# Backup Mirror (encrypted off-machine snapshot) (part 2)

## What it is

This is part 2 of the [Backup Mirror (encrypted off-machine snapshot)](backup-mirror.md) page. It carries first-time activation, the automatic-sync modes, the encryption envelope the archives use, the archive's internal storage layout, retention, the defences the restore path applies to an untrusted archive, and the IPC channels the settings panel drives.

## Where to find it

Backup Mirror is configured at Settings → Backup & Restore → Backup Mirror, where the folder, passphrase, retention and Enable toggle live and where the mirror and restore cards report status; [Backup Mirror (encrypted off-machine snapshot)](backup-mirror.md) walks that panel end to end, including the two restore paths and the two-device Sync now flow. The reference tables and mechanics on this page are read from the same panel or from the code paths named below.

## How it behaves

### Activation (first-time setup)

Three small conveniences make first-time setup near-one-click. They are pure UX around the same encrypted write/restore core — they change nothing about the crypto or the no-escrow model:

- **Cloud-folder detection.** Omniscio probes for existing Dropbox / OneDrive / iCloud Drive / Google Drive roots (reading the `%OneDrive%` env var and Dropbox's own `info.json`, so a relocated install is still found) and offers each as a one-click chip that targets `<root>/Omniscio-mirror`. Read-only + best-effort — an empty result just means "Browse manually" — and the subfolder is created on the first write (its parent, the detected root, already exists). Channel: `backup-mirror:detect-cloud-folders`.
- **Passphrase generator.** **Generate strong passphrase** produces a diceware-style, typeable passphrase (≈56 bits) in the app window — it never crosses the IPC boundary. A mandatory **I've saved this passphrase** checkbox gates the first write, scoped to genuine first-time setup (`lastBackupMirrorAt` empty) so it never blocks an already-working mirror.
- **Auto-sync default.** Enabling the mirror for the first time sets **Automatic sync** to `session` (see [Automatic sync](#automatic-sync-hands-off-two-devices)) — only at first setup, and never overriding a returning user's deliberate Off.

Implementation: `src/shared/backup-mirror-activation.ts` (generator + the enable predicate), `src/main/services/backup/detect-cloud-folders.ts` (detector). Locked by the Activation invariants in `backup-mirror-contract.md`.

### Automatic sync (hands-off, two devices)

Sync now is one click — but you can also have Omniscio do it on its own. **Settings → Backup & Restore → Backup Mirror → Automatic sync** is a dropdown with three choices (`backupMirrorAutoSyncMode`):

- **Off** — nothing automatic; the manual **Sync now** / **Mirror now** buttons are the only path. (`off` is the stored default, but the FIRST time you enable the mirror Omniscio bumps you to `session` below — only at that genuine first setup, and never overriding a returning user who set it back to Off. See [Activation defaults](#activation-first-time-setup).)
- **When I open & close Omniscio** (`session`) — Omniscio does a **pull-then-push** when it launches (receives the other device's latest _and_ sends yours) and a fast **pull-only** catch-up when you quit. Once per session — the sane cadence for a snapshot of ~1–3 GB before compression, and enough to keep two devices in step. (The send happens at launch, not quit, so a big write can never hang the app while it closes — see below.)
- **On a schedule** (`scheduled`) — the same pull-then-push on a coarse timer you pick: every 1 / 3 / 6 / 12 hours (`backupMirrorAutoSyncIntervalMinutes`). Sub-hourly is intentionally not offered — each snapshot can be 1–3 GB before compression.

**It never rewrites a big archive for nothing.** Two cheap checks gate the heavy work:

- **Pull short-circuit** — a stat-only look at the folder; if no archive newer than the one it last pulled (`lastAutoSyncPulledMirrorId`) has appeared, it skips the decrypt entirely.
- **Push short-circuit** — a cheap fingerprint of your database (newest message row + time, newest session row, `lastPushedMirrorChangeMarker`). If nothing changed since the last mirror, the push is skipped — so "open Omniscio, glance at it, close it" writes nothing. The marker errs toward pushing, never toward skipping a real change. The mirror that rides a local backup uses the SAME fingerprint, and stamps it on success, so a backup round that carried no new data costs no write and the next scheduled push does not re-encrypt a bundle that is already current.

The push is deliberately **always preceded by a pull** (never push-only), because the retention prune sweeps the whole folder newest-first — pulling first guarantees the other device's archive is merged before any prune could remove it. **The push runs at LAUNCH and on the scheduled timer, NEVER on the shutdown path** (F1): `writeBackupMirror` is a multi-GB snapshot + streamed encrypt-and-write to a user-chosen (often cloud) folder — so a shutdown push could hang app quit past the 15s safety net (and an offline cloud folder would block forever). It streams now (memory-bounded, non-blocking main thread), which removes the freeze risk, but it stays off the quit path because the write itself can still take a long time or block on an offline mount. Quitting does a fast pull-only; this device's work propagates on the next launch's push. Also: the pull only advances its "already seen" marker when the folder was fully readable, so a foreign archive still uploading is re-checked next time instead of skipped forever.

**If an automatic push of a fully-configured mirror can't complete** — the folder vanished, the disk is full, a write errored — Omniscio raises a persistent **"Backup Mirror sync is failing"** card in the inbox so a broken off-site mirror isn't silently buried in the settings panel. It's reconciled on every push and backfilled when Omniscio opens (so a failure from last shutdown isn't missed), and it clears itself the moment a push succeeds again.

**If the mirror is enabled but not finished** — you flipped the toggle on but haven't set a passphrase (or picked a folder) yet — Omniscio shows a separate, gentle **"Finish setting up Backup Mirror"** reminder (tailored to what's missing) instead of the alarming failure card. Nothing is backed up off-site until setup is complete, so the reminder is the honest signal — not a runtime error about a mirror that never ran. It appears the moment you enable an unconfigured mirror, is re-checked at every launch (regardless of auto-sync mode), and clears itself the instant you finish setup or turn the mirror off. This is why turning the mirror on without a passphrase no longer produces a misleading "sync is failing" notice. See `backup-failure-alert-contract.md`.

**Status you can see.** When automatic sync is on, the Settings panel shows **Last synced …** plus, when relevant, **"New work available from your other device"** (a newer foreign archive exists) or **"This device has changes to send"** (your DB changed since the last push). Failures surface as a plain message rather than failing silently.

**What it is not.** Not live/continuous sync — it fires at launch, at quit, or on the timer, and relies on your cloud-sync client to move the files between devices. A brand-new device still needs the folder + passphrase entered once (the passphrase can't be auto-derived without defeating the encryption); after that one-time pairing it's hands-off.

Behind the scenes: `src/main/services/backup/backup-mirror-auto-sync.ts` — `runLaunchPull()` → `runAutoSync()` (launch: pull **and** push), `runAutoSync({ push: false })` inside `gracefulShutdown()` (shutdown: pull-only, before the early config flush), `refreshAutoSyncScheduler()` (the `scheduled` timer: pull **and** push). The launch pull+push is a deferred startup task that runs after the window is shown, so a slow write is never on the quit critical path; the timer is (re)armed live when you change the mode, the interval, or the **Enable** toggle. All three modes additionally require the master toggle plus a folder and a passphrase (`isBackupMirrorFullyConfigured`) — turning the mirror off stops the scheduled push and disarms the timer immediately, not at the next restart.

### Encryption

Same key derivation and cipher as [Setup Backup to Gmail](setup-backup.md): AES-256-GCM with a PBKDF2-SHA256 key at 600,000 iterations. The salt and the base nonce are random per archive, so two mirrors of identical state produce different ciphertexts. The file is a stream of frames rather than one sealed blob: a fixed header (the marker `AMCSTRM1`, a stream version, the key-derivation mode, the salt, the base nonce and the frame size), then frames of up to 1 MiB, each carrying its own length, a final-frame flag, its ciphertext and its own 16-byte AES-GCM tag. The header is bound into every frame's authentication and each frame's nonce comes from its position plus the final-frame flag, so a reordered, duplicated, dropped or truncated frame fails decryption just like a tampered byte.

**Compression happens before encryption.** The whole archive is compressed with zstd first and only then split into frames — never frame by frame, because a frame's ciphertext is capped at 1 MiB and an incompressible chunk grows slightly. Ciphertext cannot be compressed, so this is the only point where the file can shrink, and a database-heavy mirror ends up much smaller than the state it holds. This is stream version 2, the version written now. Mirrors written before compression (stream version 1: the same frames with no compression step) still restore — the reader takes the version from the header and decompresses only version 2 — and so do the oldest whole-buffer archives, whose envelope is `[salt(16) | iv(12) | ciphertext(N) | authTag(16)]`.

Wrong passphrase or any tampered byte fails decryption — there is no partial recovery, no oracle, no graceful degradation.

The passphrase is stored locally in `config.json` under `backupMirrorPassphrase`, safeStorage-encrypted with the `enc:` prefix (same scheme as OAuth tokens). It is never written into the archive and never crosses the IPC boundary outward — `backup-mirror:get-status` returns only `hasPassphrase: boolean`.

Implementation: `src/main/services/backup/mirror-stream-crypto.ts` (the stream envelope and its compression step, shared with Portable Backup) and `src/main/services/backup/setup-backup-crypto.ts` (the key derivation shared with Setup Backup, and the older whole-buffer envelope).

### Storage layout

Each archive lives at `<mirrorPath>/mirror-<id>.amcmirror` where `<id>` is the ISO timestamp at write time with `:` and `.` replaced by `-` (so it's filesystem-safe and time-orderable). The archive's internal layout, once decrypted and decompressed:

```
manifest.json          // schema/version/byte-counts; first thing the restore reads
mission-control.db     // VACUUM'd snapshot
config.json            // settings + prefs (provider/integration secrets stripped or machine-bound — re-enter on a new machine)
attachments/           // entire tree, preserved with subdirectory structure
  …
claude-code/           // Claude Code files from ~/.claude/ (optional)
  CLAUDE.md            // global rules
  projects/
    <project-key>/
      CLAUDE.md        // per-project instructions (if present)
      memory/          // per-project auto-memory
        …
skills/                // user-authored ~/.claude/skills/ (optional)
  <skill-name>/        // one dir per skill — SKILL.md + any scripts/assets
    …
global-claude-config/  // ~/.claude/ config allowlist (optional; never secrets/creds)
  settings.json        // permissions, hooks, model/statusline prefs
  keybindings.json     // custom shortcuts (if present)
  scripts/             // deployed automation scripts (if present)
    …
recipes/               // ~/.claude/recipes/*.recipe.json definitions (optional)
  <name>.recipe.json   // file-based recipe defs (DB rows travel separately)
vault/                 // KMS/Nothari vault: notes + assets (optional)
  <note>.md            // user-authored notes (any subdirectory depth)
  assets/              // image store the notes reference
    …
project-notes/         // per-project free-text notes (optional)
  <projectId>.md       // one file per project
books/                 // user-imported Books-plugin books (always, when any exist)
  <bookId>/            // one dir per book — EPUB/PDF + cover
    …
google-workspace-mcp/  // google-workspace MCP audit store (when configured)
  workspace.db         // audit_log + batch history — the DB only, never credentials/
arij-attachments/      // Arij issue attachments (optional)
  <issueId>/           // one subfolder per issue
screenshots/           // captured screenshots (always, when any exist)
  <uuid>.png           // + annotation sidecars
screen-recordings/     // screen recordings (opt-in — backupMirrorIncludeScreenRecordings)
  …                    // MP4s, segments, thumbnails (excludes the screenshots subdir)
```

Writes are atomic — Omniscio writes to `mirror-<id>.amcmirror.tmp` first, then `renameSync`s to the final name. Cloud-sync clients can otherwise pick up a partially-written file and replicate the corruption.

### Retention

After every successful write, Omniscio reads the mirror folder, sorts archives newest-first, and `unlink`s anything beyond the configured count (`backupMirrorRetentionCount`, default 5). Only files matching `mirror-*.amcmirror` are touched — random files you've dropped in that folder are left alone. The retention sweep is best-effort: failure to delete an old file is logged but does not fail the mirror write.

**Cross-device-safe.** When two devices share the folder, the prune keeps the newest `retentionCount` archives folder-wide **and** the newest archive of every _other_ device, so one device can never delete another device's latest snapshot before it has been pulled and merged. Each archive's filename encodes the writing device (its id is `<time>.<hostslug>.<random>`), so the prune tells devices apart without decrypting; the random suffix also stops two devices writing in the same millisecond from colliding on one filename. A single-device user sees identical behaviour (its own archives still prune folder-wide to `retentionCount`).

### Restore safety

The decrypt path defends against more than honest mistakes — a passphrase-encrypted archive still arrives over an untrusted folder (a Dropbox share, a USB stick, a network drive a coworker has access to). Defenses:

- **AEAD failure means hard stop.** A wrong passphrase or tampered byte fails GCM authentication; the restore throws `Failed to decrypt mirror — wrong passphrase or tampered file` before any byte is staged.
- **Manifest validation.** A missing or malformed `manifest.json`, a `manifestVersion` other than 1 or 2, or a missing required field throws before any DB write. Unsupported versions get an explicit message so the user knows whether to upgrade Omniscio or use a different archive.
- **Manifest `id` charset guard.** The archive's self-reported `id` is validated to the writer's charset (`[A-Za-z0-9._-]`, no `..`) before it can be interpolated into a temp file path on the merge path — an unvalidated `id` could otherwise steer a write out of the userData dir. Passphrase-gated (the archive must decrypt first), so this is defense-in-depth.
- **DB-missing-from-archive.** An archive without `mission-control.db` (e.g. a partial or corrupt ZIP) throws and cleans up any staging dir that the staging-phase touched.
- **Schema mismatch (merge only).** The schema-version gate above. Replace mode is allowed to cross schemas because the on-disk DB carries its own version and Omniscio's startup migrations will upgrade it.
- **Pre-restore snapshots are mandatory (replace only).** If the snapshot copy fails (disk full, I/O error, permission denied), the restore aborts before staging any files. The user's current state is preserved. There is no "skip the snapshot and restore anyway" fallback.
- **Atomic swap.** The swap step is the last thing to run. If a crash occurs between staging and swap, the live state is still intact and a restart resumes the swap. If a crash occurs during the swap (rare — `renameSync` is atomic), the next startup completes whatever steps remained.

## For agents

### IPC channels

| Channel                        | Direction       | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `backup-mirror:get-status`     | renderer → main | Fetch the panel state — `enabled`, `hasPassphrase` (boolean projection), `mirrorPath`, `lastBackupMirrorAt`, `lastBackupMirrorError`, `retentionCount`, plus the auto-sync fields `autoSyncMode`, `autoSyncIntervalMinutes`, `lastAutoSyncAt`, `remoteHasNewer`, `localHasUnsentChanges`. The plaintext passphrase **never** crosses outward.                                                                                                                                                                                        |
| `backup-mirror:run-now`        | renderer → main | Force an immediate mirror write (synchronous — the IPC path has no request deadline, so it returns the manifest payload for the panel toast). On success writes `lastBackupMirrorAt`, clears `lastBackupMirrorError`, and clears the backup-mirror-failing inbox card. On failure records `lastBackupMirrorError`, raises that inbox card, and rethrows so the panel surfaces the message. (The twin CLI route `POST /backup-mirror/run-now` instead runs the write in the BACKGROUND and returns `202` at once — see the contract.) |
| `backup-mirror:sync-now`       | renderer → main | One-button **Sync now**: pull the newest _other_-device archive (merge) then push a fresh snapshot of this device. Pull-then-push so the retention prune can't drop the foreign archive first. Reads the stored passphrase in main (never over IPC); returns per-half `{ pull, push }` outcomes so a failed push never hides a merged pull.                                                                                                                                                                                          |
| `backup-mirror:list`           | renderer → main | List `.amcmirror` files in the configured folder — stats-only (id, absolutePath, sizeBytes, modifiedAt). Does NOT decrypt or open any archive. Returns an empty array when `backupMirrorPath` is unset.                                                                                                                                                                                                                                                                                                                              |
| `backup-mirror:pick-folder`    | renderer → main | Open an OS directory picker with `createDirectory` permission for the "Pick folder" button. Returns `{ folderPath: string \| null }` — null when the user cancels.                                                                                                                                                                                                                                                                                                                                                                   |
| `backup-mirror:restore`        | renderer → main | Restore from a previously-written archive. Mode = `replace` or `merge-conversations`. Replace stages files for next-startup swap and returns `requiresRestart=true`; merge applies directly to the live DB.                                                                                                                                                                                                                                                                                                                          |
| `backup-mirror:browse-preview` | renderer → main | Open an OS file picker filtered for `.amcmirror`, decrypt the manifest, mint a provenance ticket. Returns preview data (manifest summary, sameHostname flag) + a single-use ticket. Returns error `'cancelled'` when the user dismisses the picker.                                                                                                                                                                                                                                                                                  |
| `backup-mirror:browse-execute` | renderer → main | Redeem a provenance ticket from `browse-preview` and execute `restoreFromMirror`. The renderer never names a filesystem path — the ticket maps back to the path the user picked. A stale/replayed ticket returns an error envelope.                                                                                                                                                                                                                                                                                                  |

Handlers live in `src/main/ipc/backup-mirror-handlers.ts`; the writer in `src/main/services/backup/backup-mirror-service.ts`; the restore in `src/main/services/backup/backup-mirror-restore-service.ts`.

## Related

This page is the companion to [Backup Mirror (encrypted off-machine snapshot)](backup-mirror.md), which covers the feature itself: what gets backed up, when it writes, the restore flow and resume after restore. [Setup Backup to Gmail](setup-backup.md) is the lighter, configuration-only sibling that shares this page's crypto envelope, [Portable Backup](portable-backup.md) is the export that escrows a recovery code instead, and [tray-and-window.md](tray-and-window.md) covers the graceful-shutdown sequencing the pending-restore swap depends on.
