Skill: danielwangyy/openclaw-state-backup (version 1.0.2)

--- name: openclaw-state-backup version: 1.0.2 description: Create, inspect, and restore versioned OpenClaw state backups with rollback safety. Use when backing up or migrating OpenClaw memory, workspace state, gateway config, cron/session state, or when restoring a previously captured snapshot after breakage, config mistakes, host migration, or context-loss concerns. --- # OpenClaw State Backup Create and restore **versioned, restorable snapshots** of mutable OpenClaw state. ## What changes over time Treat these as **mutable state** and include them in backups when they exist: - `~/.openclaw/openclaw.json` — runtime config - `~/.openclaw/sessions.json` — session metadata - `~/.openclaw/restart-sentinel.json` — recent restart delivery state - `~/.openclaw/memory/` — vector index / memory DBs - `~/.openclaw/agents/` — per-agent runtime/session state - `workspace/MEMORY.md` - `workspace/memory/` - `workspace/SESSION-STATE.md` - `workspace/HEARTBEAT.md` - `workspace/TOOLS.md` - `workspace/skills/` — user-authored skills and local skill state Treat these as **mostly static/user-maintained bootstrap files** and back them up when you want a full environment restore, but do not rely on them as fast-changing runtime state: - `workspace/SOUL.md` - `workspace/USER.md` - `workspace/IDENTITY.md` - `workspace/AGENTS.md` - `workspace/BOOTSTRAP.md` (if still present) ## Backup strategy Use the bundled scripts for deterministic behavior. ### Create backup Run `scripts/backup_state.py` with: - `--workspace <path>` - `--state-dir <path>` (usually `~/.openclaw`) - `--output-dir <path>` for generated snapshots - optional `--label <name>` - optional `--mode mutable|full` (default: `mutable`) - optional repeated `--include-prefix <relative-path-prefix>` - optional repeated `--exclude-prefix <relative-path-prefix>` `mutable` captures changing state only. `full` adds mostly-static workspace identity/bootstrap files too. The script writes: - a timestamped `.tar.gz` - a `manifest.json` - checksums for every stored file - compatibility metadata (`formatVersion`, OpenClaw version, host/platform) - applied include/exclude filter metadata ### Restore backup Run `scripts/restore_state.py` with: - `--archive <path-to-tar.gz>` - `--workspace <path>` - `--state-dir <path>` - optional `--verify-only` - optional `--dry-run` - optional `--allow-version-mismatch` - optional `--report-dir <path>` - optional repeated `--include-prefix <relative-path-prefix>` - optional repeated `--exclude-prefix <relative-path-prefix>` Restore behavior: 1. verify archive structure + checksums 2. compare compatibility metadata 3. optionally narrow restore scope with include/exclude prefixes 4. build a restore plan (`create` / `update` / `unchanged` / `missingFromArchive`) 5. always write a JSON restore/dry-run report to disk 6. if `--dry-run`, stop after writing the diff-style report 7. otherwise create a **pre-restore rollback backup** automatically 8. restore files into place 9. write a final restore report showing what changed and where rollback lives ## Safety rules - Always prefer `--verify-only` first when archive provenance is uncertain. - Never overwrite from an archive that fails checksum verification. - Never delete unrelated files outside the managed backup manifest. - If compatibility check fails, stop unless the user explicitly wants `--allow-version-mismatch`. - Before reporting restore success, mention the generated rollback archive path. ## File model The scripts split backup contents into: - `mutable/` — runtime-changing state - `static/` — mostly-stable workspace identity/configuration files - `meta/manifest.json` — archive manifest Read the manifest if you need to inspect contents without restoring. ## When to use which mode - Use **mutable** for routine safety snapshots and frequent backups. - Use **full** before machine migration, risky reconfiguration, or major cleanup. ## Recovery guidance If a restore causes problems, immediately restore the auto-generated **pre-restore rollback archive** created by `restore_state.py`.

path: LICENSE
content: MIT License

Copyright (c) 2026 Daniel Wang

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


path: README.md
content: # OpenClaw State Backup

A standalone OpenClaw skill for creating **versioned, restorable backups** of OpenClaw state with checksum verification, dry-run restore plans, include/exclude filters, and automatic pre-restore rollback snapshots.

> Built for people who want a safer way to back up and restore OpenClaw without manually copying a pile of runtime, memory, and workspace files.

## Highlights

- **Versioned backup archives** (`.tar.gz`)
- **Manifest + SHA-256 checksums** for every archived file
- **Mutable vs full backup modes**
- **Dry-run restore plans** before writing anything
- **JSON restore reports** written to disk
- **Include / exclude prefix filters** for targeted backup or restore
- **Automatic pre-restore rollback backup** before modifying current state
- **Compatibility checks** for OpenClaw version mismatches

## Why this exists

OpenClaw state is spread across multiple places:

- gateway/runtime config
- session metadata
- memory indexes and notes
- workspace memory files
- user-authored skills

That makes ad-hoc copying fragile and partial restores risky.

This skill provides a repeatable workflow with:

- a machine-readable manifest
- checksum verification
- restore previews
- rollback protection

## Repository contents

- `SKILL.md` — skill instructions for OpenClaw / ClawHub
- `README.md` — human-facing project overview
- `LICENSE` — MIT license
- `scripts/backup_state.py` — create versioned backup archives
- `scripts/restore_state.py` — verify, diff, and restore archives

## What gets backed up

### Mutable state

Examples:

- `~/.openclaw/openclaw.json`
- `~/.openclaw/sessions.json`
- `~/.openclaw/restart-sentinel.json`
- `~/.openclaw/memory/`
- `~/.openclaw/agents/`
- `workspace/MEMORY.md`
- `workspace/memory/`
- `workspace/SESSION-STATE.md`
- `workspace/HEARTBEAT.md`
- `workspace/TOOLS.md`
- `workspace/skills/`

### Optional static workspace files

Included in `full` mode:

- `workspace/SOUL.md`
- `workspace/USER.md`
- `workspace/IDENTITY.md`
- `workspace/AGENTS.md`
- `workspace/BOOTSTRAP.md`

## Usage

### Create a routine mutable backup

```bash
python scripts/backup_state.py \
  --workspace ~/.openclaw/workspace \
  --state-dir ~/.openclaw \
  --output-dir ./backups \
  --mode mutable \
  --label nightly
```

### Create a full backup before migration

```bash
python scripts/backup_state.py \
  --workspace ~/.openclaw/workspace \
  --state-dir ~/.openclaw \
  --output-dir ./backups \
  --mode full \
  --label migration
```

### Verify an archive without restoring

```bash
python scripts/restore_state.py \
  --archive ./backups/openclaw-backup-migration-full-20260311T000000Z.tar.gz \
  --workspace ~/.openclaw/workspace \
  --state-dir ~/.openclaw \
  --verify-only
```

### Preview restore changes with a dry-run

```bash
python scripts/restore_state.py \
  --archive ./backups/openclaw-backup-migration-full-20260311T000000Z.tar.gz \
  --workspace ~/.openclaw/workspace \
  --state-dir ~/.openclaw \
  --dry-run
```

### Restore for real

```bash
python scripts/restore_state.py \
  --archive ./backups/openclaw-backup-migration-full-20260311T000000Z.tar.gz \
  --workspace ~/.openclaw/workspace \
  --state-dir ~/.openclaw
```

## Restore safety model

Before a real restore, the tool will:

1. verify archive structure and checksums
2. check compatibility metadata
3. generate a restore plan
4. create an automatic **pre-restore rollback archive**
5. copy archived files into place
6. write a JSON restore report

This is intentionally conservative:

- it restores files included in the archive
- it does **not** delete unrelated files outside the manifest

## Filtering

Both backup and restore support repeated prefix filters:

- `--include-prefix <relative-path-prefix>`
- `--exclude-prefix <relative-path-prefix>`

Examples:

```bash
# only memory-related content
--include-prefix MEMORY.md --include-prefix memory

# exclude skills from a mutable backup
--exclude-prefix skills
```

## Privacy note

This repository contains only the standalone `openclaw-state-backup` skill.
It is intentionally separated from a larger private OpenClaw workspace so unrelated personal files, memories, and local runtime data are not published with it.

## Roadmap

Potential future improvements:

- optional compression/encryption profiles
- richer restore diff summaries
- pluggable retention policies
- export/import helpers for migration workflows

## License

MIT


