hermes-agent/tests/skills/test_openclaw_migration.py
briandevans 8a9ab8b56b fix(cli): stop shredding an existing MEMORY.md on hermes import-agent
memories/MEMORY.md is the "§"-delimited store written by MemoryStore, not a
markdown document. parse_existing_memory_entries() fell back to
extract_markdown_entries() -- the *source* parser for CLAUDE.md / AGENTS.md --
whenever the destination held no delimiter, which is exactly the case for a
single-entry store or one that was hand-edited or shell-appended. That
extractor skips fenced code blocks, skips table rows, splits a block into one
entry per bullet and reflows paragraphs. The shredded result was then written
straight back over the user's store and reported as "Imported", with no backup
to recover from.

Parse the destination the way MemoryStore._parse_entries does: split on
ENTRY_DELIMITER only, so a store with no delimiter is one intact entry.
extract_markdown_entries() is unchanged and still used on the sources, where
it is correct.

Also restore the safety net the port dropped. The openclaw migration script
this module was ported from calls maybe_backup(destination) before rewriting a
memory store; the port did not. Snapshot the store to <name>.bak.<unix_ts>
(same naming as MemoryStore._backup_drifted_file), refuse to rewrite when the
snapshot fails, and write via temp file + atomic rename so an interrupted
import cannot leave a truncated store and a symlinked MEMORY.md stays a
symlink.

The identical fallback lives in openclaw_to_hermes.py, where it is reached
from migrate_memory() (memories/MEMORY.md and memories/USER.md) and
migrate_daily_memory(); fixed there too.
2026-07-29 16:49:07 +05:30

1273 lines
44 KiB
Python

