Skill: zurbrick/agent-backup (version 1.1.0)

--- name: openclaw-backup description: > Encrypted backup and restore for OpenClaw agents. Two-tier archives: operational data safe for cloud storage, secrets encrypted with age for local recovery. Manifest verification with SHA-256 checksums. Atomic restore with staging, health checks, and auto-rollback. GitHub push with secrets protection. Daily scheduled backups. Use when setting up disaster recovery, backing up your agent, restoring from backup, or pushing archives to GitHub. --- # 🔐 Agent Backup One command to backup. One command to restore. Everything encrypted, verified, and rollback-safe. ## Quick Start ```bash # Backup (operational only — safe for cloud) bash {baseDir}/scripts/backup.sh # Backup with encrypted secrets bash {baseDir}/scripts/backup.sh --include-secrets --age-recipient age1... # Verify bash {baseDir}/scripts/verify.sh --manifest <path>/manifest.json --archive <path>/backup.tar.gz # Restore (dry-run first) bash {baseDir}/scripts/restore.sh --manifest <path>/manifest.json --archive <path>/backup.tar.gz --dry-run # Restore for real bash {baseDir}/scripts/restore.sh --manifest <path>/manifest.json --archive <path>/backup.tar.gz # Push to GitHub (operational only, secrets blocked if unencrypted) bash {baseDir}/scripts/push-to-github.sh --manifest <path>/manifest.json --archive <path>/backup.tar.gz # Schedule daily 4 AM backups bash {baseDir}/scripts/schedule.sh ``` ## Two-Tier Archive Model | Tier | Contents | Cloud safe? | Encrypted? | |------|----------|-------------|------------| | **Operational** | Workspace, redacted config, crons | ✅ Yes | No (no secrets) | | **Secrets** | .env, agent auth profiles | ❌ Local only | ✅ Required (age) | Default: operational only. Secrets are opt-in via `--include-secrets`. ## Restore Safety Restore uses a 7-step safety flow: 1. Verify manifest checksums 2. Extract to staging (not live directory) 3. Verify critical files in staging 4. Backup current state to `.pre-restore-backup-TIMESTAMP` 5. Atomic swap 6. Health check (`pre-restart-check.sh` if available) 7. Auto-rollback on failure Flags: `--dry-run` (preview only), `--force` (non-interactive) ## Prerequisites - `age` for secrets encryption: `brew install age` or `apt install age` - `gh` for GitHub push (optional): `brew install gh` ## Configuration Set encryption via environment or flags: ```bash # Environment export AGE_RECIPIENT="age1your_public_key" export AGE_PASSPHRASE_FILE="/path/to/passphrase" # Or flags bash {baseDir}/scripts/backup.sh --include-secrets --age-recipient age1... ``` ## Workflows - `bash {baseDir}/scripts/weekly-verify.sh` — verify all backup sets, prune by daily/weekly/monthly retention, and clean orphaned files. - `bash {baseDir}/scripts/monthly-drill.sh` — run a dry-run restore against the newest backup set and report pass/fail. - `bash {baseDir}/scripts/pre-change-snapshot.sh` — create a fast operational-only snapshot before config edits or gateway restarts. - `.github/workflows/verify-backup.yml` — GitHub Actions CI that builds a fixture backup, validates manifest checksums, extracts the archive, and checks critical files. ## Reference Files - `{baseDir}/references/restore-guide.md` — full disaster recovery walkthrough - `{baseDir}/references/what-to-backup.md` — every file explained - `{baseDir}/references/retention-policy.md` — how long to keep backups

path: CHANGELOG.md
content: # Changelog

## 1.1.0 - 2026-03-19

- Added `scripts/weekly-verify.sh` for fleet-wide verification, retention pruning, and orphan cleanup
- Added `scripts/monthly-drill.sh` for dry-run restore drills with Telegram-friendly pass/fail output
- Added `scripts/pre-change-snapshot.sh` for fast operational-only rollback snapshots before config changes
- Added `.github/workflows/verify-backup.yml` to validate generated backup archives on every push
- Documented workflow usage and scheduling in `SKILL.md` and `README.md`
- Updated backup manifest version to `1.1.0`

## 1.0.0 - 2026-03-19

- Initial release
- Added backup, restore, verify, GitHub push, and schedule scripts
- Added restore and inventory references
- Added repo template `.gitignore`


path: README.md
content: # 🔐 Agent Backup — Disaster Recovery for OpenClaw

Your agent forgets nothing — but your hard drive can. This skill creates encrypted, verifiable backups of your entire OpenClaw installation with one command.

## Why This Exists

Without this skill, a dead Mac Studio means:
- ✅ **5 minutes** to restore workspace (daily backup covers this)
- ❌ **Hours** to recreate 43 cron definitions from memory
- ❌ **Hours** to rebuild 11 agent configs, channel settings, provider auth
- ❌ **Hunting** through dashboards to re-gather 8+ API keys

With this skill: **one command to backup, one command to restore.** Everything.

## What Gets Backed Up

| Archive | Contents | Where it goes |
|---------|----------|---------------|
| **Operational** `openclaw-backup-*.tar.gz` | Workspace (SOUL, MEMORY, scripts, skills, memory), config (redacted), crons | Local + Google Drive + GitHub ✅ |
| **Secrets** `openclaw-secrets-*.tar.gz.age` | .env (API keys), agent auth profiles (OAuth tokens) | 🔒 Local only, encrypted with `age` |
| **Manifest** `manifest.json` | Checksums, versions, file list, timestamps | Alongside both archives |

**Secrets never touch cloud storage unencrypted.** The push-to-github script hard-refuses if it detects unencrypted secrets.

## Quick Start

```bash
# Basic backup (operational only — safe for cloud)
bash scripts/backup.sh

# Full backup including encrypted secrets
bash scripts/backup.sh --include-secrets --age-recipient age1your_public_key_here

# Verify a backup
bash scripts/verify.sh --manifest path/to/manifest.json --archive path/to/backup.tar.gz

# Dry-run a restore (shows what would happen, changes nothing)
bash scripts/restore.sh --manifest path/to/manifest.json --archive path/to/backup.tar.gz --dry-run

# Real restore
bash scripts/restore.sh --manifest path/to/manifest.json --archive path/to/backup.tar.gz

# Push to private GitHub repo
bash scripts/push-to-github.sh --manifest path/to/manifest.json --archive path/to/backup.tar.gz

# Schedule daily 4 AM backups
bash scripts/schedule.sh
```

## How Restore Works (Safety First)

Restore doesn't just dump files — it uses a 7-step safety flow:

```
1. Verify checksums from manifest
2. Extract to staging directory (not live)
3. Verify critical files exist in staging
4. Backup current state → .pre-restore-backup-TIMESTAMP
5. Atomic swap: staging → live
6. Health check (pre-restart-check.sh)
7. Auto-rollback if health check fails
```

**If anything goes wrong, your previous state is preserved automatically.**

## Setup

### Prerequisites
- `age` — for secrets encryption: `brew install age` (macOS) or `apt install age` (Linux)
- `gh` — for GitHub push: `brew install gh` (optional)

### Generate your encryption key (one time)
```bash
age-keygen -o ~/.config/age/keys.txt
# Save the public key (age1...) — used for encryption
# Save keys.txt somewhere safe — needed for decryption
```

### Schedule daily backups
```bash
bash scripts/schedule.sh
```
This creates (or replaces) an OpenClaw cron job that runs at 4 AM daily.

## Workflows

### `weekly-verify.sh`
Runs a full weekly hygiene pass across `~/.openclaw/backups/`:
- verifies every backup set with a `manifest.json`
- reports total / OK / failed / missing-manifest counts
- prunes expired backup runs using the 14 daily / 8 weekly / 6 monthly retention policy
- removes orphaned manifests and orphaned encrypted secrets files
- prints a short Telegram-friendly summary for cron delivery

```bash
bash scripts/weekly-verify.sh
```

### `monthly-drill.sh`
Runs a monthly restore rehearsal against the newest valid backup set:
- finds the latest run with a manifest + operational archive
- executes `restore.sh --dry-run`
- checks for an explicit dry-run pass signal
- verifies any secrets archive is still encrypted as `.age`
- prints `Restore drill PASSED ✅` or `Restore drill FAILED 🔴 — ...`

```bash
bash scripts/monthly-drill.sh
```