path: scripts/backup_state.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
import platform
import socket
import tarfile
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable

FORMAT_VERSION = 1

MUTABLE_WORKSPACE_FILES = [
    "MEMORY.md",
    "SESSION-STATE.md",
    "HEARTBEAT.md",
    "TOOLS.md",
]
MUTABLE_WORKSPACE_DIRS = [
    "memory",
    "skills",
]
MUTABLE_STATE_FILES = [
    "openclaw.json",
    "sessions.json",
    "restart-sentinel.json",
]
MUTABLE_STATE_DIRS = [
    "memory",
    "agents",
]
STATIC_WORKSPACE_FILES = [
    "SOUL.md",
    "USER.md",
    "IDENTITY.md",
    "AGENTS.md",
    "BOOTSTRAP.md",
]


@dataclass
class CollectedFile:
    source: Path
    archive_path: str
    sha256: str
    size: int
    kind: str


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


def normalize_prefix(prefix: str) -> str:
    return prefix.strip().strip("/")


def should_include_relative(rel_path: str, include_prefixes: list[str], exclude_prefixes: list[str]) -> bool:
    rel_norm = rel_path.strip("/")
    if include_prefixes:
        if not any(rel_norm == p or rel_norm.startswith(p + "/") for p in include_prefixes):
            return False
    if exclude_prefixes:
        if any(rel_norm == p or rel_norm.startswith(p + "/") for p in exclude_prefixes):
            return False
    return True


def iter_files(
    base: Path,
    rel_paths: Iterable[str],
    archive_prefix: str,
    kind: str,
    include_prefixes: list[str] | None = None,
    exclude_prefixes: list[str] | None = None,
) -> list[CollectedFile]:
    include_prefixes = include_prefixes or []
    exclude_prefixes = exclude_prefixes or []
    collected: list[CollectedFile] = []
    for rel in rel_paths:
        path = base / rel
        if not path.exists():
            continue
        if path.is_file():
            rel_norm = rel.replace("\\", "/")
            if not should_include_relative(rel_norm, include_prefixes, exclude_prefixes):
                continue
            collected.append(
                CollectedFile(
                    source=path,
                    archive_path=f"{archive_prefix}/{rel_norm}",
                    sha256=sha256_file(path),
                    size=path.stat().st_size,
                    kind=kind,
                )
            )
        elif path.is_dir():
            for file in sorted(p for p in path.rglob("*") if p.is_file()):
                rel_file = file.relative_to(base).as_posix()
                if not should_include_relative(rel_file, include_prefixes, exclude_prefixes):
                    continue
                collected.append(
                    CollectedFile(
                        source=file,
                        archive_path=f"{archive_prefix}/{rel_file}",
                        sha256=sha256_file(file),
                        size=file.stat().st_size,
                        kind=kind,
                    )
                )
    return collected


def detect_openclaw_version(state_dir: Path) -> str | None:
    candidates = [
        Path(__file__).resolve().parents[4] / "package.json",
        Path.home() / ".nvm/versions/node/current/lib/node_modules/openclaw/package.json",
        Path.home() / ".openclaw/openclaw/package.json",
    ]
    for pkg in candidates:
        if not pkg.exists():
            continue
        try:
            return json.loads(pkg.read_text())["version"]
        except Exception:
            continue
    return None


def build_manifest(
    files: list[CollectedFile],
    mode: str,
    label: str | None,
    workspace: Path,
    state_dir: Path,
    include_prefixes: list[str] | None = None,
    exclude_prefixes: list[str] | None = None,
) -> dict:
    now = datetime.now(timezone.utc).isoformat()
    return {
        "formatVersion": FORMAT_VERSION,
        "createdAt": now,
        "mode": mode,
        "label": label,
        "filters": {
            "includePrefixes": include_prefixes or [],
            "excludePrefixes": exclude_prefixes or [],
        },
        "host": {
            "hostname": socket.gethostname(),
            "platform": platform.platform(),
            "python": platform.python_version(),
        },
        "openclaw": {
            "version": detect_openclaw_version(state_dir),
            "workspace": str(workspace),
            "stateDir": str(state_dir),
        },
        "files": [
            {
                "path": f.archive_path,
                "sha256": f.sha256,
                "size": f.size,
                "kind": f.kind,
            }
            for f in files
        ],
    }


