mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
feat(claw-migrate): harden OpenClaw import with plan-first apply, redaction, and pre-migration backup (#16911)
* feat(claw-migrate): harden OpenClaw import with plan-first apply, redaction, and pre-migration backup Adopts four design patterns from OpenClaw's reciprocal migrate-hermes importer so both migration paths have the same safety posture. - **Refuse-on-conflict apply.** 'hermes claw migrate' now refuses to execute when the plan has any conflict items, unless --overwrite is set. Previously the user could say 'yes, proceed' and end up with a silent partial migration that skipped every conflicting item. - **Engine-level secret redaction.** The report.json and summary.md written to disk (and --json stdout) run through a redactor that matches OpenClaw's key-name markers and value-shape patterns (sk-*, ghp_*, xox*-, AIza*, Bearer *). Prevents accidental API key leakage in bug reports and support channels. - **Pre-migration tarball snapshot.** Apply creates one timestamped restore-point archive of ~/.hermes/ at ~/.hermes/migration/pre-migration-backups/ before any mutation, excluding regenerable directories (sessions, logs, cache). Opt out with --no-backup. - **Blocked-by-earlier-conflict sequencing.** If a config.yaml write hits conflict/error mid-apply, subsequent config-mutating options are marked skipped with reason 'blocked by earlier apply conflict' rather than attempting partial writes. - **Structured warnings[] and next_steps[] on the report** — actionable guidance surfaces in both JSON output and summary.md. - **--json output mode** — emits the redacted report on stdout for CI. Also flips --preset full to NOT auto-enable --migrate-secrets. Users now have to opt in to secret import explicitly, mirroring OpenClaw's two-phase posture. Status/kind/action constants are defined (STATUS_MIGRATED etc) with values that match the existing strings the script emits, so the report schema is backward-compatible. ItemResult gains a 'sensitive' bool field that redaction and consumers can key off. Validation: 26 new unit tests + 1 updated test in tests/skills/ test_openclaw_migration_hardening.py and test_claw.py cover redaction (key markers, value patterns, recursion, on-disk), warnings/next_steps, blocked-by-earlier sequencing, --json mode, and the preset-flip. Manual E2E against a fake $HERMES_HOME with real-shaped secrets confirmed: (1) secrets never appear in stdout or on disk, (2) _cmd_migrate refuses apply when plan has conflicts, (3) --overwrite proceeds past the guard and the backup tarball is created, (4) --no-backup skips the archive. Related docs: website/docs/guides/migrate-from-openclaw.md and website/docs/reference/cli-commands.md updated to reflect the preset-flip and new --no-backup flag. * refactor(claw-migrate): reuse hermes backup system for pre-migration snapshot Drops the inline tarball in hermes_cli/claw.py in favor of hermes_cli.backup.create_pre_migration_backup(), which shares an implementation with create_pre_update_backup via a new _write_full_zip_backup helper. Benefits: - Consistent exclusion rules with hermes backup (_EXCLUDED_DIRS, _EXCLUDED_SUFFIXES, _EXCLUDED_NAMES — single source of truth). - SQLite safe-copy via _safe_copy_db (state.db restores cleanly). - Zip format restorable with 'hermes import <archive>'. - Lives under ~/.hermes/backups/pre-migration-*.zip alongside pre-update-*.zip — one place for all snapshot archives. - Auto-prune rotation with separate keep counters (pre-migration keeps 5, pre-update keeps 5, they don't touch each other's files). 7 new tests in tests/hermes_cli/test_backup.py lock the contract: directory location, shared exclusion rules, _validate_backup_zip acceptance (i.e. restorable with 'hermes import'), non-recursive into prior backups, rotation, missing-home handling, and the invariant that pre-migration rotation never touches pre-update backups. Help text and docs updated — the restore hint now says 'hermes import <name>' instead of 'tar -xzf <archive> -C ~/'. * chore(claw-migrate): use backup._format_size and drop duplicate output line Minor polish using another existing primitive from hermes_cli.backup: - Show backup archive size with _format_size (e.g. '(245 B)' or '(2.4 MB)') matching the format hermes backup already uses. - Drop the duplicate 'Pre-migration backup saved' line after Migration Results — the earlier 'Pre-migration backup: <path> (<size>)' line already surfaces the path before apply runs. --------- Co-authored-by: teknium1 <teknium@users.noreply.github.com>
This commit is contained in:
parent
a83f669bcf
commit
cf0852f92e
9 changed files with 1050 additions and 88 deletions
|
|
@ -1462,3 +1462,103 @@ class TestRunPreUpdateBackup:
|
|||
_run_pre_update_backup(Namespace(no_backup=True, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert "skipped (--no-backup)" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-migration backup (hermes claw migrate safety net)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPreMigrationBackup:
|
||||
"""Tests for create_pre_migration_backup — the auto-backup
|
||||
``hermes claw migrate`` runs before mutating ~/.hermes/."""
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(self, tmp_path):
|
||||
root = tmp_path / ".hermes"
|
||||
root.mkdir()
|
||||
_make_hermes_tree(root)
|
||||
return root
|
||||
|
||||
def test_creates_backup_under_backups_dir(self, hermes_home):
|
||||
from hermes_cli.backup import create_pre_migration_backup
|
||||
out = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
assert out is not None
|
||||
assert out.exists()
|
||||
# Shares the backups/ directory with pre-update backups so `hermes
|
||||
# import` and the update-backup listing both pick them up.
|
||||
assert out.parent == hermes_home / "backups"
|
||||
assert out.name.startswith("pre-migration-")
|
||||
assert out.suffix == ".zip"
|
||||
|
||||
def test_backup_uses_shared_exclusion_rules(self, hermes_home):
|
||||
"""Pre-migration backup reuses the same exclusion rules as
|
||||
``hermes backup`` / ``create_pre_update_backup`` — no drift."""
|
||||
from hermes_cli.backup import create_pre_migration_backup
|
||||
out = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
assert out is not None
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
names = set(zf.namelist())
|
||||
# User data present
|
||||
assert "config.yaml" in names
|
||||
assert ".env" in names
|
||||
assert "skills/my-skill/SKILL.md" in names
|
||||
# Same exclusions as the shared helper
|
||||
assert not any(n.startswith("hermes-agent/") for n in names)
|
||||
assert not any("__pycache__" in n for n in names)
|
||||
assert "gateway.pid" not in names
|
||||
|
||||
def test_restorable_with_hermes_import(self, hermes_home, tmp_path):
|
||||
"""The zip produced by pre-migration backup must be a valid Hermes
|
||||
backup — `hermes import` should accept it."""
|
||||
from hermes_cli.backup import create_pre_migration_backup, _validate_backup_zip
|
||||
out = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
assert out is not None
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
valid, _reason = _validate_backup_zip(zf)
|
||||
assert valid, "pre-migration zip failed _validate_backup_zip"
|
||||
|
||||
def test_does_not_recurse_into_prior_backups(self, hermes_home):
|
||||
from hermes_cli.backup import create_pre_migration_backup
|
||||
out1 = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
assert out1 is not None
|
||||
out2 = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
assert out2 is not None
|
||||
with zipfile.ZipFile(out2) as zf:
|
||||
names = zf.namelist()
|
||||
assert not any(n.startswith("backups/") for n in names)
|
||||
|
||||
def test_rotation_keeps_only_n(self, hermes_home):
|
||||
import time as _t
|
||||
from hermes_cli.backup import create_pre_migration_backup
|
||||
|
||||
created = []
|
||||
for _ in range(7):
|
||||
out = create_pre_migration_backup(hermes_home=hermes_home, keep=3)
|
||||
if out is not None:
|
||||
created.append(out)
|
||||
_t.sleep(1.05) # timestamp resolution
|
||||
|
||||
remaining = sorted((hermes_home / "backups").glob("pre-migration-*.zip"))
|
||||
assert len(remaining) <= 3, f"expected <=3 backups retained, got {len(remaining)}"
|
||||
|
||||
def test_missing_hermes_home_returns_none(self, tmp_path):
|
||||
"""Fresh install with no ~/.hermes yet — nothing to back up."""
|
||||
from hermes_cli.backup import create_pre_migration_backup
|
||||
missing = tmp_path / "does-not-exist"
|
||||
out = create_pre_migration_backup(hermes_home=missing)
|
||||
assert out is None
|
||||
|
||||
def test_does_not_touch_pre_update_backups(self, hermes_home):
|
||||
"""Pre-migration rotation must only prune pre-migration-*.zip files,
|
||||
leaving pre-update-*.zip backups untouched."""
|
||||
from hermes_cli.backup import create_pre_update_backup, create_pre_migration_backup
|
||||
update_backup = create_pre_update_backup(hermes_home=hermes_home, keep=5)
|
||||
assert update_backup is not None and update_backup.exists()
|
||||
# Spin up a lot of migration backups with keep=1
|
||||
import time as _t
|
||||
for _ in range(3):
|
||||
out = create_pre_migration_backup(hermes_home=hermes_home, keep=1)
|
||||
assert out is not None
|
||||
_t.sleep(1.05)
|
||||
# Update backup must still be there
|
||||
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue