Skill: ryanedick/backup-and-restore (version 1.3.0)

--- name: backup description: Backup, restore, disaster recovery, and migration for OpenClaw. Encrypts and stores ~/.openclaw/ locally and to cloud destinations (S3, R2, B2, GCS, Google Drive, rsync). Use when the user asks about backups, snapshots, disaster recovery, migration, or restoring OpenClaw from a backup. metadata: { "openclaw": { "requires": { "bins": ["gpg", "tar"], "optionalBins": ["jq", "aws", "gsutil", "gcloud", "b2", "rclone", "rsync"] }, "env": ["BACKUP_PASSPHRASE", "BACKUP_ENCRYPT", "BACKUP_RETAIN_DAYS", "BACKUP_STOP_GATEWAY", "BACKUP_DIR"], "credentials": ["~/.openclaw/credentials/backup/backup-passphrase"], "optionalCredentials": ["~/.openclaw/credentials/backup/aws-credentials", "~/.openclaw/credentials/backup/r2-credentials", "~/.openclaw/credentials/backup/b2-credentials", "~/.openclaw/credentials/backup/gcs-key.json", "~/.openclaw/credentials/backup/rclone.conf"], }, } --- # Backup Skill Backup and restore your entire OpenClaw installation — config, credentials, workspace, memory, and skills. ## Requirements **Required:** `gpg`, `tar` (typically pre-installed on Linux) **Optional** (for cloud uploads): `jq`, `aws` (S3/R2), `gsutil`/`gcloud` (GCS), `b2` (Backblaze), `rclone` (Google Drive), `rsync` **Environment variables:** `BACKUP_PASSPHRASE`, `BACKUP_ENCRYPT`, `BACKUP_RETAIN_DAYS`, `BACKUP_STOP_GATEWAY`, `BACKUP_DIR` **Credential files** (created during setup, stored at `~/.openclaw/credentials/backup/`): - `backup-passphrase` — required for encrypted full backups - `aws-credentials`, `r2-credentials`, `b2-credentials`, `gcs-key.json`, `rclone.conf` — optional, per cloud provider ## Quick Start ```bash # Run a backup now — creates TWO files: full (encrypted) + workspace-only ~/.openclaw/workspace/skills/backup/scripts/backup.sh # Upload both backups to configured cloud destinations ~/.openclaw/workspace/skills/backup/scripts/upload.sh # Full restore (same environment / disaster recovery) ~/.openclaw/workspace/skills/backup/scripts/restore.sh ~/backups/openclaw/openclaw-myhost-20260215-full.tar.gz.gpg # Workspace-only restore (any environment — just the agent's brain) ~/.openclaw/workspace/skills/backup/scripts/restore.sh ~/backups/openclaw/openclaw-myhost-20260215-workspace.tar.gz ``` ## Interactive Setup For guided setup, read `references/setup-guide.md` and follow the conversational flow with the user. This walks through encryption, backup mode, schedule, and cloud destination configuration. ## Manual Usage ### backup.sh — Create local backups Every run produces **two files**: 1. **Full backup** (`*-full.tar.gz.gpg`) — everything including credentials, encrypted. For disaster recovery on the same or similar environment. 2. **Workspace backup** (`*-workspace.tar.gz.gpg`) — just `~/.openclaw/workspace/` (memory, skills, files), encrypted. Safe to restore on any environment without affecting gateway config. This is the agent's brain. ```bash # Default: creates both files ./scripts/backup.sh # Skip gateway stop/restart (for testing) BACKUP_STOP_GATEWAY=false ./scripts/backup.sh ``` Saves to `~/backups/openclaw/`. ### upload.sh — Upload to cloud ```bash # Upload latest local backup to all configured destinations ./scripts/upload.sh # Upload a specific file ./scripts/upload.sh /path/to/backup.tar.gz.gpg ``` ### restore.sh — Restore from backup ```bash # Full restore (disaster recovery — replaces entire ~/.openclaw/) ./scripts/restore.sh openclaw-myhost-20260215-full.tar.gz.gpg # Workspace-only restore (just the agent brain — keeps your config/credentials) ./scripts/restore.sh openclaw-myhost-20260215-workspace.tar.gz # Extract only workspace from a full backup ./scripts/restore.sh --workspace-only openclaw-myhost-20260215-full.tar.gz.gpg # From cloud ./scripts/restore.sh s3://mybucket/openclaw/openclaw-myhost-20260215-workspace.tar.gz ``` Automatically detects workspace backups by filename. Creates a safety copy before extracting. **Note:** Workspace-only restores don't require a gateway restart — the agent picks up the new files on its next session. Full restores stop and replace the entire `~/.openclaw/` directory, so the gateway needs to be restarted afterward. ### test-backup.sh — Validate setup ```bash ./scripts/test-backup.sh ``` Creates a tiny test file, encrypts, uploads to all destinations, verifies, cleans up. Exit 0 = all good. ## Config Reference Config lives at `~/.openclaw/workspace/skills/backup/config.json`: | Field | Type | Default | Description | |-------|------|---------|-------------| | `encrypt` | bool | `true` | AES-256 GPG symmetric encryption (for full backups) | | `retainDays` | number | `30` | Auto-prune local backups older than this | | `schedule` | string | `"daily"` | `daily`, `weekly`, or `manual` | | `destinations` | array | `[]` | Cloud upload targets (see destinations.md) | ## Environment Variables All settings can be overridden via env vars: | Variable | Description | |----------|-------------| | `BACKUP_ENCRYPT` | `true` or `false` (for full backups) | | `BACKUP_RETAIN_DAYS` | Number of days to keep old backups | | `BACKUP_PASSPHRASE` | Encryption passphrase (or read from credentials file) | | `BACKUP_STOP_GATEWAY` | `true` (default) or `false` | | `BACKUP_DIR` | Override backup output directory | ## Credentials Stored in `~/.openclaw/credentials/backup/`: - `backup-passphrase` — encryption passphrase - `aws-credentials` — for S3 - `r2-credentials` — for Cloudflare R2 - `b2-credentials` — for Backblaze B2 - `gcs-key.json` — Google Cloud Storage service account key - `rclone.conf` — for Google Drive (rclone config) ## Security Notes - Backups are encrypted by default with AES-256 (GPG symmetric) - **Full-mode backups REQUIRE encryption** — the script will refuse to run without a passphrase when mode=full, since credentials would be stored in plaintext - Workspace-only backups are also encrypted — they contain personal data (memory, notes, conversations) - The passphrase file at `~/.openclaw/credentials/backup/backup-passphrase` should be readable only by the owner (mode 600) - On first use, always walk the user through setting a passphrase (see `references/setup-guide.md`) - If no passphrase is set, default to portable mode — never store credentials unencrypted - Local backups are auto-pruned after the configured retention period - Remote backups are never auto-deleted (see `references/destinations.md` for lifecycle guidance)

path: references/destinations.md
content: # Cloud Destination Setup

## Amazon S3

**CLI needed:** `aws` (AWS CLI)
```bash
pip install awscli
# or: sudo apt install awscli
```

**Setup:**
1. Create an S3 bucket in your AWS account
2. Create an IAM user with S3 write access (or use an existing one)
3. Save credentials to `~/.openclaw/credentials/backup/aws-credentials`:
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
```

**Config entry:**
```json
{ "type": "s3", "bucket": "s3://your-bucket/openclaw-backups", "region": "us-east-1" }
```

---

## Cloudflare R2

R2 is S3-compatible, so it uses the AWS CLI with a custom endpoint.

**CLI needed:** `aws` (AWS CLI)

**Setup:**
1. Create an R2 bucket in your Cloudflare dashboard
2. Go to **Storage & Databases → R2 Object Storage → Overview** → find **Account Details** panel → **API Tokens: Manage** → **Create User Token** (read/write, scoped to bucket)
3. Note the S3 endpoint URL: `https://<account-id>.r2.cloudflarestorage.com`
4. Save credentials to `~/.openclaw/credentials/backup/r2-credentials`:
```bash
export AWS_ACCESS_KEY_ID="your-r2-access-key"
export AWS_SECRET_ACCESS_KEY="your-r2-secret-key"
```

**Config entry:**
```json
{ "type": "r2", "bucket": "your-bucket-name", "accountId": "your-cf-account-id", "endpoint": "https://your-account-id.r2.cloudflarestorage.com" }
```

---

## Backblaze B2

**CLI needed:** `b2`
```bash
pip install b2
```

**Setup:**
1. Create a B2 bucket at backblaze.com
2. Create an application key with write access to the bucket
3. Save credentials to `~/.openclaw/credentials/backup/b2-credentials`:
```bash
export B2_APPLICATION_KEY_ID="your-key-id"
export B2_APPLICATION_KEY="your-key"
```
4. Authorize: `b2 authorize-account $B2_APPLICATION_KEY_ID $B2_APPLICATION_KEY`

**Config entry:**
```json
{ "type": "b2", "bucket": "your-bucket-name" }
```

---

## Google Cloud Storage (GCS)

**CLI needed:** `gsutil` (part of Google Cloud SDK)
```bash
# Debian/Ubuntu:
sudo snap install google-cloud-cli --classic
# or: pip install gsutil
```

**Setup:**
1. Create a GCS bucket in your Google Cloud project
2. Create a service account with Storage Object Admin role
3. Download the JSON key file
4. Save it to `~/.openclaw/credentials/backup/gcs-key.json`

**Config entry:**
```json
{ "type": "gcs", "bucket": "gs://your-bucket/openclaw-backups" }
```

---

## Google Drive

**CLI needed:** `rclone`
```bash
# Install via package manager (recommended):
sudo apt install rclone
# or see https://rclone.org/install/ for other methods
```

**Setup:**
1. Run `rclone config` and set up a Google Drive remote named `gdrive`
2. Follow the OAuth flow to authorize
3. Copy the generated config to `~/.openclaw/credentials/backup/rclone.conf`
   (usually found at `~/.config/rclone/rclone.conf` after setup)

**Config entry:**
```json
{ "type": "gdrive", "folder": "OpenClaw Backups" }
```

---

## Remote Retention & Lifecycle

Local backups are auto-pruned after 30 days (configurable). **Remote backups are never automatically deleted** — this is intentional. Storage is cheap, and offsite backups should be your safety net.

To manage remote retention, set lifecycle rules directly on your storage provider:

- **S3:** Bucket → Management → Lifecycle rules → Expire objects after N days
- **R2:** Bucket → Settings → Object lifecycle rules → Delete after N days
- **B2:** Bucket Settings → Lifecycle Rules → Keep only last N days
- **GCS:** Bucket → Lifecycle → Add rule → Delete object, age > N days
- **Google Drive:** Manual cleanup or use rclone `--min-age` with `rclone delete`

This keeps delete permissions out of your backup credentials — which is best practice. Write-only backup tokens can't accidentally (or maliciously) wipe your backup history.

---

## rsync

**CLI needed:** `rsync` (usually pre-installed on Linux)

**Setup:**
1. Ensure SSH key-based auth is configured to the target host
2. Create the target directory on the remote host

**Config entry:**
```json
{ "type": "rsync", "target": "user@host:/path/to/backups" }
```


path: references/setup-guide.md
content: # Backup Setup — Conversational Guide

This is the flow the agent should follow when a user asks to set up backups.

---

## Step 1: Welcome

> I'll help you set up OpenClaw backups. Here's what gets backed up:
>
> - **Configuration** — gateway settings, environment
> - **Workspace** — skills, memory, notes, everything in `~/.openclaw/workspace/`
> - **Credentials** — API keys, tokens (in full mode)
>
> Backups are created locally first at `~/backups/openclaw/`, then optionally uploaded to cloud storage.

---

## Step 2: Encryption Passphrase

> Backups are **encrypted by default** with AES-256 (GPG symmetric). You'll need a passphrase.
>
> **You'll need a passphrase to encrypt your backups.** This is required for full backups (which include API keys and credentials). Store it somewhere safe — a password manager, written down, anywhere outside this server.
>
> If you'd prefer not to set a passphrase, we can use **portable mode** instead — it skips credentials entirely so encryption is optional. Good for migrating your agent's memory and config to a new machine, but you'd need to re-enter API keys.
>
> What passphrase would you like to use?

Save the passphrase to `~/.openclaw/credentials/backup/backup-passphrase` (mode 600).

**Important:** If no passphrase is provided and the user wants full backups, do NOT proceed. Full-mode backups contain credentials and must be encrypted. Either get a passphrase or switch to portable mode.

If they choose portable/unencrypted, set `encrypt: false` and `mode: "portable"` in config.

---

## Step 3: Backup Mode

> **Backup modes:**
> - **Full** (default) — Everything including credentials. Always encrypted. Best for disaster recovery.
> - **Portable** — Skips credentials directory. Can be unencrypted. Best for migrating to a new machine.
>
> Which mode? (full/portable)

Update `config.json` with the chosen mode.

---

## Step 4: Schedule

> **How often should backups run?**
> - **Daily** (recommended) — via cron job
> - **Weekly** — once a week
> - **Manual** — only when you run it yourself
>
> I recommend daily — backups are small and old ones are auto-cleaned after 30 days.

Update `config.json` with the schedule. If daily/weekly, set up a cron job:

```bash
# Daily at 3 AM
0 3 * * * ~/.openclaw/workspace/skills/backup/scripts/backup.sh >> ~/backups/openclaw/backup.log 2>&1

# Weekly on Sunday at 3 AM
0 3 * * 0 ~/.openclaw/workspace/skills/backup/scripts/backup.sh >> ~/backups/openclaw/backup.log 2>&1
```

If they also want cloud upload after backup, chain the scripts:
```bash
0 3 * * * ~/.openclaw/workspace/skills/backup/scripts/backup.sh >> ~/backups/openclaw/backup.log 2>&1 && ~/.openclaw/workspace/skills/backup/scripts/upload.sh >> ~/backups/openclaw/upload.log 2>&1
```

---

## Step 5: Cloud Destinations

> **Where should backups be uploaded?** (optional — local backups always happen)
>
> Available destinations:
> 1. **Amazon S3** — reliable, pay-per-use
> 2. **Cloudflare R2** — S3-compatible, no egress fees
> 3. **Backblaze B2** — cheapest storage
> 4. **Google Cloud Storage** — if you're in the GCP ecosystem
> 5. **Google Drive** — free tier available, familiar
> 6. **rsync** — any SSH server
> 7. **None** — local only for now
>
> You can pick multiple. Which ones?

---

## Step 6: Configure Each Destination

For each chosen destination, refer to `destinations.md` for the specific setup instructions.

General flow per destination:
1. Check if the required CLI tool is installed (offer to install if not)
2. Ask for the required credentials/config
3. Save credentials to `~/.openclaw/credentials/backup/<provider>-credentials`
4. Add the destination entry to `config.json`
5. Run `test-backup.sh` to verify connectivity

---

## Step 7: Run Test

> Let me run a quick test to make sure everything works...

```bash
~/.openclaw/workspace/skills/backup/scripts/test-backup.sh
```

Report results. If anything fails, help debug.

---

## Step 8: Confirm & Summary

> ✓ **Backup setup complete!**
>
> **Configuration:**
> - Mode: [full/portable]
> - Encryption: [yes/no]
> - Schedule: [daily/weekly/manual]
> - Local storage: ~/backups/openclaw/ (retained 30 days)
> - Cloud destinations: [list or "none"]
>
> **Quick commands:**
> - Run backup now: `~/.openclaw/workspace/skills/backup/scripts/backup.sh`
> - Upload to cloud: `~/.openclaw/workspace/skills/backup/scripts/upload.sh`
> - Restore: `~/.openclaw/workspace/skills/backup/scripts/restore.sh <file>`
> - Test setup: `~/.openclaw/workspace/skills/backup/scripts/test-backup.sh`


path: scripts/backup.sh
content: #!/usr/bin/env bash
# backup.sh — Create local backups of ~/.openclaw/
# Always produces two files:
#   1. Full backup (encrypted) — everything, for disaster recovery on same/similar environment
#   2. Workspace-only backup — just the workspace (memory, skills, files), safe for any environment
# Part of the OpenClaw backup skill.
set -euo pipefail

# ---------------------------------------------------------------------------
# Paths & defaults
# ---------------------------------------------------------------------------
OPENCLAW_DIR="$HOME/.openclaw"
SKILL_DIR="$OPENCLAW_DIR/workspace/skills/backup"
CONFIG_FILE="$SKILL_DIR/config.json"
CRED_DIR="$OPENCLAW_DIR/credentials/backup"
DEFAULT_BACKUP_DIR="$HOME/backups/openclaw"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log()  { echo "[backup] $(date '+%H:%M:%S') $*"; }
die()  { echo "[backup] ERROR: $*" >&2; exit 1; }

read_config() {
  local key="$1"
  if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then
    jq -r ".$key // empty" "$CONFIG_FILE" 2>/dev/null || true
  fi
}

# ---------------------------------------------------------------------------
# Configuration (config.json → env var → default)
# ---------------------------------------------------------------------------
ENCRYPT="${BACKUP_ENCRYPT:-$(read_config encrypt)}"
ENCRYPT="${ENCRYPT:-true}"

RETAIN_DAYS="${BACKUP_RETAIN_DAYS:-$(read_config retainDays)}"
RETAIN_DAYS="${RETAIN_DAYS:-30}"

STOP_GATEWAY="${BACKUP_STOP_GATEWAY:-true}"

BACKUP_DIR="${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}"

# Passphrase: env var → file
PASSPHRASE="${BACKUP_PASSPHRASE:-}"
if [[ -z "$PASSPHRASE" && -f "$CRED_DIR/backup-passphrase" ]]; then
  PASSPHRASE="$(cat "$CRED_DIR/backup-passphrase")"
fi

# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------
[[ -d "$OPENCLAW_DIR" ]] || die "OpenClaw directory not found at $OPENCLAW_DIR"

if [[ "$ENCRYPT" == "true" && -z "$PASSPHRASE" ]]; then
  die "Encryption enabled but no passphrase found. Set BACKUP_PASSPHRASE or create $CRED_DIR/backup-passphrase"
fi

if ! command -v jq &>/dev/null; then
  log "WARNING: jq not installed — config.json will not be read. Install with: sudo apt install jq"
fi

# ---------------------------------------------------------------------------
# Prepare
# ---------------------------------------------------------------------------
mkdir -p "$BACKUP_DIR"

TIMESTAMP="$(date '+%Y%m%d-%H%M')"
HOSTNAME_SHORT="$(hostname -s 2>/dev/null || echo unknown)"

# ---------------------------------------------------------------------------
# Stop gateway for consistency
# ---------------------------------------------------------------------------
GATEWAY_WAS_RUNNING=false
if [[ "$STOP_GATEWAY" == "true" ]]; then
  if command -v openclaw &>/dev/null && openclaw gateway status 2>/dev/null | grep -qi "running"; then
    log "Stopping gateway for consistent backup..."
    openclaw gateway stop 2>/dev/null || true
    GATEWAY_WAS_RUNNING=true
  fi
fi

cleanup() {
  if [[ "$GATEWAY_WAS_RUNNING" == "true" ]]; then
    log "Restarting gateway..."
    openclaw gateway start 2>/dev/null || log "WARNING: Failed to restart gateway. Run: openclaw gateway start"
  fi
}
trap cleanup EXIT

# ---------------------------------------------------------------------------
# 1. Full backup (encrypted)
# ---------------------------------------------------------------------------
FULL_NAME="openclaw-${HOSTNAME_SHORT}-${TIMESTAMP}-full.tar.gz"
FULL_PATH="$BACKUP_DIR/$FULL_NAME"

log "Creating full backup..."
tar czf "$FULL_PATH" -C "$HOME" .openclaw/
log "Full archive: $FULL_PATH ($(du -h "$FULL_PATH" | cut -f1))"

if [[ "$ENCRYPT" == "true" ]]; then
  log "Encrypting full backup with AES-256..."
  gpg --batch --yes --symmetric --cipher-algo AES256 \
    --passphrase "$PASSPHRASE" \
    --output "${FULL_PATH}.gpg" \
    "$FULL_PATH"
  rm -f "$FULL_PATH"
  FULL_PATH="${FULL_PATH}.gpg"
  log "Encrypted: $FULL_PATH ($(du -h "$FULL_PATH" | cut -f1))"
else
  log "WARNING: Full backup is NOT encrypted — contains credentials in plaintext"
fi

# ---------------------------------------------------------------------------
# 2. Workspace-only backup (safe for any environment)
# ---------------------------------------------------------------------------
WS_NAME="openclaw-${HOSTNAME_SHORT}-${TIMESTAMP}-workspace.tar.gz"
WS_PATH="$BACKUP_DIR/$WS_NAME"

log "Creating workspace-only backup..."
tar czf "$WS_PATH" -C "$HOME" .openclaw/workspace/
log "Workspace archive: $WS_PATH ($(du -h "$WS_PATH" | cut -f1))"

if [[ "$ENCRYPT" == "true" ]]; then
  log "Encrypting workspace backup..."
  gpg --batch --yes --symmetric --cipher-algo AES256 \
    --passphrase "$PASSPHRASE" \
    --output "${WS_PATH}.gpg" \
    "$WS_PATH"
  rm -f "$WS_PATH"
  WS_PATH="${WS_PATH}.gpg"
  log "Encrypted: $WS_PATH ($(du -h "$WS_PATH" | cut -f1))"
fi

# ---------------------------------------------------------------------------
# Prune old backups
# ---------------------------------------------------------------------------
if [[ "$RETAIN_DAYS" -gt 0 ]]; then
  PRUNED=$(find "$BACKUP_DIR" -name 'openclaw-*.tar.gz*' -mtime +"$RETAIN_DAYS" -print -delete 2>/dev/null | wc -l)
  if [[ "$PRUNED" -gt 0 ]]; then
    log "Pruned $PRUNED backup(s) older than $RETAIN_DAYS days"
  fi
fi

# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
log "Backup complete:"
log "  Full:      $FULL_PATH"
log "  Workspace: $WS_PATH"

# Output the full backup path (used by upload.sh)
echo "$FULL_PATH"


path: scripts/restore.sh
content: #!/usr/bin/env bash
# restore.sh — Restore OpenClaw from a backup file.
# Use a *-full.tar.gz(.gpg) file for full disaster recovery (same environment).
# Use a *-workspace.tar.gz file to restore just the agent workspace (any environment).
# Part of the OpenClaw backup skill.
set -euo pipefail

# ---------------------------------------------------------------------------
# Paths & defaults
# ---------------------------------------------------------------------------
OPENCLAW_DIR="$HOME/.openclaw"
CRED_DIR="$OPENCLAW_DIR/credentials/backup"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log()  { echo "[restore] $(date '+%H:%M:%S') $*"; }
die()  { echo "[restore] ERROR: $*" >&2; exit 1; }

safe_load_creds() {
  # Safely read KEY=VALUE pairs from a credential file without executing arbitrary shell
  local file="$1"
  [[ -f "$file" ]] || return 1
  while IFS='=' read -r key value; do
    # Skip comments and empty lines
    [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
    # Strip 'export ' prefix if present
    key="${key#export }"
    # Trim whitespace
    key="$(echo "$key" | xargs)"
    # Strip surrounding quotes from value
    value="${value#\"}"
    value="${value%\"}"
    value="${value#\'}"
    value="${value%\'}"
    export "$key=$value"
  done < "$file"
}

# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------
WORKSPACE_ONLY=false
SOURCE=""

for arg in "$@"; do
  case "$arg" in
    --workspace-only) WORKSPACE_ONLY=true ;;
    -*) die "Unknown option: $arg" ;;
    *) SOURCE="$arg" ;;
  esac
done

if [[ -z "$SOURCE" ]]; then
  echo "Usage: restore.sh [--workspace-only] <backup-file-or-cloud-url>"
  echo ""
  echo "Backup types:"
  echo "  *-full.tar.gz.gpg     Full restore (config, credentials, workspace, sessions)"
  echo "  *-workspace.tar.gz    Workspace only (memory, skills, files — safe for any environment)"
  echo ""
  echo "Options:"
  echo "  --workspace-only      Only extract the workspace from a full backup"
  echo ""
  echo "Examples:"
  echo "  restore.sh openclaw-myhost-20260215-full.tar.gz.gpg"
  echo "  restore.sh openclaw-myhost-20260215-workspace.tar.gz"
  echo "  restore.sh --workspace-only openclaw-myhost-20260215-full.tar.gz.gpg"
  echo "  restore.sh s3://mybucket/openclaw/openclaw-myhost-20260215-full.tar.gz.gpg"
  exit 1
fi

# Auto-detect workspace backup by filename
if [[ "$SOURCE" == *-workspace.tar.gz* ]]; then
  WORKSPACE_ONLY=true
fi

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

