mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +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>
794 lines
32 KiB
Python
794 lines
32 KiB
Python
"""Tests for hermes claw commands."""
|
|
|
|
from argparse import Namespace
|
|
import subprocess
|
|
from types import ModuleType
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import claw as claw_mod
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_migration_script
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindMigrationScript:
|
|
"""Test script discovery in known locations."""
|
|
|
|
def test_finds_project_root_script(self, tmp_path):
|
|
script = tmp_path / "openclaw_to_hermes.py"
|
|
script.write_text("# placeholder")
|
|
with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script):
|
|
assert claw_mod._find_migration_script() == script
|
|
|
|
def test_finds_installed_script(self, tmp_path):
|
|
installed = tmp_path / "installed.py"
|
|
installed.write_text("# placeholder")
|
|
with (
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"),
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", installed),
|
|
):
|
|
assert claw_mod._find_migration_script() == installed
|
|
|
|
def test_returns_none_when_missing(self, tmp_path):
|
|
with (
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
|
|
):
|
|
assert claw_mod._find_migration_script() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_openclaw_dirs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindOpenclawDirs:
|
|
"""Test discovery of OpenClaw directories."""
|
|
|
|
def test_finds_openclaw_dir(self, tmp_path):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
with patch("pathlib.Path.home", return_value=tmp_path):
|
|
found = claw_mod._find_openclaw_dirs()
|
|
assert openclaw in found
|
|
|
|
def test_finds_legacy_dirs(self, tmp_path):
|
|
clawdbot = tmp_path / ".clawdbot"
|
|
clawdbot.mkdir()
|
|
moltbot = tmp_path / ".moltbot"
|
|
moltbot.mkdir()
|
|
with patch("pathlib.Path.home", return_value=tmp_path):
|
|
found = claw_mod._find_openclaw_dirs()
|
|
assert len(found) == 2
|
|
assert clawdbot in found
|
|
assert moltbot in found
|
|
|
|
def test_returns_empty_when_none_exist(self, tmp_path):
|
|
with patch("pathlib.Path.home", return_value=tmp_path):
|
|
found = claw_mod._find_openclaw_dirs()
|
|
assert found == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _scan_workspace_state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScanWorkspaceState:
|
|
"""Test scanning for workspace state files."""
|
|
|
|
def test_finds_root_state_files(self, tmp_path):
|
|
(tmp_path / "todo.json").write_text("{}")
|
|
(tmp_path / "sessions").mkdir()
|
|
findings = claw_mod._scan_workspace_state(tmp_path)
|
|
descs = [desc for _, desc in findings]
|
|
assert any("todo.json" in d for d in descs)
|
|
assert any("sessions" in d for d in descs)
|
|
|
|
def test_finds_workspace_state_files(self, tmp_path):
|
|
ws = tmp_path / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
(ws / "sessions").mkdir()
|
|
findings = claw_mod._scan_workspace_state(tmp_path)
|
|
descs = [desc for _, desc in findings]
|
|
assert any("workspace/todo.json" in d for d in descs)
|
|
assert any("workspace/sessions" in d for d in descs)
|
|
|
|
def test_ignores_hidden_dirs(self, tmp_path):
|
|
scan_dir = tmp_path / "scan_target"
|
|
scan_dir.mkdir()
|
|
hidden = scan_dir / ".git"
|
|
hidden.mkdir()
|
|
(hidden / "todo.json").write_text("{}")
|
|
findings = claw_mod._scan_workspace_state(scan_dir)
|
|
assert len(findings) == 0
|
|
|
|
def test_empty_dir_returns_empty(self, tmp_path):
|
|
scan_dir = tmp_path / "scan_target"
|
|
scan_dir.mkdir()
|
|
findings = claw_mod._scan_workspace_state(scan_dir)
|
|
assert findings == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _archive_directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestArchiveDirectory:
|
|
"""Test directory archival (rename)."""
|
|
|
|
def test_renames_to_pre_migration(self, tmp_path):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
(source / "test.txt").write_text("data")
|
|
|
|
archive_path = claw_mod._archive_directory(source)
|
|
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
|
assert archive_path.is_dir()
|
|
assert not source.exists()
|
|
assert (archive_path / "test.txt").read_text() == "data"
|
|
|
|
def test_adds_timestamp_when_archive_exists(self, tmp_path):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
# Pre-existing archive
|
|
(tmp_path / ".openclaw.pre-migration").mkdir()
|
|
|
|
archive_path = claw_mod._archive_directory(source)
|
|
assert ".pre-migration-" in archive_path.name
|
|
assert archive_path.is_dir()
|
|
assert not source.exists()
|
|
|
|
def test_dry_run_does_not_rename(self, tmp_path):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
|
|
archive_path = claw_mod._archive_directory(source, dry_run=True)
|
|
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
|
assert source.is_dir() # Still exists
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claw_command routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClawCommand:
|
|
"""Test the claw_command router."""
|
|
|
|
def test_routes_to_migrate(self):
|
|
args = Namespace(claw_action="migrate", source=None, dry_run=True,
|
|
preset="full", overwrite=False, migrate_secrets=False,
|
|
workspace_target=None, skill_conflict="skip", yes=False)
|
|
with patch.object(claw_mod, "_cmd_migrate") as mock:
|
|
claw_mod.claw_command(args)
|
|
mock.assert_called_once_with(args)
|
|
|
|
def test_routes_to_cleanup(self):
|
|
args = Namespace(claw_action="cleanup", source=None, dry_run=False, yes=False)
|
|
with patch.object(claw_mod, "_cmd_cleanup") as mock:
|
|
claw_mod.claw_command(args)
|
|
mock.assert_called_once_with(args)
|
|
|
|
def test_routes_clean_alias(self):
|
|
args = Namespace(claw_action="clean", source=None, dry_run=False, yes=False)
|
|
with patch.object(claw_mod, "_cmd_cleanup") as mock:
|
|
claw_mod.claw_command(args)
|
|
mock.assert_called_once_with(args)
|
|
|
|
def test_shows_help_for_no_action(self, capsys):
|
|
args = Namespace(claw_action=None)
|
|
claw_mod.claw_command(args)
|
|
captured = capsys.readouterr()
|
|
assert "migrate" in captured.out
|
|
assert "cleanup" in captured.out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cmd_migrate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCmdMigrate:
|
|
"""Test the migrate command handler."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_openclaw_running(self):
|
|
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
|
yield
|
|
|
|
def test_error_when_source_missing(self, tmp_path, capsys):
|
|
args = Namespace(
|
|
source=str(tmp_path / "nonexistent"),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
claw_mod._cmd_migrate(args)
|
|
captured = capsys.readouterr()
|
|
assert "not found" in captured.out
|
|
|
|
def test_error_when_script_missing(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
with (
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
captured = capsys.readouterr()
|
|
assert "Migration script not found" in captured.out
|
|
|
|
def test_dry_run_succeeds(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
script = tmp_path / "script.py"
|
|
script.write_text("# placeholder")
|
|
|
|
# Build a fake migration module
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 0, "skipped": 5, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "skipped", "reason": "Not found"},
|
|
],
|
|
"preset": "full",
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=script),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Dry Run Results" in captured.out
|
|
assert "5 skipped" in captured.out
|
|
|
|
def test_execute_with_confirmation(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("agent:\n max_turns: 90\n")
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 2, "skipped": 1, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
|
|
{"kind": "memory", "status": "migrated", "destination": str(tmp_path / "memories/MEMORY.md")},
|
|
],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="user-data", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = True
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "prompt_yes_no", return_value=True),
|
|
patch("sys.stdin", mock_stdin),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Migration Results" in captured.out
|
|
assert "Migration complete!" in captured.out
|
|
|
|
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
|
|
"""Dry run should not modify the source directory."""
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 2, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
"preset": "full",
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
assert openclaw_dir.is_dir() # Source untouched
|
|
|
|
def test_execute_cancelled_by_user(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("")
|
|
|
|
# Preview must succeed before the confirmation prompt is shown
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 1, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [{"kind": "soul", "status": "migrated", "source": "s", "destination": "d", "reason": ""}],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = True
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
|
patch("sys.stdin", mock_stdin),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Migration cancelled" in captured.out
|
|
|
|
def test_execute_with_yes_skips_confirmation(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("")
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=True,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "prompt_yes_no") as mock_prompt,
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
mock_prompt.assert_not_called()
|
|
|
|
def test_handles_migration_error(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("")
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Could not load migration script" in captured.out
|
|
|
|
def test_full_preset_does_not_enable_secrets_silently(self, tmp_path, capsys):
|
|
"""The 'full' preset must NOT auto-enable migrate_secrets.
|
|
|
|
Users have to opt in to secret import explicitly via --migrate-secrets,
|
|
even under the 'full' preset. This mirrors OpenClaw's migrate-hermes
|
|
posture (two-phase import) and prevents a 'full' run from silently
|
|
copying API keys.
|
|
"""
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, # Not explicitly set by user
|
|
workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
no_backup=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
# Migrator should have been called with migrate_secrets=False — the
|
|
# 'full' preset on its own no longer opts the user into secret import.
|
|
call_kwargs = fake_mod.Migrator.call_args[1]
|
|
assert call_kwargs["migrate_secrets"] is False
|
|
|
|
def test_full_preset_with_explicit_migrate_secrets_passes_through(self, tmp_path, capsys):
|
|
"""Explicit --migrate-secrets still works under --preset full."""
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=True, # Explicitly requested
|
|
workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
no_backup=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
call_kwargs = fake_mod.Migrator.call_args[1]
|
|
assert call_kwargs["migrate_secrets"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cmd_cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCmdCleanup:
|
|
"""Test the cleanup command handler."""
|
|
|
|
def test_no_dirs_found(self, tmp_path, capsys):
|
|
args = Namespace(source=None, dry_run=False, yes=False)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
|
|
claw_mod._cmd_cleanup(args)
|
|
captured = capsys.readouterr()
|
|
assert "No OpenClaw directories found" in captured.out
|
|
|
|
def test_dry_run_lists_dirs(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
ws = openclaw / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
|
|
args = Namespace(source=None, dry_run=True, yes=False)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Would archive" in captured.out
|
|
assert openclaw.is_dir() # Not actually archived
|
|
|
|
def test_archives_with_yes(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
(openclaw / "workspace").mkdir()
|
|
(openclaw / "workspace" / "todo.json").write_text("{}")
|
|
|
|
args = Namespace(source=None, dry_run=False, yes=True)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Archived" in captured.out
|
|
assert "Cleaned up 1" in captured.out
|
|
assert not openclaw.exists()
|
|
assert (tmp_path / ".openclaw.pre-migration").is_dir()
|
|
|
|
def test_skips_when_user_declines(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = True
|
|
|
|
args = Namespace(source=None, dry_run=False, yes=False)
|
|
with (
|
|
patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]),
|
|
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
|
patch("sys.stdin", mock_stdin),
|
|
):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Skipped" in captured.out
|
|
assert openclaw.is_dir()
|
|
|
|
def test_explicit_source(self, tmp_path, capsys):
|
|
custom_dir = tmp_path / "my-openclaw"
|
|
custom_dir.mkdir()
|
|
(custom_dir / "todo.json").write_text("{}")
|
|
|
|
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Archived" in captured.out
|
|
assert not custom_dir.exists()
|
|
|
|
def test_shows_workspace_details(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
ws = openclaw / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
(ws / "SOUL.md").write_text("# Soul")
|
|
|
|
args = Namespace(source=None, dry_run=True, yes=False)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "workspace/" in captured.out
|
|
assert "todo.json" in captured.out
|
|
|
|
def test_handles_multiple_dirs(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
clawdbot = tmp_path / ".clawdbot"
|
|
clawdbot.mkdir()
|
|
|
|
args = Namespace(source=None, dry_run=False, yes=True)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw, clawdbot]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Cleaned up 2" in captured.out
|
|
assert not openclaw.exists()
|
|
assert not clawdbot.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _print_migration_report
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrintMigrationReport:
|
|
"""Test the report formatting function."""
|
|
|
|
def test_dry_run_report(self, capsys):
|
|
report = {
|
|
"summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
|
{"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"},
|
|
{"kind": "skills", "status": "conflict", "reason": "already exists"},
|
|
{"kind": "tts-assets", "status": "skipped", "reason": "not found"},
|
|
],
|
|
"preset": "full",
|
|
}
|
|
claw_mod._print_migration_report(report, dry_run=True)
|
|
captured = capsys.readouterr()
|
|
assert "Dry Run Results" in captured.out
|
|
assert "Would migrate" in captured.out
|
|
assert "2 would migrate" in captured.out
|
|
assert "--dry-run" in captured.out
|
|
|
|
def test_execute_report(self, capsys):
|
|
report = {
|
|
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
|
],
|
|
"output_dir": "/home/user/.hermes/migration/openclaw/20250312T120000",
|
|
}
|
|
claw_mod._print_migration_report(report, dry_run=False)
|
|
captured = capsys.readouterr()
|
|
assert "Migration Results" in captured.out
|
|
assert "Migrated" in captured.out
|
|
assert "Full report saved to" in captured.out
|
|
|
|
def test_empty_report(self, capsys):
|
|
report = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
claw_mod._print_migration_report(report, dry_run=False)
|
|
captured = capsys.readouterr()
|
|
assert "Nothing to migrate" in captured.out
|
|
|
|
|
|
class TestDetectOpenclawProcesses:
|
|
def test_returns_match_when_pgrep_finds_openclaw(self):
|
|
with patch.object(claw_mod, "sys") as mock_sys:
|
|
mock_sys.platform = "linux"
|
|
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
|
# systemd check misses, pgrep finds openclaw
|
|
mock_subprocess.run.side_effect = [
|
|
MagicMock(returncode=1, stdout=""), # systemctl
|
|
MagicMock(returncode=0, stdout="1234\n"), # pgrep
|
|
]
|
|
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
|
result = claw_mod._detect_openclaw_processes()
|
|
assert len(result) == 1
|
|
assert "1234" in result[0]
|
|
|
|
def test_returns_empty_when_pgrep_finds_nothing(self):
|
|
with patch.object(claw_mod, "sys") as mock_sys:
|
|
mock_sys.platform = "darwin"
|
|
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
|
mock_subprocess.run.side_effect = [
|
|
MagicMock(returncode=1, stdout=""), # systemctl (not found)
|
|
MagicMock(returncode=1, stdout=""), # pgrep
|
|
]
|
|
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
|
result = claw_mod._detect_openclaw_processes()
|
|
assert result == []
|
|
|
|
def test_detects_systemd_service(self):
|
|
with patch.object(claw_mod, "sys") as mock_sys:
|
|
mock_sys.platform = "linux"
|
|
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
|
mock_subprocess.run.side_effect = [
|
|
MagicMock(returncode=0, stdout="active\n"), # systemctl
|
|
MagicMock(returncode=1, stdout=""), # pgrep
|
|
]
|
|
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
|
result = claw_mod._detect_openclaw_processes()
|
|
assert len(result) == 1
|
|
assert "systemd" in result[0]
|
|
|
|
def test_returns_match_on_windows_when_openclaw_exe_running(self):
|
|
with patch.object(claw_mod, "sys") as mock_sys:
|
|
mock_sys.platform = "win32"
|
|
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
|
mock_subprocess.run.side_effect = [
|
|
MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"),
|
|
]
|
|
result = claw_mod._detect_openclaw_processes()
|
|
assert len(result) >= 1
|
|
assert any("openclaw.exe" in r for r in result)
|
|
|
|
def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self):
|
|
with patch.object(claw_mod, "sys") as mock_sys:
|
|
mock_sys.platform = "win32"
|
|
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
|
mock_subprocess.run.side_effect = [
|
|
MagicMock(returncode=0, stdout=""), # tasklist openclaw.exe
|
|
MagicMock(returncode=0, stdout=""), # tasklist clawd.exe
|
|
MagicMock(returncode=0, stdout="1234\n"), # PowerShell
|
|
]
|
|
result = claw_mod._detect_openclaw_processes()
|
|
assert len(result) >= 1
|
|
assert any("node.exe" in r for r in result)
|
|
|
|
def test_returns_empty_on_windows_when_nothing_found(self):
|
|
with patch.object(claw_mod, "sys") as mock_sys:
|
|
mock_sys.platform = "win32"
|
|
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
|
mock_subprocess.run.side_effect = [
|
|
MagicMock(returncode=0, stdout=""),
|
|
MagicMock(returncode=0, stdout=""),
|
|
MagicMock(returncode=0, stdout=""),
|
|
]
|
|
result = claw_mod._detect_openclaw_processes()
|
|
assert result == []
|
|
|
|
|
|
class TestWarnIfOpenclawRunning:
|
|
def test_noop_when_not_running(self, capsys):
|
|
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
|
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|
|
|
|
def test_warns_and_exits_when_running_and_user_declines(self, capsys):
|
|
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
|
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
|
|
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
|
assert exc_info.value.code == 0
|
|
captured = capsys.readouterr()
|
|
assert "OpenClaw appears to be running" in captured.out
|
|
|
|
def test_warns_and_continues_when_running_and_user_accepts(self, capsys):
|
|
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
|
with patch.object(claw_mod, "prompt_yes_no", return_value=True):
|
|
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
|
|
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
|
captured = capsys.readouterr()
|
|
assert "OpenClaw appears to be running" in captured.out
|
|
|
|
def test_warns_and_continues_in_auto_yes_mode(self, capsys):
|
|
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
|
claw_mod._warn_if_openclaw_running(auto_yes=True)
|
|
captured = capsys.readouterr()
|
|
assert "OpenClaw appears to be running" in captured.out
|
|
|
|
def test_warns_and_continues_in_non_interactive_session(self, capsys):
|
|
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
|
with patch.object(claw_mod.sys.stdin, "isatty", return_value=False):
|
|
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
|
captured = capsys.readouterr()
|
|
assert "OpenClaw appears to be running" in captured.out
|
|
assert "Non-interactive session" in captured.out
|