mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
* 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>
391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""Tests for the OpenClaw→Hermes migration hardening features.
|
|
|
|
Covers the changes in the "claw migrate hardening" PR:
|
|
- secret redaction (engine-level, applied to report JSON)
|
|
- warnings[] / next_steps[] on the report
|
|
- blocked-by-earlier-conflict sequencing for config.yaml mutations
|
|
- --json output mode on the migration script
|
|
- enum-like constants and ItemResult.sensitive field
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
SCRIPT_PATH = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "optional-skills"
|
|
/ "migration"
|
|
/ "openclaw-migration"
|
|
/ "scripts"
|
|
/ "openclaw_to_hermes.py"
|
|
)
|
|
|
|
|
|
def _load():
|
|
spec = importlib.util.spec_from_file_location("openclaw_to_hermes_hard", SCRIPT_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# Redaction
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
def test_redact_replaces_secret_by_key_name():
|
|
mod = _load()
|
|
out = mod.redact_migration_value({"OPENROUTER_API_KEY": "sk-or-v1-abcdef12345678"})
|
|
assert out["OPENROUTER_API_KEY"] == mod.REDACTED_MIGRATION_VALUE
|
|
|
|
|
|
def test_redact_replaces_secret_by_value_pattern():
|
|
mod = _load()
|
|
# Even under a non-secret-looking key, the sk-... pattern should be replaced inline.
|
|
out = mod.redact_migration_value({"note": "use sk-or-v1-9Xs7fF2JkLmNpQrT to authenticate"})
|
|
assert "sk-or-" not in out["note"]
|
|
assert mod.REDACTED_MIGRATION_VALUE in out["note"]
|
|
|
|
|
|
def test_redact_handles_github_token_pattern():
|
|
mod = _load()
|
|
out = mod.redact_migration_value({"detail": "token: ghp_1234567890abcdef1234"})
|
|
assert "ghp_" not in out["detail"]
|
|
assert mod.REDACTED_MIGRATION_VALUE in out["detail"]
|
|
|
|
|
|
def test_redact_handles_slack_token_pattern():
|
|
mod = _load()
|
|
out = mod.redact_migration_value("xoxb-1234567890-abcdef")
|
|
assert out == mod.REDACTED_MIGRATION_VALUE
|
|
|
|
|
|
def test_redact_handles_google_api_key_pattern():
|
|
mod = _load()
|
|
out = mod.redact_migration_value("AIzaSyA-abc123def456ghi")
|
|
# Google key is a prefix — whole value is scrubbed
|
|
assert "AIza" not in out
|
|
|
|
|
|
def test_redact_handles_bearer_header():
|
|
mod = _load()
|
|
out = mod.redact_migration_value({"hint": "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc"})
|
|
# Key "hint" is not a secret marker — only the Bearer <token> substring
|
|
# gets scrubbed inline by the value pattern.
|
|
assert "Bearer eyJ" not in out["hint"]
|
|
assert mod.REDACTED_MIGRATION_VALUE in out["hint"]
|
|
|
|
|
|
def test_redact_is_recursive():
|
|
mod = _load()
|
|
nested = {
|
|
"outer": {
|
|
"items": [
|
|
{"password": "hunter2"},
|
|
{"details": {"apiKey": "my-key"}},
|
|
],
|
|
},
|
|
}
|
|
out = mod.redact_migration_value(nested)
|
|
assert out["outer"]["items"][0]["password"] == mod.REDACTED_MIGRATION_VALUE
|
|
assert out["outer"]["items"][1]["details"]["apiKey"] == mod.REDACTED_MIGRATION_VALUE
|
|
|
|
|
|
def test_redact_preserves_non_secret_keys_and_values():
|
|
mod = _load()
|
|
input_data = {"name": "hermes", "count": 42, "tags": ["a", "b"]}
|
|
out = mod.redact_migration_value(input_data)
|
|
assert out == input_data
|
|
|
|
|
|
def test_redact_normalizes_key_case_and_punctuation():
|
|
mod = _load()
|
|
# "Api Key", "api-key", "API_KEY" all normalize the same way.
|
|
for key in ("Api Key", "api-key", "API_KEY", "apikey"):
|
|
out = mod.redact_migration_value({key: "secret"})
|
|
assert out[key] == mod.REDACTED_MIGRATION_VALUE, f"failed to redact: {key}"
|
|
|
|
|
|
def test_redact_leaves_env_secretref_alone():
|
|
"""SecretRef-like shapes ({source: env, id: ...}) are pointers, not secrets."""
|
|
mod = _load()
|
|
ref = {"source": "env", "id": "OPENAI_API_KEY"}
|
|
out = mod.redact_migration_value({"apiKey": ref})
|
|
# The key "apiKey" itself triggers redaction today — this test locks that in.
|
|
# If we later want to exempt SecretRef values the way OpenClaw does, update
|
|
# both this test and _redact_internal together.
|
|
assert out["apiKey"] == mod.REDACTED_MIGRATION_VALUE
|
|
|
|
|
|
def test_write_report_redacts_api_keys_on_disk(tmp_path):
|
|
mod = _load()
|
|
report = {
|
|
"timestamp": "20260427T120000",
|
|
"mode": "execute",
|
|
"source_root": "/src",
|
|
"target_root": "/tgt",
|
|
"summary": {"migrated": 1, "conflict": 0, "error": 0, "skipped": 0, "archived": 0},
|
|
"items": [
|
|
{
|
|
"kind": "provider-keys",
|
|
"source": "openclaw.json",
|
|
"destination": "/tgt/.env",
|
|
"status": "migrated",
|
|
"reason": "",
|
|
"details": {"OPENROUTER_API_KEY": "sk-or-v1-1234567890abcdef"},
|
|
},
|
|
],
|
|
}
|
|
mod.write_report(tmp_path, report)
|
|
persisted = json.loads((tmp_path / "report.json").read_text())
|
|
# The raw secret must not appear anywhere in the persisted JSON.
|
|
assert "sk-or-v1-1234567890abcdef" not in (tmp_path / "report.json").read_text()
|
|
assert persisted["items"][0]["details"]["OPENROUTER_API_KEY"] == mod.REDACTED_MIGRATION_VALUE
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# Warnings and next-steps
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
def _make_minimal_migrator(mod, tmp_path, **overrides):
|
|
source = tmp_path / "openclaw"
|
|
source.mkdir()
|
|
# Minimal valid OpenClaw layout so the Migrator constructor doesn't choke.
|
|
(source / "openclaw.json").write_text("{}", encoding="utf-8")
|
|
target = tmp_path / "hermes"
|
|
target.mkdir()
|
|
defaults = dict(
|
|
source_root=source,
|
|
target_root=target,
|
|
execute=False,
|
|
workspace_target=None,
|
|
overwrite=False,
|
|
migrate_secrets=False,
|
|
output_dir=None,
|
|
selected_options=set(),
|
|
)
|
|
defaults.update(overrides)
|
|
return mod.Migrator(**defaults)
|
|
|
|
|
|
def test_dry_run_report_includes_rerun_next_step(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path)
|
|
report = migrator.migrate()
|
|
steps = report["next_steps"]
|
|
assert any("dry-run" in step.lower() or "re-run" in step.lower() for step in steps)
|
|
|
|
|
|
def test_conflict_produces_overwrite_warning(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
|
|
# Inject a conflict on a config.yaml target to exercise the warning pathway.
|
|
migrator.record(
|
|
"tts-config",
|
|
source=None,
|
|
destination=migrator.target_root / "config.yaml",
|
|
status=mod.STATUS_CONFLICT,
|
|
reason="TTS already configured",
|
|
)
|
|
report = migrator.build_report()
|
|
assert any("--overwrite" in w for w in report["warnings"])
|
|
# The conflict on config.yaml should have flipped the block flag too.
|
|
assert migrator._config_apply_blocked is True
|
|
|
|
|
|
def test_error_produces_inspect_warning(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
|
|
migrator.record("mcp-servers", None, None, mod.STATUS_ERROR, "Bad YAML")
|
|
report = migrator.build_report()
|
|
assert any("failed" in w.lower() for w in report["warnings"])
|
|
|
|
|
|
def test_provider_keys_skipped_warning_when_secrets_disabled(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path, execute=True, migrate_secrets=False)
|
|
migrator.record(
|
|
"provider-keys",
|
|
source=None,
|
|
destination=None,
|
|
status=mod.STATUS_SKIPPED,
|
|
reason="--migrate-secrets not set",
|
|
)
|
|
report = migrator.build_report()
|
|
assert any("--migrate-secrets" in w for w in report["warnings"])
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# Blocked-by-earlier-conflict sequencing
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
def test_config_apply_block_flips_on_config_yaml_conflict(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
|
|
assert migrator._config_apply_blocked is False
|
|
migrator.record(
|
|
"model-config",
|
|
source=None,
|
|
destination=migrator.target_root / "config.yaml",
|
|
status=mod.STATUS_CONFLICT,
|
|
)
|
|
assert migrator._config_apply_blocked is True
|
|
|
|
|
|
def test_config_apply_block_flips_on_config_yaml_error(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
|
|
migrator.record(
|
|
"tts-config",
|
|
source=None,
|
|
destination=migrator.target_root / "config.yaml",
|
|
status=mod.STATUS_ERROR,
|
|
reason="YAML write failed",
|
|
)
|
|
assert migrator._config_apply_blocked is True
|
|
|
|
|
|
def test_config_apply_block_does_not_flip_on_non_config_conflict(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
|
|
migrator.record(
|
|
"skill",
|
|
source=None,
|
|
destination=migrator.target_root / "skills" / "foo" / "SKILL.md",
|
|
status=mod.STATUS_CONFLICT,
|
|
)
|
|
assert migrator._config_apply_blocked is False
|
|
|
|
|
|
def test_run_if_selected_skips_config_ops_after_block(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(
|
|
mod, tmp_path, execute=True, selected_options={"model-config", "tts-config"}
|
|
)
|
|
migrator._config_apply_blocked = True
|
|
called = []
|
|
migrator.run_if_selected("tts-config", lambda: called.append(True))
|
|
assert called == []
|
|
# The skipped record uses the blocked reason.
|
|
blocked = [i for i in migrator.items if i.kind == "tts-config"]
|
|
assert len(blocked) == 1
|
|
assert blocked[0].status == mod.STATUS_SKIPPED
|
|
assert blocked[0].reason == mod.REASON_BLOCKED_BY_APPLY_CONFLICT
|
|
|
|
|
|
def test_run_if_selected_runs_non_config_ops_even_after_block(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(
|
|
mod, tmp_path, execute=True, selected_options={"soul"}
|
|
)
|
|
migrator._config_apply_blocked = True
|
|
called = []
|
|
migrator.run_if_selected("soul", lambda: called.append(True))
|
|
assert called == [True]
|
|
|
|
|
|
def test_dry_run_never_blocks_even_after_conflict(tmp_path):
|
|
"""Dry runs must preview the full plan — blocking mid-preview would hide
|
|
conflicts and mislead the user about what would actually happen."""
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(
|
|
mod, tmp_path, execute=False, selected_options={"tts-config"}
|
|
)
|
|
migrator._config_apply_blocked = True
|
|
called = []
|
|
migrator.run_if_selected("tts-config", lambda: called.append(True))
|
|
assert called == [True]
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# --json output mode
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
def test_json_mode_emits_structured_report(tmp_path):
|
|
"""End-to-end: run the CLI with --json and no --execute, parse stdout."""
|
|
source = tmp_path / "openclaw"
|
|
source.mkdir()
|
|
(source / "openclaw.json").write_text(
|
|
json.dumps({"agents": {"defaults": {"model": "openrouter/anthropic/claude-sonnet-4"}}}),
|
|
encoding="utf-8",
|
|
)
|
|
target = tmp_path / "hermes"
|
|
target.mkdir()
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT_PATH),
|
|
"--source", str(source),
|
|
"--target", str(target),
|
|
"--json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
payload = json.loads(result.stdout)
|
|
assert "summary" in payload
|
|
assert "warnings" in payload
|
|
assert "next_steps" in payload
|
|
assert payload["mode"] == "dry-run"
|
|
|
|
|
|
def test_json_mode_redacts_secrets_in_output(tmp_path):
|
|
"""Even plan-only JSON output goes through the redactor — the stdout
|
|
capture path is what gets piped into CI / support tickets."""
|
|
source = tmp_path / "openclaw"
|
|
source.mkdir()
|
|
(source / "openclaw.json").write_text("{}", encoding="utf-8")
|
|
# Plant a fake OpenClaw .env with a recognizably-shaped key.
|
|
(source / ".env").write_text(
|
|
"OPENROUTER_API_KEY=sk-or-v1-abcdef1234567890abcdef\n", encoding="utf-8"
|
|
)
|
|
target = tmp_path / "hermes"
|
|
target.mkdir()
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT_PATH),
|
|
"--source", str(source),
|
|
"--target", str(target),
|
|
"--migrate-secrets", # so provider-keys surface in the plan
|
|
"--json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
# The raw key value must never appear in the JSON output.
|
|
assert "sk-or-v1-abcdef1234567890abcdef" not in result.stdout
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# ItemResult schema additions
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
def test_item_result_has_sensitive_field():
|
|
mod = _load()
|
|
item = mod.ItemResult(kind="x", source=None, destination=None, status="migrated")
|
|
assert item.sensitive is False
|
|
|
|
|
|
def test_record_honors_sensitive_flag(tmp_path):
|
|
mod = _load()
|
|
migrator = _make_minimal_migrator(mod, tmp_path)
|
|
migrator.record("x", None, None, "migrated", sensitive=True)
|
|
assert migrator.items[0].sensitive is True
|
|
|
|
|
|
def test_status_constants_match_historical_strings():
|
|
"""Downstream consumers (claw.py, tests, docs) depend on these string values."""
|
|
mod = _load()
|
|
assert mod.STATUS_MIGRATED == "migrated"
|
|
assert mod.STATUS_SKIPPED == "skipped"
|
|
assert mod.STATUS_CONFLICT == "conflict"
|
|
assert mod.STATUS_ERROR == "error"
|
|
assert mod.STATUS_ARCHIVED == "archived"
|