# ---------------------------------------------------------------------------
# Download if remote
# ---------------------------------------------------------------------------
if [[ "$SOURCE" == s3://* ]]; then
  log "Downloading from S3..."
  LOCAL_FILE="$WORK_DIR/$(basename "$SOURCE")"
  if [[ -f "$CRED_DIR/aws-credentials" ]]; then
    safe_load_creds "$CRED_DIR/aws-credentials"
  fi
  aws s3 cp "$SOURCE" "$LOCAL_FILE"

elif [[ "$SOURCE" == gs://* ]]; then
  log "Downloading from GCS..."
  LOCAL_FILE="$WORK_DIR/$(basename "$SOURCE")"
  if [[ -f "$CRED_DIR/gcs-key.json" ]]; then
    gcloud auth activate-service-account --key-file="$CRED_DIR/gcs-key.json" 2>/dev/null || true
  fi
  gsutil cp "$SOURCE" "$LOCAL_FILE"

elif [[ -f "$SOURCE" ]]; then
  LOCAL_FILE="$SOURCE"

else
  die "Source not found or unsupported: $SOURCE"
fi

log "Restoring from: $LOCAL_FILE"
if [[ "$WORKSPACE_ONLY" == "true" ]]; then
  log "Mode: workspace-only (memory, skills, files)"
else
  log "Mode: full restore (config, credentials, workspace, sessions)"
fi

# ---------------------------------------------------------------------------
# Decrypt if needed
# ---------------------------------------------------------------------------
ARCHIVE="$LOCAL_FILE"
if [[ "$ARCHIVE" == *.gpg ]]; then
  log "Decrypting..."

  PASSPHRASE="${BACKUP_PASSPHRASE:-}"
  if [[ -z "$PASSPHRASE" && -f "$CRED_DIR/backup-passphrase" ]]; then
    PASSPHRASE="$(cat "$CRED_DIR/backup-passphrase")"
  fi
  [[ -n "$PASSPHRASE" ]] || die "Encrypted backup but no passphrase. Set BACKUP_PASSPHRASE or create $CRED_DIR/backup-passphrase"

  DECRYPTED="$WORK_DIR/$(basename "${ARCHIVE%.gpg}")"
  gpg --batch --yes --decrypt \
    --passphrase "$PASSPHRASE" \
    --output "$DECRYPTED" \
    "$ARCHIVE"

  ARCHIVE="$DECRYPTED"
  log "Decrypted successfully"
fi

# ---------------------------------------------------------------------------
# Stop gateway
# ---------------------------------------------------------------------------
if command -v openclaw &>/dev/null; then
  if openclaw gateway status 2>/dev/null | grep -qi "running"; then
    log "Stopping gateway..."
    openclaw gateway stop 2>/dev/null || true
  fi
fi

# ---------------------------------------------------------------------------
# Restore
# ---------------------------------------------------------------------------
if [[ "$WORKSPACE_ONLY" == "true" ]]; then
  # Workspace-only: only replace the workspace directory
  WORKSPACE_DIR="$OPENCLAW_DIR/workspace"

  if [[ -d "$WORKSPACE_DIR" ]]; then
    SAFETY="${WORKSPACE_DIR}.pre-restore"
    [[ -d "$SAFETY" ]] && rm -rf "$SAFETY"
    log "Moving existing workspace → ${SAFETY}/"
    mv "$WORKSPACE_DIR" "$SAFETY"
  fi

  log "Extracting workspace..."
  # Handle both archive types:
  # - workspace archive: contains .openclaw/workspace/
  # - full archive with --workspace-only: contains .openclaw/ (extract only workspace subdir)
  if tar -tzf "$ARCHIVE" 2>/dev/null | head -1 | grep -q "^\.openclaw/workspace/"; then
    # Workspace-only archive or full archive — extract just the workspace path
    tar xzf "$ARCHIVE" -C "$HOME" .openclaw/workspace/
  else
    die "Could not find workspace directory in archive"
  fi

  log "Workspace restored to $WORKSPACE_DIR"
else
  # Full restore: replace entire ~/.openclaw/
  if [[ -d "$OPENCLAW_DIR" ]]; then
    SAFETY="$HOME/.openclaw.pre-restore"
    [[ -d "$SAFETY" ]] && rm -rf "$SAFETY"
    log "Moving existing ~/.openclaw/ → ~/.openclaw.pre-restore/"
    mv "$OPENCLAW_DIR" "$SAFETY"
  fi

  log "Extracting full backup..."
  tar xzf "$ARCHIVE" -C "$HOME"
  log "Full restore complete"
fi

# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
if [[ "$WORKSPACE_ONLY" == "true" ]]; then
  log "Workspace restored! Your gateway config and credentials are untouched."
  echo ""
  echo "Next steps:"
  echo "  1. Start the gateway:  openclaw gateway start"
  echo "  2. Your agent's memory, skills, and files are restored"
  echo "  3. Previous workspace saved at: ${WORKSPACE_DIR}.pre-restore/"
else
  log "Full restore complete!"
  echo ""
  echo "Your previous state is saved at ~/.openclaw.pre-restore/"
  echo ""
  echo "Next steps:"
  echo "  1. Review the restored files at ~/.openclaw/"
  echo "  2. Start the gateway:  openclaw gateway start"
  echo "  3. If everything works, remove the safety copy:"
  echo "     rm -rf ~/.openclaw.pre-restore/"
fi


path: scripts/test-backup.sh
content: #!/usr/bin/env bash
# test-backup.sh — Validate backup setup: encryption + all cloud destinations.
# Part of the OpenClaw backup skill.
set -euo pipefail

# ---------------------------------------------------------------------------
# Paths & defaults
# ---------------------------------------------------------------------------
OPENCLAW_DIR="$HOME/.openclaw"
SKILL_DIR="$OPENCLAW_DIR/workspace/skills/backup"
CONFIG_FILE="$SKILL_DIR/config.json"
CRED_DIR="$OPENCLAW_DIR/credentials/backup"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log()  { echo "[test] $(date '+%H:%M:%S') $*"; }
die()  { echo "[test] ERROR: $*" >&2; exit 1; }
warn() { echo "[test] WARNING: $*" >&2; }
pass() { echo "[test] ✓ $*"; }
fail() { echo "[test] ✗ $*" >&2; }

safe_load_creds() {
  # Safely read KEY=VALUE pairs from a credential file without executing arbitrary shell
  local file="$1"
  [[ -f "$file" ]] || return 1
  while IFS='=' read -r key value; do
    # Skip comments and empty lines
    [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
    # Strip 'export ' prefix if present
    key="${key#export }"
    # Trim whitespace
    key="$(echo "$key" | xargs)"
    # Strip surrounding quotes from value
    value="${value#\"}"
    value="${value%\"}"
    value="${value#\'}"
    value="${value%\'}"
    export "$key=$value"
  done < "$file"
}

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

FAILURES=0

# ---------------------------------------------------------------------------
# Check prerequisites
# ---------------------------------------------------------------------------
log "Checking prerequisites..."

if command -v jq &>/dev/null; then
  pass "jq installed"
else
  fail "jq not installed (sudo apt install jq)"
  ((FAILURES++))
fi

# ---------------------------------------------------------------------------
# Test encryption
# ---------------------------------------------------------------------------
log "Testing encryption..."

ENCRYPT="true"
if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then
  ENCRYPT=$(jq -r '.encrypt // true' "$CONFIG_FILE")
fi

if [[ "$ENCRYPT" == "true" ]]; then
  PASSPHRASE="${BACKUP_PASSPHRASE:-}"
  if [[ -z "$PASSPHRASE" && -f "$CRED_DIR/backup-passphrase" ]]; then
    PASSPHRASE="$(cat "$CRED_DIR/backup-passphrase")"
  fi

  if [[ -z "$PASSPHRASE" ]]; then
    fail "Encryption enabled but no passphrase found"
    ((FAILURES++))
  else
    # Create test file, encrypt, decrypt, verify
    TEST_FILE="$WORK_DIR/test-data.txt"
    echo "openclaw-backup-test-$(date +%s)" > "$TEST_FILE"

    gpg --batch --yes --symmetric --cipher-algo AES256 \
      --passphrase "$PASSPHRASE" \
      --output "$TEST_FILE.gpg" \
      "$TEST_FILE" 2>/dev/null

    gpg --batch --yes --decrypt \
      --passphrase "$PASSPHRASE" \
      --output "$TEST_FILE.dec" \
      "$TEST_FILE.gpg" 2>/dev/null

    if diff -q "$TEST_FILE" "$TEST_FILE.dec" &>/dev/null; then
      pass "Encryption round-trip OK"
    else
      fail "Encryption round-trip failed — decrypted data doesn't match"
      ((FAILURES++))
    fi
  fi
else
  log "Encryption disabled, skipping test"
fi

# ---------------------------------------------------------------------------
# Test cloud destinations
# ---------------------------------------------------------------------------
if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then
  DEST_COUNT=$(jq '.destinations | length' "$CONFIG_FILE")
else
  DEST_COUNT=0
fi

if [[ "$DEST_COUNT" -eq 0 ]]; then
  log "No cloud destinations configured — skipping upload tests"
else
  # Create a tiny test file for upload
  TEST_UPLOAD="$WORK_DIR/openclaw-test-$(date +%s).txt"
  echo "openclaw backup connectivity test" > "$TEST_UPLOAD"
  TEST_NAME="$(basename "$TEST_UPLOAD")"

  for i in $(seq 0 $((DEST_COUNT - 1))); do
    DEST=$(jq ".destinations[$i]" "$CONFIG_FILE")
    DTYPE=$(echo "$DEST" | jq -r '.type')

    log "Testing $DTYPE destination..."

    case "$DTYPE" in
      s3)
        BUCKET=$(echo "$DEST" | jq -r '.bucket')
        REGION=$(echo "$DEST" | jq -r '.region // "us-east-1"')
        safe_load_creds "$CRED_DIR/aws-credentials" || true

        if aws s3 cp "$TEST_UPLOAD" "${BUCKET%/}/$TEST_NAME" --region "$REGION" 2>&1 && \
           aws s3 rm "${BUCKET%/}/$TEST_NAME" --region "$REGION" 2>&1; then
          pass "S3 upload + cleanup OK"
        else
          fail "S3 upload failed"; ((FAILURES++))
        fi
        ;;

      r2)
        BUCKET=$(echo "$DEST" | jq -r '.bucket')
        ENDPOINT=$(echo "$DEST" | jq -r '.endpoint')
        safe_load_creds "$CRED_DIR/r2-credentials" || true

        if aws s3 cp "$TEST_UPLOAD" "s3://${BUCKET}/$TEST_NAME" --endpoint-url "$ENDPOINT" 2>&1 && \
           aws s3 rm "s3://${BUCKET}/$TEST_NAME" --endpoint-url "$ENDPOINT" 2>&1; then
          pass "R2 upload + cleanup OK"
        else
          fail "R2 upload failed"; ((FAILURES++))
        fi
        ;;

      b2)
        BUCKET=$(echo "$DEST" | jq -r '.bucket')
        safe_load_creds "$CRED_DIR/b2-credentials" || true

        if b2 upload-file "$BUCKET" "$TEST_UPLOAD" "$TEST_NAME" 2>&1; then
          b2 delete-file-version "$TEST_NAME" 2>/dev/null || true
          pass "B2 upload OK"
        else
          fail "B2 upload failed"; ((FAILURES++))
        fi
        ;;

      gcs)
        BUCKET=$(echo "$DEST" | jq -r '.bucket')
        [[ -f "$CRED_DIR/gcs-key.json" ]] && \
          gcloud auth activate-service-account --key-file="$CRED_DIR/gcs-key.json" 2>/dev/null || true

        if gsutil cp "$TEST_UPLOAD" "${BUCKET%/}/$TEST_NAME" 2>&1 && \
           gsutil rm "${BUCKET%/}/$TEST_NAME" 2>&1; then
          pass "GCS upload + cleanup OK"
        else
          fail "GCS upload failed"; ((FAILURES++))
        fi
        ;;

      gdrive)
        FOLDER=$(echo "$DEST" | jq -r '.folder // "OpenClaw Backups"')
        RCLONE_CONF="$CRED_DIR/rclone.conf"

        if [[ ! -f "$RCLONE_CONF" ]]; then
          fail "rclone.conf not found"; ((FAILURES++))
        elif rclone copy "$TEST_UPLOAD" "gdrive:$FOLDER/" --config "$RCLONE_CONF" 2>&1 && \
             rclone delete "gdrive:$FOLDER/$TEST_NAME" --config "$RCLONE_CONF" 2>&1; then
          pass "Google Drive upload + cleanup OK"
        else
          fail "Google Drive upload failed"; ((FAILURES++))
        fi
        ;;

      rsync)
        TARGET=$(echo "$DEST" | jq -r '.target')
        if rsync -avz "$TEST_UPLOAD" "$TARGET/" 2>&1; then
          pass "rsync upload OK"
        else
          fail "rsync upload failed"; ((FAILURES++))
        fi
        ;;

      *)
        fail "Unknown destination type: $DTYPE"; ((FAILURES++))
        ;;
    esac
  done
fi

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
if [[ "$FAILURES" -eq 0 ]]; then
  log "All tests passed ✓"
  exit 0
else
  log "$FAILURES test(s) failed ✗"
  exit 1
fi


path: scripts/upload.sh
content: #!/usr/bin/env bash
# upload.sh — Upload the latest local backup to configured cloud destinations.
# Part of the OpenClaw backup skill.
set -euo pipefail

# ---------------------------------------------------------------------------
# Paths & defaults
# ---------------------------------------------------------------------------
OPENCLAW_DIR="$HOME/.openclaw"
SKILL_DIR="$OPENCLAW_DIR/workspace/skills/backup"
CONFIG_FILE="$SKILL_DIR/config.json"
CRED_DIR="$OPENCLAW_DIR/credentials/backup"
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/openclaw}"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log()  { echo "[upload] $(date '+%H:%M:%S') $*"; }
die()  { echo "[upload] ERROR: $*" >&2; exit 1; }
warn() { echo "[upload] WARNING: $*" >&2; }

require_cmd() {
  command -v "$1" &>/dev/null || die "$1 is required but not installed. $2"
}

safe_load_creds() {
  # Safely read KEY=VALUE pairs from a credential file without executing arbitrary shell
  local file="$1"
  [[ -f "$file" ]] || return 1
  while IFS='=' read -r key value; do
    # Skip comments and empty lines
    [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
    # Strip 'export ' prefix if present
    key="${key#export }"
    # Trim whitespace
    key="$(echo "$key" | xargs)"
    # Strip surrounding quotes from value
    value="${value#\"}"
    value="${value%\"}"
    value="${value#\'}"
    value="${value%\'}"
    export "$key=$value"
  done < "$file"
}

# ---------------------------------------------------------------------------
# Find backup files
# ---------------------------------------------------------------------------
BACKUP_FILES=()
if [[ "${1:-}" != "" && -f "${1:-}" ]]; then
  BACKUP_FILES+=("$1")
else
  # Find the latest full and workspace backups
  LATEST_FULL="$(ls -t "$BACKUP_DIR"/openclaw-*-full.tar.gz* 2>/dev/null | head -1 || true)"
  LATEST_WS="$(ls -t "$BACKUP_DIR"/openclaw-*-workspace.tar.gz* 2>/dev/null | head -1 || true)"

  # Fallback: try old naming format (pre-1.0.2)
  if [[ -z "$LATEST_FULL" && -z "$LATEST_WS" ]]; then
    LATEST_FULL="$(ls -t "$BACKUP_DIR"/openclaw-*.tar.gz* 2>/dev/null | head -1 || true)"
  fi

  [[ -n "$LATEST_FULL" ]] && BACKUP_FILES+=("$LATEST_FULL")
  [[ -n "$LATEST_WS" ]] && BACKUP_FILES+=("$LATEST_WS")
  [[ ${#BACKUP_FILES[@]} -gt 0 ]] || die "No backup files found in $BACKUP_DIR"
fi

log "Uploading ${#BACKUP_FILES[@]} file(s)"

# ---------------------------------------------------------------------------
# Read destinations from config
# ---------------------------------------------------------------------------
if ! command -v jq &>/dev/null; then
  die "jq is required to read config.json. Install with: sudo apt install jq"
fi

[[ -f "$CONFIG_FILE" ]] || die "Config not found: $CONFIG_FILE"

DEST_COUNT=$(jq '.destinations | length' "$CONFIG_FILE")
if [[ "$DEST_COUNT" -eq 0 ]]; then
  die "No destinations configured in $CONFIG_FILE"
fi

# ---------------------------------------------------------------------------
# Upload functions
# ---------------------------------------------------------------------------
upload_s3() {
  local bucket region
  bucket=$(echo "$1" | jq -r '.bucket')
  region=$(echo "$1" | jq -r '.region // "us-east-1"')

  require_cmd aws "Install with: pip install awscli"

  # Load credentials if available
  if [[ -f "$CRED_DIR/aws-credentials" ]]; then
    safe_load_creds "$CRED_DIR/aws-credentials"
  fi

  aws s3 cp "$BACKUP_FILE" "${bucket%/}/$FILENAME" --region "$region"
}

upload_r2() {
  local bucket endpoint
  bucket=$(echo "$1" | jq -r '.bucket')
  endpoint=$(echo "$1" | jq -r '.endpoint')

  require_cmd aws "Install with: pip install awscli"

  # Load R2 credentials
  if [[ -f "$CRED_DIR/r2-credentials" ]]; then
    safe_load_creds "$CRED_DIR/r2-credentials"
  fi

  aws s3 cp "$BACKUP_FILE" "s3://${bucket}/$FILENAME" \
    --endpoint-url "$endpoint"
}

upload_b2() {
  local bucket
  bucket=$(echo "$1" | jq -r '.bucket')

  require_cmd b2 "Install with: pip install b2"

  # Load B2 credentials
  if [[ -f "$CRED_DIR/b2-credentials" ]]; then
    safe_load_creds "$CRED_DIR/b2-credentials"
  fi

  b2 upload-file "$bucket" "$BACKUP_FILE" "$FILENAME"
}

upload_gcs() {
  local bucket
  bucket=$(echo "$1" | jq -r '.bucket')

  require_cmd gsutil "Install with: pip install gsutil or snap install google-cloud-cli"

  # Activate service account if key exists
  if [[ -f "$CRED_DIR/gcs-key.json" ]]; then
    gcloud auth activate-service-account --key-file="$CRED_DIR/gcs-key.json" 2>/dev/null || true
  fi

  gsutil cp "$BACKUP_FILE" "${bucket%/}/$FILENAME"
}

upload_gdrive() {
  local folder
  folder=$(echo "$1" | jq -r '.folder // "OpenClaw Backups"')

  require_cmd rclone "Install from https://rclone.org/install/"

  local rclone_conf="$CRED_DIR/rclone.conf"
  [[ -f "$rclone_conf" ]] || die "rclone.conf not found at $rclone_conf"

  rclone copy "$BACKUP_FILE" "gdrive:$folder/" --config "$rclone_conf"
}

upload_rsync() {
  local target
  target=$(echo "$1" | jq -r '.target')

  require_cmd rsync "Install with: sudo apt install rsync"

  rsync -avz "$BACKUP_FILE" "$target/"
}

# ---------------------------------------------------------------------------
# Process each file × each destination
# ---------------------------------------------------------------------------
SUCCESSES=0
FAILURES=0
TOTAL=0

for BACKUP_FILE in "${BACKUP_FILES[@]}"; do
  FILENAME="$(basename "$BACKUP_FILE")"
  log "Uploading: $FILENAME"

  for i in $(seq 0 $((DEST_COUNT - 1))); do
    DEST=$(jq ".destinations[$i]" "$CONFIG_FILE")
    DTYPE=$(echo "$DEST" | jq -r '.type')
    TOTAL=$((TOTAL + 1))

    log "  → $DTYPE..."

    if upload_"$DTYPE" "$DEST" 2>&1; then
      log "  ✓ $DTYPE upload succeeded"
      SUCCESSES=$((SUCCESSES + 1))
    else
      warn "  ✗ $DTYPE upload failed"
      FAILURES=$((FAILURES + 1))
    fi
  done
done

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
log "Upload complete: $SUCCESSES succeeded, $FAILURES failed (of $TOTAL uploads)"

[[ "$FAILURES" -eq 0 ]] || exit 1


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