diff --git a/platform/docs/BACKUP_AND_RECOVERY.md b/platform/docs/BACKUP_AND_RECOVERY.md index 752793f..548eaec 100644 --- a/platform/docs/BACKUP_AND_RECOVERY.md +++ b/platform/docs/BACKUP_AND_RECOVERY.md @@ -7,6 +7,11 @@ - harness results you want to keep - compose state for Postgres / LiteLLM / Qdrant if you need continuity +`clawops recovery backup-create` now writes archives under the StrongClaw state +root (for example `~/.local/state/strongclaw/backups` on Linux) instead of +`~/.openclaw/backups`, which prevents backup self-inclusion during fallback +archive traversal. + ## Included commands - `clawops recovery backup-create` @@ -21,16 +26,31 @@ OpenClaw CLI path (`openclaw-cli`) or the local tar fallback path ## Scheduled maintenance -StrongClaw host service activation now installs a daily maintenance schedule at `04:00` local time: +StrongClaw host service activation now installs independent daily jobs: + +- backup create at `03:00` local time +- backup verify at `03:30` local time +- prune retention at `04:00` local time + +systemd units/timers: + +- `openclaw-backup-create.timer` -> `openclaw-backup-create.service` +- `openclaw-backup-verify.timer` -> `openclaw-backup-verify.service` +- `openclaw-maintenance.timer` -> `openclaw-maintenance.service` + +launchd agents: -- systemd: `openclaw-maintenance.timer` -> `openclaw-maintenance.service` -- launchd: `ai.openclaw.maintenance` +- `ai.openclaw.backup-create` +- `ai.openclaw.backup-verify` +- `ai.openclaw.maintenance` -The scheduled command is: +Commands: +- `clawops recovery --home-dir backup-create` +- `clawops recovery --home-dir backup-verify latest` - `clawops recovery --home-dir prune-retention` -This maintenance path is idempotent and retention-only. It prunes expired +The prune path is idempotent and retention-only. It prunes expired StrongClaw-owned backup and log artifacts and does not mutate upstream OpenClaw internals or shared `/tmp/openclaw` state by default. diff --git a/platform/launchd/ai.openclaw.backup-create.plist.template b/platform/launchd/ai.openclaw.backup-create.plist.template new file mode 100644 index 0000000..37e74eb --- /dev/null +++ b/platform/launchd/ai.openclaw.backup-create.plist.template @@ -0,0 +1,51 @@ + + + + + Label + ai.openclaw.backup-create + ProgramArguments + + /bin/sh + -lc + set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-create + + WorkingDirectory + __REPO_ROOT__ + EnvironmentVariables + + HOME + __HOME_DIR__ + XDG_CONFIG_HOME + __HOME_DIR__/.config + OPENCLAW_HOME + __OPENCLAW_HOME__ + OPENCLAW_STATE_DIR + __STATE_DIR__ + OPENCLAW_CONFIG_PATH + __OPENCLAW_CONFIG_PATH__ + OPENCLAW_CONFIG + __OPENCLAW_CONFIG__ + OPENCLAW_PROFILE + __OPENCLAW_PROFILE__ + STRONGCLAW_RUNTIME_ROOT + __STRONGCLAW_RUNTIME_ROOT__ + PATH + __HOME_DIR__/.config/varlock/bin:__HOME_DIR__/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +__LAUNCHD_EXTRA_ENV__ + + StartCalendarInterval + + Hour + 3 + Minute + 0 + + StandardOutPath + __STATE_DIR__/logs/launchd-backup-create.out.log + StandardErrorPath + __STATE_DIR__/logs/launchd-backup-create.err.log + KeepAlive + + + diff --git a/platform/launchd/ai.openclaw.backup-verify.plist.template b/platform/launchd/ai.openclaw.backup-verify.plist.template new file mode 100644 index 0000000..cefa07a --- /dev/null +++ b/platform/launchd/ai.openclaw.backup-verify.plist.template @@ -0,0 +1,51 @@ + + + + + Label + ai.openclaw.backup-verify + ProgramArguments + + /bin/sh + -lc + set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-verify latest + + WorkingDirectory + __REPO_ROOT__ + EnvironmentVariables + + HOME + __HOME_DIR__ + XDG_CONFIG_HOME + __HOME_DIR__/.config + OPENCLAW_HOME + __OPENCLAW_HOME__ + OPENCLAW_STATE_DIR + __STATE_DIR__ + OPENCLAW_CONFIG_PATH + __OPENCLAW_CONFIG_PATH__ + OPENCLAW_CONFIG + __OPENCLAW_CONFIG__ + OPENCLAW_PROFILE + __OPENCLAW_PROFILE__ + STRONGCLAW_RUNTIME_ROOT + __STRONGCLAW_RUNTIME_ROOT__ + PATH + __HOME_DIR__/.config/varlock/bin:__HOME_DIR__/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +__LAUNCHD_EXTRA_ENV__ + + StartCalendarInterval + + Hour + 3 + Minute + 30 + + StandardOutPath + __STATE_DIR__/logs/launchd-backup-verify.out.log + StandardErrorPath + __STATE_DIR__/logs/launchd-backup-verify.err.log + KeepAlive + + + diff --git a/platform/launchd/ai.openclaw.maintenance.plist.template b/platform/launchd/ai.openclaw.maintenance.plist.template index 5e7e68b..782304b 100644 --- a/platform/launchd/ai.openclaw.maintenance.plist.template +++ b/platform/launchd/ai.openclaw.maintenance.plist.template @@ -8,7 +8,7 @@ /bin/sh -lc - set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-create; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-verify latest; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" prune-retention + set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" prune-retention WorkingDirectory __REPO_ROOT__ diff --git a/platform/systemd/openclaw-backup-create.service b/platform/systemd/openclaw-backup-create.service new file mode 100644 index 0000000..a79082c --- /dev/null +++ b/platform/systemd/openclaw-backup-create.service @@ -0,0 +1,16 @@ +[Unit] +Description=StrongClaw backup creation task +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=__REPO_ROOT__ +Environment=OPENCLAW_STATE_DIR=__STATE_DIR__ +Environment=OPENCLAW_HOME=__OPENCLAW_HOME__ +Environment=OPENCLAW_CONFIG_PATH=__OPENCLAW_CONFIG_PATH__ +Environment=OPENCLAW_CONFIG=__OPENCLAW_CONFIG__ +Environment=OPENCLAW_PROFILE=__OPENCLAW_PROFILE__ +Environment=STRONGCLAW_RUNTIME_ROOT=__STRONGCLAW_RUNTIME_ROOT__ +Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-create diff --git a/platform/systemd/openclaw-backup-create.timer b/platform/systemd/openclaw-backup-create.timer new file mode 100644 index 0000000..db55c83 --- /dev/null +++ b/platform/systemd/openclaw-backup-create.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run StrongClaw backup creation daily + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true +Unit=openclaw-backup-create.service + +[Install] +WantedBy=timers.target diff --git a/platform/systemd/openclaw-backup-verify.service b/platform/systemd/openclaw-backup-verify.service new file mode 100644 index 0000000..24f94e6 --- /dev/null +++ b/platform/systemd/openclaw-backup-verify.service @@ -0,0 +1,16 @@ +[Unit] +Description=StrongClaw backup verification task +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=__REPO_ROOT__ +Environment=OPENCLAW_STATE_DIR=__STATE_DIR__ +Environment=OPENCLAW_HOME=__OPENCLAW_HOME__ +Environment=OPENCLAW_CONFIG_PATH=__OPENCLAW_CONFIG_PATH__ +Environment=OPENCLAW_CONFIG=__OPENCLAW_CONFIG__ +Environment=OPENCLAW_PROFILE=__OPENCLAW_PROFILE__ +Environment=STRONGCLAW_RUNTIME_ROOT=__STRONGCLAW_RUNTIME_ROOT__ +Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-verify latest diff --git a/platform/systemd/openclaw-backup-verify.timer b/platform/systemd/openclaw-backup-verify.timer new file mode 100644 index 0000000..d7466a5 --- /dev/null +++ b/platform/systemd/openclaw-backup-verify.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run StrongClaw backup verification daily + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +Unit=openclaw-backup-verify.service + +[Install] +WantedBy=timers.target diff --git a/platform/systemd/openclaw-maintenance.service b/platform/systemd/openclaw-maintenance.service index bbc700b..3319e9b 100644 --- a/platform/systemd/openclaw-maintenance.service +++ b/platform/systemd/openclaw-maintenance.service @@ -13,6 +13,4 @@ Environment=OPENCLAW_CONFIG=__OPENCLAW_CONFIG__ Environment=OPENCLAW_PROFILE=__OPENCLAW_PROFILE__ Environment=STRONGCLAW_RUNTIME_ROOT=__STRONGCLAW_RUNTIME_ROOT__ Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin -ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-create -ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-verify latest ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ prune-retention diff --git a/src/clawops/assets/platform/docs/BACKUP_AND_RECOVERY.md b/src/clawops/assets/platform/docs/BACKUP_AND_RECOVERY.md index 752793f..548eaec 100644 --- a/src/clawops/assets/platform/docs/BACKUP_AND_RECOVERY.md +++ b/src/clawops/assets/platform/docs/BACKUP_AND_RECOVERY.md @@ -7,6 +7,11 @@ - harness results you want to keep - compose state for Postgres / LiteLLM / Qdrant if you need continuity +`clawops recovery backup-create` now writes archives under the StrongClaw state +root (for example `~/.local/state/strongclaw/backups` on Linux) instead of +`~/.openclaw/backups`, which prevents backup self-inclusion during fallback +archive traversal. + ## Included commands - `clawops recovery backup-create` @@ -21,16 +26,31 @@ OpenClaw CLI path (`openclaw-cli`) or the local tar fallback path ## Scheduled maintenance -StrongClaw host service activation now installs a daily maintenance schedule at `04:00` local time: +StrongClaw host service activation now installs independent daily jobs: + +- backup create at `03:00` local time +- backup verify at `03:30` local time +- prune retention at `04:00` local time + +systemd units/timers: + +- `openclaw-backup-create.timer` -> `openclaw-backup-create.service` +- `openclaw-backup-verify.timer` -> `openclaw-backup-verify.service` +- `openclaw-maintenance.timer` -> `openclaw-maintenance.service` + +launchd agents: -- systemd: `openclaw-maintenance.timer` -> `openclaw-maintenance.service` -- launchd: `ai.openclaw.maintenance` +- `ai.openclaw.backup-create` +- `ai.openclaw.backup-verify` +- `ai.openclaw.maintenance` -The scheduled command is: +Commands: +- `clawops recovery --home-dir backup-create` +- `clawops recovery --home-dir backup-verify latest` - `clawops recovery --home-dir prune-retention` -This maintenance path is idempotent and retention-only. It prunes expired +The prune path is idempotent and retention-only. It prunes expired StrongClaw-owned backup and log artifacts and does not mutate upstream OpenClaw internals or shared `/tmp/openclaw` state by default. diff --git a/src/clawops/assets/platform/launchd/ai.openclaw.backup-create.plist.template b/src/clawops/assets/platform/launchd/ai.openclaw.backup-create.plist.template new file mode 100644 index 0000000..37e74eb --- /dev/null +++ b/src/clawops/assets/platform/launchd/ai.openclaw.backup-create.plist.template @@ -0,0 +1,51 @@ + + + + + Label + ai.openclaw.backup-create + ProgramArguments + + /bin/sh + -lc + set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-create + + WorkingDirectory + __REPO_ROOT__ + EnvironmentVariables + + HOME + __HOME_DIR__ + XDG_CONFIG_HOME + __HOME_DIR__/.config + OPENCLAW_HOME + __OPENCLAW_HOME__ + OPENCLAW_STATE_DIR + __STATE_DIR__ + OPENCLAW_CONFIG_PATH + __OPENCLAW_CONFIG_PATH__ + OPENCLAW_CONFIG + __OPENCLAW_CONFIG__ + OPENCLAW_PROFILE + __OPENCLAW_PROFILE__ + STRONGCLAW_RUNTIME_ROOT + __STRONGCLAW_RUNTIME_ROOT__ + PATH + __HOME_DIR__/.config/varlock/bin:__HOME_DIR__/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +__LAUNCHD_EXTRA_ENV__ + + StartCalendarInterval + + Hour + 3 + Minute + 0 + + StandardOutPath + __STATE_DIR__/logs/launchd-backup-create.out.log + StandardErrorPath + __STATE_DIR__/logs/launchd-backup-create.err.log + KeepAlive + + + diff --git a/src/clawops/assets/platform/launchd/ai.openclaw.backup-verify.plist.template b/src/clawops/assets/platform/launchd/ai.openclaw.backup-verify.plist.template new file mode 100644 index 0000000..cefa07a --- /dev/null +++ b/src/clawops/assets/platform/launchd/ai.openclaw.backup-verify.plist.template @@ -0,0 +1,51 @@ + + + + + Label + ai.openclaw.backup-verify + ProgramArguments + + /bin/sh + -lc + set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-verify latest + + WorkingDirectory + __REPO_ROOT__ + EnvironmentVariables + + HOME + __HOME_DIR__ + XDG_CONFIG_HOME + __HOME_DIR__/.config + OPENCLAW_HOME + __OPENCLAW_HOME__ + OPENCLAW_STATE_DIR + __STATE_DIR__ + OPENCLAW_CONFIG_PATH + __OPENCLAW_CONFIG_PATH__ + OPENCLAW_CONFIG + __OPENCLAW_CONFIG__ + OPENCLAW_PROFILE + __OPENCLAW_PROFILE__ + STRONGCLAW_RUNTIME_ROOT + __STRONGCLAW_RUNTIME_ROOT__ + PATH + __HOME_DIR__/.config/varlock/bin:__HOME_DIR__/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +__LAUNCHD_EXTRA_ENV__ + + StartCalendarInterval + + Hour + 3 + Minute + 30 + + StandardOutPath + __STATE_DIR__/logs/launchd-backup-verify.out.log + StandardErrorPath + __STATE_DIR__/logs/launchd-backup-verify.err.log + KeepAlive + + + diff --git a/src/clawops/assets/platform/launchd/ai.openclaw.maintenance.plist.template b/src/clawops/assets/platform/launchd/ai.openclaw.maintenance.plist.template index 5e7e68b..782304b 100644 --- a/src/clawops/assets/platform/launchd/ai.openclaw.maintenance.plist.template +++ b/src/clawops/assets/platform/launchd/ai.openclaw.maintenance.plist.template @@ -8,7 +8,7 @@ /bin/sh -lc - set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-create; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" backup-verify latest; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" prune-retention + set -euo pipefail; "__PYTHON_EXECUTABLE__" -m clawops recovery --home-dir "__HOME_DIR__" prune-retention WorkingDirectory __REPO_ROOT__ diff --git a/src/clawops/assets/platform/systemd/openclaw-backup-create.service b/src/clawops/assets/platform/systemd/openclaw-backup-create.service new file mode 100644 index 0000000..a79082c --- /dev/null +++ b/src/clawops/assets/platform/systemd/openclaw-backup-create.service @@ -0,0 +1,16 @@ +[Unit] +Description=StrongClaw backup creation task +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=__REPO_ROOT__ +Environment=OPENCLAW_STATE_DIR=__STATE_DIR__ +Environment=OPENCLAW_HOME=__OPENCLAW_HOME__ +Environment=OPENCLAW_CONFIG_PATH=__OPENCLAW_CONFIG_PATH__ +Environment=OPENCLAW_CONFIG=__OPENCLAW_CONFIG__ +Environment=OPENCLAW_PROFILE=__OPENCLAW_PROFILE__ +Environment=STRONGCLAW_RUNTIME_ROOT=__STRONGCLAW_RUNTIME_ROOT__ +Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-create diff --git a/src/clawops/assets/platform/systemd/openclaw-backup-create.timer b/src/clawops/assets/platform/systemd/openclaw-backup-create.timer new file mode 100644 index 0000000..db55c83 --- /dev/null +++ b/src/clawops/assets/platform/systemd/openclaw-backup-create.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run StrongClaw backup creation daily + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true +Unit=openclaw-backup-create.service + +[Install] +WantedBy=timers.target diff --git a/src/clawops/assets/platform/systemd/openclaw-backup-verify.service b/src/clawops/assets/platform/systemd/openclaw-backup-verify.service new file mode 100644 index 0000000..24f94e6 --- /dev/null +++ b/src/clawops/assets/platform/systemd/openclaw-backup-verify.service @@ -0,0 +1,16 @@ +[Unit] +Description=StrongClaw backup verification task +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=__REPO_ROOT__ +Environment=OPENCLAW_STATE_DIR=__STATE_DIR__ +Environment=OPENCLAW_HOME=__OPENCLAW_HOME__ +Environment=OPENCLAW_CONFIG_PATH=__OPENCLAW_CONFIG_PATH__ +Environment=OPENCLAW_CONFIG=__OPENCLAW_CONFIG__ +Environment=OPENCLAW_PROFILE=__OPENCLAW_PROFILE__ +Environment=STRONGCLAW_RUNTIME_ROOT=__STRONGCLAW_RUNTIME_ROOT__ +Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-verify latest diff --git a/src/clawops/assets/platform/systemd/openclaw-backup-verify.timer b/src/clawops/assets/platform/systemd/openclaw-backup-verify.timer new file mode 100644 index 0000000..d7466a5 --- /dev/null +++ b/src/clawops/assets/platform/systemd/openclaw-backup-verify.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run StrongClaw backup verification daily + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +Unit=openclaw-backup-verify.service + +[Install] +WantedBy=timers.target diff --git a/src/clawops/assets/platform/systemd/openclaw-maintenance.service b/src/clawops/assets/platform/systemd/openclaw-maintenance.service index bbc700b..3319e9b 100644 --- a/src/clawops/assets/platform/systemd/openclaw-maintenance.service +++ b/src/clawops/assets/platform/systemd/openclaw-maintenance.service @@ -13,6 +13,4 @@ Environment=OPENCLAW_CONFIG=__OPENCLAW_CONFIG__ Environment=OPENCLAW_PROFILE=__OPENCLAW_PROFILE__ Environment=STRONGCLAW_RUNTIME_ROOT=__STRONGCLAW_RUNTIME_ROOT__ Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin -ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-create -ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ backup-verify latest ExecStart=__PYTHON_EXECUTABLE__ -m clawops recovery --home-dir __HOME_DIR__ prune-retention diff --git a/src/clawops/strongclaw_recovery.py b/src/clawops/strongclaw_recovery.py index fe4152b..e6f5400 100644 --- a/src/clawops/strongclaw_recovery.py +++ b/src/clawops/strongclaw_recovery.py @@ -10,6 +10,7 @@ import tarfile import time +from clawops.app_paths import strongclaw_state_dir from clawops.cli_roots import add_ignored_repo_root_alias, warn_ignored_repo_root_argument from clawops.strongclaw_runtime import ( CommandError, @@ -29,8 +30,9 @@ class BackupCreateResult: def backups_dir(*, home_dir: pathlib.Path | None = None) -> pathlib.Path: - """Return the backup archive directory.""" - return resolve_home_dir(home_dir) / ".openclaw" / "backups" + """Return the StrongClaw-managed backup archive directory.""" + resolved_home = resolve_home_dir(home_dir) + return strongclaw_state_dir(home_dir=resolved_home) / "backups" def openclaw_state_dir(*, home_dir: pathlib.Path | None = None) -> pathlib.Path: @@ -38,6 +40,11 @@ def openclaw_state_dir(*, home_dir: pathlib.Path | None = None) -> pathlib.Path: return resolve_home_dir(home_dir) / ".openclaw" +def legacy_backups_dir(*, home_dir: pathlib.Path | None = None) -> pathlib.Path: + """Return the legacy OpenClaw-local backup archive directory.""" + return openclaw_state_dir(home_dir=home_dir) / "backups" + + def latest_backup_path(*, home_dir: pathlib.Path | None = None) -> pathlib.Path: """Return the newest backup archive.""" archive_candidates = sorted(backups_dir(home_dir=home_dir).glob("*.tar.gz")) @@ -46,25 +53,78 @@ def latest_backup_path(*, home_dir: pathlib.Path | None = None) -> pathlib.Path: return max(archive_candidates, key=lambda candidate: candidate.stat().st_mtime) +def _is_path_within(path: pathlib.Path, root: pathlib.Path) -> bool: + """Return whether *path* is contained by *root* after resolution.""" + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _safe_unlink(path: pathlib.Path) -> None: + """Remove one file when present.""" + try: + path.unlink() + except FileNotFoundError: + return + + +def _write_tar_archive( + archive_path: pathlib.Path, + *, + state_dir: pathlib.Path, + include_root: pathlib.Path, + exclude_roots: tuple[pathlib.Path, ...], +) -> None: + """Write a fallback tar archive with explicit path exclusions.""" + with tarfile.open(archive_path, "w:gz") as archive: + for path in state_dir.rglob("*"): + if path.is_symlink(): + continue + if not (path.is_file() or path.is_dir()): + continue + resolved_path = path.resolve() + if any(_is_path_within(resolved_path, root) for root in exclude_roots): + continue + archive.add(path, arcname=path.relative_to(include_root), recursive=False) + + def _create_backup_result(*, home_dir: pathlib.Path | None = None) -> BackupCreateResult: """Create one backup archive and record which backup path was used.""" + resolved_home_dir = resolve_home_dir(home_dir) archive_root = backups_dir(home_dir=home_dir) archive_root.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime()) archive_path = archive_root / f"openclaw-{stamp}.tar.gz" + archive_tmp_path = archive_root / f".{archive_path.name}.tmp" + exclude_roots: tuple[pathlib.Path, ...] = ( + archive_root.resolve(), + legacy_backups_dir(home_dir=home_dir).resolve(), + ) if shutil.which("openclaw") is not None: + _safe_unlink(archive_tmp_path) result = run_command( - ["openclaw", "backup", "create", str(archive_path)], timeout_seconds=600 + ["openclaw", "backup", "create", str(archive_tmp_path)], timeout_seconds=600 ) if result.ok: + archive_tmp_path.replace(archive_path) return BackupCreateResult(archive_path=archive_path, mode="openclaw-cli") + _safe_unlink(archive_tmp_path) state_dir = openclaw_state_dir(home_dir=home_dir) - archive_name = archive_path.name - with tarfile.open(archive_path, "w:gz") as archive: - for path in state_dir.rglob("*"): - if archive_name in path.as_posix(): - continue - archive.add(path, arcname=path.relative_to(resolve_home_dir(home_dir))) + _safe_unlink(archive_tmp_path) + try: + _write_tar_archive( + archive_tmp_path, + state_dir=state_dir, + include_root=resolved_home_dir, + exclude_roots=exclude_roots, + ) + archive_tmp_path.replace(archive_path) + except (OSError, tarfile.TarError) as exc: + _safe_unlink(archive_tmp_path) + _safe_unlink(archive_path) + raise CommandError(f"backup creation failed: {exc}") from exc return BackupCreateResult(archive_path=archive_path, mode="tar-fallback") diff --git a/src/clawops/strongclaw_services.py b/src/clawops/strongclaw_services.py index 4e5ba55..c9b6b66 100644 --- a/src/clawops/strongclaw_services.py +++ b/src/clawops/strongclaw_services.py @@ -27,10 +27,14 @@ LAUNCHD_ACTIVATE_LABELS: Final[tuple[str, ...]] = ( "ai.openclaw.sidecars", "ai.openclaw.gateway", + "ai.openclaw.backup-create", + "ai.openclaw.backup-verify", "ai.openclaw.maintenance", ) LAUNCHD_GATEWAY_LABEL: Final[str] = "ai.openclaw.gateway" LAUNCHD_SIDECARS_LABEL: Final[str] = "ai.openclaw.sidecars" +LAUNCHD_BACKUP_CREATE_LABEL: Final[str] = "ai.openclaw.backup-create" +LAUNCHD_BACKUP_VERIFY_LABEL: Final[str] = "ai.openclaw.backup-verify" LAUNCHD_MAINTENANCE_LABEL: Final[str] = "ai.openclaw.maintenance" LAUNCHD_GATEWAY_TIMEOUT_ENV_VAR: Final[str] = "STRONGCLAW_LAUNCHD_GATEWAY_TIMEOUT_SECONDS" LAUNCHD_SIDECARS_TIMEOUT_ENV_VAR: Final[str] = "STRONGCLAW_LAUNCHD_SIDECARS_TIMEOUT_SECONDS" @@ -47,6 +51,8 @@ SYSTEMD_ACTIVATE_UNITS: Final[tuple[str, ...]] = ( "openclaw-sidecars.service", "openclaw-gateway.service", + "openclaw-backup-create.timer", + "openclaw-backup-verify.timer", "openclaw-maintenance.timer", ) @@ -294,6 +300,8 @@ def activate_services( ) gateway_plist = output_dir / f"{LAUNCHD_GATEWAY_LABEL}.plist" sidecars_plist = output_dir / f"{LAUNCHD_SIDECARS_LABEL}.plist" + backup_create_plist = output_dir / f"{LAUNCHD_BACKUP_CREATE_LABEL}.plist" + backup_verify_plist = output_dir / f"{LAUNCHD_BACKUP_VERIFY_LABEL}.plist" maintenance_plist = output_dir / f"{LAUNCHD_MAINTENANCE_LABEL}.plist" emit_structured_log( "clawops.services.activate", @@ -323,6 +331,32 @@ def activate_services( persistent=True, timeout_seconds=gateway_timeout_seconds, ) + emit_structured_log( + "clawops.services.activate", + { + "service_manager": "launchd", + "step": "backup_create_bootstrap", + "label": LAUNCHD_BACKUP_CREATE_LABEL, + }, + ) + _activate_launchd_service( + domain, + LAUNCHD_BACKUP_CREATE_LABEL, + backup_create_plist, + ) + emit_structured_log( + "clawops.services.activate", + { + "service_manager": "launchd", + "step": "backup_verify_bootstrap", + "label": LAUNCHD_BACKUP_VERIFY_LABEL, + }, + ) + _activate_launchd_service( + domain, + LAUNCHD_BACKUP_VERIFY_LABEL, + backup_verify_plist, + ) emit_structured_log( "clawops.services.activate", { diff --git a/tests/suites/contracts/repo/test_scripts_migration_surfaces.py b/tests/suites/contracts/repo/test_scripts_migration_surfaces.py index 5cb3cef..dce0705 100644 --- a/tests/suites/contracts/repo/test_scripts_migration_surfaces.py +++ b/tests/suites/contracts/repo/test_scripts_migration_surfaces.py @@ -55,9 +55,21 @@ def test_service_templates_call_repo_venv_python() -> None: maintenance_service = (REPO_ROOT / "platform/systemd/openclaw-maintenance.service").read_text( encoding="utf-8" ) + backup_create_service = ( + REPO_ROOT / "platform/systemd/openclaw-backup-create.service" + ).read_text(encoding="utf-8") + backup_verify_service = ( + REPO_ROOT / "platform/systemd/openclaw-backup-verify.service" + ).read_text(encoding="utf-8") maintenance_timer = (REPO_ROOT / "platform/systemd/openclaw-maintenance.timer").read_text( encoding="utf-8" ) + backup_create_timer = (REPO_ROOT / "platform/systemd/openclaw-backup-create.timer").read_text( + encoding="utf-8" + ) + backup_verify_timer = (REPO_ROOT / "platform/systemd/openclaw-backup-verify.timer").read_text( + encoding="utf-8" + ) sidecars = (REPO_ROOT / "platform/systemd/openclaw-sidecars.service").read_text( encoding="utf-8" ) @@ -73,20 +85,34 @@ def test_service_templates_call_repo_venv_python() -> None: launchd_maintenance = ( REPO_ROOT / "platform/launchd/ai.openclaw.maintenance.plist.template" ).read_text(encoding="utf-8") + launchd_backup_create = ( + REPO_ROOT / "platform/launchd/ai.openclaw.backup-create.plist.template" + ).read_text(encoding="utf-8") + launchd_backup_verify = ( + REPO_ROOT / "platform/launchd/ai.openclaw.backup-verify.plist.template" + ).read_text(encoding="utf-8") assert "scripts/ops/" not in gateway assert "scripts/ops/" not in sidecars assert "__PYTHON_EXECUTABLE__ -m clawops" in gateway assert "__PYTHON_EXECUTABLE__ -m clawops" in browserlab assert "__PYTHON_EXECUTABLE__ -m clawops" in maintenance_service + assert "__PYTHON_EXECUTABLE__ -m clawops" in backup_create_service + assert "__PYTHON_EXECUTABLE__ -m clawops" in backup_verify_service assert "__PYTHON_EXECUTABLE__ -m clawops" in sidecars assert "openclaw-sidecars.service" in gateway assert "Unit=openclaw-maintenance.service" in maintenance_timer + assert "Unit=openclaw-backup-create.service" in backup_create_timer + assert "Unit=openclaw-backup-verify.service" in backup_verify_timer assert "__PYTHON_EXECUTABLE__" in launchd_gateway assert "__PYTHON_EXECUTABLE__" in launchd_maintenance - assert "backup-create" in launchd_maintenance - assert "backup-verify latest" in launchd_maintenance + assert "__PYTHON_EXECUTABLE__" in launchd_backup_create + assert "__PYTHON_EXECUTABLE__" in launchd_backup_verify + assert "backup-create" not in launchd_maintenance + assert "backup-verify latest" not in launchd_maintenance assert "prune-retention" in launchd_maintenance + assert "backup-create" in launchd_backup_create + assert "backup-verify latest" in launchd_backup_verify assert ( "Environment=PATH=%h/.config/varlock/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" in gateway diff --git a/tests/suites/unit/clawops/test_strongclaw_recovery.py b/tests/suites/unit/clawops/test_strongclaw_recovery.py index 540bdca..21c97e3 100644 --- a/tests/suites/unit/clawops/test_strongclaw_recovery.py +++ b/tests/suites/unit/clawops/test_strongclaw_recovery.py @@ -12,6 +12,7 @@ import pytest from clawops import strongclaw_recovery +from clawops.app_paths import strongclaw_state_dir from clawops.strongclaw_runtime import CommandError, ExecResult from tests.plugins.infrastructure.context import TestContext @@ -79,6 +80,63 @@ def test_backup_create_cli_reports_tar_fallback_and_round_trips( ) == "ready\n" +def test_backup_root_defaults_to_strongclaw_state_dir(tmp_path: Path) -> None: + """Backups should default to the StrongClaw-owned state tree.""" + home_dir = tmp_path / "home" + expected_root = strongclaw_state_dir(home_dir=home_dir) / "backups" + backup_root = strongclaw_recovery.backups_dir(home_dir=home_dir) + + assert backup_root == expected_root + assert not backup_root.is_relative_to(strongclaw_recovery.openclaw_state_dir(home_dir=home_dir)) + + +def test_backup_create_fallback_excludes_legacy_openclaw_backup_root( + tmp_path: Path, + test_context: TestContext, +) -> None: + """Fallback tar backups should never include legacy `~/.openclaw/backups` entries.""" + home_dir = tmp_path / "home" + state_dir = _init_openclaw_home(home_dir) + legacy_archive = state_dir / "backups" / "old.tar.gz" + legacy_archive.parent.mkdir(parents=True, exist_ok=True) + legacy_archive.write_text("legacy\n", encoding="utf-8") + test_context.patch.patch_object(strongclaw_recovery.shutil, "which", new=_missing_tool) + + archive_path = strongclaw_recovery.create_backup(home_dir=home_dir) + with tarfile.open(archive_path, "r:gz") as archive: + member_names = [member.name for member in archive.getmembers()] + + assert all(not name.startswith(".openclaw/backups") for name in member_names) + + +def test_backup_create_failure_cleans_partial_archive( + tmp_path: Path, + test_context: TestContext, +) -> None: + """Backup creation failures should remove temporary and partial archive files.""" + home_dir = tmp_path / "home" + _init_openclaw_home(home_dir) + test_context.patch.patch_object(strongclaw_recovery.shutil, "which", new=_missing_tool) + + def _failing_write_tar_archive(*args: object, **kwargs: object) -> None: + archive_path = cast(Path, args[0]) + archive_path.write_bytes(b"partial") + raise OSError("no space left on device") + + test_context.patch.patch_object( + strongclaw_recovery, + "_write_tar_archive", + new=_failing_write_tar_archive, + ) + + with pytest.raises(CommandError, match="backup creation failed"): + strongclaw_recovery.create_backup(home_dir=home_dir) + + backup_root = strongclaw_recovery.backups_dir(home_dir=home_dir) + remaining_files = list(backup_root.iterdir()) if backup_root.exists() else [] + assert remaining_files == [] + + def test_restore_backup_rejects_traversal_members( tmp_path: Path, test_context: TestContext, diff --git a/tests/suites/unit/clawops/test_strongclaw_services.py b/tests/suites/unit/clawops/test_strongclaw_services.py index 66fba8e..bbdb90f 100644 --- a/tests/suites/unit/clawops/test_strongclaw_services.py +++ b/tests/suites/unit/clawops/test_strongclaw_services.py @@ -377,6 +377,8 @@ def fake_wait(label: str, *, persistent: bool, timeout_seconds: int) -> None: ("wait", "ai.openclaw.sidecars:False"), ("activate", "gui/501:ai.openclaw.gateway"), ("wait", "ai.openclaw.gateway:True"), + ("activate", "gui/501:ai.openclaw.backup-create"), + ("activate", "gui/501:ai.openclaw.backup-verify"), ("activate", "gui/501:ai.openclaw.maintenance"), ] @@ -519,6 +521,8 @@ def _emit_structured_log(event: str, payload: object) -> None: ("systemctl", "--user", "daemon-reload"), ("systemctl", "--user", "enable", "--now", "openclaw-sidecars.service"), ("systemctl", "--user", "enable", "--now", "openclaw-gateway.service"), + ("systemctl", "--user", "enable", "--now", "openclaw-backup-create.timer"), + ("systemctl", "--user", "enable", "--now", "openclaw-backup-verify.timer"), ("systemctl", "--user", "enable", "--now", "openclaw-maintenance.timer"), ] assert observed_events[0] == ( @@ -528,6 +532,8 @@ def _emit_structured_log(event: str, payload: object) -> None: assert [event[1].get("unit") for event in observed_events[1:]] == [ "openclaw-sidecars.service", "openclaw-gateway.service", + "openclaw-backup-create.timer", + "openclaw-backup-verify.timer", "openclaw-maintenance.timer", ]