### `pre-change-snapshot.sh`
Creates a fast operational-only snapshot before config edits or gateway restarts:
- skips secrets, GitHub push, and cloud concerns
- renames the archive to `openclaw-snapshot-pre-change-TIMESTAMP.tar.gz`
- keeps only the newest 5 snapshot runs
- outputs the snapshot path so you have a rollback reference

```bash
bash scripts/pre-change-snapshot.sh
```

### GitHub Actions: `verify-backup.yml`
On every push, CI now:
- generates a fixture backup from a temporary OpenClaw home
- validates manifest checksum metadata
- extracts the operational archive
- verifies critical files exist in the archive

## Scheduling with OpenClaw cron

Example system events for recurring workflow checks:

### Weekly verify — Sundays at 3 AM
```bash
openclaw cron create \
  --name "OpenClaw Weekly Backup Verify" \
  --cron "0 3 * * 0" \
  --system-event "Run bash '$HOME/.openclaw/workspace/skills/openclaw-backup/scripts/weekly-verify.sh' and return the output exactly."
```

### Monthly drill — 1st of month at 3 AM
```bash
openclaw cron create \
  --name "OpenClaw Monthly Restore Drill" \
  --cron "0 3 1 * *" \
  --system-event "Run bash '$HOME/.openclaw/workspace/skills/openclaw-backup/scripts/monthly-drill.sh' and return the output exactly."
```

## Disaster Recovery Scenarios

### 🔴 Mac died / disk failure
1. Get a new Mac, install OpenClaw: `npm i -g openclaw && openclaw onboard`
2. Download latest backup from Google Drive or clone from GitHub
3. `bash scripts/restore.sh --manifest ... --archive ... --secrets ... --age-identity ~/.config/age/keys.txt`
4. `openclaw gateway restart`
5. Reinstall Ollama models: `brew install ollama && ollama pull nomic-embed-text`

### 🟡 Config corruption (bad edit, env var removed)
1. Find the last good backup: `ls ~/.openclaw/backups/`
2. Partial restore — config only:
```bash
TMP=$(mktemp -d)
tar -xzf path/to/backup.tar.gz -C "$TMP"
cp "$TMP/openclaw/openclaw.json" ~/.openclaw/openclaw.json
openclaw gateway restart
```

### 🟢 Accidental file deletion
1. Check git first: `cd ~/.openclaw/workspace && git log --oneline -5`
2. Restore specific file: `git checkout HEAD~1 -- MEMORY.md`
3. If not in git, extract from backup:
```bash
TMP=$(mktemp -d)
tar -xzf path/to/backup.tar.gz -C "$TMP"
cp "$TMP/openclaw/workspace/MEMORY.md" ~/.openclaw/workspace/MEMORY.md
```

## Security Model

| Principle | Implementation |
|-----------|---------------|
| **Secrets never in cloud unencrypted** | `age` encryption required, push-to-github refuses plaintext |
| **Config redacted before packaging** | Token/key/secret fields stripped from openclaw.json |
| **Restore is non-destructive by default** | Staging → swap → rollback. Previous state always preserved |
| **Dry-run before real restore** | `--dry-run` shows exactly what would change |
| **Manifest verification** | SHA-256 checksums validated before any restore |
| **Non-interactive safety** | Refuses destructive restore without TTY unless `--force` |

## File Reference

```
scripts/
├── backup.sh               Create operational + encrypted secrets archives
├── weekly-verify.sh        Verify all runs, prune retention, clean orphans
├── monthly-drill.sh        Dry-run restore drill against the newest backup
├── pre-change-snapshot.sh  Fast operational-only rollback snapshot
├── restore.sh              Staged restore with validation and rollback
├── verify.sh               Manifest + checksum validation
├── push-to-github.sh       GitHub private repo push (secrets-protected)
└── schedule.sh             Create/replace daily 4 AM backup cron

.github/workflows/
└── verify-backup.yml       CI check for generated backup archives

references/
├── restore-guide.md        Detailed disaster recovery walkthrough
├── what-to-backup.md       Every file explained — what matters and why
└── retention-policy.md     How long to keep backups, pruning rules
```

## Built By

[Don Zurbrick](https://github.com/zurbrick) — battle-tested on a production OpenClaw agent with 11 agents, 43 crons, and 6 model providers. Council-reviewed by 5 AI models across 4 providers before publishing.

## License

MIT


path: references/restore-guide.md
content: # Restore Guide

## Overview

Use this guide when moving OpenClaw to a new machine, recovering from disk loss, rolling back to a known-good state, or restoring only selected pieces of the agent.

## Backup set layout

A backup run now creates a timestamped run directory containing:

- `openclaw-backup-YYYY-MM-DD.tar.gz` — operational archive
- `openclaw-secrets-YYYY-MM-DD.tar.gz.age` — optional encrypted secrets archive
- `manifest.json` — checksums, sizes, versions, timestamp, and file inventory

Operational archives are the only files intended for cloud/private GitHub storage by default. Secrets archives stay local unless the operator explicitly decides otherwise.

## Full restore on a new Mac or Linux host

1. Install the base OpenClaw environment first.
   - Confirm the `openclaw` CLI works.
   - Sign in or pair the node if your setup requires it.
2. Install `age` if you plan to restore secrets.
   - macOS: `brew install age`
   - Linux: `apt install age`
3. Copy the backup set onto the new machine.
4. Verify the archive set before restoring:

   ```bash
   bash scripts/verify.sh --manifest /path/to/manifest.json --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz [--secrets /path/to/openclaw-secrets-YYYY-MM-DD.tar.gz.age]
   ```

5. Dry-run the restore first:

   ```bash
   bash scripts/restore.sh --manifest /path/to/manifest.json --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz --dry-run
   ```

6. Restore it for real:

   ```bash
   bash scripts/restore.sh --manifest /path/to/manifest.json --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz
   ```

7. If you also need secrets, provide decryption material:

   ```bash
   bash scripts/restore.sh \
     --manifest /path/to/manifest.json \
     --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz \
     --secrets /path/to/openclaw-secrets-YYYY-MM-DD.tar.gz.age \
     --age-identity ~/.config/age/keys.txt
   ```

8. Restart the gateway:

   ```bash
   openclaw gateway restart
   ```

9. Re-test the system.
   - `openclaw gateway status`
   - Run a simple local task
   - Confirm scheduled jobs are present and sane

## Atomic restore behavior

`restore.sh` now restores through a safety workflow:

1. Verify checksums from `manifest.json`
2. Extract to `~/.openclaw/.restore-staging`
3. Confirm critical files exist in staging
4. Move the current installation to `~/.openclaw/.pre-restore-backup-TIMESTAMP`
5. Atomically move staged content into place
6. Run `workspace/scripts/pre-restart-check.sh` if present
7. Roll back automatically if the health check fails
8. Suggest `openclaw gateway restart`

## Partial restore

If only one area is broken, extract the operational archive to a temp directory and copy the specific subtree you need.

### Restore workspace only

```bash
TMP_DIR="$(mktemp -d)"
tar -xzf /path/to/openclaw-backup-YYYY-MM-DD.tar.gz -C "$TMP_DIR"
cp -R "$TMP_DIR/openclaw/workspace/." "$HOME/.openclaw/workspace/"
rm -rf "$TMP_DIR"
```

### Restore config only

```bash
TMP_DIR="$(mktemp -d)"
tar -xzf /path/to/openclaw-backup-YYYY-MM-DD.tar.gz -C "$TMP_DIR"
cp "$TMP_DIR/openclaw/openclaw.json" "$HOME/.openclaw/openclaw.json"
rm -rf "$TMP_DIR"
```

### Restore cron definitions only

```bash
TMP_DIR="$(mktemp -d)"
tar -xzf /path/to/openclaw-backup-YYYY-MM-DD.tar.gz -C "$TMP_DIR"
mkdir -p "$HOME/.openclaw/cron"
cp "$TMP_DIR/openclaw/cron/jobs.json" "$HOME/.openclaw/cron/jobs.json"
rm -rf "$TMP_DIR"
```

## Post-restore checklist

- Verify critical files exist
- Review secrets restoration separately from operational data
- Restart OpenClaw gateway
- Confirm cron jobs still make sense on this host
- Reinstall or reconnect external dependencies not stored in the archive
- Run one real task to prove the agent is alive


path: references/retention-policy.md
content: # Retention Policy

Use a split retention policy so operational archives remain convenient while secrets archives stay tightly controlled.

## Recommended baseline

### Local machine
- Keep the last 14 daily backup run directories
- Keep the last 8 weekly backup run directories
- Keep the last 6 monthly backup run directories
- Keep encrypted secrets archives only as long as the matching operational archive is still in retention

### Private GitHub / cloud
- Push only the operational archive + manifest by default
- Keep the last 30 operational backup run directories or 90 days, whichever is shorter
- Never push plaintext secrets archives
- Push encrypted secrets archives only with explicit operator intent and a documented reason

## Operational rules

1. Verify each backup set before deleting older ones.
2. Do not keep standalone secrets archives after deleting their matching manifest.
3. Rotate age keys or passphrases whenever you suspect exposure.
4. Test one restore dry-run per quarter.
5. After a successful real restore, create a fresh backup set immediately.

## Suggested cleanup workflow

1. Sort backup run directories by timestamp.
2. Keep the newest sets that satisfy your daily/weekly/monthly targets.
3. Delete old run directories as a unit — operational archive, secrets archive, and manifest together.
4. Never keep orphaned manifests or orphaned secrets archives.


path: references/what-to-backup.md
content: # What to Back Up

Use this inventory to decide what belongs in an OpenClaw recovery plan.

## Operational archive (safe for cloud/private GitHub storage)

| Path | Included by default | Why it matters | If you lose it |
|---|---:|---|---|
| `$HOME/.openclaw/workspace/` | Yes | Core agent memory, prompts, notes, local scripts, custom skills, active artifacts | You lose the agent's working brain and custom operating context |
| `$HOME/.openclaw/workspace/SOUL.md` | Via `workspace/` | Identity, tone, and operator behavior | The agent still runs but loses persona and behavioral nuance |
| `$HOME/.openclaw/workspace/MEMORY.md` | Via `workspace/` | Durable long-term memory | You lose curated memory and preferences |
| `$HOME/.openclaw/workspace/AGENTS.md` | Via `workspace/` | Operating rules, delegation model, and safety workflow | Orchestration quality drops and guardrails may be missing |
| `$HOME/.openclaw/workspace/memory/` | Via `workspace/` | Daily logs, bank files, blockers, and deep context | History and continuity disappear |
| `$HOME/.openclaw/workspace/scripts/` | Via `workspace/` | Local automation and repair tooling | Manual rebuild of workflows and helper scripts |
| `$HOME/.openclaw/workspace/skills/` | Via `workspace/` | Installed and custom skills | You lose custom packaged capabilities |
| `$HOME/.openclaw/openclaw.json` | Yes, redacted | Main OpenClaw configuration without embedded secrets | The node may start with wrong defaults or fail to match prior behavior |
| `$HOME/.openclaw/cron/jobs.json` | Yes | Scheduled automations and recurring tasks | All recurring jobs vanish |

## Encrypted secrets archive (local recovery only by default)

| Path | Included by default | Why it matters | If you lose it |
|---|---:|---|---|
| `$HOME/.openclaw/.env` | No — opt in via `--include-secrets` | Secrets, API keys, credentials, local overrides | External integrations may fail until secrets are rebuilt |
| `$HOME/.openclaw/agents/` | No — opt in via `--include-secrets` | Local auth profiles and agent-specific credentials/state | Re-authentication and local agent setup may be required |

## Not included

| Path | Included by default | Why it matters | If you lose it |
|---|---:|---|---|
| Local model cache / Ollama data | No | Downloaded model weights and local runtime state | Re-download required; backup size stays sane |
| `.git/` repos inside workspace | Indirect if under `workspace/` | History for custom projects | Without it, rollback and provenance are harder |
| Large caches, `node_modules`, temp files | Indirectly possible under workspace | Rebuildable bulk data | Usually safe to regenerate; excluding them manually keeps backups smaller |
| OS keychains / Secure Enclave data | No | Hardware-bound secrets | Must be recreated or exported through the OS-native tooling |

## Recommendation

1. Always back up `workspace/`, redacted `openclaw.json`, and `cron/jobs.json`.
2. Include secrets only when you truly need full disaster recovery.
3. Encrypt secrets client-side with `age` before they leave the machine.
4. Treat the operational archive as shareable with a private cloud repo; treat the secrets archive as local-sensitive by default.


path: scripts/backup.sh
content: #!/usr/bin/env bash
set -euo pipefail

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
BACKUP_DIR="${BACKUP_DIR:-$OPENCLAW_DIR/backups}"
DATE_STAMP="$(date +%Y-%m-%d)"
TIMESTAMP_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RUN_ID="$(date +%Y-%m-%d_%H%M%S)"
RUN_DIR="$BACKUP_DIR/$RUN_ID"
OP_ARCHIVE="$RUN_DIR/openclaw-backup-$DATE_STAMP.tar.gz"
SECRETS_ARCHIVE="$RUN_DIR/openclaw-secrets-$DATE_STAMP.tar.gz"
SECRETS_ENCRYPTED="$SECRETS_ARCHIVE.age"
MANIFEST_PATH="$RUN_DIR/manifest.json"
INCLUDE_SECRETS=0
AGE_RECIPIENT="${AGE_RECIPIENT:-}"
AGE_PASSPHRASE_FILE="${AGE_PASSPHRASE_FILE:-}"

usage() {
  cat <<'EOF'
Usage: backup.sh [--include-secrets] [--no-secrets] [--output-dir /path/to/backups] [--age-recipient KEY] [--age-passphrase-file FILE]

Create a timestamped OpenClaw backup set.

Default behavior:
  - creates an operational archive only
  - secrets are excluded unless explicitly enabled

Options:
  --include-secrets          Create encrypted secrets archive (.tar.gz.age)
  --no-secrets               Force operational-only backup (default)
  --output-dir <dir>         Override backup root directory
  --age-recipient <key>      age recipient public key for secrets encryption
  --age-passphrase-file <f>  Read age passphrase from file for symmetric encryption
  -h, --help                 Show this help

Env alternatives:
  AGE_RECIPIENT=<age public key>
  AGE_PASSPHRASE_FILE=/path/to/passphrase.txt
EOF
}

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

while [ "$#" -gt 0 ]; do
  case "$1" in
    --include-secrets)
      INCLUDE_SECRETS=1
      shift
      ;;
    --no-secrets)
      INCLUDE_SECRETS=0
      shift
      ;;
    --output-dir)
      [ "$#" -ge 2 ] || die "--output-dir requires a path"
      BACKUP_DIR="$2"
      RUN_DIR="$BACKUP_DIR/$RUN_ID"
      OP_ARCHIVE="$RUN_DIR/openclaw-backup-$DATE_STAMP.tar.gz"
      SECRETS_ARCHIVE="$RUN_DIR/openclaw-secrets-$DATE_STAMP.tar.gz"
      SECRETS_ENCRYPTED="$SECRETS_ARCHIVE.age"
      MANIFEST_PATH="$RUN_DIR/manifest.json"
      shift 2
      ;;
    --age-recipient)
      [ "$#" -ge 2 ] || die "--age-recipient requires a key"
      AGE_RECIPIENT="$2"
      shift 2
      ;;
    --age-passphrase-file)
      [ "$#" -ge 2 ] || die "--age-passphrase-file requires a path"
      AGE_PASSPHRASE_FILE="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      die "Unknown argument: $1"
      ;;
  esac
done

command -v tar >/dev/null 2>&1 || die "tar is required"
command -v python3 >/dev/null 2>&1 || die "python3 is required"

if [ "$INCLUDE_SECRETS" -eq 1 ]; then
  command -v age >/dev/null 2>&1 || die "age is required for secrets backups. Install with: brew install age (macOS) or apt install age (Linux)"
  if [ -z "$AGE_RECIPIENT" ] && [ -z "$AGE_PASSPHRASE_FILE" ]; then
    die "Secrets backup requested, but no age key/passphrase configured. Set AGE_RECIPIENT, AGE_PASSPHRASE_FILE, or pass --age-recipient/--age-passphrase-file."
  fi
  if [ -n "$AGE_PASSPHRASE_FILE" ] && [ ! -f "$AGE_PASSPHRASE_FILE" ]; then
    die "Passphrase file not found: $AGE_PASSPHRASE_FILE"
  fi
fi

mkdir -p "$RUN_DIR"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-backup-stage.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT

OP_STAGE="$TMP_DIR/openclaw-operational"
SEC_STAGE="$TMP_DIR/openclaw-secrets"
mkdir -p "$OP_STAGE/openclaw/cron" "$SEC_STAGE/openclaw/agents"

FILES_INCLUDED_JSON="$TMP_DIR/files_included.json"
printf '[]' > "$FILES_INCLUDED_JSON"

record_file() {
  python3 - "$FILES_INCLUDED_JSON" "$1" <<'PY'
import json, sys
path, item = sys.argv[1], sys.argv[2]
with open(path, 'r', encoding='utf-8') as f:
    data = json.load(f)
if item not in data:
    data.append(item)
with open(path, 'w', encoding='utf-8') as f:
    json.dump(data, f)
PY
}

copy_dir() {
  src="$1"
  dest="$2"
  label="$3"
  if [ -d "$src" ]; then
    mkdir -p "$(dirname "$dest")"
    cp -R "$src" "$dest"
    record_file "$label"
    info "Added: $label"
  else
    warn "Missing directory, skipped: $src"
  fi
}

copy_file() {
  src="$1"
  dest="$2"
  label="$3"
  if [ -f "$src" ]; then
    mkdir -p "$(dirname "$dest")"
    cp "$src" "$dest"
    record_file "$label"
    info "Added: $label"
  else
    warn "Missing file, skipped: $src"
  fi
}

redact_openclaw_json() {
  src="$1"
  dest="$2"
  python3 - "$src" "$dest" <<'PY'
import json, sys
from pathlib import Path
src, dest = Path(sys.argv[1]), Path(sys.argv[2])
secret_words = ('token', 'secret', 'password', 'key', 'auth', 'cookie', 'session', 'credential', 'bearer')

def redact(value, key_name=''):
    if isinstance(value, dict):
        out = {}
        for k, v in value.items():
            lowered = k.lower()
            if any(word in lowered for word in secret_words):
                out[k] = '[REDACTED]'
            else:
                out[k] = redact(v, k)
        return out
    if isinstance(value, list):
        return [redact(v, key_name) for v in value]
    if isinstance(value, str) and any(word in key_name.lower() for word in secret_words):
        return '[REDACTED]'
    return value

data = json.loads(src.read_text(encoding='utf-8'))
dest.write_text(json.dumps(redact(data), indent=2, sort_keys=True) + '\n', encoding='utf-8')
PY
}

copy_dir "$OPENCLAW_DIR/workspace" "$OP_STAGE/openclaw/workspace" "workspace/"
if [ -f "$OPENCLAW_DIR/openclaw.json" ]; then
  mkdir -p "$OP_STAGE/openclaw"
  redact_openclaw_json "$OPENCLAW_DIR/openclaw.json" "$OP_STAGE/openclaw/openclaw.json"
  record_file "openclaw.json (redacted)"
  info "Added: openclaw.json (redacted)"
else
  warn "Missing file, skipped: $OPENCLAW_DIR/openclaw.json"
fi
copy_file "$OPENCLAW_DIR/cron/jobs.json" "$OP_STAGE/openclaw/cron/jobs.json" "cron/jobs.json"

if [ "$INCLUDE_SECRETS" -eq 1 ]; then
  copy_file "$OPENCLAW_DIR/.env" "$SEC_STAGE/openclaw/.env" ".env"
  copy_dir "$OPENCLAW_DIR/agents" "$SEC_STAGE/openclaw/agents" "agents/"
fi

[ -d "$OP_STAGE/openclaw/workspace" ] || [ -f "$OP_STAGE/openclaw/openclaw.json" ] || [ -f "$OP_STAGE/openclaw/cron/jobs.json" ] || die "Nothing was staged for operational backup."

(
  cd "$OP_STAGE"
  tar -czf "$OP_ARCHIVE" openclaw
)

if [ "$INCLUDE_SECRETS" -eq 1 ]; then
  if [ ! -f "$SEC_STAGE/openclaw/.env" ] && [ ! -d "$SEC_STAGE/openclaw/agents" ]; then
    warn "Secrets backup requested but no secrets files were found; skipping secrets archive."
    INCLUDE_SECRETS=0
  else
    (
      cd "$SEC_STAGE"
      tar -czf "$SECRETS_ARCHIVE" openclaw
    )

    if [ -n "$AGE_RECIPIENT" ]; then
      age -r "$AGE_RECIPIENT" -o "$SECRETS_ENCRYPTED" "$SECRETS_ARCHIVE"
    else
      AGE_PASSPHRASE="$(cat "$AGE_PASSPHRASE_FILE")" age -p -o "$SECRETS_ENCRYPTED" "$SECRETS_ARCHIVE" <<< "$AGE_PASSPHRASE"
    fi
    rm -f "$SECRETS_ARCHIVE"
    info "Created encrypted secrets archive: $SECRETS_ENCRYPTED"
  fi
fi

OPENCLAW_VERSION="$(openclaw --version 2>/dev/null || echo 'unknown')"
NODE_VERSION="$(node --version 2>/dev/null || echo 'unknown')"
OS_NAME="$(uname -srm)"

python3 - "$MANIFEST_PATH" "$OP_ARCHIVE" "$SECRETS_ENCRYPTED" "$FILES_INCLUDED_JSON" "$TIMESTAMP_ISO" "$OPENCLAW_VERSION" "$NODE_VERSION" "$OS_NAME" "$INCLUDE_SECRETS" <<'PY'
import hashlib, json, os, sys
from pathlib import Path
manifest, op_archive, sec_archive, files_json, timestamp, openclaw_version, node_version, os_name, include_secrets = sys.argv[1:]
include_secrets = int(include_secrets)
files_included = json.loads(Path(files_json).read_text(encoding='utf-8'))

def sha256(path):
    h = hashlib.sha256()
    with open(path, 'rb') as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b''):
            h.update(chunk)
    return h.hexdigest()

payload = {
    'version': '1.1.0',
    'openclaw_version': openclaw_version.strip(),
    'timestamp': timestamp,
    'archives': {
        'operational': {
            'file': os.path.basename(op_archive),
            'sha256': sha256(op_archive),
            'size': os.path.getsize(op_archive),
        },
        'secrets': {
            'file': os.path.basename(sec_archive) if include_secrets and os.path.exists(sec_archive) else None,
            'sha256': sha256(sec_archive) if include_secrets and os.path.exists(sec_archive) else None,
            'size': os.path.getsize(sec_archive) if include_secrets and os.path.exists(sec_archive) else 0,
            'encrypted': bool(include_secrets and os.path.exists(sec_archive)),
        },
    },
    'files_included': files_included,
    'node_version': node_version.strip(),
    'os': os_name.strip(),
}
Path(manifest).write_text(json.dumps(payload, indent=2, sort_keys=True) + '\n', encoding='utf-8')
PY

info "Created operational archive: $OP_ARCHIVE"
info "Created manifest: $MANIFEST_PATH"
if [ "$INCLUDE_SECRETS" -eq 0 ]; then
  info "Secrets archive: not created (default operational-only mode)"
fi
info "Next step: bash scripts/verify.sh --manifest '$MANIFEST_PATH' --archive '$OP_ARCHIVE'"


path: scripts/monthly-drill.sh
content: #!/usr/bin/env bash
set -euo pipefail

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
BACKUP_DIR="${BACKUP_DIR:-$OPENCLAW_DIR/backups}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RESTORE_SCRIPT="$SCRIPT_DIR/restore.sh"

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

[ -x "$RESTORE_SCRIPT" ] || die "restore.sh is required and must be executable: $RESTORE_SCRIPT"
command -v python3 >/dev/null 2>&1 || die "python3 is required"
mkdir -p "$BACKUP_DIR"

LATEST="$(python3 - "$BACKUP_DIR" <<'PY'
import json, os, sys
from pathlib import Path
root = Path(sys.argv[1])
candidates = []
for run_dir in sorted([p for p in root.iterdir() if p.is_dir()]):
    manifest = run_dir / 'manifest.json'
    if not manifest.exists():
        continue
    try:
        data = json.loads(manifest.read_text(encoding='utf-8'))
    except Exception:
        continue
    ts = data.get('timestamp', '')
    archives = sorted(list(run_dir.glob('openclaw-backup-*.tar.gz')) + list(run_dir.glob('openclaw-snapshot-pre-change-*.tar.gz')))
    archive = archives[0] if archives else None
    secrets = next(iter(sorted(run_dir.glob('openclaw-secrets-*.tar.gz.age'))), None)
    if archive is None:
        continue
    candidates.append((ts, str(manifest), str(archive), str(secrets) if secrets else ''))