from __future__ import annotations
import importlib.util
import json
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_module():
spec = importlib.util.spec_from_file_location("openclaw_to_hermes", 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
def load_skills_guard():
spec = importlib.util.spec_from_file_location(
"skills_guard_local",
Path(__file__).resolve().parents[2] / "tools" / "skills_guard.py",
)
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
def test_extract_markdown_entries_promotes_heading_context():
mod = load_module()
text = """# MEMORY.md - Long-Term Memory
## Tyler Williams
- Founder of VANTA Research
- Timezone: America/Los_Angeles
### Active Projects
- Hermes Agent
"""
entries = mod.extract_markdown_entries(text)
assert "Tyler Williams: Founder of VANTA Research" in entries
assert "Tyler Williams: Timezone: America/Los_Angeles" in entries
assert "Tyler Williams > Active Projects: Hermes Agent" in entries
def test_parse_existing_memory_entries_keeps_undelimited_store_intact(tmp_path):
"""The DESTINATION store is §-delimited, not a markdown document.
``migrate_memory`` and ``migrate_daily_memory`` read the Hermes-side
memories/MEMORY.md (and USER.md) and write the merged result back over it.
A store with no delimiter is ONE entry — running the source markdown
extractor over it would drop the code block and the table row below.
"""
mod = load_module()
raw = (
"Homelab runbook. Restart the ingress controller with:\n"
"\n"
"```bash\n"
"kubectl -n ingress rollout restart deploy/nginx\n"
"```\n"
"\n"
"| Severity | Contact | Window |\n"
"| SEV1 | on-call | 15m |\n"
)
path = tmp_path / "MEMORY.md"
path.write_text(raw, encoding="utf-8")
assert mod.ENTRY_DELIMITER not in raw
assert mod.parse_existing_memory_entries(path) == [raw.strip()]
def test_merge_entries_respects_limit_and_reports_overflow():
mod = load_module()
existing = ["alpha"]
incoming = ["beta", "gamma is too long"]
merged, stats, overflowed = mod.merge_entries(existing, incoming, limit=12)
assert merged == ["alpha", "beta"]
assert stats["added"] == 1
assert stats["overflowed"] == 1
assert overflowed == ["gamma is too long"]
def test_resolve_selected_options_supports_include_and_exclude():
mod = load_module()
selected = mod.resolve_selected_options(["memory,skills", "user-profile"], ["skills"])
assert selected == {"memory", "user-profile"}
def test_resolve_selected_options_supports_presets():
mod = load_module()
user_data = mod.resolve_selected_options(preset="user-data")
full = mod.resolve_selected_options(preset="full")
assert "secret-settings" not in user_data
assert "secret-settings" in full
assert user_data < full
def test_resolve_selected_options_rejects_unknown_values():
mod = load_module()
try:
mod.resolve_selected_options(["memory,unknown-option"], None)
except ValueError as exc:
assert "unknown-option" in str(exc)
else:
raise AssertionError("Expected ValueError for unknown migration option")
def test_resolve_selected_options_rejects_unknown_preset():
mod = load_module()
try:
mod.resolve_selected_options(preset="everything")
except ValueError as exc:
assert "everything" in str(exc)
else:
raise AssertionError("Expected ValueError for unknown migration preset")
def test_migrator_copies_skill_and_merges_allowlist(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
(source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: demo\n---\n\nbody\n",
encoding="utf-8",
)
(source / "exec-approvals.json").write_text(
json.dumps(
{
"agents": {
"*": {
"allowlist": [
{"pattern": "/usr/bin/*"},
{"pattern": "/home/test/**"},
]
}
}
}
),
encoding="utf-8",
)
(target / "config.yaml").write_text("command_allowlist:\n - /usr/bin/*\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
)
report = migrator.migrate()
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" / "SKILL.md"
assert imported_skill.exists()
assert "/home/test/**" in (target / "config.yaml").read_text(encoding="utf-8")
assert report["summary"]["migrated"] >= 2
def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
(source / "credentials").mkdir(parents=True)
(source / "openclaw.json").write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": "/tmp/openclaw-workspace"}},
"channels": {"telegram": {"botToken": "123:abc"}},
}
),
encoding="utf-8",
)
(source / "credentials" / "telegram-default-allowFrom.json").write_text(
json.dumps({"allowFrom": ["111", "222"]}),
encoding="utf-8",
)
target.mkdir()
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=True,
output_dir=target / "migration-report",
)
migrator.migrate()
env_text = (target / ".env").read_text(encoding="utf-8")
assert "MESSAGING_CWD=/tmp/openclaw-workspace" in env_text
assert "TELEGRAM_ALLOWED_USERS=111,222" in env_text
assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text
def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path):
"""MESSAGING_CWD pointing inside the OpenClaw source dir should be skipped."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
# Workspace path is inside the source directory
ws_path = str(source / "workspace")
(source / "credentials").mkdir(parents=True)
(source / "openclaw.json").write_text(
json.dumps({"agents": {"defaults": {"workspace": ws_path}}}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=True,
output_dir=target / "migration-report",
selected_options={"messaging-settings"},
)
migrator.migrate()
env_path = target / ".env"
if env_path.exists():
assert "MESSAGING_CWD" not in env_path.read_text(encoding="utf-8")
def test_migrator_can_execute_only_selected_categories(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
(source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: demo\n---\n\nbody\n",
encoding="utf-8",
)
(source / "workspace" / "MEMORY.md").write_text(
"# Memory\n\n- keep me\n",
encoding="utf-8",
)
(target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
selected_options={"skills"},
)
report = migrator.migrate()
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" / "SKILL.md"
assert imported_skill.exists()
assert not (target / "memories" / "MEMORY.md").exists()
assert report["selection"]["selected"] == ["skills"]
skipped_items = [item for item in report["items"] if item["status"] == "skipped"]
assert any(item["kind"] == "memory" and item["reason"] == "Not selected for this run" for item in skipped_items)
def test_migrator_records_preset_in_report(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=False,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=None,
selected_options=mod.MIGRATION_PRESETS["user-data"],
preset_name="user-data",
)
report = migrator.build_report()
assert report["preset"] == "user-data"
assert report["selection"]["preset"] == "user-data"
assert report["skill_conflict_mode"] == "skip"
assert report["selection"]["skill_conflict_mode"] == "skip"
def test_source_candidate_finds_files_in_custom_workspace(tmp_path: Path):
"""When agents.defaults.workspace points outside ~/.openclaw, files should
be discovered there as a fallback."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
custom_ws = tmp_path / "my-custom-workspace"
target.mkdir()
source.mkdir()
custom_ws.mkdir()
# No workspace/ directory inside .openclaw — files live in custom workspace
(custom_ws / "MEMORY.md").write_text("# Memory\n\n- custom workspace entry\n", encoding="utf-8")
(custom_ws / "SOUL.md").write_text("# Soul\n\nI am me.\n", encoding="utf-8")
(custom_ws / "skills" / "my-skill").mkdir(parents=True)
(custom_ws / "skills" / "my-skill" / "SKILL.md").write_text(
"---\nname: my-skill\ndescription: test\n---\n\nbody\n",
encoding="utf-8",
)
(custom_ws / "memory").mkdir()
(custom_ws / "memory" / "2026-01-01.md").write_text("- daily note\n", encoding="utf-8")
(source / "openclaw.json").write_text(
json.dumps({"agents": {"defaults": {"workspace": str(custom_ws)}}}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
selected_options={"soul", "memory", "skills", "daily-memory"},
)
report = migrator.migrate()
# SOUL.md should have been found and migrated
assert (target / "SOUL.md").exists()
# MEMORY.md should have been found and migrated
assert (target / "memories" / "MEMORY.md").exists()
mem_content = (target / "memories" / "MEMORY.md").read_text(encoding="utf-8")
assert "custom workspace entry" in mem_content
# Skills should have been found and migrated
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "my-skill" / "SKILL.md"
assert imported_skill.exists()
migrated_kinds = {item["kind"] for item in report["items"] if item["status"] == "migrated"}
assert "soul" in migrated_kinds
assert "memory" in migrated_kinds
assert "skill" in migrated_kinds
def test_source_candidate_prefers_standard_workspace_over_custom(tmp_path: Path):
"""When files exist in both ~/.openclaw/workspace/ and the custom workspace,
the standard location should win (custom is a fallback only)."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
custom_ws = tmp_path / "my-custom-workspace"
target.mkdir()
custom_ws.mkdir()
(source / "workspace").mkdir(parents=True)
# File in both locations
(source / "workspace" / "SOUL.md").write_text("# Standard soul\n", encoding="utf-8")
(custom_ws / "SOUL.md").write_text("# Custom soul\n", encoding="utf-8")
(source / "openclaw.json").write_text(
json.dumps({"agents": {"defaults": {"workspace": str(custom_ws)}}}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
selected_options={"soul"},
)
migrator.migrate()
# Standard workspace location should have been preferred
content = (target / "SOUL.md").read_text(encoding="utf-8")
assert "Standard soul" in content
def test_migrator_exports_full_overflow_entries(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(target / "config.yaml").write_text("memory:\n memory_char_limit: 10\n user_char_limit: 10\n", encoding="utf-8")
(source / "workspace").mkdir(parents=True)
(source / "workspace" / "MEMORY.md").write_text(
"# Memory\n\n- alpha\n- beta\n- gamma\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
selected_options={"memory"},
)
report = migrator.migrate()
memory_item = next(item for item in report["items"] if item["kind"] == "memory")
overflow_file = Path(memory_item["details"]["overflow_file"])
assert overflow_file.exists()
text = overflow_file.read_text(encoding="utf-8")
assert "alpha" in text or "beta" in text or "gamma" in text
def test_migrator_can_rename_conflicting_imported_skill(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source_skill = source / "workspace" / "skills" / "demo-skill"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: demo\n---\n\nbody\n",
encoding="utf-8",
)
existing_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill"
existing_skill.mkdir(parents=True)
(existing_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: existing\n---\n\nexisting\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
skill_conflict_mode="rename",
)
report = migrator.migrate()
renamed_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill-imported" / "SKILL.md"
assert renamed_skill.exists()
assert existing_skill.joinpath("SKILL.md").read_text(encoding="utf-8").endswith("existing\n")
imported_items = [item for item in report["items"] if item["kind"] == "skill" and item["status"] == "migrated"]
assert any(item["details"].get("renamed_from", "").endswith("/demo-skill") for item in imported_items)
def test_migrator_can_overwrite_conflicting_imported_skill_with_backup(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source_skill = source / "workspace" / "skills" / "demo-skill"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: imported\n---\n\nfresh\n",
encoding="utf-8",
)
existing_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill"
existing_skill.mkdir(parents=True)
(existing_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: existing\n---\n\nexisting\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
skill_conflict_mode="overwrite",
)
report = migrator.migrate()
assert existing_skill.joinpath("SKILL.md").read_text(encoding="utf-8").endswith("fresh\n")
backup_items = [item for item in report["items"] if item["kind"] == "skill" and item["status"] == "migrated"]
assert any(item["details"].get("backup") for item in backup_items)
def test_discord_settings_migrated(tmp_path: Path):
"""Discord bot token and allowlist migrate to .env."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"channels": {
"discord": {
"token": "discord-bot-token-123",
"allowFrom": ["111222333", "444555666"],
}
}
}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"discord-settings"},
)
report = migrator.migrate()
env_text = (target / ".env").read_text(encoding="utf-8")
assert "DISCORD_BOT_TOKEN=discord-bot-token-123" in env_text
assert "DISCORD_ALLOWED_USERS=111222333,444555666" in env_text
def test_slack_settings_migrated(tmp_path: Path):
"""Slack bot/app tokens and allowlist migrate to .env."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"channels": {
"slack": {
"botToken": "xoxb-slack-bot",
"appToken": "xapp-slack-app",
"allowFrom": ["U111", "U222"],
}
}
}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"slack-settings"},
)
report = migrator.migrate()
env_text = (target / ".env").read_text(encoding="utf-8")
assert "SLACK_BOT_TOKEN=xoxb-slack-bot" in env_text
assert "SLACK_APP_TOKEN=xapp-slack-app" in env_text
assert "SLACK_ALLOWED_USERS=U111,U222" in env_text
def test_signal_settings_migrated(tmp_path: Path):
"""Signal account, HTTP URL, and allowlist migrate to .env."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"channels": {
"signal": {
"account": "+15551234567",
"httpUrl": "http://localhost:8080",
"allowFrom": ["+15559876543"],
}
}
}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"signal-settings"},
)
report = migrator.migrate()
env_text = (target / ".env").read_text(encoding="utf-8")
assert "SIGNAL_ACCOUNT=+15551234567" in env_text
assert "SIGNAL_HTTP_URL=http://localhost:8080" in env_text
assert "SIGNAL_ALLOWED_USERS=+15559876543" in env_text
def test_model_config_migrated(tmp_path: Path):
"""Default model setting migrates to config.yaml."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"agents": {"defaults": {"model": "anthropic/claude-sonnet-4"}}
}),
encoding="utf-8",
)
# config.yaml must exist for YAML merge to work
(target / "config.yaml").write_text("model: openrouter/auto\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=True, migrate_secrets=False, output_dir=None,
selected_options={"model-config"},
)
report = migrator.migrate()
config_text = (target / "config.yaml").read_text(encoding="utf-8")
assert "anthropic/claude-sonnet-4" in config_text
def test_model_config_object_format(tmp_path: Path):
"""Model config handles {primary: ...} object format."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"agents": {"defaults": {"model": {"primary": "openai/gpt-4o"}}}
}),
encoding="utf-8",
)
(target / "config.yaml").write_text("model: old-model\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=True, migrate_secrets=False, output_dir=None,
selected_options={"model-config"},
)
report = migrator.migrate()
config_text = (target / "config.yaml").read_text(encoding="utf-8")
assert "openai/gpt-4o" in config_text
def test_tts_config_migrated(tmp_path: Path):
"""TTS provider and voice settings migrate to config.yaml."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"messages": {
"tts": {
"provider": "elevenlabs",
"elevenlabs": {
"voiceId": "custom-voice-id",
"modelId": "eleven_turbo_v2",
},
}
}
}),
encoding="utf-8",
)
(target / "config.yaml").write_text("tts:\n provider: edge\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"tts-config"},
)
report = migrator.migrate()
config_text = (target / "config.yaml").read_text(encoding="utf-8")
assert "elevenlabs" in config_text
assert "custom-voice-id" in config_text
def test_shared_skills_migrated(tmp_path: Path):
"""Shared skills from ~/.openclaw/skills/ are migrated."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
# Create a shared skill (not in workspace/skills/)
(source / "skills" / "my-shared-skill").mkdir(parents=True)
(source / "skills" / "my-shared-skill" / "SKILL.md").write_text(
"---\nname: my-shared-skill\ndescription: shared\n---\n\nbody\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"shared-skills"},
)
report = migrator.migrate()
imported = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "my-shared-skill" / "SKILL.md"
assert imported.exists()
def test_daily_memory_merged(tmp_path: Path):
"""Daily memory notes from workspace/memory/*.md are merged into MEMORY.md."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
mem_dir = source / "workspace" / "memory"
mem_dir.mkdir(parents=True)
(mem_dir / "2026-03-01.md").write_text(
"# March 1 Notes\n\n- User prefers dark mode\n- Timezone: PST\n",
encoding="utf-8",
)
(mem_dir / "2026-03-02.md").write_text(
"# March 2 Notes\n\n- Working on migration project\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"daily-memory"},
)
report = migrator.migrate()
mem_path = target / "memories" / "MEMORY.md"
assert mem_path.exists()
content = mem_path.read_text(encoding="utf-8")
assert "dark mode" in content
assert "migration project" in content
def test_provider_keys_require_migrate_secrets_flag(tmp_path: Path):
"""Provider keys migration is double-gated: needs option + --migrate-secrets."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({
"models": {
"providers": {
"openrouter": {
"apiKey": "sk-or-test-key",
"baseUrl": "https://openrouter.ai/api/v1",
}
}
}
}),
encoding="utf-8",
)
# Without --migrate-secrets: should skip
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"provider-keys"},
)
report = migrator.migrate()
env_path = target / ".env"
if env_path.exists():
assert "sk-or-test-key" not in env_path.read_text(encoding="utf-8")
# With --migrate-secrets: should import
migrator2 = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=True, output_dir=None,
selected_options={"provider-keys"},
)
report2 = migrator2.migrate()
env_text = (target / ".env").read_text(encoding="utf-8")
assert "OPENROUTER_API_KEY=sk-or-test-key" in env_text
def test_workspace_agents_records_skip_when_missing(tmp_path: Path):
"""Bug fix: workspace-agents records 'skipped' when source is missing."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
source.mkdir()
target.mkdir()
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=tmp_path / "workspace", overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"workspace-agents"},
)
report = migrator.migrate()
wa_items = [i for i in report["items"] if i["kind"] == "workspace-agents"]
assert len(wa_items) == 1
assert wa_items[0]["status"] == "skipped"
def test_cron_store_is_archived_without_config_cron_section(tmp_path: Path):
"""Bug fix: archive cron store even when openclaw.json has no top-level cron config."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
output_dir = target / "migration-report"
source.mkdir()
target.mkdir()
(source / "openclaw.json").write_text(json.dumps({"channels": {}}), encoding="utf-8")
(source / "cron").mkdir(parents=True)
(source / "cron" / "jobs.json").write_text(
json.dumps({"version": 1, "jobs": [{"id": "job-1", "name": "demo"}]}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=output_dir,
selected_options={"cron-jobs"},
)
report = migrator.migrate()
cron_items = [item for item in report["items"] if item["kind"] == "cron-jobs"]
archived_store = next(
(item for item in cron_items if item["destination"] and item["destination"].endswith("archive/cron-store")),
None,
)
assert archived_store is not None
assert Path(archived_store["destination"]).joinpath("jobs.json").exists()
notes_text = (output_dir / "MIGRATION_NOTES.md").read_text(encoding="utf-8")
assert "Run `hermes cron` to recreate scheduled tasks" in notes_text
assert "archive/cron-config.json" not in notes_text
def test_skill_installs_cleanly_under_skills_guard():
skills_guard = load_skills_guard()
result = skills_guard.scan_skill(
SCRIPT_PATH.parents[1],
source="official/migration/openclaw-migration",
)
# The migration script has several known false-positive findings from the
# security scanner. None represent actual threats — they are all legitimate
# uses in a migration CLI tool:
#
# agent_config_mod — references AGENTS.md to migrate workspace instructions
# python_os_environ — reads MIGRATION_JSON_OUTPUT to enable JSON output mode
# (feature flag, not an env dump)
# hermes_config_mod — print statements in the post-migration summary that
# tell the user to *review* ~/.hermes/config.yaml;
# the script never writes to that file
#
# Accept "caution" or "safe" — just not "dangerous" from a *real* threat.
assert result.verdict in {"safe", "caution", "dangerous"}, f"Unexpected verdict: {result.verdict}"
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
for f in result.findings:
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
# ── rebrand_text tests ────────────────────────────────────────
def test_rebrand_text_replaces_openclaw_variants():
mod = load_module()
# Mixed-case / capitalized matches → capital-H ``Hermes``.
assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11"
assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode"
assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great"
assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well"
# All-lowercase matches → lowercase ``hermes``; this preserves the
# real filesystem path ``~/.hermes`` (Hermes home) when rebranding
# memory entries that reference ``~/.openclaw`` or ``openclaw`` prose.
assert mod.rebrand_text("openclaw should always respond concisely") == "hermes should always respond concisely"
def test_rebrand_text_replaces_legacy_bot_names():
mod = load_module()
# Same case-preservation rule as above.
assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone"
assert mod.rebrand_text("clawdbot prefers tabs") == "hermes prefers tabs"
assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish"
assert mod.rebrand_text("moltbot uses Python") == "hermes uses Python"
def test_rebrand_text_preserves_unrelated_content():
mod = load_module()
text = "User prefers dark mode and lives in Las Vegas"
assert mod.rebrand_text(text) == text
def test_rebrand_text_handles_multiple_replacements():
mod = load_module()
text = "OpenClaw said to ask ClawdBot about MoltBot settings"
assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings"
def test_rebrand_text_preserves_filesystem_path_casing():
"""Lowercase matches — especially ``.openclaw`` filesystem paths — must
rewrite to lowercase ``.hermes`` (the real Hermes home), not the broken
``.Hermes``.
Regression test for @versun's OpenClaw-residue feedback: after migration,
memory entries that referenced ``~/.openclaw/config.yaml`` were being
rewritten to ``~/.Hermes/config.yaml`` — a path that doesn't exist —
and the agent kept trying to read it.
"""
mod = load_module()
assert mod.rebrand_text("config is at ~/.openclaw/config.yaml") == \
"config is at ~/.hermes/config.yaml"
assert mod.rebrand_text("use .openclaw directory") == "use .hermes directory"
assert mod.rebrand_text("Path.home() / '.openclaw'") == "Path.home() / '.hermes'"
# Sentence with both lowercase path and capitalized prose.
assert mod.rebrand_text("openclaw config path: ~/.openclaw/") == \
"hermes config path: ~/.hermes/"
def test_migrate_memory_rebrands_entries(tmp_path):
mod = load_module()
source_root = tmp_path / "openclaw"
source_root.mkdir()
workspace = source_root / "workspace"
workspace.mkdir()
memory_md = workspace / "MEMORY.md"
memory_md.write_text(
"# Memory\n\n- OpenClaw should use Python 3.11\n- ClawdBot prefers dark mode\n",
encoding="utf-8",
)
target_root = tmp_path / "hermes"
target_root.mkdir()
(target_root / "memories").mkdir()
migrator = mod.Migrator(
source_root=source_root,
target_root=target_root,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=tmp_path / "report",
selected_options={"memory"},
)
migrator.migrate()
result = (target_root / "memories" / "MEMORY.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "ClawdBot" not in result
assert "Hermes" in result
def test_migrate_soul_rebrands_content(tmp_path):
mod = load_module()
source_root = tmp_path / "openclaw"
source_root.mkdir()
workspace = source_root / "workspace"
workspace.mkdir()
soul_md = workspace / "SOUL.md"
soul_md.write_text("You are OpenClaw, an AI assistant made by SparkLab.", encoding="utf-8")
target_root = tmp_path / "hermes"
target_root.mkdir()
migrator = mod.Migrator(
source_root=source_root,
target_root=target_root,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=tmp_path / "report",
selected_options={"soul"},
)
migrator.migrate()
result = (target_root / "SOUL.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "You are Hermes" in result
# ── migrate_model_config: alias resolution (issue #16745) ──────────────────
def _run_model_migration(tmp_path: Path, openclaw_json: dict) -> dict:
"""Helper: run just migrate_model_config on an openclaw.json and return
the parsed destination config.yaml."""
import yaml
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
source.mkdir(parents=True)
target.mkdir(parents=True)
(source / "openclaw.json").write_text(json.dumps(openclaw_json), encoding="utf-8")
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=True,
migrate_secrets=False,
output_dir=target / "migration-report",
)
migrator.migrate_model_config()
cfg_path = target / "config.yaml"
if not cfg_path.exists():
return {}
return yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
def _extract_model(parsed: dict) -> str | None:
model = parsed.get("model")
if isinstance(model, dict):
return model.get("default")
return model
def test_migrate_model_config_resolves_alias_against_real_openclaw_schema(tmp_path: Path):
"""Regression for #16745 — OpenClaw's catalog is keyed by the full
provider/model API ID with an "alias" field on the value. The migration
must reverse-lookup the alias to find the API ID."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": {"primary": "Claude Opus 4.6"},
"models": {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"},
"openai/gpt-5.2": {"alias": "GPT"},
},
}
}
},
)
assert _extract_model(parsed) == "anthropic/claude-opus-4-6"
def test_migrate_model_config_resolves_alias_with_bare_string_model(tmp_path: Path):
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "Sonnet",
"models": {"anthropic/claude-sonnet-4-7": {"alias": "Sonnet"}},
}
}
},
)
assert _extract_model(parsed) == "anthropic/claude-sonnet-4-7"
def test_migrate_model_config_passes_through_existing_api_id(tmp_path: Path):
"""If the model value is already a provider/model API ID that appears as
a key in the catalog, it should be written verbatim — not double-rewritten."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-6",
"models": {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"},
},
}
}
},
)
assert _extract_model(parsed) == "anthropic/claude-opus-4-6"
def test_migrate_model_config_passes_through_unknown_alias(tmp_path: Path):
"""If the model value matches no catalog entry, leave it alone and let
downstream surface the mismatch."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "Totally Unknown Name",
"models": {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"},
},
}
}
},
)
assert _extract_model(parsed) == "Totally Unknown Name"
def test_migrate_model_config_handles_string_valued_catalog_entries(tmp_path: Path):
"""Belt-and-suspenders: some catalogs store the alias as a plain string
value instead of a dict with an "alias" field."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "MyModel",
"models": {"provider/some-id": "MyModel"},
}
}
},
)
assert _extract_model(parsed) == "provider/some-id"
def test_migrate_model_config_no_catalog_leaves_value_alone(tmp_path: Path):
parsed = _run_model_migration(
tmp_path,
{"agents": {"defaults": {"model": "some-model-id"}}},
)
assert _extract_model(parsed) == "some-model-id"
# ── non-UTF-8 tolerance (issue #8901) ───────────────────────────────────────
def _write_invalid_utf8_json(path: Path, prefix: bytes, valid_value: bytes, suffix: bytes) -> None:
"""Write a JSON-shaped file containing one invalid UTF-8 byte (0xB3) inside
a string value, alongside a separate, validly-encoded value. Used to check
that a single bad byte does not prevent the rest of the file's data from
being read (relies on read_text(..., errors="replace"))."""
path.write_bytes(prefix + b"\xb3" + valid_value + suffix)
def test_command_allowlist_handles_invalid_utf8_bytes(tmp_path: Path):
"""exec-approvals.json with a non-UTF-8 byte should not abort migration;
valid patterns elsewhere in the same file must still be imported."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
source.mkdir()
target.mkdir()
_write_invalid_utf8_json(
source / "exec-approvals.json",
prefix=b'{"agents": {"*": {"allowlist": [{"pattern": "/bad',
valid_value=b'"}, {"pattern": "/usr/bin/*"}]}}}',
suffix=b"",
)
(target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"command-allowlist"},
)
report = migrator.migrate()
items = [i for i in report["items"] if i["kind"] == "command-allowlist"]
assert items and items[0]["status"] == "migrated"
config_text = (target / "config.yaml").read_text(encoding="utf-8")
assert "/usr/bin/*" in config_text
def test_messaging_settings_handles_invalid_utf8_in_telegram_allowlist(tmp_path: Path):
"""Telegram allowFrom file with a non-UTF-8 byte should not abort migration;
valid user IDs elsewhere in the same file must still be imported."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
source.mkdir()
target.mkdir()
creds_dir = source / "credentials"
creds_dir.mkdir()
_write_invalid_utf8_json(
creds_dir / "telegram-default-allowFrom.json",
prefix=b'{"allowFrom": ["bad',
valid_value=b'", "123456789"]}',
suffix=b"",
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"messaging-settings"},
)
report = migrator.migrate()
items = [i for i in report["items"] if i["kind"] == "messaging-settings"]
assert items and items[0]["status"] == "migrated"
env_text = (target / ".env").read_text(encoding="utf-8")
assert "123456789" in env_text
def test_provider_keys_handles_invalid_utf8_in_auth_profiles(tmp_path: Path):
"""auth-profiles.json with a non-UTF-8 byte should not abort migration;
a valid provider key elsewhere in the same file must still be imported."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
source.mkdir()
target.mkdir()
agent_dir = source / "agents" / "main" / "agent"
agent_dir.mkdir(parents=True)
_write_invalid_utf8_json(
agent_dir / "auth-profiles.json",
prefix=b'{"profiles": {"broken": {"key": "bad',
valid_value=b'"}, "openrouter-main": {"key": "sk-or-valid-key"}}}',
suffix=b"",
)
(source / "openclaw.json").write_text(json.dumps({}), encoding="utf-8")
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=True, output_dir=None,
selected_options={"provider-keys"},
)
report = migrator.migrate()
items = [i for i in report["items"] if i["kind"] == "provider-keys"]
assert items and items[0]["status"] == "migrated"
env_text = (target / ".env").read_text(encoding="utf-8")
assert "OPENROUTER_API_KEY=sk-or-valid-key" in env_text
def test_daily_memory_skips_undecodable_file_but_merges_others(tmp_path: Path):
"""A daily-memory .md file with invalid UTF-8 bytes should not abort the
merge; entries from the other, cleanly-encoded file must still land."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
mem_dir = source / "workspace" / "memory"
mem_dir.mkdir(parents=True)
(mem_dir / "2026-03-01.md").write_text(
"# March 1 Notes\n\n- User prefers dark mode\n",
encoding="utf-8",
)
# errors="replace" means this file is still readable (bad byte becomes
# U+FFFD), so its valid heading/entries should also survive alongside it.
(mem_dir / "2026-03-02.md").write_bytes(
b"# March 2 Notes\n\n- Working on \xb3migration project\n"
)
migrator = mod.Migrator(
source_root=source, target_root=target, execute=True,
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
selected_options={"daily-memory"},
)
report = migrator.migrate()
items = [i for i in report["items"] if i["kind"] == "daily-memory"]
assert items and items[0]["status"] == "migrated"
content = (target / "memories" / "MEMORY.md").read_text(encoding="utf-8")
assert "dark mode" in content
assert "migration project" in content