def safe_label(label: str | None) -> str:
    if not label:
        return "snapshot"
    return "".join(c if c.isalnum() or c in ("-", "_") else "-" for c in label).strip("-") or "snapshot"


def collect_files(
    workspace: Path,
    state_dir: Path,
    mode: str,
    include_prefixes: list[str] | None = None,
    exclude_prefixes: list[str] | None = None,
) -> list[CollectedFile]:
    include_prefixes = include_prefixes or []
    exclude_prefixes = exclude_prefixes or []
    files: list[CollectedFile] = []
    files += iter_files(workspace, MUTABLE_WORKSPACE_FILES, "mutable/workspace", "workspace-mutable", include_prefixes, exclude_prefixes)
    files += iter_files(workspace, MUTABLE_WORKSPACE_DIRS, "mutable/workspace", "workspace-mutable", include_prefixes, exclude_prefixes)
    files += iter_files(state_dir, MUTABLE_STATE_FILES, "mutable/state", "state-mutable", include_prefixes, exclude_prefixes)
    files += iter_files(state_dir, MUTABLE_STATE_DIRS, "mutable/state", "state-mutable", include_prefixes, exclude_prefixes)
    if mode == "full":
        files += iter_files(workspace, STATIC_WORKSPACE_FILES, "static/workspace", "workspace-static", include_prefixes, exclude_prefixes)
    return files