if not candidates:
    sys.exit(1)
candidates.sort(reverse=True)
print('\n'.join(candidates[0]))
PY
)" || {
  info "Restore drill FAILED 🔴 — no valid backup set found in $BACKUP_DIR"
  exit 1
}

MANIFEST_PATH="$(printf '%s\n' "$LATEST" | sed -n '2p')"
ARCHIVE_PATH="$(printf '%s\n' "$LATEST" | sed -n '3p')"
SECRETS_PATH="$(printf '%s\n' "$LATEST" | sed -n '4p')"

TMP_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/openclaw-monthly-drill.XXXXXX")"
trap 'rm -f "$TMP_OUTPUT"' EXIT

if bash "$RESTORE_SCRIPT" --manifest "$MANIFEST_PATH" --archive "$ARCHIVE_PATH" --dry-run >"$TMP_OUTPUT" 2>&1; then
  restore_ok=1
else
  restore_ok=0
fi

reason=""
if [ "$restore_ok" -ne 1 ]; then
  reason="restore dry-run failed"
fi

if [ -z "$reason" ] && [ -n "$SECRETS_PATH" ] && [ ! -f "$SECRETS_PATH" ]; then
  reason="manifest references a missing secrets archive"
fi

if [ -z "$reason" ] && [ -n "$SECRETS_PATH" ] && [ "${SECRETS_PATH##*.}" != "age" ]; then
  reason="secrets archive exists but is not age-encrypted"
fi

if [ -z "$reason" ]; then
  if ! grep -q 'DRY RUN: checks passed' "$TMP_OUTPUT"; then
    reason="restore output did not confirm dry-run pass"
  fi
fi

run_id="$(basename "$(dirname "$MANIFEST_PATH")")"
if [ -z "$reason" ]; then
  info "Restore drill PASSED ✅"
  info "Run: $run_id"
  info "Archive: $(basename "$ARCHIVE_PATH")"
  if [ -n "$SECRETS_PATH" ]; then
    info "Secrets: encrypted ✅"
  else
    info "Secrets: none in latest backup"
  fi
else
  detail="$(tail -n 1 "$TMP_OUTPUT" 2>/dev/null | tr -d '\r')"
  [ -n "$detail" ] || detail="$reason"
  info "Restore drill FAILED 🔴 — $reason"
  info "Run: $run_id"
  info "Detail: $detail"
  exit 1
fi


path: scripts/pre-change-snapshot.sh
content: #!/usr/bin/env bash
set -euo pipefail

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
BACKUP_DIR="${BACKUP_DIR:-$OPENCLAW_DIR/backups}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh"

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

[ -x "$BACKUP_SCRIPT" ] || die "backup.sh is required and must be executable: $BACKUP_SCRIPT"
command -v python3 >/dev/null 2>&1 || die "python3 is required"
mkdir -p "$BACKUP_DIR"

TMP_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/openclaw-snapshot.XXXXXX")"
STAGE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-snapshot-stage.XXXXXX")"
trap 'rm -f "$TMP_OUTPUT"; rm -rf "$STAGE_ROOT"' EXIT

bash "$BACKUP_SCRIPT" --no-secrets --output-dir "$STAGE_ROOT" >"$TMP_OUTPUT"

STAGED_RUN_DIR="$(sed -n 's/^Created manifest: //p' "$TMP_OUTPUT" | tail -n 1 | xargs dirname)"
MANIFEST_PATH="$(sed -n 's/^Created manifest: //p' "$TMP_OUTPUT" | tail -n 1)"
OP_ARCHIVE="$(sed -n 's/^Created operational archive: //p' "$TMP_OUTPUT" | tail -n 1)"

[ -n "$STAGED_RUN_DIR" ] || die "Could not determine backup run directory"
[ -f "$MANIFEST_PATH" ] || die "Manifest missing after snapshot creation"
[ -f "$OP_ARCHIVE" ] || die "Operational archive missing after snapshot creation"

TIMESTAMP="$(date +%Y-%m-%d_%H%M%S)"
RUN_DIR="$BACKUP_DIR/pre-change-$TIMESTAMP"
mkdir -p "$RUN_DIR"
SNAPSHOT_NAME="openclaw-snapshot-pre-change-$TIMESTAMP.tar.gz"
SNAPSHOT_PATH="$RUN_DIR/$SNAPSHOT_NAME"
mv "$OP_ARCHIVE" "$SNAPSHOT_PATH"

FINAL_MANIFEST="$RUN_DIR/manifest.json"
python3 - "$MANIFEST_PATH" "$FINAL_MANIFEST" "$SNAPSHOT_NAME" <<'PY'
import json, sys
from pathlib import Path
source_manifest, final_manifest, snapshot_name = sys.argv[1:4]
data = json.loads(Path(source_manifest).read_text(encoding='utf-8'))
if 'archives' in data and 'operational' in data['archives']:
    data['archives']['operational']['file'] = snapshot_name
Path(final_manifest).write_text(json.dumps(data, indent=2, sort_keys=True) + '\n', encoding='utf-8')
PY

python3 - "$BACKUP_DIR" <<'PY'
from pathlib import Path
import shutil, sys
root = Path(sys.argv[1])
snapshots = []
for run_dir in root.iterdir():
    if not run_dir.is_dir():
        continue
    for archive in run_dir.glob('openclaw-snapshot-pre-change-*.tar.gz'):
        snapshots.append((archive.stat().st_mtime, run_dir, archive))
snapshots.sort(reverse=True)
for _, run_dir, archive in snapshots[5:]:
    shutil.rmtree(run_dir, ignore_errors=True)
PY

info "Snapshot ready ✅"
info "Path: $SNAPSHOT_PATH"
info "Rollback ref: $RUN_DIR"


path: scripts/push-to-github.sh
content: #!/usr/bin/env bash
set -euo pipefail

REPO_NAME="openclaw-backup"
OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
BACKUP_DIR="${BACKUP_DIR:-$OPENCLAW_DIR/backups}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ARCHIVE_PATH=""
MANIFEST_PATH=""
SECRETS_PATH=""

usage() {
  cat <<'EOF'
Usage: push-to-github.sh [repo-name] --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz --manifest /path/to/manifest.json [--secrets /path/to/openclaw-secrets-YYYY-MM-DD.tar.gz.age]

Push an operational OpenClaw backup set to a private GitHub repo using gh.
Refuses to push any unencrypted secrets archive.
EOF
}

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

POSITIONAL=()
while [ "$#" -gt 0 ]; do
  case "$1" in
    --archive)
      [ "$#" -ge 2 ] || die "--archive requires a path"
      ARCHIVE_PATH="$2"
      shift 2
      ;;
    --manifest)
      [ "$#" -ge 2 ] || die "--manifest requires a path"
      MANIFEST_PATH="$2"
      shift 2
      ;;
    --secrets)
      [ "$#" -ge 2 ] || die "--secrets requires a path"
      SECRETS_PATH="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    --*)
      die "Unknown argument: $1"
      ;;
    *)
      POSITIONAL+=("$1")
      shift
      ;;
  esac
done

if [ "${#POSITIONAL[@]}" -gt 1 ]; then
  die "Only one repo name may be provided"
elif [ "${#POSITIONAL[@]}" -eq 1 ]; then
  REPO_NAME="${POSITIONAL[0]}"
fi

command -v gh >/dev/null 2>&1 || die "gh CLI is required"
command -v python3 >/dev/null 2>&1 || die "python3 is required"
gh auth status >/dev/null 2>&1 || die "gh CLI is not authenticated"

if [ -z "$ARCHIVE_PATH" ] || [ -z "$MANIFEST_PATH" ]; then
  LATEST_MANIFEST="$(find "$BACKUP_DIR" -type f -name 'manifest.json' | sort | tail -n 1)"
  if [ -n "$LATEST_MANIFEST" ] && [ -z "$MANIFEST_PATH" ]; then
    MANIFEST_PATH="$LATEST_MANIFEST"
  fi
  if [ -n "$MANIFEST_PATH" ] && [ -z "$ARCHIVE_PATH" ]; then
    ARCHIVE_PATH="$(python3 - "$MANIFEST_PATH" <<'PY'
import json, os, sys
path = sys.argv[1]
base = os.path.dirname(path)
manifest = json.load(open(path, encoding='utf-8'))
print(os.path.join(base, manifest['archives']['operational']['file']))
PY
)"
  fi
fi

[ -n "$ARCHIVE_PATH" ] || die "No operational archive found"
[ -n "$MANIFEST_PATH" ] || die "No manifest found"
[ -f "$ARCHIVE_PATH" ] || die "Archive not found: $ARCHIVE_PATH"
[ -f "$MANIFEST_PATH" ] || die "Manifest not found: $MANIFEST_PATH"

if [ -n "$SECRETS_PATH" ]; then
  [ -f "$SECRETS_PATH" ] || die "Secrets archive not found: $SECRETS_PATH"
  [[ "$SECRETS_PATH" == *.age ]] || die "Refusing to push unencrypted secrets archive: $SECRETS_PATH"
else
  EXPECTED_SECRETS="$(python3 - "$MANIFEST_PATH" <<'PY'
import json, os, sys
manifest = json.load(open(sys.argv[1], encoding='utf-8'))
name = manifest.get('archives', {}).get('secrets', {}).get('file') or ''
base = os.path.dirname(sys.argv[1])
print(os.path.join(base, name) if name else '')
PY
)"
  if [ -n "$EXPECTED_SECRETS" ] && [ -f "$EXPECTED_SECRETS" ]; then
    SECRETS_PATH="$EXPECTED_SECRETS"
    [[ "$SECRETS_PATH" == *.age ]] || die "Refusing to push unencrypted secrets archive: $SECRETS_PATH"
  fi
fi

OWNER="$(gh api user --jq .login)"
REMOTE="https://github.com/$OWNER/$REPO_NAME.git"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-github.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT

if gh repo view "$OWNER/$REPO_NAME" >/dev/null 2>&1; then
  info "Using existing repo: $OWNER/$REPO_NAME"
  gh repo clone "$OWNER/$REPO_NAME" "$WORK_DIR/$REPO_NAME" >/dev/null
else
  info "Creating private repo: $OWNER/$REPO_NAME"
  gh repo create "$REPO_NAME" --private --clone --description "OpenClaw backup archives" "$WORK_DIR/$REPO_NAME" >/dev/null
fi

REPO_DIR="$WORK_DIR/$REPO_NAME"
mkdir -p "$REPO_DIR/archives"

if [ ! -f "$REPO_DIR/.gitignore" ]; then
  cp "$SKILL_DIR/templates/.gitignore" "$REPO_DIR/.gitignore"
fi

cp "$ARCHIVE_PATH" "$REPO_DIR/archives/$(basename "$ARCHIVE_PATH")"
cp "$MANIFEST_PATH" "$REPO_DIR/archives/$(basename "$MANIFEST_PATH")"
if [ -n "$SECRETS_PATH" ]; then
  cp "$SECRETS_PATH" "$REPO_DIR/archives/$(basename "$SECRETS_PATH")"
fi

(
  cd "$REPO_DIR"
  git add .gitignore archives/*
  if git diff --cached --quiet; then
    info "No changes to commit."
  else
    git commit -m "Backup $(basename "$ARCHIVE_PATH")" >/dev/null
    git push origin HEAD >/dev/null
    info "Pushed backup to $REMOTE"
  fi
)


path: scripts/restore.sh
content: #!/usr/bin/env bash
set -euo pipefail

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
MANIFEST_PATH=""
ARCHIVE_PATH=""
SECRETS_PATH=""
AGE_IDENTITY_FILE="${AGE_IDENTITY_FILE:-}"
AGE_PASSPHRASE_FILE="${AGE_PASSPHRASE_FILE:-}"
DRY_RUN=0
FORCE=0

usage() {
  cat <<'EOF'
Usage: restore.sh --manifest /path/to/manifest.json --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz [--secrets /path/to/openclaw-secrets-YYYY-MM-DD.tar.gz.age] [--age-identity FILE | --age-passphrase-file FILE] [--dry-run] [--force|--yes]

Restore an OpenClaw backup with staging, checksum validation, and rollback.
EOF
}

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

while [ "$#" -gt 0 ]; do
  case "$1" in
    --manifest)
      [ "$#" -ge 2 ] || die "--manifest requires a path"
      MANIFEST_PATH="$2"
      shift 2
      ;;
    --archive)
      [ "$#" -ge 2 ] || die "--archive requires a path"
      ARCHIVE_PATH="$2"
      shift 2
      ;;
    --secrets)
      [ "$#" -ge 2 ] || die "--secrets requires a path"
      SECRETS_PATH="$2"
      shift 2
      ;;
    --age-identity)
      [ "$#" -ge 2 ] || die "--age-identity requires a path"
      AGE_IDENTITY_FILE="$2"
      shift 2
      ;;
    --age-passphrase-file)
      [ "$#" -ge 2 ] || die "--age-passphrase-file requires a path"
      AGE_PASSPHRASE_FILE="$2"
      shift 2
      ;;
    --dry-run)
      DRY_RUN=1
      shift
      ;;
    --force|--yes)
      FORCE=1
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      if [ -z "$ARCHIVE_PATH" ] && [ -f "$1" ]; then
        ARCHIVE_PATH="$1"
        shift
      else
        die "Unknown argument: $1"
      fi
      ;;
  esac
done

[ -n "$MANIFEST_PATH" ] || die "Manifest path is required"
[ -n "$ARCHIVE_PATH" ] || die "Operational archive path is required"
[ -f "$MANIFEST_PATH" ] || die "Manifest not found: $MANIFEST_PATH"
[ -f "$ARCHIVE_PATH" ] || die "Archive not found: $ARCHIVE_PATH"
command -v python3 >/dev/null 2>&1 || die "python3 is required"

if [ "$FORCE" -ne 1 ] && [ "$DRY_RUN" -ne 1 ] && ! [ -t 0 ]; then
  die "Destructive restore requires a TTY. Re-run interactively or pass --force/--yes."
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_SCRIPT="$SCRIPT_DIR/verify.sh"
[ -x "$VERIFY_SCRIPT" ] || die "verify.sh is required and must be executable"

bash "$VERIFY_SCRIPT" --manifest "$MANIFEST_PATH" --archive "$ARCHIVE_PATH" ${SECRETS_PATH:+--secrets "$SECRETS_PATH"}

python3 - "$MANIFEST_PATH" <<'PY'
import json, subprocess, sys
from pathlib import Path
manifest = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
backup_version = manifest.get('version', 'unknown')
openclaw_version = manifest.get('openclaw_version', 'unknown')
current = 'unknown'
try:
    current = subprocess.check_output(['openclaw', '--version'], text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
    pass
print(f'Manifest version: {backup_version}')
print(f'Backup OpenClaw version: {openclaw_version}')
print(f'Current OpenClaw version: {current}')
if backup_version.split('.')[0] != '1':
    print('ERROR: Unsupported backup manifest major version', file=sys.stderr)
    sys.exit(1)
PY

TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-restore.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
STAGING_DIR="$OPENCLAW_DIR/.restore-staging"
PRE_RESTORE_BACKUP="$OPENCLAW_DIR/.pre-restore-backup-$(date +%Y-%m-%d_%H%M%S)"
RESTORE_ROOT="$TMP_DIR/restore"
mkdir -p "$RESTORE_ROOT"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"

tar -xzf "$ARCHIVE_PATH" -C "$RESTORE_ROOT"
[ -d "$RESTORE_ROOT/openclaw" ] || die "Operational archive does not contain top-level openclaw/ directory"
cp -R "$RESTORE_ROOT/openclaw/." "$STAGING_DIR/"

if [ -n "$SECRETS_PATH" ]; then
  command -v age >/dev/null 2>&1 || die "age is required for secrets restore. Install with: brew install age (macOS) or apt install age (Linux)"
  [ -f "$SECRETS_PATH" ] || die "Secrets archive not found: $SECRETS_PATH"
  DECRYPTED_SECRETS="$TMP_DIR/secrets.tar.gz"
  if [ -n "$AGE_IDENTITY_FILE" ]; then
    age --decrypt -i "$AGE_IDENTITY_FILE" -o "$DECRYPTED_SECRETS" "$SECRETS_PATH"
  elif [ -n "$AGE_PASSPHRASE_FILE" ]; then
    AGE_PASSPHRASE="$(cat "$AGE_PASSPHRASE_FILE")" age --decrypt -o "$DECRYPTED_SECRETS" "$SECRETS_PATH" <<< "$AGE_PASSPHRASE"
  else
    die "Secrets archive provided, but no decryption material was supplied. Use --age-identity or --age-passphrase-file."
  fi
  mkdir -p "$TMP_DIR/secrets"
  tar -xzf "$DECRYPTED_SECRETS" -C "$TMP_DIR/secrets"
  [ -d "$TMP_DIR/secrets/openclaw" ] || die "Secrets archive does not contain top-level openclaw/ directory"
  cp -R "$TMP_DIR/secrets/openclaw/." "$STAGING_DIR/"
fi

check_staged() {
  rel="$1"
  [ -e "$STAGING_DIR/$rel" ] || die "Critical staged path missing: $rel"
}
check_staged "workspace"
check_staged "openclaw.json"
check_staged "cron/jobs.json"

info "Prepared restore staging at: $STAGING_DIR"
info "Pre-restore backup will be stored at: $PRE_RESTORE_BACKUP"

if [ "$DRY_RUN" -eq 1 ]; then
  info "DRY RUN: checks passed. Would back up current state, swap staged files into $OPENCLAW_DIR, run health check, and rollback on failure."
  exit 0
fi

if [ "$FORCE" -ne 1 ]; then
  echo
  printf 'Restore into %s? Type RESTORE to continue: ' "$OPENCLAW_DIR"
  read -r CONFIRM
  [ "$CONFIRM" = "RESTORE" ] || die "Restore cancelled."
fi

mkdir -p "$(dirname "$OPENCLAW_DIR")"
if [ -e "$OPENCLAW_DIR" ]; then
  mv "$OPENCLAW_DIR" "$PRE_RESTORE_BACKUP"
fi

restore_failed=0
rollback() {
  warn "Restore health check failed. Rolling back."
  rm -rf "$OPENCLAW_DIR"
  if [ -e "$PRE_RESTORE_BACKUP" ]; then
    mv "$PRE_RESTORE_BACKUP" "$OPENCLAW_DIR"
  fi
}

if ! mv "$STAGING_DIR" "$OPENCLAW_DIR"; then
  restore_failed=1
fi

if [ "$restore_failed" -eq 1 ]; then
  rollback
  die "Atomic swap failed; rolled back."
fi

HEALTHCHECK_SCRIPT="$OPENCLAW_DIR/workspace/scripts/pre-restart-check.sh"
if [ -f "$HEALTHCHECK_SCRIPT" ]; then
  if ! bash "$HEALTHCHECK_SCRIPT"; then
    rollback
    die "Health check failed after restore; rolled back."
  fi
else
  info "Health check script not found; skipped: $HEALTHCHECK_SCRIPT"
fi

info "Restore complete. Previous state saved at: $PRE_RESTORE_BACKUP"
info "Suggested next step: openclaw gateway restart"


path: scripts/schedule.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh"
CRON_NAME="${CRON_NAME:-OpenClaw Daily Backup}"
CRON_EXPR="${CRON_EXPR:-0 4 * * *}"
CRON_TZ="${CRON_TZ:-}"

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

command -v openclaw >/dev/null 2>&1 || die "openclaw CLI is required"
command -v python3 >/dev/null 2>&1 || die "python3 is required"
[ -x "$BACKUP_SCRIPT" ] || die "Backup script is not executable: $BACKUP_SCRIPT"

SYSTEM_EVENT="Run the scheduled OpenClaw backup now by executing: bash '$BACKUP_SCRIPT'. Default to operational-only mode unless Don explicitly requested encrypted secrets backup. After completion, report the manifest path, operational archive path, and whether verification is needed."

EXISTING_ID="$(openclaw cron list --json | python3 - "$CRON_NAME" <<'PY'
import json, sys
name = sys.argv[1]
try:
    data = json.load(sys.stdin)
except Exception:
    print('')
    raise SystemExit(0)
for job in data.get('jobs', []):
    if job.get('name') == name:
        print(job.get('id') or '')
        break
PY
)"

if [ -n "$EXISTING_ID" ]; then
  info "Replacing existing cron: $CRON_NAME ($EXISTING_ID)"
  openclaw cron delete "$EXISTING_ID" >/dev/null
else
  info "Creating cron: $CRON_NAME"
fi

if [ -n "$CRON_TZ" ]; then
  RESULT="$(openclaw cron create --json --name "$CRON_NAME" --cron "$CRON_EXPR" --tz "$CRON_TZ" --system-event "$SYSTEM_EVENT")"
else
  RESULT="$(openclaw cron create --json --name "$CRON_NAME" --cron "$CRON_EXPR" --system-event "$SYSTEM_EVENT")"
fi

printf '%s\n' "$RESULT"

NEXT_RUN="$(printf '%s' "$RESULT" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("job",{}).get("nextRunAtMs") or data.get("nextRunAt") or "")')"
if [ -z "$NEXT_RUN" ]; then
  warn "Cron created, but nextRunAtMs/nextRunAt was not returned. Verify with: openclaw cron list"
else
  info "Verified next run marker: $NEXT_RUN"
fi

info "Note: Existing cron entries with the same name are replaced instead of duplicated."


path: scripts/verify.sh
content: #!/usr/bin/env bash
set -euo pipefail

MANIFEST_PATH=""
ARCHIVE_PATH=""
SECRETS_PATH=""

usage() {
  cat <<'EOF'
Usage: verify.sh --manifest /path/to/manifest.json --archive /path/to/openclaw-backup-YYYY-MM-DD.tar.gz [--secrets /path/to/openclaw-secrets-YYYY-MM-DD.tar.gz.age]

Validate manifest checksums and verify critical files exist inside the operational archive.
EOF
}

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

while [ "$#" -gt 0 ]; do
  case "$1" in
    --manifest)
      [ "$#" -ge 2 ] || die "--manifest requires a path"
      MANIFEST_PATH="$2"
      shift 2
      ;;
    --archive)
      [ "$#" -ge 2 ] || die "--archive requires a path"
      ARCHIVE_PATH="$2"
      shift 2
      ;;
    --secrets)
      [ "$#" -ge 2 ] || die "--secrets requires a path"
      SECRETS_PATH="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      if [ -z "$ARCHIVE_PATH" ] && [ -f "$1" ] && [ "${1##*.}" = "gz" ]; then
        ARCHIVE_PATH="$1"
        shift
      else
        die "Unknown argument: $1"
      fi
      ;;
  esac
done

[ -n "$MANIFEST_PATH" ] || die "Manifest path is required"
[ -n "$ARCHIVE_PATH" ] || die "Operational archive path is required"
[ -f "$MANIFEST_PATH" ] || die "Manifest not found: $MANIFEST_PATH"
[ -f "$ARCHIVE_PATH" ] || die "Archive not found: $ARCHIVE_PATH"
command -v python3 >/dev/null 2>&1 || die "python3 is required"

python3 - "$MANIFEST_PATH" "$ARCHIVE_PATH" "$SECRETS_PATH" <<'PY'
import hashlib, json, os, sys
from pathlib import Path
manifest_path, archive_path, secrets_path = sys.argv[1:]
manifest = json.loads(Path(manifest_path).read_text(encoding='utf-8'))
errors = []

def sha256(path):
    h = hashlib.sha256()
    with open(path, 'rb') as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b''):
            h.update(chunk)
    return h.hexdigest()

operational = manifest.get('archives', {}).get('operational', {})
if operational.get('file') != os.path.basename(archive_path):
    errors.append(f"Operational archive filename mismatch: manifest={operational.get('file')} actual={os.path.basename(archive_path)}")
if operational.get('sha256') != sha256(archive_path):
    errors.append('Operational archive checksum mismatch')
if int(operational.get('size') or 0) != os.path.getsize(archive_path):
    errors.append('Operational archive size mismatch')

secrets = manifest.get('archives', {}).get('secrets', {})
if secrets.get('file'):
    if not secrets.get('encrypted'):
        errors.append('Manifest says secrets archive exists but encrypted=false')
    if not secrets_path:
        errors.append('Secrets archive expected by manifest but --secrets was not provided')
    elif not os.path.exists(secrets_path):
        errors.append(f'Secrets archive not found: {secrets_path}')
    else:
        if os.path.basename(secrets_path) != secrets.get('file'):
            errors.append(f"Secrets archive filename mismatch: manifest={secrets.get('file')} actual={os.path.basename(secrets_path)}")
        if not secrets_path.endswith('.age'):
            errors.append('Secrets archive must be encrypted (.age)')
        if sha256(secrets_path) != secrets.get('sha256'):
            errors.append('Secrets archive checksum mismatch')
else:
    if secrets_path:
        errors.append('Secrets archive supplied, but manifest does not expect one')

if errors:
    for err in errors:
        print(f'ERROR: {err}', file=sys.stderr)
    sys.exit(1)
PY

TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-verify.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT

tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
ROOT="$TMP_DIR/openclaw"
[ -d "$ROOT" ] || die "Archive missing openclaw/ root"

STATUS=0
require_path() {
  rel="$1"
  if [ -e "$ROOT/$rel" ]; then
    info "OK: $rel"
  else
    warn "Missing: $rel"
    STATUS=1
  fi
}

require_path "workspace"
require_path "openclaw.json"
require_path "cron/jobs.json"

if [ "$STATUS" -eq 0 ]; then
  echo "VALID"
else
  echo "MISSING"
  exit 1
fi


path: scripts/weekly-verify.sh
content: #!/usr/bin/env bash
set -euo pipefail

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
BACKUP_DIR="${BACKUP_DIR:-$OPENCLAW_DIR/backups}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_SCRIPT="$SCRIPT_DIR/verify.sh"

warn() { printf 'WARNING: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*"; }
die() { warn "$*"; exit 1; }

command -v python3 >/dev/null 2>&1 || die "python3 is required"
[ -x "$VERIFY_SCRIPT" ] || die "verify.sh is required and must be executable: $VERIFY_SCRIPT"

mkdir -p "$BACKUP_DIR"

TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-weekly-verify.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT

METRICS_JSON="$TMP_DIR/metrics.json"
PRUNE_JSON="$TMP_DIR/prune.json"
ORPHAN_JSON="$TMP_DIR/orphans.json"
RUNS_TSV="$TMP_DIR/runs.tsv"
PRUNE_LIST="$TMP_DIR/prune.list"

printf '{"total":0,"ok":0,"failed":0,"missing_manifest":0}' > "$METRICS_JSON"
printf '{"deleted_runs":0}' > "$PRUNE_JSON"
printf '{"manifests_without_archives":0,"secrets_without_manifests":0}' > "$ORPHAN_JSON"
: > "$RUNS_TSV"
: > "$PRUNE_LIST"

update_metric() {
  python3 - "$METRICS_JSON" "$1" <<'PY'
import json, sys
path, key = sys.argv[1:3]
with open(path, 'r', encoding='utf-8') as f:
    data = json.load(f)
data[key] = int(data.get(key, 0)) + 1
with open(path, 'w', encoding='utf-8') as f:
    json.dump(data, f)
PY
}

update_prune() {
  python3 - "$PRUNE_JSON" "$1" <<'PY'
import json, sys
path, key = sys.argv[1:3]
with open(path, 'r', encoding='utf-8') as f:
    data = json.load(f)
data[key] = int(data.get(key, 0)) + 1
with open(path, 'w', encoding='utf-8') as f:
    json.dump(data, f)
PY
}

update_orphan() {
  python3 - "$ORPHAN_JSON" "$1" <<'PY'
import json, sys
path, key = sys.argv[1:3]
with open(path, 'r', encoding='utf-8') as f:
    data = json.load(f)
data[key] = int(data.get(key, 0)) + 1
with open(path, 'w', encoding='utf-8') as f:
    json.dump(data, f)
PY
}

find_run_archive() {
  find "$1" -maxdepth 1 -type f \( -name 'openclaw-backup-*.tar.gz' -o -name 'openclaw-snapshot-pre-change-*.tar.gz' \) | sort | head -n 1
}

find_run_secrets() {
  find "$1" -maxdepth 1 -type f -name 'openclaw-secrets-*.tar.gz.age' | sort | head -n 1
}

collect_runs() {
  find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | while IFS= read -r run_dir; do
    manifest="$run_dir/manifest.json"
    if [ ! -f "$manifest" ]; then
      update_metric missing_manifest
      continue
    fi

    update_metric total
    archive="$(find_run_archive "$run_dir")"
    secrets="$(find_run_secrets "$run_dir")"

    if [ -z "$archive" ]; then
      update_metric failed
      continue
    fi

    if [ -n "$secrets" ]; then
      if bash "$VERIFY_SCRIPT" --manifest "$manifest" --archive "$archive" --secrets "$secrets" >/dev/null 2>&1; then
        update_metric ok
      else
        update_metric failed
      fi
    else
      if bash "$VERIFY_SCRIPT" --manifest "$manifest" --archive "$archive" >/dev/null 2>&1; then
        update_metric ok
      else
        update_metric failed
      fi
    fi

    ts="$(python3 - "$manifest" <<'PY'
import json, sys
from pathlib import Path
try:
    data = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
except Exception:
    print('')
    raise SystemExit(0)
print(data.get('timestamp', ''))
PY
)"
    if [ -n "$ts" ]; then
      printf '%s\t%s\n' "$ts" "$run_dir" >> "$RUNS_TSV"
    fi
  done
}

collect_runs

python3 - "$RUNS_TSV" "$PRUNE_LIST" <<'PY'
import datetime as dt
import sys
from collections import OrderedDict
from pathlib import Path

runs_file, out_file = sys.argv[1:3]
entries = []
for line in Path(runs_file).read_text(encoding='utf-8').splitlines():
    if not line.strip():
        continue
    ts, run_dir = line.split('\t', 1)
    try:
        when = dt.datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ')
    except ValueError:
        continue
    entries.append((when, run_dir))
entries.sort(reverse=True)

keep = set()
for _, run_dir in entries[:14]:
    keep.add(run_dir)

weekly = OrderedDict()
monthly = OrderedDict()
for when, run_dir in entries:
    iso_year, iso_week, _ = when.isocalendar()
    weekly.setdefault((iso_year, iso_week), run_dir)
    monthly.setdefault((when.year, when.month), run_dir)

for run_dir in list(weekly.values())[:8]:
    keep.add(run_dir)
for run_dir in list(monthly.values())[:6]:
    keep.add(run_dir)

with open(out_file, 'w', encoding='utf-8') as f:
    for _, run_dir in entries:
        if run_dir not in keep:
            f.write(run_dir + '\n')
PY

while IFS= read -r prune_dir; do
  [ -n "$prune_dir" ] || continue
  if [ -d "$prune_dir" ]; then
    rm -rf "$prune_dir"
    update_prune deleted_runs
  fi
done < "$PRUNE_LIST"

find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | while IFS= read -r run_dir; do
  manifest="$run_dir/manifest.json"
  archive="$(find_run_archive "$run_dir")"

  if [ -f "$manifest" ] && [ -z "$archive" ]; then
    rm -f "$manifest"
    update_orphan manifests_without_archives
  fi

  if [ ! -f "$manifest" ]; then
    find "$run_dir" -maxdepth 1 -type f -name 'openclaw-secrets-*.tar.gz.age' | while IFS= read -r orphan_secret; do
      rm -f "$orphan_secret"
      update_orphan secrets_without_manifests
    done
  fi

done

metrics_line="$(python3 - "$METRICS_JSON" "$PRUNE_JSON" "$ORPHAN_JSON" <<'PY'
import json, sys
m = json.load(open(sys.argv[1], 'r', encoding='utf-8'))
p = json.load(open(sys.argv[2], 'r', encoding='utf-8'))
o = json.load(open(sys.argv[3], 'r', encoding='utf-8'))
status = '✅' if int(m.get('failed', 0)) == 0 and int(m.get('missing_manifest', 0)) == 0 else '⚠️'
print(status)
print(f"Archives: {m.get('total',0)} total | {m.get('ok',0)} OK | {m.get('failed',0)} failed | {m.get('missing_manifest',0)} missing manifest")
print(f"Cleanup: {p.get('deleted_runs',0)} pruned | {o.get('manifests_without_archives',0)} orphan manifests removed | {o.get('secrets_without_manifests',0)} orphan secrets removed")
PY
)"

status_icon="$(printf '%s' "$metrics_line" | sed -n '1p')"
summary_one="$(printf '%s' "$metrics_line" | sed -n '2p')"
summary_two="$(printf '%s' "$metrics_line" | sed -n '3p')"

info "${status_icon} Weekly backup verify"
info "$summary_one"
info "$summary_two"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)