From 2dfd73a497dd10c8e53d82698b4c9e43cd857957 Mon Sep 17 00:00:00 2001 From: in-liberty420 Date: Sun, 12 Apr 2026 10:03:55 -0300 Subject: [PATCH] fix(migration): resolve workspace files from agents.defaults.workspace OpenClaw users who started before the rebrand (when the project was clawd/clawdbot) often have a custom workspace directory configured via agents.defaults.workspace in openclaw.json (e.g. ~/clawd/ instead of ~/.openclaw/workspace/). The migration tool only checked hardcoded relative paths (workspace/, workspace-main/, workspace-assistant/) inside the source root, so files like MEMORY.md, skills, and daily memory in custom workspaces were silently skipped. This change: - Reads agents.defaults.workspace from openclaw.json at init time - Uses it as a final fallback in source_candidate() when files aren't found in the standard locations - Standard workspace paths are still preferred (custom is fallback only) - Custom workspace is only used when it's outside the source_root tree (avoids double-matching when workspace/ is the default) Adds two tests: - Custom workspace files are discovered and migrated - Standard workspace location is preferred over custom --- .../scripts/openclaw_to_hermes.py | 48 ++++++++++ tests/skills/test_openclaw_migration.py | 96 +++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index adfbd9f59b..b7beefcd47 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -619,6 +619,25 @@ class Migrator: self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None self.items: List[ItemResult] = [] + # Resolve the configured workspace directory from openclaw.json. + # Many users (especially those who started before the OpenClaw rebrand) + # have a custom workspace path (e.g. ~/clawd/) that differs from the + # default ~/.openclaw/workspace/. Reading agents.defaults.workspace + # lets source_candidate() find files in the actual workspace. + self._custom_workspace: Optional[Path] = None + oc_config = self._load_openclaw_config_early() + ws = (oc_config.get("agents", {}).get("defaults", {}).get("workspace") or "").strip() + if ws: + ws_path = Path(ws).expanduser().resolve() + # Only use it if it exists and is outside the source_root tree + # (otherwise the standard relative-path logic already covers it). + if ws_path.is_dir(): + try: + ws_path.relative_to(self.source_root) + except ValueError: + # ws_path is outside source_root — use it as custom workspace + self._custom_workspace = ws_path + config = load_yaml_file(self.target_root / "config.yaml") mem_cfg = config.get("memory", {}) if isinstance(config.get("memory"), dict) else {} self.memory_limit = int(mem_cfg.get("memory_char_limit", DEFAULT_MEMORY_CHAR_LIMIT)) @@ -632,6 +651,18 @@ class Migrator: + ", ".join(sorted(SKILL_CONFLICT_MODES)) ) + def _load_openclaw_config_early(self) -> Dict[str, Any]: + """Load openclaw.json during __init__ (before migrate() is called).""" + for name in ("openclaw.json", "clawdbot.json", "moltbot.json"): + config_path = self.source_root / name + if config_path.exists(): + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + continue + return {} + def is_selected(self, option_id: str) -> bool: return option_id in self.selected_options @@ -673,6 +704,23 @@ class Migrator: alt = self.source_root / "workspace-main" / suffix if alt.exists(): return alt + + # Final fallback: check the configured workspace directory from + # agents.defaults.workspace in openclaw.json. Users who started + # before the OpenClaw rebrand (when the project was named clawd / + # clawdbot) often have a custom workspace path outside ~/.openclaw/. + if self._custom_workspace: + for rel in relative_paths: + # Strip the leading "workspace/" or "workspace.default/" + # prefix to get the bare filename/subpath. + for prefix in ("workspace/", "workspace.default/"): + if rel.startswith(prefix): + suffix = rel[len(prefix):] + alt = self._custom_workspace / suffix + if alt.exists(): + return alt + break + return None def resolve_skill_destination(self, destination: Path) -> Path: diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index c880d64532..0698323b1b 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -280,6 +280,102 @@ def test_migrator_records_preset_in_report(tmp_path: Path): 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"