def main() -> int:
    ap = argparse.ArgumentParser(description="Create versioned OpenClaw state backup archive")
    ap.add_argument("--workspace", required=True)
    ap.add_argument("--state-dir", required=True)
    ap.add_argument("--output-dir", required=True)
    ap.add_argument("--label")
    ap.add_argument("--mode", choices=["mutable", "full"], default="mutable")
    ap.add_argument("--include-prefix", action="append", default=[])
    ap.add_argument("--exclude-prefix", action="append", default=[])
    args = ap.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    state_dir = Path(args.state_dir).expanduser().resolve()
    output_dir = Path(args.output_dir).expanduser().resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    include_prefixes = [normalize_prefix(p) for p in args.include_prefix if normalize_prefix(p)]
    exclude_prefixes = [normalize_prefix(p) for p in args.exclude_prefix if normalize_prefix(p)]

    files = collect_files(workspace, state_dir, args.mode, include_prefixes, exclude_prefixes)
    manifest = build_manifest(files, args.mode, args.label, workspace, state_dir, include_prefixes, exclude_prefixes)

    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    label = safe_label(args.label)
    archive_name = f"openclaw-backup-{label}-{args.mode}-{stamp}.tar.gz"
    archive_path = output_dir / archive_name

    with tempfile.TemporaryDirectory(prefix="openclaw-backup-") as tmp:
        tmpdir = Path(tmp)
        meta_dir = tmpdir / "meta"
        meta_dir.mkdir(parents=True, exist_ok=True)
        (meta_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False))

        with tarfile.open(archive_path, "w:gz") as tar:
            tar.add(meta_dir / "manifest.json", arcname="meta/manifest.json")
            for f in files:
                tar.add(f.source, arcname=f.archive_path, recursive=False)

    print(json.dumps({
        "ok": True,
        "archive": str(archive_path),
        "files": len(files),
        "mode": args.mode,
        "label": args.label,
        "filters": {
            "includePrefixes": include_prefixes,
            "excludePrefixes": exclude_prefixes,
        },
    }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/restore_state.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
import shutil
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from backup_state import FORMAT_VERSION  # type: ignore
import backup_state  # type: ignore


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


def load_manifest(tmpdir: Path) -> dict[str, Any]:
    manifest_path = tmpdir / "meta" / "manifest.json"
    if not manifest_path.exists():
        raise RuntimeError("manifest.json missing from archive")
    return json.loads(manifest_path.read_text())


def check_compat(manifest: dict[str, Any], allow_version_mismatch: bool) -> list[str]:
    warnings: list[str] = []
    if manifest.get("formatVersion") != FORMAT_VERSION:
        raise RuntimeError(f"Unsupported formatVersion: {manifest.get('formatVersion')}")
    archived_version = ((manifest.get("openclaw") or {}).get("version"))
    current_version = backup_state.detect_openclaw_version(Path.home() / ".openclaw")
    if archived_version and current_version and archived_version != current_version:
        msg = f"OpenClaw version mismatch: archive={archived_version} current={current_version}"
        if allow_version_mismatch:
            warnings.append(msg)
        else:
            raise RuntimeError(msg)
    return warnings


def verify_manifest(tmpdir: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
    restored: list[dict[str, Any]] = []
    for item in manifest.get("files", []):
        path = tmpdir / item["path"]
        if not path.exists():
            raise RuntimeError(f"Archive file missing: {item['path']}")
        digest = sha256_file(path)
        if digest != item["sha256"]:
            raise RuntimeError(f"Checksum mismatch: {item['path']}")
        restored.append(item)
    return restored


def archive_to_dest(rel: str, workspace: Path, state_dir: Path) -> Path:
    rel_path = Path(rel)
    parts = rel_path.parts
    if len(parts) < 3:
        raise RuntimeError(f"Unexpected archive path: {rel}")
    scope = parts[1]
    remainder = Path(*parts[2:])
    if scope == "workspace":
        return workspace / remainder
    if scope == "state":
        return state_dir / remainder
    raise RuntimeError(f"Unknown archive scope: {scope}")


def write_report(report_dir: Path, report: dict[str, Any], prefix: str = "restore-report") -> str:
    report_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    path = report_dir / f"{prefix}-{stamp}.json"
    path.write_text(json.dumps(report, ensure_ascii=False, indent=2))
    return str(path)


def make_rollback(
    workspace: Path,
    state_dir: Path,
    output_dir: Path,
    include_prefixes: list[str] | None = None,
    exclude_prefixes: list[str] | None = None,
) -> str:
    output_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    rollback_dir = output_dir / "rollbacks"
    rollback_dir.mkdir(parents=True, exist_ok=True)
    label = f"pre-restore-{stamp}"
    files = backup_state.collect_files(
        workspace,
        state_dir,
        "full",
        include_prefixes=include_prefixes,
        exclude_prefixes=exclude_prefixes,
    )
    manifest = backup_state.build_manifest(
        files,
        "full",
        label,
        workspace,
        state_dir,
        include_prefixes=include_prefixes,
        exclude_prefixes=exclude_prefixes,
    )
    archive_name = f"openclaw-backup-{backup_state.safe_label(label)}-full-{stamp}.tar.gz"
    archive_path = rollback_dir / archive_name
    with tempfile.TemporaryDirectory(prefix="openclaw-rollback-") as td:
        tmpdir = Path(td)
        meta_dir = tmpdir / "meta"
        meta_dir.mkdir(parents=True, exist_ok=True)
        (meta_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
        with tarfile.open(archive_path, "w:gz") as tar:
            tar.add(meta_dir / "manifest.json", arcname="meta/manifest.json")
            for f in files:
                tar.add(f.source, arcname=f.archive_path, recursive=False)
    return str(archive_path)


def build_plan(
    verified_files: list[dict[str, Any]],
    tmpdir: Path,
    workspace: Path,
    state_dir: Path,
    include_prefixes: list[str] | None = None,
    exclude_prefixes: list[str] | None = None,
) -> dict[str, Any]:
    plan_items: list[dict[str, Any]] = []
    counts = {"create": 0, "update": 0, "unchanged": 0}
    for item in verified_files:
        dst = archive_to_dest(item["path"], workspace, state_dir)
        src_sha = item["sha256"]
        exists = dst.exists()
        dst_sha = sha256_file(dst) if exists and dst.is_file() else None
        if not exists:
            action = "create"
        elif dst_sha == src_sha:
            action = "unchanged"
        else:
            action = "update"
        counts[action] += 1
        plan_items.append({
            "archivePath": item["path"],
            "targetPath": str(dst),
            "action": action,
            "sourceSha256": src_sha,
            "targetSha256": dst_sha,
            "size": item.get("size"),
            "kind": item.get("kind"),
        })

    target_paths = {entry["targetPath"] for entry in plan_items}
    missing_from_archive: list[str] = []
    current_files = backup_state.collect_files(
        workspace,
        state_dir,
        manifest_mode_guess(verified_files),
        include_prefixes=include_prefixes,
        exclude_prefixes=exclude_prefixes,
    )
    for f in current_files:
        if str(f.source.resolve()) not in target_paths:
            missing_from_archive.append(str(f.source.resolve()))

    return {
        "summary": {
            "create": counts["create"],
            "update": counts["update"],
            "unchanged": counts["unchanged"],
            "missingFromArchive": len(missing_from_archive),
            "total": len(plan_items),
        },
        "changes": plan_items,
        "missingFromArchive": missing_from_archive,
    }


def manifest_mode_guess(verified_files: list[dict[str, Any]]) -> str:
    if any(str(item.get("path", "")).startswith("static/") for item in verified_files):
        return "full"
    return "mutable"


def main() -> int:
    ap = argparse.ArgumentParser(description="Restore versioned OpenClaw state backup archive")
    ap.add_argument("--archive", required=True)
    ap.add_argument("--workspace", required=True)
    ap.add_argument("--state-dir", required=True)
    ap.add_argument("--verify-only", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--allow-version-mismatch", action="store_true")
    ap.add_argument("--rollback-dir")
    ap.add_argument("--report-dir")
    ap.add_argument("--include-prefix", action="append", default=[])
    ap.add_argument("--exclude-prefix", action="append", default=[])
    args = ap.parse_args()

    archive = Path(args.archive).expanduser().resolve()
    workspace = Path(args.workspace).expanduser().resolve()
    state_dir = Path(args.state_dir).expanduser().resolve()
    rollback_dir = Path(args.rollback_dir).expanduser().resolve() if args.rollback_dir else archive.parent
    report_dir = Path(args.report_dir).expanduser().resolve() if args.report_dir else archive.parent
    include_prefixes = [backup_state.normalize_prefix(p) for p in args.include_prefix if backup_state.normalize_prefix(p)]
    exclude_prefixes = [backup_state.normalize_prefix(p) for p in args.exclude_prefix if backup_state.normalize_prefix(p)]

    if not archive.exists():
        raise SystemExit(f"Archive not found: {archive}")

    with tempfile.TemporaryDirectory(prefix="openclaw-restore-") as td:
        tmpdir = Path(td)
        with tarfile.open(archive, "r:gz") as tar:
            tar.extractall(tmpdir)

        manifest = load_manifest(tmpdir)
        warnings = check_compat(manifest, args.allow_version_mismatch)
        verified_files = verify_manifest(tmpdir, manifest)
        if include_prefixes or exclude_prefixes:
            filtered = []
            for item in verified_files:
                rel = "/".join(Path(item["path"]).parts[2:])
                if backup_state.should_include_relative(rel, include_prefixes, exclude_prefixes):
                    filtered.append(item)
            verified_files = filtered
        plan = build_plan(verified_files, tmpdir, workspace, state_dir, include_prefixes, exclude_prefixes)

        pre_report = {
            "ok": True,
            "verified": True,
            "dryRun": bool(args.dry_run),
            "archive": str(archive),
            "files": len(verified_files),
            "warnings": warnings,
            "filters": {
                "includePrefixes": include_prefixes,
                "excludePrefixes": exclude_prefixes,
            },
            "plan": plan,
        }

        if args.verify_only or args.dry_run:
            report_path = write_report(report_dir, pre_report)
            pre_report["reportPath"] = report_path
            print(json.dumps(pre_report, ensure_ascii=False, indent=2))
            return 0

        rollback_archive = make_rollback(workspace, state_dir, rollback_dir, include_prefixes, exclude_prefixes)
        restored_paths = []
        for item in verified_files:
            src = tmpdir / item["path"]
            dst = archive_to_dest(item["path"], workspace, state_dir)
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)
            restored_paths.append(str(dst))

        final_report = {
            "ok": True,
            "archive": str(archive),
            "rollbackArchive": rollback_archive,
            "restored": len(restored_paths),
            "warnings": warnings,
            "filters": {
                "includePrefixes": include_prefixes,
                "excludePrefixes": exclude_prefixes,
            },
            "plan": plan,
        }
        report_path = write_report(report_dir, final_report)
        final_report["reportPath"] = report_path
        print(json.dumps(final_report, ensure_ascii=False, indent=2))
        return 0


if __name__ == "__main__":
    raise SystemExit(main())


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