From 139c724b5f20d136de48c2e80506b3915ac2141c Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Mon, 10 Aug 2026 00:38:00 -0400 Subject: [PATCH 01/15] docs: Add OpenClaw snapshot and redeploy design Design for snapshotting the OpenClaw automation host for easy redeploy to a new VM. Approach: clean rebuild + state via openclaw backup create, separate age-encrypted secrets bundle (keypair model), and core-repo-only re-clone driven by config/core-repos.txt + the org profile. Ref epic rossoctl/automation#32 org-portability work. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- ...08-10-openclaw-snapshot-redeploy-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/specs/2026-08-10-openclaw-snapshot-redeploy-design.md diff --git a/docs/specs/2026-08-10-openclaw-snapshot-redeploy-design.md b/docs/specs/2026-08-10-openclaw-snapshot-redeploy-design.md new file mode 100644 index 0000000..66771ca --- /dev/null +++ b/docs/specs/2026-08-10-openclaw-snapshot-redeploy-design.md @@ -0,0 +1,200 @@ +# OpenClaw Snapshot & Redeploy Design + +**Date:** 2026-08-10 +**Status:** Design (awaiting review) +**Related:** org-portability design (`docs/specs/2026-07-29-org-portability-design.md`), `config/core-repos.txt`, `config/org.env` + +## Problem + +The automation suite (scanners, fixers, standing orders, cron jobs) runs on a single +OpenClaw host. The disk holding that host's state will be migrated soon and may move to a +different virtual machine. Today there is no repeatable way to reconstruct the host: the +runtime, the OpenClaw state, the agent workspaces, the credentials, and the automation +source clones are all entangled on one disk, with no documented boundary between what is +authoritative and what is regenerable. + +The goal is a **snapshot** that lets an operator redeploy on a new VM and have the crons and +automation "simply work," without hand-reconstructing the host from memory. + +## Goal + +Produce a small, portable snapshot plus a runbook such that, on a fresh VM, a single +`restore.sh` run reconstructs a working OpenClaw instance: config, cron jobs, credentials, +per-agent workspaces, memory, the core automation clones, and the service plumbing. + +**Non-goals:** a raw disk image; preserving regenerable artifacts (`node_modules`, package +caches, downloaded runtimes); preserving runtime scratch (logs, delivery queues, session +transcripts, cron run history); reproducing non-core repo clones. + +## Approach: clean rebuild + state + +The snapshot is **not** a disk image. It is three artifacts plus a generated runbook: + +1. **State archive** — the authoritative OpenClaw state, produced by the first-class + `openclaw backup create` command (see OpenClaw docs `cli/backup`). This captures the + state directory (`~/.openclaw`), the active config file, the external `credentials/` + directory, and the configured per-agent workspaces. It automatically **excludes** + live-mutation paths (logs, delivery queue, session `.jsonl`, `cron/runs`) and rebuildable + runtime roots (`dev/`, `git/`, `npm/`, `tools/`, plugin `node_modules/`). + +2. **Secrets bundle** — a *separately* captured, encrypted blob of the sensitive files that + must survive migration but must never sit in the state archive (see "Secrets" below). + +3. **Manifest + runbook** — a generated `manifest.json` recording the reconstruction facts + (versions, service unit, gateway port, per-core-repo git state) and a `RUNBOOK.md` with + the ordered restore steps derived from it. + +On restore, the new VM installs OpenClaw fresh (pinned to the captured version), restores the +state archive, decrypts and places the secrets, re-clones only the core repos at their +recorded branches, installs the service unit, and starts the gateway. Regenerable material is +rebuilt by the install rather than carried in the snapshot. + +### Why clean rebuild over bit-for-bit + +The automation source clones under the clone root total ~900 MB on disk, of which the large +majority is `.git` history. Re-cloning from the remotes reduces that to a few KB of manifest +text and eliminates accumulated drift and stale in-state backups. The tradeoff — that +re-cloning only reproduces what is on the remote — is safe here: verification showed **no +core repo carries unpushed commits**; the only local state is checked-out branches and a few +KB of dirty diffs, which the snapshot captures explicitly as patches. + +## What is authoritative vs regenerable + +| Category | Examples | Snapshot treatment | +|----------|----------|--------------------| +| OpenClaw state | config, cron jobs, credentials dir, agent SQLite, memory | State archive (authoritative) | +| Agent workspaces | per-agent working dirs mapped via the config `workspace` field (memory, `AGENTS.md`, heartbeat/identity docs, dreams) | State archive via workspace discovery (authoritative — NOT re-cloned) | +| Secrets | `~/.openclaw/.env`, service env file, `~/.npmrc` token, SSH private key, PATs | Encrypted secrets bundle (out-of-band) | +| Core automation clones | the repos in `config/core-repos.txt` | Re-cloned at recorded branch + patched (manifest) | +| Runtime | OpenClaw version, node version, service unit, gateway port | Manifest facts (reinstalled) | +| Regenerable | `node_modules`, package caches, downloaded runtimes, plugin deps | Dropped; rebuilt by install | +| Runtime scratch | logs, delivery queue, session transcripts, cron run history, in-state `*.bak`/`*.clobbered` config copies | Dropped (excluded by `openclaw backup`) | +| Non-core clones | any clone not in `config/core-repos.txt` | Not reproduced; re-clone on demand later | + +**Key correction from initial assumptions:** the per-agent workspace directories are +authoritative agent state (they hold memory and identity docs), captured by `openclaw backup` +via workspace discovery — they are NOT version-controlled and must NOT be treated as +re-clonable. Conversely, the automation clones ARE re-clonable and should not be archived +wholesale. + +## Secrets + +Secrets are captured as a **separate encrypted artifact**, never embedded in the state +archive, so the state archive can be shared for debugging without leaking credentials, and +the two can live in different storage with different retention. + +### Encryption: age, keypair model + +Encryption uses [`age`](https://age-encryption.org) with an asymmetric keypair: + +- The operator generates a keypair once with `age-keygen`. The **private key stays off every + VM** (operator's laptop or password manager). +- The **public key** (an encrypt-only capability) is placed on the host / in the toolkit + config. `snapshot-secrets.sh` encrypts to it with `age -r `. +- Decryption at restore requires the private key: either decrypt on the operator's laptop and + push plaintext over the (already-encrypted) SSH channel, or copy the private key to the new + VM briefly, `age -d -i `, then shred it. + +`age` is chosen over `gpg` for the minimal keypair workflow (three commands, no keyring/trust +model). Because `age` is not assumed present on any host, the toolkit ships a **self-contained +static `age` binary** rather than depending on a system package manager or `sudo`. The binary +rides along in the snapshot so the new VM can decrypt without a bootstrap dependency. + +### Secrets in scope + +The service env file, the OpenClaw env file, the npm registry token, the SSH private key, and +any stored PAT files. Channel credential/allowlist files under the state's `credentials/` +directory travel inside the state archive (they are part of OpenClaw state); the separate +secrets bundle covers host-level secrets outside the state directory. + +## Repo handling (core repos only) + +The set of repos to reproduce is **not hardcoded**. `restore.sh` reads `config/core-repos.txt` +(bare repo names) and the loaded org profile (`config/org.env`: `ORG`, `FORK_OWNER`, +`REPOS_DIR`), exactly as the rest of the suite does per the org-portability design. This keeps +the snapshot org-agnostic: pointing the suite at a different org reproduces that org's core +repos with no change to the toolkit. + +For each core repo the manifest records `{ path, origin, branch, dirty, unpushed }`. Restore +clones the repo, checks out the recorded branch, and re-applies any captured dirty diff +(saved as a patch at capture time). Non-core clones are intentionally not reproduced. + +## Components + +The toolkit lives in this repo under `snapshot/`. Each script has one responsibility, a +clear interface, and is independently runnable. + +- **`snapshot-state.sh`** — guarded wrapper around `openclaw backup create --verify --output + `. Preflight: gateway healthy, sufficient disk, config valid (falls back to + `--no-include-workspace` if the config is invalid, per the backup docs). Emits the backup + JSON result (resolved sources, `skippedVolatileCount`). Depends on: the `openclaw` CLI. + +- **`snapshot-secrets.sh`** — tars the in-scope secret files and pipes through + `age -r ` to a single `secrets.age`. Never writes plaintext to disk. Prints a + checklist of captured file **names** (never values). Depends on: the bundled `age` binary + and the operator's public key. + +- **`snapshot-manifest.sh`** — read-only introspection that generates `manifest.json` and + `RUNBOOK.md`: OpenClaw version, node version, service unit contents, gateway port, and the + per-core-repo git state. Depends on: `git`, the service manager query, the org profile + + `core-repos.txt`. + +- **`snapshot.sh`** — top-level driver. Runs the three capture scripts into one dated output + directory. Refuses to overwrite an existing dated directory. + +- **`restore.sh`** — the reverse, run on the new VM from inside the snapshot directory. + Executes the ordered flow, fail-fast, and finishes with a verification pass. + +## Artifact layout + +One `snapshot.sh` run produces a single dated directory: + +``` +openclaw-snapshot-YYYY-MM-DD/ +├── state.tar.gz # openclaw backup create --verify output +├── secrets.age # encrypted host-level secrets +├── manifest.json # versions, service unit, gateway port, per-core-repo git state +├── RUNBOOK.md # ordered restore steps, generated from the manifest +└── age # self-contained static binary, so restore can decrypt unaided +``` + +The operator copies this directory to the new VM (scp/rsync over SSH) and runs `restore.sh` +from inside it. The operator carries only the private key separately. + +## Restore flow + +`restore.sh` runs these steps in order, stopping on the first failure. Each step is guarded so +a half-failed restore can be re-run: + +1. Bootstrap `age` (from the bundled binary). +2. Install OpenClaw pinned to the manifest version, plus the recorded node version. +3. Decrypt `secrets.age` (operator provides the private key) and place the secret files. +4. Restore `state.tar.gz` (OpenClaw state, config, credentials, agent workspaces, memory). +5. Re-clone the core repos into `REPOS_DIR`, checkout each recorded branch, re-apply patches. +6. Install and enable the service unit; start the gateway. +7. Verify. + +## Error handling & verification + +- **Capture-time verification** — `openclaw backup create --verify` validates the state + archive immediately after writing it, so corruption is caught while re-capture is still + possible, not at restore. +- **Secrets checklist** — capture prints the names of files included (never contents) so the + operator can confirm nothing is missing. +- **Drift is reported, not silently lost** — the manifest flags any core repo with dirty or + unpushed work; captured patches carry the dirty diffs forward. +- **Restore verification** — the final step compares the live cron job count and configured + agent list against the manifest and prints a diff. A mismatch fails the restore loudly. +- **No-overwrite guarantees** — `snapshot.sh` refuses to reuse an existing dated directory; + `openclaw backup` refuses to overwrite an existing archive and rejects self-inclusion. + +## Open questions / future work + +- **Scheduling** — whether `snapshot.sh` should run on a cron for periodic backups, or only + on-demand before a migration. The keypair model supports unattended capture (the host holds + only the public key), so a scheduled variant is feasible later. +- **Retention & destination** — where snapshot directories are stored off-host and how long + they are kept. Out of scope for this design. +- **Restore-time OpenClaw version drift** — if the pinned version is no longer installable, + the runbook should document the nearest-compatible upgrade path. +``` \ No newline at end of file From 04779fc5a5ea8a740370ca9eadd7c719e84b9fe7 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Wed, 26 Aug 2026 16:14:04 -0400 Subject: [PATCH 02/15] chore: Pin openclaw backup and age contracts for snapshot toolkit Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/README.md | 92 +++++++++++++++++++ .../snapshot/backup-create.dryrun.json | 39 ++++++++ 2 files changed, 131 insertions(+) create mode 100644 snapshot/README.md create mode 100644 tests/fixtures/snapshot/backup-create.dryrun.json diff --git a/snapshot/README.md b/snapshot/README.md new file mode 100644 index 0000000..03e9ff2 --- /dev/null +++ b/snapshot/README.md @@ -0,0 +1,92 @@ +# OpenClaw Snapshot Toolkit + +Capture an OpenClaw automation host into a small, portable snapshot and restore it +on a fresh VM so cron jobs and automation "simply work." See the design spec at +`docs/specs/2026-08-10-openclaw-snapshot-redeploy-design.md`. + +The snapshot is **not** a disk image. It is three artifacts plus a generated runbook, +produced into one dated directory: + +``` +openclaw-snapshot-YYYY-MM-DD/ +├── state.tar.gz # openclaw backup create --verify output +├── secrets.age # age-encrypted host-level secrets (never plaintext on disk) +├── manifest.json # versions, service unit, gateway port, per-core-repo git state +├── RUNBOOK.md # ordered restore steps, generated from the manifest +└── age # self-contained static binary, so restore can decrypt unaided +``` + +## Contracts + +These are the external contracts the toolkit depends on, pinned from the real host +(`OpenClaw 2026.5.12`) so downstream scripts reference facts rather than guesses. +Re-pin them if the OpenClaw version changes. + +### `openclaw backup` subcommands + +`openclaw backup` exposes exactly two subcommands: + +- **`create`** — writes a backup archive (config, credentials, sessions, workspaces). +- **`verify `** — validates an archive and its embedded manifest. + +There is **no `restore` subcommand.** Restore is performed by extracting the archive +tarball into place and then validating it with `openclaw backup verify`. `restore.sh` +must not invoke a nonexistent `openclaw backup restore`. + +### `openclaw backup create` flags + +| Flag | Meaning | +|------|---------| +| `--output ` | Archive path or destination directory | +| `--verify` | Verify the archive immediately after writing it | +| `--json` | Emit machine-readable JSON | +| `--dry-run` | Print the plan without writing the archive | +| `--only-config` | Back up only the active JSON config file | +| `--no-include-workspace` | Exclude agent workspace directories | + +### `openclaw backup create --json` result shape + +Pinned from `openclaw backup create --dry-run --json`. A non-dry-run create emits the +same top-level shape with `dryRun: false` and `verified` reflecting `--verify`. A +sanitized sample (generic paths, no host identity) lives at +`tests/fixtures/snapshot/backup-create.dryrun.json`. + +Top-level fields: + +| Field | Type | Notes | +|-------|------|-------| +| `createdAt` | string | ISO-8601 timestamp | +| `archiveRoot` | string | Directory name inside the tarball | +| `archivePath` | string | **Absolute path to the written `.tar.gz`** (the archive-path field) | +| `dryRun` | boolean | `true` only under `--dry-run` | +| `includeWorkspace` | boolean | `false` under `--no-include-workspace` | +| `onlyConfig` | boolean | `true` under `--only-config` | +| `verified` | boolean | `true` when `--verify` succeeded | +| `assets` | array | Included sources (see below) | +| `skipped` | array | Sources deliberately not archived | +| `skippedVolatileCount` | number | Count of volatile paths auto-excluded (logs, queues, session `.jsonl`, cron runs) | + +`assets[]` element: `{ kind: "state" | "workspace", sourcePath, displayPath, archivePath }`. + +`skipped[]` element: `{ kind, sourcePath, displayPath, reason, coveredBy }` — e.g. the +in-state `~/.openclaw/workspace` is skipped with `reason: "covered"`, `coveredBy: "~/.openclaw"`. + +### `age` encryption (keypair model) + +Secrets are encrypted with [`age`](https://age-encryption.org) using an asymmetric +keypair. The private key never touches any VM. + +```sh +# One-time, on the operator's machine only. Private key stays here (or a password manager). +age-keygen -o ~/openclaw-snapshot.key +# Prints the PUBLIC key (age1...) to stdout; record it. The file holds the PRIVATE key. + +# Capture time (host holds only the public key — encrypt-only capability): +age -r age1 < plaintext > secrets.age # snapshot-secrets.sh does this via a tar pipe + +# Restore time (operator supplies the private key): +age -d -i ~/openclaw-snapshot.key < secrets.age > plaintext +``` + +`age` is not assumed present on any host, so the toolkit ships a self-contained static +`age` binary in `snapshot/bin/age` and rides a copy along in each snapshot directory. diff --git a/tests/fixtures/snapshot/backup-create.dryrun.json b/tests/fixtures/snapshot/backup-create.dryrun.json new file mode 100644 index 0000000..f1bb52a --- /dev/null +++ b/tests/fixtures/snapshot/backup-create.dryrun.json @@ -0,0 +1,39 @@ +{ + "createdAt": "2026-01-01T00-00-00.000Z", + "archiveRoot": "2026-01-01T00-00-00.000Z-openclaw-backup", + "archivePath": "/home/acme/2026-01-01T00-00-00.000Z-openclaw-backup.tar.gz", + "dryRun": true, + "includeWorkspace": true, + "onlyConfig": false, + "verified": false, + "assets": [ + { + "kind": "state", + "sourcePath": "/home/acme/.openclaw", + "displayPath": "~/.openclaw", + "archivePath": "2026-01-01T00-00-00.000Z-openclaw-backup/payload/posix/home/acme/.openclaw" + }, + { + "kind": "workspace", + "sourcePath": "/home/acme/workspaces/main", + "displayPath": "~/workspaces/main", + "archivePath": "2026-01-01T00-00-00.000Z-openclaw-backup/payload/posix/home/acme/workspaces/main" + }, + { + "kind": "workspace", + "sourcePath": "/home/acme/workspaces/dev", + "displayPath": "~/workspaces/dev", + "archivePath": "2026-01-01T00-00-00.000Z-openclaw-backup/payload/posix/home/acme/workspaces/dev" + } + ], + "skipped": [ + { + "kind": "workspace", + "sourcePath": "/home/acme/.openclaw/workspace", + "displayPath": "~/.openclaw/workspace", + "reason": "covered", + "coveredBy": "~/.openclaw" + } + ], + "skippedVolatileCount": 0 +} From 7b0d26aedf202ca02e54c9663e6e2a71f4de4e34 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Thu, 27 Aug 2026 08:14:17 -0400 Subject: [PATCH 03/15] feat: Add snapshot lib helpers (secret allowlist, version readers) Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/lib-snapshot.sh | 82 +++++++++++++++++++++++++++++ tests/test-lib-snapshot.sh | 105 +++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 snapshot/lib-snapshot.sh create mode 100755 tests/test-lib-snapshot.sh diff --git a/snapshot/lib-snapshot.sh b/snapshot/lib-snapshot.sh new file mode 100644 index 0000000..91e5cbf --- /dev/null +++ b/snapshot/lib-snapshot.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Shared helpers for the OpenClaw snapshot toolkit. Keeps the entry-point +# scripts (snapshot-*.sh, restore.sh) thin. +# +# Source this at the top of a snapshot script: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$SCRIPT_DIR/lib-snapshot.sh" +# +# Portability: bash 3.2+ (macOS default). No bash-4-only features. +# +# Contract: NEVER print secret file contents. snapshot_secret_paths emits +# path NAMES only; reading/encrypting the contents is snapshot-secrets.sh's job. + +# Guard against double-sourcing. +if [ -n "${_LIB_SNAPSHOT_LOADED:-}" ]; then + return 0 +fi +_LIB_SNAPSHOT_LOADED=1 + +# Print the host-level secret files that exist, one absolute path per line. +# +# These are the secrets that must survive migration but must NEVER sit in the +# openclaw state archive: the OpenClaw env file, the gateway service env file, +# the npm registry token, stored PAT files, and the SSH private key. The base +# directory is $SNAPSHOT_HOME (default $HOME) so the allowlist can be exercised +# hermetically against a fixture tree. +# +# Absent candidates are silently skipped. Only names are printed, never the +# contents of any file. Exits 0 even when nothing matches (the caller decides +# whether an empty set is an error). +snapshot_secret_paths() { + local base="${SNAPSHOT_HOME:-$HOME}" + + # Candidate secret files, relative to the base home directory. + local candidates=( + ".openclaw/.env" + ".openclaw/gateway.systemd.env" + ".npmrc" + "new_pat.txt" + ".ssh/id_ecdsa" + ) + + # Emit only the candidates that actually exist as regular files. + local rel + for rel in "${candidates[@]}"; do + if [ -f "$base/$rel" ]; then + printf '%s\n' "$base/$rel" + fi + done +} + +# Print the OpenClaw version token (e.g. "2026.5.12"). +# +# `openclaw --version` prints a banner like "OpenClaw 2026.5.12 (f066dd2)"; +# this extracts just the dotted version field. Returns 1 if openclaw is not +# on PATH. The binary is overridable via $OPENCLAW_BIN (default "openclaw"). +read_openclaw_version() { + local bin="${OPENCLAW_BIN:-openclaw}" + + # Fail loud when the binary is missing so callers can react. + if ! command -v "$bin" >/dev/null 2>&1; then + return 1 + fi + + # The version is the second whitespace-delimited field of the banner. + "$bin" --version 2>/dev/null | awk '{ print $2; exit }' +} + +# Print `node --version` verbatim (e.g. "v22.22.2"). +# +# Returns 1 if node is not on PATH. The binary is overridable via $NODE_BIN +# (default "node"). +read_node_version() { + local bin="${NODE_BIN:-node}" + + # Fail loud when the binary is missing. + if ! command -v "$bin" >/dev/null 2>&1; then + return 1 + fi + + "$bin" --version 2>/dev/null +} diff --git a/tests/test-lib-snapshot.sh b/tests/test-lib-snapshot.sh new file mode 100755 index 0000000..d1f03cb --- /dev/null +++ b/tests/test-lib-snapshot.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies snapshot/lib-snapshot.sh: +# - snapshot_secret_paths() lists host-level secret files that exist under +# $SNAPSHOT_HOME, skips absent ones, and NEVER prints file contents +# - read_openclaw_version() prints the version token, fails if openclaw missing +# - read_node_version() prints `node --version` verbatim +# Hermetic: $SNAPSHOT_HOME points at a temp fixture tree, and fake +# openclaw/node binaries are injected on PATH. No real host state is read. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../snapshot/lib-snapshot.sh" + +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT +fail=0 + +# Build a fake home with SOME secret files present and SOME absent. +FAKE_HOME="$TEST_TMPDIR/home" +mkdir -p "$FAKE_HOME/.openclaw" "$FAKE_HOME/.ssh" + +# A distinctive marker no real secret would contain; used to prove contents +# never leak into snapshot_secret_paths output. +MARKER="SECRET_CONTENT_MARKER_zzz987" +printf 'TOKEN=%s\n' "$MARKER" > "$FAKE_HOME/.openclaw/.env" +printf 'GATEWAY_ENV=%s\n' "$MARKER" > "$FAKE_HOME/.openclaw/gateway.systemd.env" +printf '//registry.npmjs.org/:_authToken=%s\n' "$MARKER" > "$FAKE_HOME/.npmrc" +printf -- '-----BEGIN KEY-----\n%s\n' "$MARKER" > "$FAKE_HOME/.ssh/id_ecdsa" +# Deliberately absent: new_pat.txt (a stored-PAT candidate). Must be SKIPPED. + +# snapshot_secret_paths lists the present secret files, one per line. +got=$(SNAPSHOT_HOME="$FAKE_HOME" snapshot_secret_paths) + +# Every present secret file must appear in the listing. +for want in \ + "$FAKE_HOME/.openclaw/.env" \ + "$FAKE_HOME/.openclaw/gateway.systemd.env" \ + "$FAKE_HOME/.npmrc" \ + "$FAKE_HOME/.ssh/id_ecdsa"; do + if ! printf '%s\n' "$got" | grep -Fxq "$want"; then + echo "FAIL secret_paths: present file not listed: $want" + fail=1 + fi +done + +# The absent candidate must not appear. +if printf '%s\n' "$got" | grep -Fq "new_pat.txt"; then + echo "FAIL secret_paths: absent file listed" + fail=1 +fi + +# CRITICAL: file contents must never be printed. +if printf '%s\n' "$got" | grep -Fq "$MARKER"; then + echo "FAIL secret_paths: leaked file CONTENTS into output" + fail=1 +fi + +# Absent-home edge: no candidates present -> empty output, exit 0. +empty=$(SNAPSHOT_HOME="$TEST_TMPDIR/nonexistent-home" snapshot_secret_paths || true) +if [ -n "$empty" ]; then + echo "FAIL secret_paths: expected empty for absent home, got [$empty]" + fail=1 +fi + +# Fake openclaw / node on PATH for the version readers. +FAKE_BIN="$TEST_TMPDIR/bin" +mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/openclaw" <<'EOF' +#!/usr/bin/env bash +# Mimics the `openclaw --version` banner shape. +echo "OpenClaw 2026.5.12 (f066dd2)" +EOF +cat > "$FAKE_BIN/node" <<'EOF' +#!/usr/bin/env bash +echo "v22.22.2" +EOF +chmod +x "$FAKE_BIN/openclaw" "$FAKE_BIN/node" + +# read_openclaw_version prints just the version token. +got=$(PATH="$FAKE_BIN:$PATH" read_openclaw_version) +if [ "$got" != "2026.5.12" ]; then + echo "FAIL read_openclaw_version: got [$got]" + fail=1 +fi + +# read_node_version prints node --version verbatim. +got=$(PATH="$FAKE_BIN:$PATH" read_node_version) +if [ "$got" != "v22.22.2" ]; then + echo "FAIL read_node_version: got [$got]" + fail=1 +fi + +# read_openclaw_version fails (nonzero) when openclaw is absent. +if ( PATH="/usr/bin:/bin"; read_openclaw_version ) >/dev/null 2>&1; then + echo "FAIL read_openclaw_version: should fail when openclaw missing" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: lib-snapshot (secret allowlist names-only, version readers)" +else + exit 1 +fi From 1e869d9e489a0f64dd984fb20cf8e4eda8c5fe43 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Thu, 27 Aug 2026 13:37:51 -0400 Subject: [PATCH 04/15] feat: Add snapshot manifest and runbook generator Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/snapshot-manifest.sh | 196 +++++++++++++++++++++++++++++++ tests/test-snapshot-manifest.sh | 199 ++++++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 snapshot/snapshot-manifest.sh create mode 100755 tests/test-snapshot-manifest.sh diff --git a/snapshot/snapshot-manifest.sh b/snapshot/snapshot-manifest.sh new file mode 100644 index 0000000..6c042be --- /dev/null +++ b/snapshot/snapshot-manifest.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +set -euo pipefail + +# snapshot-manifest.sh -- read-only introspection that records the facts needed +# to reconstruct the host on a fresh VM. Writes: +# /manifest.json -- runtime facts + per-core-repo git state +# /RUNBOOK.md -- human-readable restore steps, from the manifest +# +# Usage: +# snapshot-manifest.sh --outdir +# +# Portability: bash 3.2+ (macOS default). No bash-4-only features. +# +# Read-only: this script never mutates host state and never prints or records +# secret file contents. +# +# Discussion #62 (owner-vs-org identity): each repo's `origin` is read from the +# clone's REAL `git remote get-url origin`, never rebuilt as "$ORG/". The +# origin is the authoritative clone identity; the bare name is only a locator. +# So a repo owned by an individual rather than the org is captured faithfully, +# and the manifest stays correct whether or not core-repos.txt later grows from +# bare names to full OWNER/repo slugs. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../scripts/program-lib.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib-snapshot.sh" + +# Parse arguments. +OUTDIR="" +while [ "$#" -gt 0 ]; do + case "$1" in + --outdir) + OUTDIR="$2" + shift 2 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + echo "Usage: snapshot-manifest.sh --outdir " >&2 + exit 1 + ;; + esac +done + +# --outdir is required. +if [ -z "$OUTDIR" ]; then + echo "ERROR: --outdir is required." >&2 + exit 1 +fi + +# Resolve org identity and validate the repos directory before touching clones. +load_org_profile +validate_repos_dir "$REPOS_DIR" + +mkdir -p "$OUTDIR" + +# Runtime facts. Versions come from the Task 1 readers; port and unit are read +# from the environment so the caller (or a test) can supply them. On the real +# host the driver populates them from the running systemd user unit. +openclaw_version=$(read_openclaw_version || echo "unknown") +node_version=$(read_node_version || echo "unknown") +gateway_port="${GATEWAY_PORT:-18789}" +service_unit="${SERVICE_UNIT:-openclaw-gateway.service}" + +# Collect per-repo git state into a JSON array, one object per core repo that +# exists as a clone under $REPOS_DIR. Absent clones are skipped. +repos_json="[]" +while IFS= read -r name; do + # Skip blank lines defensively. + if [ -z "$name" ]; then + continue + fi + + repo_dir="$REPOS_DIR/$name" + + # Only record repos that are actually cloned locally. + if [ ! -d "$repo_dir/.git" ]; then + continue + fi + + # origin: the REAL remote URL, or empty when the clone has no origin. + # (#62: never reconstruct this from $ORG.) + origin=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || echo "") + + # branch: the checked-out branch name (or a detached-HEAD marker). + branch=$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + + # dirty: true when the working tree has uncommitted changes. + if [ -n "$(git -C "$repo_dir" status --porcelain 2>/dev/null)" ]; then + dirty="true" + else + dirty="false" + fi + + # unpushed: true when HEAD has commits not present on its upstream. When no + # upstream is configured we cannot compare, so report false (nothing to push + # to a known remote); the dirty flag and origin still carry the local state. + unpushed="false" + if git -C "$repo_dir" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null 2>&1; then + ahead=$(git -C "$repo_dir" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo "0") + if [ "$ahead" != "0" ]; then + unpushed="true" + fi + fi + + # Append this repo as a JSON object. jq handles all string escaping. + repo_obj=$(jq -n \ + --arg name "$name" \ + --arg origin "$origin" \ + --arg branch "$branch" \ + --argjson dirty "$dirty" \ + --argjson unpushed "$unpushed" \ + '{name: $name, origin: $origin, branch: $branch, dirty: $dirty, unpushed: $unpushed}') + repos_json=$(printf '%s\n' "$repos_json" | jq --argjson obj "$repo_obj" '. + [$obj]') +done < "$OUTDIR/manifest.json" + +# Generate the human runbook from the manifest just written. +runbook="$OUTDIR/RUNBOOK.md" +{ + echo "# OpenClaw Restore Runbook" + echo + echo "Generated from manifest.json. Follow the steps in order; stop on the first failure." + echo + echo "## Captured runtime" + echo + echo "- OpenClaw version: \`$openclaw_version\`" + echo "- node version: \`$node_version\`" + echo "- Gateway port: \`$gateway_port\`" + echo "- Service unit: \`$service_unit\`" + echo + echo "## Restore order" + echo + echo "1. Bootstrap the bundled \`age\` binary." + echo "2. Install OpenClaw pinned to \`$openclaw_version\` and node \`$node_version\`." + echo "3. Decrypt \`secrets.age\` with the operator's private key and place the secret files." + echo "4. Extract \`state.tar.gz\` in place, then run \`openclaw backup verify\` on it." + echo "5. Re-clone the core repos below, checkout each recorded branch, re-apply any dirty diff." + echo "6. Install and enable the service unit; start the gateway." + echo "7. Verify (cron count and agent list vs. this manifest)." + echo + echo "## Core repos" + echo +} > "$runbook" + +# One runbook line per repo, built in plain shell (readable, and the origin +# fallback text is easier to get right here than inside a jq interpolation). +# Iterate by index and pull each field with its own jq call: this sidesteps the +# TSV pitfall where bash `read` collapses the empty-origin field (a tab is an +# IFS-whitespace char, so consecutive tabs merge and shift the columns). +repo_count=$(jq '.repos | length' "$OUTDIR/manifest.json") +i=0 +while [ "$i" -lt "$repo_count" ]; do + r_name=$(jq -r ".repos[$i].name" "$OUTDIR/manifest.json") + r_origin=$(jq -r ".repos[$i].origin" "$OUTDIR/manifest.json") + r_branch=$(jq -r ".repos[$i].branch" "$OUTDIR/manifest.json") + r_dirty=$(jq -r ".repos[$i].dirty" "$OUTDIR/manifest.json") + r_unpushed=$(jq -r ".repos[$i].unpushed" "$OUTDIR/manifest.json") + + # Clone target: the recorded origin, or a fallback note when none was captured. + if [ -n "$r_origin" ]; then + clone_target="clone \`$r_origin\`" + else + clone_target="clone (no origin recorded; fall back to \$ORG/$r_name)" + fi + + line="- \`$r_name\` -> $clone_target, checkout \`$r_branch\`" + if [ "$r_dirty" = "true" ]; then + line="$line (had uncommitted changes -- see patch)" + fi + if [ "$r_unpushed" = "true" ]; then + line="$line (had UNPUSHED commits -- verify remote)" + fi + + echo "$line" >> "$runbook" + i=$((i + 1)) +done + +echo "Wrote $OUTDIR/manifest.json and $runbook" diff --git a/tests/test-snapshot-manifest.sh b/tests/test-snapshot-manifest.sh new file mode 100755 index 0000000..43c799e --- /dev/null +++ b/tests/test-snapshot-manifest.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies snapshot/snapshot-manifest.sh: +# - writes manifest.json with runtime facts (versions, gateway port, service +# unit) and per-core-repo git state (name, origin, branch, dirty, unpushed) +# - reads each repo's origin from the REAL `git remote get-url origin`, never +# an $ORG-interpolated slug (discussion #62: owner may not equal org) +# - falls back to empty origin when a clone has no origin remote +# - generates a human RUNBOOK.md +# - never leaks secret-shaped content into the manifest +# Hermetic: fake org profile via $ORG_PROFILE_FILE + $CORE_REPOS_FILE, real +# throwaway git clones under a temp $REPOS_DIR, fake openclaw/node on PATH. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST_SH="$SCRIPT_DIR/../snapshot/snapshot-manifest.sh" + +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT +fail=0 + +# Fake org profile: ORG=acme, fork owner acmefork. +cat > "$TEST_TMPDIR/org.env" <<'EOF' +PROFILE_ORG=acme +PROFILE_FORK_OWNER=acmefork +PROFILE_REMAP="" +EOF + +# Core repo allowlist: two repos. +cat > "$TEST_TMPDIR/core.txt" <<'EOF' +tool-one +tool-two +EOF + +# Repos dir with two real git clones. +REPOS_DIR="$TEST_TMPDIR/repos" +mkdir -p "$REPOS_DIR" + +# Repo one: origin owned by SOMEONE ELSE (not acme), on a feature branch, dirty. +NONACME_ORIGIN="git@github.com:someone-else/tool-one.git" +git init -q "$REPOS_DIR/tool-one" +( + cd "$REPOS_DIR/tool-one" + git config user.email "t@example.com" + git config user.name "t" + git remote add origin "$NONACME_ORIGIN" + echo "hello" > file.txt + git add file.txt + git commit -qm "init" + git checkout -q -b feat/x + # Leave a dirty working tree. + echo "changed" >> file.txt +) + +# Repo two: NO origin remote -> origin should be recorded as empty. +git init -q "$REPOS_DIR/tool-two" +( + cd "$REPOS_DIR/tool-two" + git config user.email "t@example.com" + git config user.name "t" + echo "world" > file.txt + git add file.txt + git commit -qm "init" +) + +# Fake openclaw / node so the version readers resolve. +FAKE_BIN="$TEST_TMPDIR/bin" +mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/openclaw" <<'EOF' +#!/usr/bin/env bash +echo "OpenClaw 2026.5.12 (f066dd2)" +EOF +cat > "$FAKE_BIN/node" <<'EOF' +#!/usr/bin/env bash +echo "v22.22.2" +EOF +chmod +x "$FAKE_BIN/openclaw" "$FAKE_BIN/node" + +OUTDIR="$TEST_TMPDIR/out" + +# Run the manifest generator with all seams pointed at the fixtures. +PATH="$FAKE_BIN:$PATH" \ +ORG_PROFILE_FILE="$TEST_TMPDIR/org.env" \ +CORE_REPOS_FILE="$TEST_TMPDIR/core.txt" \ +REPOS_DIR="$REPOS_DIR" \ +GATEWAY_PORT="18789" \ +SERVICE_UNIT="openclaw-gateway.service" \ +bash "$MANIFEST_SH" --outdir "$OUTDIR" + +MANIFEST="$OUTDIR/manifest.json" + +# The manifest file must exist and be valid JSON. +if [ ! -f "$MANIFEST" ]; then + echo "FAIL manifest: $MANIFEST not created" + fail=1 +elif ! jq empty "$MANIFEST" 2>/dev/null; then + echo "FAIL manifest: not valid JSON" + fail=1 +fi + +# Runtime facts must match the fixtures. +if [ -f "$MANIFEST" ]; then + got_ver=$(jq -r '.openclawVersion' "$MANIFEST") + if [ "$got_ver" != "2026.5.12" ]; then + echo "FAIL manifest: openclawVersion got [$got_ver]" + fail=1 + fi + + got_node=$(jq -r '.nodeVersion' "$MANIFEST") + if [ "$got_node" != "v22.22.2" ]; then + echo "FAIL manifest: nodeVersion got [$got_node]" + fail=1 + fi + + got_port=$(jq -r '.gatewayPort' "$MANIFEST") + if [ "$got_port" != "18789" ]; then + echo "FAIL manifest: gatewayPort got [$got_port]" + fail=1 + fi + + got_unit=$(jq -r '.serviceUnit' "$MANIFEST") + if [ "$got_unit" != "openclaw-gateway.service" ]; then + echo "FAIL manifest: serviceUnit got [$got_unit]" + fail=1 + fi + + # tool-one: origin must be the REAL non-acme remote, on feat/x, dirty=true. + one_origin=$(jq -r '.repos[] | select(.name=="tool-one") | .origin' "$MANIFEST") + if [ "$one_origin" != "$NONACME_ORIGIN" ]; then + echo "FAIL manifest: tool-one origin got [$one_origin], want [$NONACME_ORIGIN]" + fail=1 + fi + + one_branch=$(jq -r '.repos[] | select(.name=="tool-one") | .branch' "$MANIFEST") + if [ "$one_branch" != "feat/x" ]; then + echo "FAIL manifest: tool-one branch got [$one_branch]" + fail=1 + fi + + one_dirty=$(jq -r '.repos[] | select(.name=="tool-one") | .dirty' "$MANIFEST") + if [ "$one_dirty" != "true" ]; then + echo "FAIL manifest: tool-one dirty got [$one_dirty], want true" + fail=1 + fi + + # origin must never be an $ORG-interpolated slug. + if jq -r '.repos[].origin' "$MANIFEST" | grep -Fq "acme/tool-one"; then + echo "FAIL manifest: origin was ORG-interpolated (acme/tool-one) instead of real remote" + fail=1 + fi + + # tool-two: no origin remote -> empty origin string. + two_origin=$(jq -r '.repos[] | select(.name=="tool-two") | .origin' "$MANIFEST") + if [ -n "$two_origin" ]; then + echo "FAIL manifest: tool-two origin should be empty, got [$two_origin]" + fail=1 + fi +fi + +# RUNBOOK.md must be generated. +if [ ! -f "$OUTDIR/RUNBOOK.md" ]; then + echo "FAIL manifest: RUNBOOK.md not created" + fail=1 +fi + +# RUNBOOK content: the real-origin repo must show its remote and branch. +if [ -f "$OUTDIR/RUNBOOK.md" ]; then + if ! grep -Fq "$NONACME_ORIGIN" "$OUTDIR/RUNBOOK.md"; then + echo "FAIL runbook: tool-one origin not present in runbook" + fail=1 + fi + if ! grep -Fq 'checkout `feat/x`' "$OUTDIR/RUNBOOK.md"; then + echo "FAIL runbook: tool-one branch not present in runbook" + fail=1 + fi + + # The empty-origin repo must render the fallback note, NOT a shifted/garbled + # line (guards the TSV empty-field pitfall). + if ! grep -Fq 'no origin recorded' "$OUTDIR/RUNBOOK.md"; then + echo "FAIL runbook: empty-origin fallback note missing for tool-two" + fail=1 + fi + if ! grep -Fq 'checkout `main`' "$OUTDIR/RUNBOOK.md"; then + echo "FAIL runbook: tool-two branch garbled (expected checkout main)" + fail=1 + fi +fi + +# Guard against secret-shaped leakage into the manifest. +if [ -f "$MANIFEST" ] && grep -Eiq 'authToken|BEGIN [A-Z]* ?PRIVATE KEY|_pat|password=' "$MANIFEST"; then + echo "FAIL manifest: secret-shaped content leaked into manifest" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: snapshot-manifest (runtime facts, real-origin per repo, runbook)" +else + exit 1 +fi From 2678abcd9b83f1d74259e6d058067a32b55172c1 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Thu, 27 Aug 2026 17:19:57 -0400 Subject: [PATCH 05/15] feat: Add age-encrypted secrets bundle capture Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/snapshot-secrets.sh | 107 ++++++++++++++++++++++++++++ tests/test-snapshot-secrets.sh | 124 +++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 snapshot/snapshot-secrets.sh create mode 100755 tests/test-snapshot-secrets.sh diff --git a/snapshot/snapshot-secrets.sh b/snapshot/snapshot-secrets.sh new file mode 100644 index 0000000..6e505ce --- /dev/null +++ b/snapshot/snapshot-secrets.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +# snapshot-secrets.sh -- capture host-level secret files into a single +# age-encrypted blob, /secrets.age. +# +# Usage: +# snapshot-secrets.sh --outdir --pubkey +# +# Portability: bash 3.2+ (macOS default). No bash-4-only features. +# +# Security contract: +# - NEVER writes plaintext to disk: tar streams straight into age via a pipe. +# - NEVER prints file contents: the checklist lists NAMES only. +# - The blob is encrypted to the operator's PUBLIC key; the private key stays +# off every VM (see snapshot/README.md). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib-snapshot.sh" + +# Parse arguments. +OUTDIR="" +PUBKEY="" +while [ "$#" -gt 0 ]; do + case "$1" in + --outdir) + OUTDIR="$2" + shift 2 + ;; + --pubkey) + PUBKEY="$2" + shift 2 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + echo "Usage: snapshot-secrets.sh --outdir --pubkey " >&2 + exit 1 + ;; + esac +done + +# Both flags are required. +if [ -z "$OUTDIR" ]; then + echo "ERROR: --outdir is required." >&2 + exit 1 +fi +if [ -z "$PUBKEY" ]; then + echo "ERROR: --pubkey is required." >&2 + exit 1 +fi + +# The age binary must be present (bundled binary or system age). +AGE_BIN="${AGE_BIN:-age}" +if ! command -v "$AGE_BIN" >/dev/null 2>&1; then + echo "ERROR: age binary not found: $AGE_BIN" >&2 + echo "Bundle snapshot/bin/age or set AGE_BIN to a static age binary." >&2 + exit 1 +fi + +# Gather the host-level secret files that exist. Fail loud if none: an empty +# secrets bundle almost certainly means a misconfigured $SNAPSHOT_HOME, and a +# silent empty blob would be a dangerous surprise at restore time. +secret_paths=$(snapshot_secret_paths) +if [ -z "$secret_paths" ]; then + echo "ERROR: no secret files found under ${SNAPSHOT_HOME:-$HOME}." >&2 + echo "Nothing to encrypt; refusing to write an empty secrets bundle." >&2 + exit 1 +fi + +mkdir -p "$OUTDIR" +BLOB="$OUTDIR/secrets.age" + +# Base directory the archived paths are made relative to, so restore can extract +# them back under the target home. +base="${SNAPSHOT_HOME:-$HOME}" + +# Build the tar member list as paths relative to $base (one per line). +# Using -C "$base" with relative members keeps the archive home-relative and +# avoids leading-slash absolute paths. +rel_members=() +while IFS= read -r p; do + if [ -z "$p" ]; then + continue + fi + # Strip the "$base/" prefix to get a home-relative member path. + rel_members+=( "${p#"$base"/}" ) +done < "$BLOB" + +# Names-only checklist (never contents), so the operator can confirm coverage. +echo "Captured secrets -> $BLOB" +echo "Included files (names only):" +while IFS= read -r p; do + if [ -z "$p" ]; then + continue + fi + echo " - ${p#"$base"/}" +done </secrets.age +# - pipes tar straight into age (NEVER writes plaintext .tar to disk) +# - the encrypted blob does not contain the plaintext marker +# - prints a names-only checklist (file names, never contents) +# - fails loud when age is missing or no secrets are found +# Hermetic: $SNAPSHOT_HOME points at a fixture tree; a fake `age` on PATH +# consumes stdin and emits only a header (it does not echo its input). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_SH="$SCRIPT_DIR/../snapshot/snapshot-secrets.sh" + +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT +fail=0 + +# Fixture home with secret files whose CONTENTS carry a distinctive marker. +FAKE_HOME="$TEST_TMPDIR/home" +mkdir -p "$FAKE_HOME/.openclaw" "$FAKE_HOME/.ssh" +MARKER="PLAINTEXT_SECRET_MARKER_qqq424" +printf 'TOKEN=%s\n' "$MARKER" > "$FAKE_HOME/.openclaw/.env" +printf 'GATEWAY_ENV=%s\n' "$MARKER" > "$FAKE_HOME/.openclaw/gateway.systemd.env" +printf '//registry.npmjs.org/:_authToken=%s\n' "$MARKER" > "$FAKE_HOME/.npmrc" +printf -- '-----BEGIN KEY-----\n%s\n' "$MARKER" > "$FAKE_HOME/.ssh/id_ecdsa" + +# Fake `age`: read all of stdin, discard it, and emit a fixed header only. +# This models a real recipient-encrypt: the plaintext must NOT survive to the +# output, so if the script ever echoed plaintext instead of piping through age +# the marker would appear in secrets.age and the test would catch it. +FAKE_BIN="$TEST_TMPDIR/bin" +mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/age" <<'EOF' +#!/usr/bin/env bash +# Consume and discard stdin; require a recipient flag; emit an armored-ish header. +recipient="" +while [ "$#" -gt 0 ]; do + case "$1" in + -r) recipient="$2"; shift 2 ;; + *) shift ;; + esac +done +if [ -z "$recipient" ]; then + echo "fake-age: missing -r recipient" >&2 + exit 2 +fi +cat >/dev/null +printf 'age-encryption.org/v1 FAKE-HEADER recipient=%s\n' "$recipient" +EOF +chmod +x "$FAKE_BIN/age" + +OUTDIR="$TEST_TMPDIR/out" +mkdir -p "$OUTDIR" +PUBKEY="age1faketestrecipientkeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Run the capture with all seams pointed at the fixtures. Capture output and +# tolerate a nonzero exit here (the assertions below judge the result), so a +# missing script surfaces as a FAIL assertion rather than aborting under set -e. +checklist=$( + SNAPSHOT_HOME="$FAKE_HOME" \ + AGE_BIN="$FAKE_BIN/age" \ + bash "$SECRETS_SH" --outdir "$OUTDIR" --pubkey "$PUBKEY" 2>&1 +) || true + +BLOB="$OUTDIR/secrets.age" + +# secrets.age must exist. +if [ ! -f "$BLOB" ]; then + echo "FAIL secrets: $BLOB not created" + fail=1 +fi + +# CRITICAL: the plaintext marker must NOT appear in the encrypted blob. +if [ -f "$BLOB" ] && grep -Fq "$MARKER" "$BLOB"; then + echo "FAIL secrets: plaintext marker leaked into secrets.age" + fail=1 +fi + +# CRITICAL: no plaintext .tar (or .tar.gz) may be left behind anywhere in outdir. +leftover=$(find "$OUTDIR" -name '*.tar' -o -name '*.tar.gz' 2>/dev/null) +if [ -n "$leftover" ]; then + echo "FAIL secrets: plaintext tar left on disk: $leftover" + fail=1 +fi + +# The checklist must list file NAMES but never the marker (contents). +if ! printf '%s\n' "$checklist" | grep -Fq ".openclaw/.env"; then + echo "FAIL secrets: checklist missing a captured file name" + fail=1 +fi +if printf '%s\n' "$checklist" | grep -Fq "$MARKER"; then + echo "FAIL secrets: checklist leaked file CONTENTS" + fail=1 +fi + +# Fail loud when age is missing. +if ( + SNAPSHOT_HOME="$FAKE_HOME" \ + AGE_BIN="$TEST_TMPDIR/no-such-age" \ + bash "$SECRETS_SH" --outdir "$OUTDIR" --pubkey "$PUBKEY" +) >/dev/null 2>&1; then + echo "FAIL secrets: should fail when age binary is missing" + fail=1 +fi + +# Fail loud when no secrets are found (empty home). +EMPTY_HOME="$TEST_TMPDIR/empty-home" +mkdir -p "$EMPTY_HOME" +if ( + SNAPSHOT_HOME="$EMPTY_HOME" \ + AGE_BIN="$FAKE_BIN/age" \ + bash "$SECRETS_SH" --outdir "$TEST_TMPDIR/out2" --pubkey "$PUBKEY" +) >/dev/null 2>&1; then + echo "FAIL secrets: should fail when no secret files are found" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: snapshot-secrets (encrypt-only, no plaintext on disk, names-only checklist)" +else + exit 1 +fi From 790aabe06620bc4e075757a8282032087b154135 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Fri, 28 Aug 2026 15:31:08 -0400 Subject: [PATCH 06/15] feat: Add guarded openclaw backup state wrapper Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/snapshot-state.sh | 86 ++++++++++++++++++++++++ tests/test-snapshot-state.sh | 127 +++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 snapshot/snapshot-state.sh create mode 100755 tests/test-snapshot-state.sh diff --git a/snapshot/snapshot-state.sh b/snapshot/snapshot-state.sh new file mode 100644 index 0000000..0b827c4 --- /dev/null +++ b/snapshot/snapshot-state.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +# snapshot-state.sh -- guarded wrapper around the first-class OpenClaw backup. +# Runs `openclaw backup create --verify --output --json`, captures the +# JSON result to /state-backup.json, and confirms the written archive +# exists. Do NOT hand-roll a tar of ~/.openclaw; the backup command owns that. +# +# Usage: +# snapshot-state.sh --outdir +# +# Portability: bash 3.2+ (macOS default). No bash-4-only features. +# +# Backup JSON contract (pinned in Task 0 / snapshot/README.md, OpenClaw 2026.5.12): +# top-level fields include createdAt, archiveRoot, archivePath (absolute path to +# the written .tar.gz), dryRun, includeWorkspace, onlyConfig, verified, +# assets[], skipped[], skippedVolatileCount. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib-snapshot.sh" + +# Parse arguments. +OUTDIR="" +while [ "$#" -gt 0 ]; do + case "$1" in + --outdir) + OUTDIR="$2" + shift 2 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + echo "Usage: snapshot-state.sh --outdir " >&2 + exit 1 + ;; + esac +done + +# --outdir is required. +if [ -z "$OUTDIR" ]; then + echo "ERROR: --outdir is required." >&2 + exit 1 +fi + +# The openclaw binary must be present. +OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}" +if ! command -v "$OPENCLAW_BIN" >/dev/null 2>&1; then + echo "ERROR: openclaw binary not found: $OPENCLAW_BIN" >&2 + exit 1 +fi + +mkdir -p "$OUTDIR" +STATE_JSON="$OUTDIR/state-backup.json" + +# Run the backup. --verify validates the archive right after writing it, so +# corruption is caught while re-capture is still cheap. --json gives us the +# machine-readable result we tee to state-backup.json. If the command fails, +# propagate the failure explicitly rather than leaving a half-written JSON. +if ! "$OPENCLAW_BIN" backup create --verify --output "$OUTDIR" --json > "$STATE_JSON"; then + echo "ERROR: 'openclaw backup create' failed; see $STATE_JSON" >&2 + exit 1 +fi + +# The captured result must be valid JSON. +if ! jq empty "$STATE_JSON" >/dev/null 2>&1; then + echo "ERROR: backup result is not valid JSON: $STATE_JSON" >&2 + exit 1 +fi + +# Confirm the archive the backup claims it wrote actually exists on disk. +archive_path=$(jq -r '.archivePath // empty' "$STATE_JSON") +if [ -z "$archive_path" ] || [ ! -f "$archive_path" ]; then + echo "ERROR: backup archive missing (archivePath=$archive_path)" >&2 + exit 1 +fi + +# Confirm the archive was verified (we asked for --verify). +verified=$(jq -r '.verified // false' "$STATE_JSON") +if [ "$verified" != "true" ]; then + echo "WARNING: backup reports verified=$verified; archive may not be validated." >&2 +fi + +skipped_volatile=$(jq -r '.skippedVolatileCount // 0' "$STATE_JSON") +echo "State backup written: $archive_path" +echo " result JSON: $STATE_JSON" +echo " verified: $verified, volatile paths skipped: $skipped_volatile" diff --git a/tests/test-snapshot-state.sh b/tests/test-snapshot-state.sh new file mode 100755 index 0000000..7dbf7dd --- /dev/null +++ b/tests/test-snapshot-state.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies snapshot/snapshot-state.sh: +# - runs `openclaw backup create --verify --output --json` +# - captures the backup JSON to /state-backup.json +# - confirms the archive named by the JSON's archivePath exists +# - exits non-zero when the backup command fails +# Hermetic: a fake `openclaw` on PATH (via $OPENCLAW_BIN) writes a fake archive +# and emits plausible backup JSON. No real host state is touched. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE_SH="$SCRIPT_DIR/../snapshot/snapshot-state.sh" + +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT +fail=0 + +# A fake openclaw that mimics `backup create --verify --output --json`: +# it writes a fake archive into the output dir and prints JSON whose top-level +# shape matches the Task 0 pinned contract (archivePath, verified, +# skippedVolatileCount, assets, skipped). +FAKE_BIN="$TEST_TMPDIR/bin" +mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/openclaw" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +# Only handle: backup create ... --output ... +outdir="" +args=("$@") +i=0 +while [ "$i" -lt "${#args[@]}" ]; do + if [ "${args[$i]}" = "--output" ]; then + j=$((i + 1)) + outdir="${args[$j]}" + fi + i=$((i + 1)) +done +if [ -z "$outdir" ]; then + echo "fake-openclaw: no --output given" >&2 + exit 3 +fi +mkdir -p "$outdir" +archive="$outdir/2026-01-01T00-00-00.000Z-openclaw-backup.tar.gz" +# Write a fake (non-empty) archive payload. +printf 'FAKE-ARCHIVE-BYTES' > "$archive" +# Emit JSON matching the pinned top-level shape. +cat </dev/null; then + echo "FAIL state: state-backup.json not valid JSON" + fail=1 +fi + +# The pinned field names must be present and sensible. +if [ -f "$STATE_JSON" ]; then + if [ "$(jq -r '.verified' "$STATE_JSON")" != "true" ]; then + echo "FAIL state: verified not true" + fail=1 + fi + if [ "$(jq -r '.skippedVolatileCount' "$STATE_JSON")" != "4" ]; then + echo "FAIL state: skippedVolatileCount not captured" + fail=1 + fi + + # The archive named by archivePath must actually exist on disk. + archive_path=$(jq -r '.archivePath' "$STATE_JSON") + if [ ! -f "$archive_path" ]; then + echo "FAIL state: archivePath does not point at an existing file: $archive_path" + fail=1 + fi +fi + +# Failure case: a fake openclaw that exits 1 -> wrapper must exit non-zero. +cat > "$FAKE_BIN/openclaw-fail" <<'EOF' +#!/usr/bin/env bash +echo '{"error":"backup failed"}' >&2 +exit 1 +EOF +chmod +x "$FAKE_BIN/openclaw-fail" + +if OPENCLAW_BIN="$FAKE_BIN/openclaw-fail" bash "$STATE_SH" --outdir "$TEST_TMPDIR/out-fail" >/dev/null 2>&1; then + echo "FAIL state: wrapper should exit non-zero when backup fails" + fail=1 +fi + +# Missing-binary case: wrapper must fail loud. +if OPENCLAW_BIN="$TEST_TMPDIR/no-such-openclaw" bash "$STATE_SH" --outdir "$TEST_TMPDIR/out-nobin" >/dev/null 2>&1; then + echo "FAIL state: wrapper should fail when openclaw binary is missing" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: snapshot-state (backup JSON captured, archive verified, failure propagated)" +else + exit 1 +fi From 6a2bc92aed9e377982762223d876f8391db5808e Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Fri, 28 Aug 2026 15:35:20 -0400 Subject: [PATCH 07/15] feat: Add top-level snapshot capture driver Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/snapshot.sh | 106 ++++++++++++++++++++++ tests/test-snapshot-driver.sh | 164 ++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 snapshot/snapshot.sh create mode 100644 tests/test-snapshot-driver.sh diff --git a/snapshot/snapshot.sh b/snapshot/snapshot.sh new file mode 100644 index 0000000..3fcea57 --- /dev/null +++ b/snapshot/snapshot.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +# snapshot.sh -- top-level capture driver. Creates one dated snapshot directory +# and runs the three capture scripts into it, then copies the age binary in so +# the bundle is self-contained for decrypt/restore on a fresh VM: +# +# openclaw-snapshot-/ +# state-backup.json + *-openclaw-backup.tar.gz (snapshot-state.sh) +# secrets.age (snapshot-secrets.sh) +# manifest.json + RUNBOOK.md (snapshot-manifest.sh) +# age (bundled binary) +# +# Usage: +# snapshot.sh --output --pubkey +# +# Portability: bash 3.2+ (macOS default). No bash-4-only features. +# +# Test seams (env overrides, all optional): +# SNAPSHOT_DATE date stamp for the dir name (default: date +%F) +# SNAPSHOT_STATE_CMD state capture command (default: sibling script) +# SNAPSHOT_SECRETS_CMD secrets capture command (default: sibling script) +# SNAPSHOT_MANIFEST_CMD manifest capture command (default: sibling script) +# AGE_BIN_SRC age binary to copy in (default: snapshot/bin/age) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Parse arguments. +OUTPUT="" +PUBKEY="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + OUTPUT="$2" + shift 2 + ;; + --pubkey) + PUBKEY="$2" + shift 2 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + echo "Usage: snapshot.sh --output --pubkey " >&2 + exit 1 + ;; + esac +done + +# Both flags are required. +if [ -z "$OUTPUT" ]; then + echo "ERROR: --output is required." >&2 + exit 1 +fi +if [ -z "$PUBKEY" ]; then + echo "ERROR: --pubkey is required." >&2 + exit 1 +fi + +# Resolve the capture commands. Default to the sibling scripts; tests override +# these to hermetic stubs. +STATE_CMD="${SNAPSHOT_STATE_CMD:-$SCRIPT_DIR/snapshot-state.sh}" +SECRETS_CMD="${SNAPSHOT_SECRETS_CMD:-$SCRIPT_DIR/snapshot-secrets.sh}" +MANIFEST_CMD="${SNAPSHOT_MANIFEST_CMD:-$SCRIPT_DIR/snapshot-manifest.sh}" + +# Resolve the age binary to bundle. Default to the checked-in static binary. +AGE_BIN_SRC="${AGE_BIN_SRC:-$SCRIPT_DIR/bin/age}" + +# Compute the dated snapshot directory. SNAPSHOT_DATE is a test seam; in +# production the date comes from `date +%F` (YYYY-MM-DD). +SNAPSHOT_DATE="${SNAPSHOT_DATE:-$(date +%F)}" +SNAP_DIR="$OUTPUT/openclaw-snapshot-$SNAPSHOT_DATE" + +# Refuse to overwrite an existing dated dir: a snapshot is a point-in-time +# artifact and silently clobbering one could destroy the only good capture. +if [ -e "$SNAP_DIR" ]; then + echo "ERROR: snapshot dir already exists: $SNAP_DIR" >&2 + echo "Refusing to overwrite. Remove it or wait for a new date." >&2 + exit 1 +fi + +mkdir -p "$SNAP_DIR" + +# Capture runtime facts + per-repo git state (manifest.json + RUNBOOK.md). +echo "==> manifest" +"$MANIFEST_CMD" --outdir "$SNAP_DIR" + +# Capture the authoritative OpenClaw state via the first-class backup. +echo "==> state" +"$STATE_CMD" --outdir "$SNAP_DIR" + +# Capture host secrets into a single age-encrypted blob. +echo "==> secrets" +"$SECRETS_CMD" --outdir "$SNAP_DIR" --pubkey "$PUBKEY" + +# Bundle the age binary so decrypt/restore works on a VM without age installed. +# Only warn (do not fail) if the source is absent: the state + manifest are +# still valuable, and the operator may supply age out of band. +if [ -f "$AGE_BIN_SRC" ]; then + cp "$AGE_BIN_SRC" "$SNAP_DIR/age" + chmod +x "$SNAP_DIR/age" +else + echo "WARNING: age binary not found at $AGE_BIN_SRC; not bundled." >&2 + echo " Supply an age binary manually before restoring secrets.age." >&2 +fi + +echo "Snapshot written: $SNAP_DIR" diff --git a/tests/test-snapshot-driver.sh b/tests/test-snapshot-driver.sh new file mode 100644 index 0000000..4adc387 --- /dev/null +++ b/tests/test-snapshot-driver.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies snapshot/snapshot.sh (top-level capture driver): +# - creates a dated output dir openclaw-snapshot- under --output +# - runs the three capture commands into that dir +# - copies the age binary in alongside the artifacts +# - refuses to overwrite an existing dated dir (second run exits non-zero) +# - fails loud on missing --output/--pubkey +# Hermetic: the three capture commands and the age source are stubbed via the +# documented seams ($SNAPSHOT_{STATE,SECRETS,MANIFEST}_CMD, $AGE_BIN_SRC), and +# $SNAPSHOT_DATE pins the date so the dir name is deterministic. No real host +# state, no network, no real openclaw/age. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DRIVER_SH="$SCRIPT_DIR/../snapshot/snapshot.sh" + +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT +fail=0 + +# A fake age binary source: the driver should copy this into the snapshot dir +# so the operator can decrypt on a machine without age installed. +AGE_SRC="$TEST_TMPDIR/age-src" +printf '#!/usr/bin/env bash\necho fake-age\n' > "$AGE_SRC" +chmod +x "$AGE_SRC" + +# Stub capture commands. Each records that it ran (into a marker file keyed by +# name) and drops a token artifact into the --outdir it was handed, so we can +# assert the driver invoked all three against the same dated dir. +STUB_BIN="$TEST_TMPDIR/stubs" +mkdir -p "$STUB_BIN" + +# state stub: takes --outdir +cat > "$STUB_BIN/state" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +outdir="" +while [ "$#" -gt 0 ]; do + case "$1" in + --outdir) outdir="$2"; shift 2 ;; + *) shift ;; + esac +done +printf 'ran\n' > "$outdir/.state-ran" +EOF + +# manifest stub: takes --outdir +cat > "$STUB_BIN/manifest" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +outdir="" +while [ "$#" -gt 0 ]; do + case "$1" in + --outdir) outdir="$2"; shift 2 ;; + *) shift ;; + esac +done +printf 'ran\n' > "$outdir/.manifest-ran" +EOF + +# secrets stub: takes --outdir --pubkey ; records the pubkey it +# was handed so we can assert the driver forwards --pubkey through. +cat > "$STUB_BIN/secrets" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +outdir="" +pubkey="" +while [ "$#" -gt 0 ]; do + case "$1" in + --outdir) outdir="$2"; shift 2 ;; + --pubkey) pubkey="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s\n' "$pubkey" > "$outdir/.secrets-pubkey" +EOF + +chmod +x "$STUB_BIN/state" "$STUB_BIN/manifest" "$STUB_BIN/secrets" + +OUTPUT="$TEST_TMPDIR/snapshots" +PUBKEY="age1faketestrecipientkeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +DATE="2026-01-02" +EXPECTED_DIR="$OUTPUT/openclaw-snapshot-$DATE" + +run_driver() { + SNAPSHOT_DATE="$DATE" \ + SNAPSHOT_STATE_CMD="$STUB_BIN/state" \ + SNAPSHOT_MANIFEST_CMD="$STUB_BIN/manifest" \ + SNAPSHOT_SECRETS_CMD="$STUB_BIN/secrets" \ + AGE_BIN_SRC="$AGE_SRC" \ + bash "$DRIVER_SH" --output "$OUTPUT" --pubkey "$PUBKEY" +} + +# First run: tolerate a nonzero exit so a missing script surfaces as a FAIL +# assertion below rather than aborting under set -e. +run_driver || true + +# The dated dir must exist. +if [ ! -d "$EXPECTED_DIR" ]; then + echo "FAIL driver: dated dir not created: $EXPECTED_DIR" + fail=1 +fi + +# All three capture commands must have run into the dated dir. +if [ ! -f "$EXPECTED_DIR/.state-ran" ]; then + echo "FAIL driver: state capture did not run into the dated dir" + fail=1 +fi +if [ ! -f "$EXPECTED_DIR/.manifest-ran" ]; then + echo "FAIL driver: manifest capture did not run into the dated dir" + fail=1 +fi +if [ ! -f "$EXPECTED_DIR/.secrets-ran" ] && [ ! -f "$EXPECTED_DIR/.secrets-pubkey" ]; then + echo "FAIL driver: secrets capture did not run into the dated dir" + fail=1 +fi + +# The driver must forward --pubkey to the secrets command verbatim. +if [ -f "$EXPECTED_DIR/.secrets-pubkey" ]; then + got_pubkey=$(cat "$EXPECTED_DIR/.secrets-pubkey") + if [ "$got_pubkey" != "$PUBKEY" ]; then + echo "FAIL driver: secrets pubkey not forwarded (got [$got_pubkey])" + fail=1 + fi +fi + +# The age binary must be copied into the dated dir and remain executable. +if [ ! -x "$EXPECTED_DIR/age" ]; then + echo "FAIL driver: age binary not copied into the snapshot dir (or not executable)" + fail=1 +fi + +# Second run against the same date must REFUSE to overwrite (non-zero). +if run_driver >/dev/null 2>&1; then + echo "FAIL driver: second run should refuse to overwrite existing dated dir" + fail=1 +fi + +# Missing --output must fail loud. +if ( + SNAPSHOT_DATE="$DATE" \ + AGE_BIN_SRC="$AGE_SRC" \ + bash "$DRIVER_SH" --pubkey "$PUBKEY" +) >/dev/null 2>&1; then + echo "FAIL driver: should fail when --output is missing" + fail=1 +fi + +# Missing --pubkey must fail loud. +if ( + SNAPSHOT_DATE="$DATE" \ + AGE_BIN_SRC="$AGE_SRC" \ + bash "$DRIVER_SH" --output "$TEST_TMPDIR/snapshots2" +) >/dev/null 2>&1; then + echo "FAIL driver: should fail when --pubkey is missing" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: snapshot-driver (dated dir, three captures, age copied, no-overwrite)" +else + exit 1 +fi From 3181c9574841704ee75a76a13b5e11457fed3aba Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Fri, 28 Aug 2026 16:26:31 -0400 Subject: [PATCH 08/15] feat: Add restore dry-run planner driven by manifest + org profile Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/restore.sh | 171 +++++++++++++++++++++++++++++++++ tests/test-snapshot-restore.sh | 151 +++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 snapshot/restore.sh create mode 100644 tests/test-snapshot-restore.sh diff --git a/snapshot/restore.sh b/snapshot/restore.sh new file mode 100644 index 0000000..70f4ba1 --- /dev/null +++ b/snapshot/restore.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +# restore.sh -- reverse the snapshot flow on a fresh VM. The DRY-RUN plan is the +# tested, reviewable unit: it prints the ordered restore steps derived from the +# snapshot's manifest.json plus the org profile, WITHOUT mutating the host. +# Real execution mutates a host (installs software, clones repos, enables a +# service) and is out of hermetic-test scope. +# +# Usage: +# restore.sh --from [--dry-run] +# +# Portability: bash 3.2+ (macOS default). No bash-4-only features. +# +# State restore contract (pinned in Task 0 / snapshot/README.md): there is NO +# `openclaw backup restore` subcommand. State is restored by extracting the +# backup archive in place and then running `openclaw backup verify` on it. +# +# Discussion #62 (owner-vs-org identity): each repo is cloned from its MANIFEST- +# RECORDED `origin` (the real captured remote URL). Only when a repo has no +# recorded origin does restore fall back to the $ORG namespace. So a repo owned +# by an individual rather than the org is restored faithfully. (Alignment with +# the newer RepoMan per-repo-owner model is tracked separately; the recorded- +# origin design keeps this correct in the meantime.) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../scripts/program-lib.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib-snapshot.sh" + +# Parse arguments. +FROM="" +DRY_RUN=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --from) + FROM="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + echo "Usage: restore.sh --from [--dry-run]" >&2 + exit 1 + ;; + esac +done + +# --from is required. +if [ -z "$FROM" ]; then + echo "ERROR: --from is required." >&2 + exit 1 +fi + +# The snapshot must carry a manifest. +MANIFEST="$FROM/manifest.json" +if [ ! -f "$MANIFEST" ]; then + echo "ERROR: no manifest.json in snapshot dir: $FROM" >&2 + exit 1 +fi +if ! jq empty "$MANIFEST" >/dev/null 2>&1; then + echo "ERROR: manifest.json is not valid JSON: $MANIFEST" >&2 + exit 1 +fi + +# Resolve org identity (for the empty-origin fallback) and the target repos dir. +load_org_profile +validate_repos_dir "$REPOS_DIR" + +# emit: in dry-run, print the step; otherwise execute it. Keeping the two modes +# behind one helper means the plan the operator reviews is exactly the sequence +# that runs for real -- no drift between "what it says" and "what it does". +emit() { + if [ "$DRY_RUN" -eq 1 ]; then + printf ' %s\n' "$*" + else + eval "$*" + fi +} + +# Read the captured runtime facts. +openclaw_version=$(jq -r '.openclawVersion // "unknown"' "$MANIFEST") +node_version=$(jq -r '.nodeVersion // "unknown"' "$MANIFEST") +service_unit=$(jq -r '.serviceUnit // "openclaw-gateway.service"' "$MANIFEST") + +# Locate the state archive. snapshot-state.sh names it by the backup's own +# archivePath (a dated *-openclaw-backup.tar.gz); accept that or a plain +# state.tar.gz. Glob is nullglob-guarded so a missing archive is reported, not +# silently skipped. +state_archive="" +for cand in "$FROM"/*openclaw-backup.tar.gz "$FROM"/state.tar.gz; do + if [ -f "$cand" ]; then + state_archive="$cand" + break + fi +done + +if [ "$DRY_RUN" -eq 1 ]; then + echo "Restore plan (dry-run) from: $FROM" +else + echo "Restoring from: $FROM" +fi + +# 1. Bootstrap the bundled age binary (so decrypt works without system age). +echo "Step 1: bootstrap age" +emit "install -m 0755 '$FROM/age' /usr/local/bin/age" + +# 2. Install the pinned OpenClaw + node versions. +echo "Step 2: install OpenClaw $openclaw_version (node $node_version)" +emit "echo 'install openclaw@$openclaw_version node@$node_version'" + +# 3. Decrypt the secrets bundle with the operator's PRIVATE key (supplied out of +# band; never stored in the snapshot). Names-only -- no contents printed. +echo "Step 3: decrypt secrets.age" +emit "age -d -i \"\$AGE_IDENTITY\" '$FROM/secrets.age' | tar -C \"\$HOME\" -xf -" + +# 4. Restore state: extract the archive in place, then VERIFY it. There is no +# `openclaw backup restore` subcommand. +echo "Step 4: restore + verify OpenClaw state" +if [ -n "$state_archive" ]; then + emit "tar -C \"\$HOME\" -xzf '$state_archive'" + emit "openclaw backup verify '$state_archive'" +else + echo " WARNING: no state archive found in $FROM (looked for *openclaw-backup.tar.gz / state.tar.gz)" >&2 +fi + +# 5. Re-clone the core repos and check out the recorded branch. Clone from each +# repo's RECORDED origin; fall back to the $ORG namespace only when empty. +echo "Step 5: re-clone core repos" +repo_count=$(jq '.repos | length' "$MANIFEST") +i=0 +while [ "$i" -lt "$repo_count" ]; do + r_name=$(jq -r ".repos[$i].name" "$MANIFEST") + r_origin=$(jq -r ".repos[$i].origin" "$MANIFEST") + r_branch=$(jq -r ".repos[$i].branch" "$MANIFEST") + r_dirty=$(jq -r ".repos[$i].dirty" "$MANIFEST") + + # Clone URL: the recorded origin is authoritative. Only synthesize a URL from + # $ORG when no origin was captured (#62: never override a real origin). + if [ -n "$r_origin" ] && [ "$r_origin" != "null" ]; then + clone_url="$r_origin" + else + clone_url="https://github.com/$ORG/$r_name.git" + echo " NOTE: $r_name had no recorded origin; using \$ORG fallback $clone_url" >&2 + fi + + target="$REPOS_DIR/$r_name" + emit "git clone '$clone_url' '$target'" + emit "git -C '$target' checkout '$r_branch'" + if [ "$r_dirty" = "true" ]; then + echo " NOTE: $r_name had uncommitted changes at capture; re-apply its patch manually" >&2 + fi + + i=$((i + 1)) +done + +# 6. Install and enable the service unit; start the gateway. +echo "Step 6: enable service unit $service_unit" +emit "systemctl --user enable --now '$service_unit'" + +# 7. Verify the running host against the captured manifest. +echo "Step 7: verify" +emit "echo 'verify: compare cron count and agent list against $MANIFEST'" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "Dry-run complete: no changes made." +fi diff --git a/tests/test-snapshot-restore.sh b/tests/test-snapshot-restore.sh new file mode 100644 index 0000000..e3ee1ed --- /dev/null +++ b/tests/test-snapshot-restore.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies snapshot/restore.sh --dry-run (the testable unit): +# - prints an ordered plan derived from manifest.json + the org profile +# - clones each repo from its RECORDED origin (not a re-derived $ORG/) +# - falls back to $ORG/ ONLY when a repo has no recorded origin +# - names the captured OpenClaw version, a `checkout `, the service +# unit, extract + `openclaw backup verify` (NOT a `backup restore` subcommand) +# - creates NOTHING under REPOS_DIR (dry-run mutates nothing) +# Hermetic: hand-authored manifest.json + empty state/secrets files, a fake org +# profile via $ORG_PROFILE_FILE/$CORE_REPOS_FILE, a temp REPOS_DIR. No network, +# no real host, no real openclaw/age/git clone. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESTORE_SH="$SCRIPT_DIR/../snapshot/restore.sh" + +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT +fail=0 + +# Fake org profile: ORG=acme (used for the empty-origin fallback only). +cat > "$TEST_TMPDIR/org.env" <<'EOF' +PROFILE_ORG=acme +PROFILE_FORK_OWNER=acmefork +PROFILE_REMAP="" +EOF + +# Core repo allowlist (matches the manifest's repo names). +cat > "$TEST_TMPDIR/core.txt" <<'EOF' +tool-one +tool-two +EOF + +REPOS_DIR="$TEST_TMPDIR/repos" +mkdir -p "$REPOS_DIR" + +# The snapshot dir being restored FROM. +SNAP="$TEST_TMPDIR/snap" +mkdir -p "$SNAP" + +# Hand-authored manifest: +# tool-one -> recorded origin owned by someone else, on feat/x +# tool-two -> NO recorded origin (empty) -> must use the $ORG fallback +NONACME_ORIGIN="git@github.com:someone-else/tool-one.git" +cat > "$SNAP/manifest.json" < "$SNAP/state.tar.gz" +: > "$SNAP/secrets.age" + +# Run the dry-run planner; capture the plan. Tolerate a nonzero exit so a +# missing script surfaces as a FAIL assertion rather than aborting under set -e. +plan=$( + ORG_PROFILE_FILE="$TEST_TMPDIR/org.env" \ + CORE_REPOS_FILE="$TEST_TMPDIR/core.txt" \ + REPOS_DIR="$REPOS_DIR" \ + bash "$RESTORE_SH" --from "$SNAP" --dry-run 2>&1 +) || true + +# The captured OpenClaw version must appear. +if ! printf '%s\n' "$plan" | grep -Fq "2026.5.12"; then + echo "FAIL restore: plan does not name the captured OpenClaw version" + fail=1 +fi + +# tool-one must clone from its RECORDED origin, not acme/tool-one. +if ! printf '%s\n' "$plan" | grep -Fq "$NONACME_ORIGIN"; then + echo "FAIL restore: tool-one not cloned from its recorded origin" + fail=1 +fi +if printf '%s\n' "$plan" | grep -Eq 'acme[:/]tool-one'; then + echo "FAIL restore: tool-one wrongly cloned from an \$ORG-derived URL" + fail=1 +fi + +# tool-two has no recorded origin -> must fall back to the $ORG namespace. +if ! printf '%s\n' "$plan" | grep -Eq 'acme[:/]tool-two'; then + echo "FAIL restore: tool-two not cloned from the \$ORG fallback (acme/tool-two)" + fail=1 +fi + +# The recorded branch must be checked out. +if ! printf '%s\n' "$plan" | grep -Fq "feat/x"; then + echo "FAIL restore: plan does not checkout the recorded branch feat/x" + fail=1 +fi + +# The service unit must be named. +if ! printf '%s\n' "$plan" | grep -Fq "openclaw-gateway.service"; then + echo "FAIL restore: plan does not name the service unit" + fail=1 +fi + +# State restore = verify, NOT a `backup restore` subcommand (pinned in Task 0). +if ! printf '%s\n' "$plan" | grep -Fq "backup verify"; then + echo "FAIL restore: plan does not include an 'openclaw backup verify' step" + fail=1 +fi +if printf '%s\n' "$plan" | grep -Fq "backup restore"; then + echo "FAIL restore: plan uses a non-existent 'backup restore' subcommand" + fail=1 +fi + +# Dry-run must mutate NOTHING under REPOS_DIR. +leftover=$(find "$REPOS_DIR" -mindepth 1 2>/dev/null) +if [ -n "$leftover" ]; then + echo "FAIL restore: dry-run created something under REPOS_DIR: $leftover" + fail=1 +fi + +# Missing --from must fail loud. +if ( + ORG_PROFILE_FILE="$TEST_TMPDIR/org.env" \ + CORE_REPOS_FILE="$TEST_TMPDIR/core.txt" \ + REPOS_DIR="$REPOS_DIR" \ + bash "$RESTORE_SH" --dry-run +) >/dev/null 2>&1; then + echo "FAIL restore: should fail when --from is missing" + fail=1 +fi + +# A --from dir without a manifest must fail loud. +EMPTY_SNAP="$TEST_TMPDIR/empty-snap" +mkdir -p "$EMPTY_SNAP" +if ( + ORG_PROFILE_FILE="$TEST_TMPDIR/org.env" \ + CORE_REPOS_FILE="$TEST_TMPDIR/core.txt" \ + REPOS_DIR="$REPOS_DIR" \ + bash "$RESTORE_SH" --from "$EMPTY_SNAP" --dry-run +) >/dev/null 2>&1; then + echo "FAIL restore: should fail when the snapshot has no manifest.json" + fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: snapshot-restore (dry-run plan: recorded-origin clone, \$ORG fallback, verify-not-restore)" +else + exit 1 +fi From 1973e17fc593a2e4de042ea22fa7072933598ecc Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Mon, 31 Aug 2026 16:59:07 -0400 Subject: [PATCH 09/15] docs: Document snapshot toolkit usage and wire CI Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- .github/workflows/tests.yml | 9 +++- snapshot/README.md | 102 ++++++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1f9748..5ddcdcb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,13 +30,20 @@ jobs: # pre-existing SC2034 arg-parsing idiom tracked separately; see the # consumer-lint follow-up issue. run: shellcheck --severity=warning scripts/core.sh scripts/github-api.sh scripts/fork.sh scripts/org.sh scripts/program-lib.sh + - name: shellcheck (snapshot toolkit) + # The snapshot scripts + their tests are clean at warning severity (they + # avoid the consumer SC2034 idiom), so lint them as their own group. + run: shellcheck --severity=warning snapshot/*.sh tests/test-lib-snapshot.sh tests/test-snapshot-*.sh - name: Run test suite run: | set -e for t in tests/test-lib-inventory.sh tests/test-lib-modules.sh \ tests/test-core-repos.sh tests/test-org-profile.sh \ tests/test-extract-broken-links.sh tests/test-parse-diff-map.sh \ - tests/test-pr-review-impact.sh tests/test-weekly-report.sh; do + tests/test-pr-review-impact.sh tests/test-weekly-report.sh \ + tests/test-lib-snapshot.sh tests/test-snapshot-manifest.sh \ + tests/test-snapshot-secrets.sh tests/test-snapshot-state.sh \ + tests/test-snapshot-driver.sh tests/test-snapshot-restore.sh; do echo "== $t ==" bash "$t" done diff --git a/snapshot/README.md b/snapshot/README.md index 03e9ff2..56e30b8 100644 --- a/snapshot/README.md +++ b/snapshot/README.md @@ -9,11 +9,12 @@ produced into one dated directory: ``` openclaw-snapshot-YYYY-MM-DD/ -├── state.tar.gz # openclaw backup create --verify output -├── secrets.age # age-encrypted host-level secrets (never plaintext on disk) -├── manifest.json # versions, service unit, gateway port, per-core-repo git state -├── RUNBOOK.md # ordered restore steps, generated from the manifest -└── age # self-contained static binary, so restore can decrypt unaided +├── -openclaw-backup.tar.gz # openclaw backup create --verify output +├── state-backup.json # the backup's machine-readable result +├── secrets.age # age-encrypted host-level secrets (never plaintext on disk) +├── manifest.json # versions, service unit, gateway port, per-core-repo git state +├── RUNBOOK.md # ordered restore steps, generated from the manifest +└── age # static binary copied in, so restore can decrypt unaided ``` ## Contracts @@ -78,15 +79,98 @@ keypair. The private key never touches any VM. ```sh # One-time, on the operator's machine only. Private key stays here (or a password manager). -age-keygen -o ~/openclaw-snapshot.key +age-keygen -o ~/.openclaw-snapshot.key # Prints the PUBLIC key (age1...) to stdout; record it. The file holds the PRIVATE key. # Capture time (host holds only the public key — encrypt-only capability): age -r age1 < plaintext > secrets.age # snapshot-secrets.sh does this via a tar pipe # Restore time (operator supplies the private key): -age -d -i ~/openclaw-snapshot.key < secrets.age > plaintext +age -d -i ~/.openclaw-snapshot.key < secrets.age > plaintext ``` -`age` is not assumed present on any host, so the toolkit ships a self-contained static -`age` binary in `snapshot/bin/age` and rides a copy along in each snapshot directory. +The private key is saved as a **dotfile** (`~/.openclaw-snapshot.key`), which keeps it out +of casual directory listings and reduces the chance another agent on the machine reads it. +It is never copied to a VM, committed to this repo, or passed through any tooling. + +`age` is not assumed present on any host, and the static binary is **not committed** to this +repo (to keep the tree binary-free). Fetch it once and drop it at `snapshot/bin/age`; the +capture driver copies that binary into each snapshot directory so restore can decrypt unaided. + +--- + +## Operator guide + +### 1. Generate the keypair (one-time, off-host) + +```sh +age-keygen -o ~/.openclaw-snapshot.key # PRIVATE key — never leaves this machine +# stdout prints: Public key: age1 — record this; it is all the host needs. +``` + +If `age-keygen` is missing locally, install `age` (`brew install age`, `apt-get install age`, +or the release below) — it provides both `age` and `age-keygen`. + +### 2. Provision the `age` binary at `snapshot/bin/age` + +The host (`x86_64` Linux) has no `age`. Fetch the static release binary, verify its +checksum, and place it — no `sudo` needed: + +```sh +# Pick the asset matching the host arch (linux-amd64 for the current host). +ver=v1.3.2 +curl -fsSLO "https://github.com/FiloSottile/age/releases/download/${ver}/age-${ver}-linux-amd64.tar.gz" +# Record the SHA-256 of what you downloaded, and pin it in your ops notes so a +# future re-fetch can be checked against it: +sha256sum "age-${ver}-linux-amd64.tar.gz" +tar -xzf "age-${ver}-linux-amd64.tar.gz" +install -m 0755 age/age snapshot/bin/age +snapshot/bin/age --version # sanity check +``` + +> The `age` releases publish a per-asset `.proof` (Sigsum transparency proof) rather than a +> combined checksums file; upstream verification instructions are in the release notes. Pin +> the exact release tag and the SHA-256 you recorded above in your ops notes, and re-pin when +> upgrading `age`. The binary is intentionally not tracked in git. + +### 3. Capture on the host + +```sh +AGE_BIN=snapshot/bin/age \ + bash snapshot/snapshot.sh --output ~/snapshots --pubkey age1 +``` + +This creates `~/snapshots/openclaw-snapshot-/` (refusing to overwrite an existing one), +runs the manifest, state, and secrets captures into it, and copies the `age` binary alongside. +The `openclaw` binary lives at `~/.npm-global/bin/openclaw`; set `OPENCLAW_BIN` if it is not on +`PATH` for the invoking shell. + +### 4. Confirm no plaintext leaked into `secrets.age` + +`strings` may be absent on a minimal host. Check the encrypted blob directly for a known +secret substring using `grep -a` (treat the binary as text). A real `age` blob is encrypted, +so nothing should match: + +```sh +snap=$(ls -d ~/snapshots/openclaw-snapshot-*/ | tail -1) +# The blob must start with the age header and contain none of your secrets: +head -c 64 "$snap/secrets.age"; echo +grep -a -c -i -e 'authToken' -e 'BEGIN .*PRIVATE KEY' "$snap/secrets.age" # expect: 0 +``` + +### 5. Verify the state archive + +```sh +~/.npm-global/bin/openclaw backup verify "$snap"/*openclaw-backup.tar.gz +``` + +### 6. Dry-run the restore (no mutations) + +```sh +bash snapshot/restore.sh --from "$snap" --dry-run +``` + +Eyeball the plan: the captured OpenClaw version, each core repo cloned from its **recorded +origin** (origins span multiple owners — e.g. `kagenti/*` and `rossoctl/*` — so the recorded +origin, not a single org, is authoritative) at its recorded branch, the service unit, and an +`openclaw backup verify` step. The dry-run creates nothing. From eaf978e1438d0884ae4e2964c5a673edd3d76c9b Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Mon, 31 Aug 2026 21:33:22 -0400 Subject: [PATCH 10/15] fix: Make snapshot capture robust to non-exec scripts The driver invoked its sibling capture scripts directly ("$CMD"), which fails with "Permission denied" when the scripts are mode 0644 -- as they are after a fresh git clone or a tarball repacked without preserving the execute bit. This bit a real live-host capture. Invoke the capture scripts through 'bash "$CMD"' so file mode no longer matters, and also set the git execute bit on the invoked scripts (intent). A new driver-test assertion runs a capture with non-executable capture scripts and requires it to succeed, so the regression cannot return silently. Also pin the verified SHA-256 for the age v1.3.2 linux-amd64 release in the operator guide. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/README.md | 2 ++ snapshot/restore.sh | 0 snapshot/snapshot-manifest.sh | 0 snapshot/snapshot-secrets.sh | 0 snapshot/snapshot-state.sh | 0 snapshot/snapshot.sh | 17 ++++++++++++++--- tests/test-snapshot-driver.sh | 24 ++++++++++++++++++++++++ 7 files changed, 40 insertions(+), 3 deletions(-) mode change 100644 => 100755 snapshot/restore.sh mode change 100644 => 100755 snapshot/snapshot-manifest.sh mode change 100644 => 100755 snapshot/snapshot-secrets.sh mode change 100644 => 100755 snapshot/snapshot-state.sh mode change 100644 => 100755 snapshot/snapshot.sh diff --git a/snapshot/README.md b/snapshot/README.md index 56e30b8..f3d6112 100644 --- a/snapshot/README.md +++ b/snapshot/README.md @@ -123,6 +123,8 @@ curl -fsSLO "https://github.com/FiloSottile/age/releases/download/${ver}/age-${v # Record the SHA-256 of what you downloaded, and pin it in your ops notes so a # future re-fetch can be checked against it: sha256sum "age-${ver}-linux-amd64.tar.gz" +# Verified 2026-08-31 for v1.3.2 linux-amd64: +# cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 tar -xzf "age-${ver}-linux-amd64.tar.gz" install -m 0755 age/age snapshot/bin/age snapshot/bin/age --version # sanity check diff --git a/snapshot/restore.sh b/snapshot/restore.sh old mode 100644 new mode 100755 diff --git a/snapshot/snapshot-manifest.sh b/snapshot/snapshot-manifest.sh old mode 100644 new mode 100755 diff --git a/snapshot/snapshot-secrets.sh b/snapshot/snapshot-secrets.sh old mode 100644 new mode 100755 diff --git a/snapshot/snapshot-state.sh b/snapshot/snapshot-state.sh old mode 100644 new mode 100755 diff --git a/snapshot/snapshot.sh b/snapshot/snapshot.sh old mode 100644 new mode 100755 index 3fcea57..21e26db --- a/snapshot/snapshot.sh +++ b/snapshot/snapshot.sh @@ -80,17 +80,28 @@ fi mkdir -p "$SNAP_DIR" +# Invoke the sibling capture scripts through `bash` rather than executing them +# directly. They are bash scripts with a bash shebang, so this is equivalent -- +# but it does NOT depend on the execute bit surviving. A fresh `git clone`, or a +# tarball repacked without preserving mode, can land these scripts as mode 0644; +# invoking `"$CMD"` there fails with "Permission denied", while `bash "$CMD"` +# works regardless. (Test seams may still point at executable stubs; bash runs +# those fine too.) +run_capture() { + bash "$@" +} + # Capture runtime facts + per-repo git state (manifest.json + RUNBOOK.md). echo "==> manifest" -"$MANIFEST_CMD" --outdir "$SNAP_DIR" +run_capture "$MANIFEST_CMD" --outdir "$SNAP_DIR" # Capture the authoritative OpenClaw state via the first-class backup. echo "==> state" -"$STATE_CMD" --outdir "$SNAP_DIR" +run_capture "$STATE_CMD" --outdir "$SNAP_DIR" # Capture host secrets into a single age-encrypted blob. echo "==> secrets" -"$SECRETS_CMD" --outdir "$SNAP_DIR" --pubkey "$PUBKEY" +run_capture "$SECRETS_CMD" --outdir "$SNAP_DIR" --pubkey "$PUBKEY" # Bundle the age binary so decrypt/restore works on a VM without age installed. # Only warn (do not fail) if the source is absent: the state + manifest are diff --git a/tests/test-snapshot-driver.sh b/tests/test-snapshot-driver.sh index 4adc387..1d5fb03 100644 --- a/tests/test-snapshot-driver.sh +++ b/tests/test-snapshot-driver.sh @@ -131,6 +131,30 @@ if [ ! -x "$EXPECTED_DIR/age" ]; then fail=1 fi +# Regression (portability): the driver must invoke the capture scripts even when +# they are NOT executable. git can track scripts as mode 0644, and a tarball may +# be repacked without preserving the execute bit -- both happened on the real +# host, where directly executing a 0644 script failed with "Permission denied". +# Point the seams at non-executable copies and require a clean capture anyway. +NX_BIN="$TEST_TMPDIR/stubs-noexec" +mkdir -p "$NX_BIN" +cp "$STUB_BIN/state" "$NX_BIN/state" +cp "$STUB_BIN/manifest" "$NX_BIN/manifest" +cp "$STUB_BIN/secrets" "$NX_BIN/secrets" +chmod -x "$NX_BIN/state" "$NX_BIN/manifest" "$NX_BIN/secrets" +NX_DATE="2026-01-03" +NX_DIR="$OUTPUT/openclaw-snapshot-$NX_DATE" +SNAPSHOT_DATE="$NX_DATE" \ +SNAPSHOT_STATE_CMD="$NX_BIN/state" \ +SNAPSHOT_MANIFEST_CMD="$NX_BIN/manifest" \ +SNAPSHOT_SECRETS_CMD="$NX_BIN/secrets" \ +AGE_BIN_SRC="$AGE_SRC" \ +bash "$DRIVER_SH" --output "$OUTPUT" --pubkey "$PUBKEY" >/dev/null 2>&1 || true +if [ ! -f "$NX_DIR/.manifest-ran" ] || [ ! -f "$NX_DIR/.state-ran" ] || [ ! -f "$NX_DIR/.secrets-pubkey" ]; then + echo "FAIL driver: capture failed when scripts were non-executable (portability regression)" + fail=1 +fi + # Second run against the same date must REFUSE to overwrite (non-zero). if run_driver >/dev/null 2>&1; then echo "FAIL driver: second run should refuse to overwrite existing dated dir" From bfbb38bcf5d31c59ec6c02443a362f80439c8d9e Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Mon, 31 Aug 2026 21:52:29 -0400 Subject: [PATCH 11/15] docs: Name real state archive in RUNBOOK step 4 The generated RUNBOOK said 'Extract state.tar.gz', a placeholder that never matches the real artifact -- the state capture names the archive after the backup's own archivePath (a dated *-openclaw-backup.tar.gz). restore.sh already globs for that name, so the tooling was correct; only the human-facing runbook prose was misleading. Describe the actual naming pattern instead. The manifest generator runs independently of the state capture and does not know the exact archive filename, so the runbook names the pattern rather than a specific file. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/snapshot-manifest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshot/snapshot-manifest.sh b/snapshot/snapshot-manifest.sh index 6c042be..415191f 100755 --- a/snapshot/snapshot-manifest.sh +++ b/snapshot/snapshot-manifest.sh @@ -151,7 +151,7 @@ runbook="$OUTDIR/RUNBOOK.md" echo "1. Bootstrap the bundled \`age\` binary." echo "2. Install OpenClaw pinned to \`$openclaw_version\` and node \`$node_version\`." echo "3. Decrypt \`secrets.age\` with the operator's private key and place the secret files." - echo "4. Extract \`state.tar.gz\` in place, then run \`openclaw backup verify\` on it." + echo "4. Extract the state archive (the dated \`*-openclaw-backup.tar.gz\` in this snapshot dir) in place, then run \`openclaw backup verify\` on it." echo "5. Re-clone the core repos below, checkout each recorded branch, re-apply any dirty diff." echo "6. Install and enable the service unit; start the gateway." echo "7. Verify (cron count and agent list vs. this manifest)." From 80634354eb11d709895828a1f01a1a699550c9a3 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 1 Sep 2026 09:59:26 -0400 Subject: [PATCH 12/15] test: Pin fixture branch to main for deterministic CI The tool-two fixture used a bare 'git init', so its branch name came from the host's init.defaultBranch. On GitHub Actions runners that is not 'main', so the manifest recorded a different branch and the 'checkout main' assertion failed on CI while passing on a dev box. Pin the branch explicitly with 'git checkout -b main' so the test is deterministic regardless of the runner's git configuration. Fixes the CI failure flagged in review on #69. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- tests/test-snapshot-manifest.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test-snapshot-manifest.sh b/tests/test-snapshot-manifest.sh index 43c799e..c82742a 100755 --- a/tests/test-snapshot-manifest.sh +++ b/tests/test-snapshot-manifest.sh @@ -56,6 +56,11 @@ git init -q "$REPOS_DIR/tool-one" git init -q "$REPOS_DIR/tool-two" ( cd "$REPOS_DIR/tool-two" + # Pin the branch to 'main' explicitly. A bare `git init` uses the host's + # init.defaultBranch, which is 'master' on GitHub Actions runners -- the + # manifest would then record 'master' and the checkout assertion below would + # fail on CI while passing on a dev box configured for 'main'. + git checkout -q -b main git config user.email "t@example.com" git config user.name "t" echo "world" > file.txt From 39fe82915ce1c9acd03ef20549e6a205426324c2 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 1 Sep 2026 12:39:55 -0400 Subject: [PATCH 13/15] docs: Add maintainer notes for easier handoff Documentation and comments only; no logic changes. Aimed at making the toolkit easy for a new maintainer to pick up: - README gains a Maintainer reference: a table of every environment seam (variable, default, purpose, consumer) and a Development section with the exact test + shellcheck commands CI runs. - Flag the secret allowlist in lib-snapshot.sh as the one list to keep current when a new host secret file appears. - Note why the manifest generator re-encodes the JSON array each iteration (jq owns escaping) so it is not 'optimized' into a bug. - Warn in tests.yml that the test/shellcheck lists are explicit, not globs, so a new test must be added there or it never runs. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- .github/workflows/tests.yml | 3 +++ snapshot/README.md | 47 +++++++++++++++++++++++++++++++++++ snapshot/lib-snapshot.sh | 3 +++ snapshot/snapshot-manifest.sh | 4 +++ 4 files changed, 57 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ddcdcb..da2d6cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,6 +35,9 @@ jobs: # avoid the consumer SC2034 idiom), so lint them as their own group. run: shellcheck --severity=warning snapshot/*.sh tests/test-lib-snapshot.sh tests/test-snapshot-*.sh - name: Run test suite + # NOTE: this list and the shellcheck lists above are EXPLICIT, not globs. + # A new test file must be added here (and to the relevant shellcheck + # step) or it silently never runs in CI. run: | set -e for t in tests/test-lib-inventory.sh tests/test-lib-modules.sh \ diff --git a/snapshot/README.md b/snapshot/README.md index f3d6112..127e89b 100644 --- a/snapshot/README.md +++ b/snapshot/README.md @@ -176,3 +176,50 @@ Eyeball the plan: the captured OpenClaw version, each core repo cloned from its origin** (origins span multiple owners — e.g. `kagenti/*` and `rossoctl/*` — so the recorded origin, not a single org, is authoritative) at its recorded branch, the service unit, and an `openclaw backup verify` step. The dry-run creates nothing. + +--- + +## Maintainer reference + +### Environment variables + +Every external dependency is an overridable environment variable (a "seam"), so +the scripts stay hermetically testable. Defaults are what production uses; tests +point these at fixtures. + +| Variable | Default | Purpose | Used by | +|----------|---------|---------|---------| +| `REPOS_DIR` | from org profile | Where core-repo clones live | manifest, restore | +| `OPENCLAW_BIN` | `openclaw` | Path to the `openclaw` binary | state, manifest (via lib) | +| `NODE_BIN` | `node` | Path to the `node` binary | manifest (via lib) | +| `SNAPSHOT_HOME` | `$HOME` | Base dir for the secret-file allowlist | secrets, lib | +| `GATEWAY_PORT` | `18789` | Gateway port recorded in the manifest | manifest | +| `SERVICE_UNIT` | `openclaw-gateway.service` | systemd unit recorded in the manifest | manifest, restore | +| `AGE_BIN` | `age` | Path to the `age` binary (encrypt/decrypt) | secrets | +| `AGE_BIN_SRC` | `snapshot/bin/age` | `age` binary bundled into the snapshot | driver | +| `SNAPSHOT_DATE` | `date +%F` | Overrides the dated dir name (tests only) | driver | +| `SNAPSHOT_STATE_CMD` / `SNAPSHOT_SECRETS_CMD` / `SNAPSHOT_MANIFEST_CMD` | the sibling scripts | Substitute capture steps (tests only) | driver | + +### The secret allowlist is the thing to keep current + +The list of host secret files to capture is hardcoded in +`snapshot/lib-snapshot.sh` (`snapshot_secret_paths`, the `candidates` array). +**To capture a newly added secret file, add its path to that array** — nothing +else discovers secrets automatically. If OpenClaw starts writing a new +credential file and it is not in that list, capture silently omits it. + +### Running the tests + +The scripts target **bash 3.2** (macOS default) — avoid bash-4-only features. +Run the suite and the linter exactly as CI does: + +```sh +for t in tests/test-lib-snapshot.sh tests/test-snapshot-*.sh; do + echo "== $t =="; bash "$t" +done +shellcheck --severity=warning snapshot/*.sh tests/test-lib-snapshot.sh tests/test-snapshot-*.sh +``` + +> **Adding a test?** CI (`.github/workflows/tests.yml`) uses explicit file +> lists, not globs. A new test file must be added to **both** the shellcheck +> step and the run-suite loop there, or it silently never runs. diff --git a/snapshot/lib-snapshot.sh b/snapshot/lib-snapshot.sh index 91e5cbf..05675e5 100644 --- a/snapshot/lib-snapshot.sh +++ b/snapshot/lib-snapshot.sh @@ -32,6 +32,9 @@ snapshot_secret_paths() { local base="${SNAPSHOT_HOME:-$HOME}" # Candidate secret files, relative to the base home directory. + # MAINTAINERS: this array is the whole secret-capture allowlist. Nothing else + # discovers secrets automatically -- to capture a newly added secret file, add + # its path here, or capture will silently omit it. local candidates=( ".openclaw/.env" ".openclaw/gateway.systemd.env" diff --git a/snapshot/snapshot-manifest.sh b/snapshot/snapshot-manifest.sh index 415191f..005069a 100755 --- a/snapshot/snapshot-manifest.sh +++ b/snapshot/snapshot-manifest.sh @@ -105,6 +105,10 @@ while IFS= read -r name; do fi # Append this repo as a JSON object. jq handles all string escaping. + # NOTE: re-encoding the whole array each iteration (`. + [$obj]`) is O(n^2), + # but the core-repo count is tiny (single digits) and letting jq own every + # append keeps all escaping correct. Don't "optimize" this into shell string + # concatenation -- that reintroduces the escaping bugs jq is here to avoid. repo_obj=$(jq -n \ --arg name "$name" \ --arg origin "$origin" \ From 879cbaec0f1671b1e496d683dbe03de7f414add2 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 1 Sep 2026 13:20:03 -0400 Subject: [PATCH 14/15] fix: Remove eval from restore, execute argv directly restore.sh built each restore step as a single string and ran it with eval. A manifest field -- e.g. a repo origin, originally captured from a git remote URL -- containing shell metacharacters could inject commands during a real restore (a tampered or adversarial manifest). Replace the eval-based emit helper with one that takes the command and its arguments as separate words and executes them via "$@", so the shell never re-parses manifest-derived data. Dry-run printing uses printf '%q' for safe, copy-paste-faithful output. The one inherently piped step (decrypt | untar) moves to emit_pipeline, which runs a fixed command string containing only trusted literals, never manifest data. Add a regression test: a manifest origin carrying a 'touch canary' payload run through a real (stubbed) restore must leave the canary absent. Verified it fails against the old eval form and passes now. Addresses the security finding in review on #69. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/restore.sh | 54 ++++++++++++++++++++++++++-------- tests/test-snapshot-restore.sh | 53 ++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/snapshot/restore.sh b/snapshot/restore.sh index 70f4ba1..1e27760 100755 --- a/snapshot/restore.sh +++ b/snapshot/restore.sh @@ -74,11 +74,35 @@ validate_repos_dir "$REPOS_DIR" # emit: in dry-run, print the step; otherwise execute it. Keeping the two modes # behind one helper means the plan the operator reviews is exactly the sequence # that runs for real -- no drift between "what it says" and "what it does". +# +# Each argument is one argv word -- callers pass the command and its arguments +# as SEPARATE words, never a single pre-quoted string. Execution is `"$@"`, so +# the shell never re-parses the words: a manifest-derived value such as a repo +# origin or path cannot inject shell metacharacters (no `eval`). In dry-run we +# print with `printf '%q'` so the shown words are safely quoted and copy-paste +# faithful. A step that genuinely needs a shell pipeline uses emit_pipeline. emit() { if [ "$DRY_RUN" -eq 1 ]; then - printf ' %s\n' "$*" + printf ' ' + printf '%q ' "$@" + printf '\n' + else + "$@" + fi +} + +# emit_pipeline: like emit, but for the one step that is inherently a shell +# pipeline (decrypt | untar). It takes a fixed command STRING that must contain +# NO caller/manifest-derived data -- only literals and controlled environment +# variables ($AGE_IDENTITY, $FROM, $HOME) that are not attacker-influenced. In +# dry-run it prints the string; for real it runs it under `bash -c`. Keeping the +# eval-like path in its own helper, fed only trusted literals, means the general +# emit path stays injection-proof. +emit_pipeline() { + if [ "$DRY_RUN" -eq 1 ]; then + printf ' %s\n' "$1" else - eval "$*" + bash -c "$1" fi } @@ -107,23 +131,26 @@ fi # 1. Bootstrap the bundled age binary (so decrypt works without system age). echo "Step 1: bootstrap age" -emit "install -m 0755 '$FROM/age' /usr/local/bin/age" +emit install -m 0755 "$FROM/age" /usr/local/bin/age -# 2. Install the pinned OpenClaw + node versions. +# 2. Install the pinned OpenClaw + node versions. This is a manual/host-specific +# step (package manager, npm, etc.), so it is described rather than executed. echo "Step 2: install OpenClaw $openclaw_version (node $node_version)" -emit "echo 'install openclaw@$openclaw_version node@$node_version'" +echo " install openclaw@$openclaw_version and node@$node_version by your host's usual method" # 3. Decrypt the secrets bundle with the operator's PRIVATE key (supplied out of # band; never stored in the snapshot). Names-only -- no contents printed. +# This is the one inherently-piped step; its command string contains only +# $FROM and controlled env vars, never manifest-derived data. echo "Step 3: decrypt secrets.age" -emit "age -d -i \"\$AGE_IDENTITY\" '$FROM/secrets.age' | tar -C \"\$HOME\" -xf -" +emit_pipeline "age -d -i \"\$AGE_IDENTITY\" '$FROM/secrets.age' | tar -C \"\$HOME\" -xf -" # 4. Restore state: extract the archive in place, then VERIFY it. There is no # `openclaw backup restore` subcommand. echo "Step 4: restore + verify OpenClaw state" if [ -n "$state_archive" ]; then - emit "tar -C \"\$HOME\" -xzf '$state_archive'" - emit "openclaw backup verify '$state_archive'" + emit tar -C "$HOME" -xzf "$state_archive" + emit openclaw backup verify "$state_archive" else echo " WARNING: no state archive found in $FROM (looked for *openclaw-backup.tar.gz / state.tar.gz)" >&2 fi @@ -149,8 +176,8 @@ while [ "$i" -lt "$repo_count" ]; do fi target="$REPOS_DIR/$r_name" - emit "git clone '$clone_url' '$target'" - emit "git -C '$target' checkout '$r_branch'" + emit git clone "$clone_url" "$target" + emit git -C "$target" checkout "$r_branch" if [ "$r_dirty" = "true" ]; then echo " NOTE: $r_name had uncommitted changes at capture; re-apply its patch manually" >&2 fi @@ -160,11 +187,12 @@ done # 6. Install and enable the service unit; start the gateway. echo "Step 6: enable service unit $service_unit" -emit "systemctl --user enable --now '$service_unit'" +emit systemctl --user enable --now "$service_unit" -# 7. Verify the running host against the captured manifest. +# 7. Verify the running host against the captured manifest. A manual check, so +# it is described rather than executed. echo "Step 7: verify" -emit "echo 'verify: compare cron count and agent list against $MANIFEST'" +echo " verify: compare cron count and agent list against $MANIFEST" if [ "$DRY_RUN" -eq 1 ]; then echo "Dry-run complete: no changes made." diff --git a/tests/test-snapshot-restore.sh b/tests/test-snapshot-restore.sh index e3ee1ed..99d3905 100644 --- a/tests/test-snapshot-restore.sh +++ b/tests/test-snapshot-restore.sh @@ -144,8 +144,59 @@ if ( fail=1 fi +# Injection safety: a manifest whose origin carries a shell-injection payload +# must NOT execute that payload during a REAL (non-dry-run) restore. emit runs +# its words via "$@", so metacharacters in a manifest field are inert data, not +# code. We stub every command the real path would invoke so nothing mutates the +# host, drop a sentinel canary, and require it to remain absent. +INJ_SNAP="$TEST_TMPDIR/inj-snap" +mkdir -p "$INJ_SNAP" +: > "$INJ_SNAP/age" +: > "$INJ_SNAP/secrets.age" +: > "$INJ_SNAP/state.tar.gz" +CANARY="$TEST_TMPDIR/pwned" +# The origin tries to break out of a clone and touch the canary file. +PAYLOAD="https://x/r.git'; touch $CANARY; echo '" +cat > "$INJ_SNAP/manifest.json" < "$INJ_BIN/$c" + chmod +x "$INJ_BIN/$c" +done + +# Run the REAL restore (no --dry-run) with stubs on PATH and AGE_IDENTITY set so +# the pipeline step has an identity value. Tolerate a nonzero exit. +( + PATH="$INJ_BIN:$PATH" \ + AGE_IDENTITY="/dev/null" \ + ORG_PROFILE_FILE="$TEST_TMPDIR/org.env" \ + CORE_REPOS_FILE="$TEST_TMPDIR/core.txt" \ + REPOS_DIR="$REPOS_DIR" \ + bash "$RESTORE_SH" --from "$INJ_SNAP" +) >/dev/null 2>&1 || true + +if [ -e "$CANARY" ]; then + echo "FAIL restore: shell injection from a manifest origin EXECUTED (canary created)" + fail=1 +fi + if [ "$fail" -eq 0 ]; then - echo "PASS: snapshot-restore (dry-run plan: recorded-origin clone, \$ORG fallback, verify-not-restore)" + echo "PASS: snapshot-restore (dry-run plan: recorded-origin clone, \$ORG fallback, verify-not-restore, injection-safe)" else exit 1 fi From e1fb8523fa865ca4821a1ae2dbd9a596a8f13aef Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 1 Sep 2026 13:22:53 -0400 Subject: [PATCH 15/15] docs: Annotate each secret-allowlist entry Each candidate secret file now carries a short inline comment naming what it is (OpenClaw env, gateway systemd env, npm auth, stored GitHub PAT, SSH private key), so a maintainer can tell what each entry is and whether it is expected on a given host. Addresses the documentation nit in review on #69. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- snapshot/lib-snapshot.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/snapshot/lib-snapshot.sh b/snapshot/lib-snapshot.sh index 05675e5..77f8c4f 100644 --- a/snapshot/lib-snapshot.sh +++ b/snapshot/lib-snapshot.sh @@ -36,11 +36,11 @@ snapshot_secret_paths() { # discovers secrets automatically -- to capture a newly added secret file, add # its path here, or capture will silently omit it. local candidates=( - ".openclaw/.env" - ".openclaw/gateway.systemd.env" - ".npmrc" - "new_pat.txt" - ".ssh/id_ecdsa" + ".openclaw/.env" # OpenClaw runtime env (API keys, tokens) + ".openclaw/gateway.systemd.env" # env file the gateway systemd unit reads + ".npmrc" # npm registry auth (may hold a token) + "new_pat.txt" # stored GitHub PAT used by the automation + ".ssh/id_ecdsa" # SSH private key for git remote access ) # Emit only the candidates that actually exist as regular files.