Files
2026-07-13 13:12:33 +08:00

1067 lines
39 KiB
Python

from __future__ import annotations
import json
import shutil
import tomllib
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Event
import pytest
from opensquilla.engine.context import load_context_files
from opensquilla.migration.openclaw import (
MigrationOptions,
OpenClawMigrator,
_ApplyRollback,
)
from opensquilla.onboarding.config_store import load_config, persist_config
from opensquilla.provider.selector import build_provider
from opensquilla.skills.loader import SkillLoader
from opensquilla.skills.types import SkillLayer
def _make_source(root: Path) -> Path:
source = root / ".openclaw"
workspace = source / "workspace"
(workspace / "memory").mkdir(parents=True)
(workspace / "skills" / "demo").mkdir(parents=True)
(workspace / "tts").mkdir(parents=True)
(source / "credentials").mkdir(parents=True)
(workspace / "SOUL.md").write_text("openclaw soul\n", encoding="utf-8")
(workspace / "AGENTS.md").write_text("agent rules\n", encoding="utf-8")
(workspace / "USER.md").write_text("user profile\n", encoding="utf-8")
(workspace / "MEMORY.md").write_text("core memory\n", encoding="utf-8")
(workspace / "memory" / "2026-05-10.md").write_text("daily note\n", encoding="utf-8")
(workspace / "skills" / "demo" / "SKILL.md").write_text(
"---\nname: demo\ndescription: Demo skill\n---\nDemo body\n",
encoding="utf-8",
)
(workspace / "tts" / "voice.txt").write_text("voice asset\n", encoding="utf-8")
(source / "exec-approvals.json").write_text(
json.dumps({"allow": ["^git status$", "^pytest "]}, ensure_ascii=False),
encoding="utf-8",
)
(source / "openclaw.json").write_text(
json.dumps(
{
"agents": {
"defaults": {
"model": "deepseek-chat",
"timeoutSeconds": 500,
}
},
"mcp": {
"servers": {
"demo-mcp": {
"command": "node",
"args": ["server.js"],
"env": {"DEMO": "1"},
}
}
},
"tools": {"exec": {"timeoutSec": 45}},
"messages": {
"telegram": {"defaultChatId": "12345"},
"discord": {"defaultChannelId": "C999"},
"tts": {"provider": "elevenlabs", "elevenlabs": {"voice": "voice-1"}},
},
},
ensure_ascii=False,
),
encoding="utf-8",
)
return source
def test_failed_rollback_retains_before_images_and_reports_recovery_path(
tmp_path: Path,
monkeypatch,
) -> None:
target = tmp_path / "target.txt"
target.write_text("before\n", encoding="utf-8")
rollback = _ApplyRollback()
backup_root = rollback._backup_root
def fail_restore(cls, image) -> None:
del cls, image
raise OSError("forced restore failure")
monkeypatch.setattr(_ApplyRollback, "_restore", classmethod(fail_restore))
try:
with pytest.raises(OSError, match="before-image backup retained") as caught:
with rollback:
rollback.capture(target)
target.write_text("after\n", encoding="utf-8")
raise RuntimeError("forced apply failure")
assert str(backup_root) in str(caught.value)
assert backup_root.is_dir()
assert (backup_root / "0").read_text(encoding="utf-8") == "before\n"
finally:
shutil.rmtree(backup_root, ignore_errors=True)
def test_late_failure_preserves_concurrent_settings_save(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
config_path.write_text("debug = false\n", encoding="utf-8")
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report_started = Event()
settings_saved = Event()
def fail_report_write(self) -> None:
del self
report_started.set()
if not settings_saved.wait(timeout=5):
raise AssertionError("settings save did not complete")
raise RuntimeError("forced late failure")
monkeypatch.setattr(OpenClawMigrator, "_write_report_files", fail_report_write)
migrator = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
)
with ThreadPoolExecutor(max_workers=1) as pool:
migration = pool.submit(migrator.migrate)
assert report_started.wait(timeout=5)
try:
settings = load_config(config_path, persist_migrations=False)
settings.debug = True
persist_config(settings, path=config_path, backup=False)
finally:
settings_saved.set()
with pytest.raises(RuntimeError, match="forced late failure"):
migration.result(timeout=5)
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["debug"] is True
assert "llm" not in persisted
def test_dry_run_does_not_write_targets(tmp_path: Path, monkeypatch) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=False)
).migrate()
assert report["apply"] is False
assert any(item["status"] == "planned" for item in report["items"])
assert not config_path.exists()
assert not (home / "workspace" / "SOUL.md").exists()
assert not (home / "skills" / "openclaw-imports").exists()
def test_dry_run_does_not_upgrade_legacy_target_config(tmp_path: Path, monkeypatch) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
fixture = (
Path(__file__).parent
/ "fixtures"
/ "homes"
/ "cli-0.1"
/ "config.toml"
)
shutil.copy2(fixture, config_path)
original = config_path.read_bytes()
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=False)
).migrate()
assert config_path.read_bytes() == original
assert list(tmp_path.glob("config.toml.backup.*")) == []
def test_apply_defers_legacy_target_config_upgrade_to_final_persist(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
original = (
f"workspace_dir = {json.dumps(str(home / 'workspace'))}\n"
f"state_dir = {json.dumps(str(home / 'state'))}\n"
"\n[llm_ensemble]\n"
"proposer_timeout_seconds = 120.0\n"
"aggregator_timeout_seconds = 120.0\n"
)
config_path.write_text(original, encoding="utf-8")
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
preset="user-data",
# Skill import is the only selected user-data operation that
# intentionally changes config; exclude it so this test isolates
# the deferred target-schema persist.
exclude=("skills", "shared-skills"),
)
).migrate()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["config_version"] == 1
assert persisted["llm_ensemble"]["proposer_timeout_seconds"] == 3600.0
assert persisted["llm_ensemble"]["aggregator_timeout_seconds"] == 3600.0
backups = list(tmp_path.glob("config.toml.backup.*"))
assert len(backups) == 1
assert backups[0].read_text(encoding="utf-8") == original
def test_apply_migrates_workspace_skills_config_and_allowlist(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
workspace = home / "workspace"
assert (workspace / "SOUL.md").read_text(encoding="utf-8") == "opensquilla soul\n"
assert (workspace / "AGENTS.md").read_text(encoding="utf-8") == "agent rules\n"
memory = (workspace / "MEMORY.md").read_text(encoding="utf-8")
assert "core memory" in memory
assert "daily note" in memory
skill_file = home / "skills" / "openclaw-imports" / "demo" / "SKILL.md"
assert skill_file.read_text(encoding="utf-8").startswith("---\nname: demo")
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert config["llm"]["model"] == "deepseek-chat"
assert config["mcp"]["enabled"] is True
assert config["mcp"]["servers"][0]["name"] == "demo-mcp"
assert str(home / "skills" / "openclaw-imports") in config["skills"]["extra_dirs"]
assert config["agent_runtime_timeout_seconds"] == 500
env_text = (home / ".env").read_text(encoding="utf-8")
assert "OPENSQUILLA_SAFE_BIN_ALLOW=^git status$,^pytest " in env_text
assert (Path(report["output_dir"]) / "report.json").is_file()
assert (Path(report["output_dir"]) / "summary.md").is_file()
def test_explicit_state_dir_normalizes_default_workspace_paths(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
config_path.write_text(
'workspace_dir = "'
+ str(Path.home() / ".opensquilla" / "workspace").replace("\\", "\\\\")
+ '"\nstate_dir = "'
+ str(Path.home() / ".opensquilla" / "state").replace("\\", "\\\\")
+ '"\n',
encoding="utf-8",
)
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
workspace = home / "workspace"
assert (workspace / "SOUL.md").read_text(encoding="utf-8") == "opensquilla soul\n"
assert not any("target exists" in item["reason"] for item in report["items"])
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert config["workspace_dir"] == str(home / "workspace")
assert config["state_dir"] == str(home / "state")
def test_secrets_are_opt_in_and_reports_are_redacted(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
(source / ".env").write_text("DEEPSEEK_API_KEY=sk-secret-value\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
no_secret_report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "no-secret.toml", apply=True)
).migrate()
assert "sk-secret-value" not in json.dumps(no_secret_report)
assert "DEEPSEEK_API_KEY=sk-secret-value" not in (home / ".env").read_text(
encoding="utf-8"
)
secret_report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "with-secret.toml",
apply=True,
migrate_secrets=True,
)
).migrate()
assert "DEEPSEEK_API_KEY=sk-secret-value" in (home / ".env").read_text(encoding="utf-8")
encoded = json.dumps(secret_report)
assert "sk-secret-value" not in encoded
assert "[redacted]" in encoded
def test_migrate_secrets_can_create_supported_channels(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
(source / ".env").write_text(
"TELEGRAM_BOT_TOKEN=tg-secret\nDISCORD_BOT_TOKEN=discord-secret\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
migrate_secrets=True,
)
).migrate()
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
channels = {entry["type"]: entry for entry in config["channels"]["channels"]}
assert channels["telegram"]["token"] == "tg-secret"
assert channels["telegram"]["default_chat_id"] == "12345"
assert channels["discord"]["token"] == "discord-secret"
assert channels["discord"]["default_channel_id"] == "C999"
assert "tg-secret" not in json.dumps(report)
assert "discord-secret" not in json.dumps(report)
def test_tts_assets_are_copied_and_tts_config_is_archived(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "config.toml", apply=True)
).migrate()
assert (home / "tts" / "voice.txt").read_text(encoding="utf-8") == "voice asset\n"
archive = Path(report["output_dir"]) / "archive" / "tts-config.json"
assert archive.is_file()
assert any(
item["kind"] == "tts-config" and item["status"] == "archived"
for item in report["items"]
)
def test_unmapped_openclaw_config_is_archived(tmp_path: Path, monkeypatch) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["plugins"] = {"enabled": True, "entries": [{"id": "custom"}]}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "config.toml", apply=True)
).migrate()
archive = Path(report["output_dir"]) / "archive" / "plugins-config.json"
assert archive.is_file()
assert any(
item["kind"] == "plugins-config" and item["status"] == "archived"
for item in report["items"]
)
def test_archive_redacts_secret_values_by_key_name(tmp_path: Path, monkeypatch) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["plugins"] = {
"entries": [
{
"id": "custom",
"config": {
"apiKey": "opaque-value-123",
"nested": {"webhookToken": "opaque-value-456"},
},
}
]
}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "config.toml", apply=True)
).migrate()
archive_text = (
Path(report["output_dir"]) / "archive" / "plugins-config.json"
).read_text(encoding="utf-8")
assert "opaque-value-123" not in archive_text
assert "opaque-value-456" not in archive_text
assert archive_text.count("[redacted]") == 2
def test_user_data_preset_skips_runtime_config_and_archives(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["plugins"] = {"entries": [{"id": "custom"}]}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
preset="user-data",
)
).migrate()
assert (home / "workspace" / "SOUL.md").is_file()
assert (home / "skills" / "openclaw-imports" / "demo" / "SKILL.md").is_file()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert str(home / "skills" / "openclaw-imports") in persisted["skills"]["extra_dirs"]
# The user-data preset skips model config entirely; with sparse
# persistence the llm section is not written at all.
assert persisted.get("llm", {}).get("model") != "deepseek-chat"
assert not (Path(report["output_dir"]) / "archive" / "plugins-config.json").exists()
assert not any(item["kind"] == "model-config" for item in report["items"])
def test_granular_archive_option_archives_only_selected_config(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["plugins"] = {"entries": [{"id": "custom"}]}
config["cron"] = {"jobs": [{"id": "nightly"}]}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "config.toml",
apply=True,
preset="user-data",
include=("plugins-config",),
)
).migrate()
archive_dir = Path(report["output_dir"]) / "archive"
assert (archive_dir / "plugins-config.json").is_file()
assert not (archive_dir / "cron-config.json").exists()
assert any(
item["kind"] == "plugins-config" and item["status"] == "archived"
for item in report["items"]
)
assert not any(item["kind"] == "cron-jobs" for item in report["items"])
def test_imported_skill_without_frontmatter_is_reported_not_loadable(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
workspace = source / "workspace"
bad_skill = workspace / "skills" / "no-frontmatter"
bad_skill.mkdir(parents=True)
(bad_skill / "SKILL.md").write_text("Missing frontmatter body\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "config.toml",
apply=True,
preset="user-data",
)
).migrate()
item = next(
item
for item in report["items"]
if item["source"] and item["source"].endswith("no-frontmatter")
)
assert item["status"] == "migrated"
assert item["details"]["opensquilla_loadable"] is False
assert "missing YAML frontmatter" in item["details"]["compatibility_issues"]
def test_crlf_skill_frontmatter_is_reported_loadable(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
skill_file = source / "workspace" / "skills" / "demo" / "SKILL.md"
skill_file.write_text(
"---\r\nname: demo\r\ndescription: Demo skill\r\n---\r\nDemo body\r\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "config.toml",
apply=True,
preset="user-data",
)
).migrate()
item = next(item for item in report["items"] if item["kind"] == "skills")
assert item["details"]["opensquilla_loadable"] is True
assert item["details"]["compatibility"] == "loadable"
def test_bom_encoded_openclaw_files_are_parsed(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = "claude-3-5-sonnet-latest"
config["models"] = {"providers": {"anthropic": {"baseUrl": "https://example.test/v1"}}}
(source / "openclaw.json").write_text(
json.dumps(config),
encoding="utf-8-sig",
)
(source / ".env").write_text(
"ANTHROPIC_API_KEY=sk-bom-secret\n",
encoding="utf-8-sig",
)
(source / "workspace" / "skills" / "demo" / "SKILL.md").write_text(
"---\nname: demo\ndescription: Demo skill\n---\nDemo body\n",
encoding="utf-8-sig",
)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
migrate_secrets=True,
)
).migrate()
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert config["llm"]["provider"] == "anthropic"
assert config["llm"]["api_key_env"] == "ANTHROPIC_API_KEY"
assert config["llm"]["base_url"] == "https://example.test/v1"
assert "ANTHROPIC_API_KEY=sk-bom-secret" in (home / ".env").read_text(
encoding="utf-8"
)
item = next(item for item in report["items"] if item["kind"] == "skills")
assert item["details"]["opensquilla_loadable"] is True
def test_model_config_resolves_object_and_alias_catalog(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = {"primary": "Claude Opus 4.6"}
config["agents"]["defaults"]["models"] = {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"}
}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["llm"]["model"] == "anthropic/claude-opus-4-6"
item = next(item for item in report["items"] if item["kind"] == "model-config")
assert item["details"]["requested_model"] == "Claude Opus 4.6"
assert item["details"]["resolved_from_alias"] is True
assert item["details"]["source_format"] == "object"
def test_provider_keys_can_migrate_from_provider_config_when_opted_in(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = "openrouter/deepseek/deepseek-v3.1"
config["models"] = {
"providers": {
"openrouter": {
"apiKey": "sk-or-from-config",
"baseUrl": "https://openrouter.example.test/api/v1",
}
}
}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
no_secret_report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "no-secret.toml", apply=True)
).migrate()
no_secret_env = (home / ".env").read_text(encoding="utf-8") if (home / ".env").exists() else ""
assert "OPENROUTER_API_KEY" not in no_secret_env
assert "sk-or-from-config" not in json.dumps(no_secret_report)
secret_report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
migrate_secrets=True,
)
).migrate()
env_text = (home / ".env").read_text(encoding="utf-8")
assert "OPENROUTER_API_KEY=sk-or-from-config" in env_text
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
# Sparse persistence omits values equal to the built-in default
# (provider "openrouter"); the effective provider must still resolve
# to the migrated value.
assert persisted["llm"].get("provider", "openrouter") == "openrouter"
assert load_config(config_path).llm.provider == "openrouter"
assert persisted["llm"]["model"] == "deepseek/deepseek-v3.1"
assert persisted["llm"]["api_key_env"] == "OPENROUTER_API_KEY"
assert persisted["llm"]["base_url"] == "https://openrouter.example.test/api/v1"
item = next(item for item in secret_report["items"] if item["kind"] == "model-config")
assert item["details"]["source_model"] == "openrouter/deepseek/deepseek-v3.1"
assert item["details"]["normalized_provider_prefix"] == "openrouter"
assert "sk-or-from-config" not in json.dumps(secret_report)
def test_zai_and_glm_models_migrate_to_zhipu_provider(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = "zai/glm-4.5"
config["models"] = {
"providers": {
"zai": {
"apiKey": "sk-zai-secret",
"baseUrl": "https://zhipu.example.test/api/paas/v4",
}
}
}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
migrate_secrets=True,
)
).migrate()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["llm"]["provider"] == "zhipu"
assert persisted["llm"]["model"] == "glm-4.5"
assert persisted["llm"]["api_key_env"] == "ZAI_API_KEY"
assert persisted["llm"]["base_url"] == "https://zhipu.example.test/api/paas/v4"
build_provider(persisted["llm"]["provider"], persisted["llm"]["model"])
def test_model_provider_conflict_with_existing_tier_profile_is_reported(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = "anthropic/claude-3-5-sonnet"
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
config_path.write_text(
"\n".join(
[
"[llm]",
'provider = "openrouter"',
'model = "deepseek/deepseek-v4-flash"',
"",
"[squilla_router]",
"enabled = true",
'tier_profile = "openrouter"',
"",
]
),
encoding="utf-8",
)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["llm"]["provider"] == "openrouter"
assert persisted["llm"]["model"] == "deepseek/deepseek-v4-flash"
assert persisted["squilla_router"]["tier_profile"] == "openrouter"
item = next(item for item in report["items"] if item["kind"] == "model-config")
assert item["details"]["tier_profile_conflict"] == "openrouter"
assert item["details"]["llm_provider_left_unchanged"] == "openrouter"
assert item["details"]["llm_model_left_unchanged"] == "deepseek/deepseek-v4-flash"
assert item["details"]["skipped_model"] == "anthropic/claude-3-5-sonnet"
assert item["details"]["manual_steps"]
def test_model_provider_conflict_with_direct_provider_preserves_model(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = "anthropic/claude-3-5-sonnet"
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
config_path.write_text(
"\n".join(
[
"[llm]",
'provider = "deepseek"',
'model = "deepseek-v4-flash"',
"",
"[squilla_router]",
"enabled = true",
'tier_profile = "deepseek"',
"",
]
),
encoding="utf-8",
)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["llm"]["provider"] == "deepseek"
assert persisted["llm"]["model"] == "deepseek-v4-flash"
item = next(item for item in report["items"] if item["kind"] == "model-config")
assert item["details"]["tier_profile_conflict"] == "deepseek"
assert item["details"]["llm_model_left_unchanged"] == "deepseek-v4-flash"
assert item["details"]["skipped_model"] == "anthropic/claude-3-5-sonnet"
def test_env_secret_migration_preserves_existing_lines_and_dedupes_keys(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"]["model"] = "openrouter/deepseek/deepseek-v3.1"
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
(source / ".env").write_text("OPENROUTER_API_KEY=sk-new\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
env_path = home / ".env"
env_path.parent.mkdir(parents=True)
env_path.write_text(
"\n".join(
[
"# existing operator note",
"",
"UNRELATED=value",
"export OPENROUTER_API_KEY=sk-old",
"",
]
),
encoding="utf-8",
)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "config.toml",
apply=True,
migrate_secrets=True,
)
).migrate()
env_text = env_path.read_text(encoding="utf-8")
assert "# existing operator note" in env_text
assert "UNRELATED=value" in env_text
assert "OPENROUTER_API_KEY=sk-new" in env_text
assert "sk-old" not in env_text
assert env_text.count("OPENROUTER_API_KEY=") == 1
def test_memory_migration_deduplicates_and_archives_overflow(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
workspace = source / "workspace"
(workspace / "MEMORY.md").write_text("shared memory\n", encoding="utf-8")
(workspace / "memory" / "2026-05-10.md").write_text("shared memory\n", encoding="utf-8")
(workspace / "memory" / "2026-05-11.md").write_text("x" * 90_000, encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
migrated = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert migrated.count("shared memory") == 1
assert "Migration overflow" in migrated
overflow = Path(report["output_dir"]) / "archive" / "memory-overflow" / "MEMORY.overflow.md"
assert overflow.is_file()
assert "x" in overflow.read_text(encoding="utf-8")
item = next(item for item in report["items"] if item["kind"] == "memory")
assert item["details"]["deduplicated_blocks"] == 1
assert "overflow" not in item["details"]
def test_workspace_text_is_rebranded_and_original_is_archived(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
workspace = source / "workspace"
(workspace / "SOUL.md").write_text(
"OpenClaw should read .openclaw paths. ClawdBot helps MoltBot.\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
migrated = (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
assert "OpenSquilla should read .opensquilla paths" in migrated
assert "ClawdBot" not in migrated
original = Path(report["output_dir"]) / "archive" / "files" / "workspace-original" / "SOUL.md"
assert original.read_text(encoding="utf-8").startswith("OpenClaw should read .openclaw")
item = next(item for item in report["items"] if item["kind"] == "soul")
assert item["details"]["semantic_conversions"] == ["openclaw-branding"]
def test_rebrand_preserves_openclaw_source_references(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
workspace = source / "workspace"
(workspace / "SOUL.md").write_text(
"\n".join(
[
"你是 OpenClaw 迁移后的助手。",
"保留 OpenClaw 来源归档用于人工复核。",
"不要只迁移 openclaw.json。",
"今天通过真实 OpenClaw CLI onboarding 和 gateway 验证流程。",
"OpenClaw 的 branding 文本需要语义转换。",
]
),
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "config.toml", apply=True)
).migrate()
migrated = (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
assert "你是 OpenSquilla 迁移后的助手。" in migrated
assert "OpenClaw 来源归档" in migrated
assert "openclaw.json" in migrated
assert "OpenClaw CLI onboarding" in migrated
assert "OpenClaw 的 branding 文本" in migrated
def test_agent_search_and_channel_semantics_are_mapped(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["agents"]["defaults"].update(
{
"thinkingDefault": "high",
"compaction": {"mode": "truncate", "model": "legacy-compact-model"},
"verboseDefault": True,
"humanDelay": {"minMs": 10, "maxMs": 20},
"userTimezone": "Asia/Shanghai",
}
)
config["messages"]["telegram"]["allowFrom"] = ["1001", "1002"]
config["messages"]["discord"]["allowedUsers"] = ["discord-user"]
config["messages"]["slack"] = {"adminUsers": ["slack-admin"]}
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
(source / ".env").write_text("BRAVE_API_KEY=brave-secret\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
config_path = tmp_path / "config.toml"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=config_path,
apply=True,
migrate_secrets=True,
)
).migrate()
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert persisted["llm"]["thinking"] == "high"
assert persisted["context_overflow_policy"] == "hard_truncate"
assert persisted["search_provider"] == "brave"
assert persisted["search_api_key_env"] == "BRAVE_API_KEY"
assert persisted["channel_admin_senders"] == {"slack": ["slack-admin"]}
assert "BRAVE_API_KEY=brave-secret" in (home / ".env").read_text(encoding="utf-8")
notes = Path(report["output_dir"]) / "MIGRATION_NOTES.md"
assert notes.is_file()
notes_text = notes.read_text(encoding="utf-8")
assert "verboseDefault" in notes_text
assert "humanDelay" in notes_text
assert "legacy-compact-model" in notes_text
assert "allowFrom" in notes_text
assert "allowedUsers" in notes_text
def test_dry_run_report_includes_semantic_notes(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
config = json.loads((source / "openclaw.json").read_text(encoding="utf-8"))
config["messages"]["telegram"]["allowFrom"] = ["1001"]
(source / "openclaw.json").write_text(json.dumps(config), encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "config.toml", apply=False)
).migrate()
assert report["apply"] is False
assert any("allowFrom" in note for note in report["notes"])
assert not Path(report["output_dir"], "MIGRATION_NOTES.md").exists()
def test_rebranded_daily_memory_original_is_archived(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
workspace = source / "workspace"
(workspace / "MEMORY.md").write_text("stable memory\n", encoding="utf-8")
(workspace / "memory" / "2026-05-12.md").write_text(
"OpenClaw daily memory\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "config.toml", apply=True)
).migrate()
migrated = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert "OpenSquilla daily memory" in migrated
original = (
Path(report["output_dir"])
/ "archive"
/ "files"
/ "workspace-original"
/ "memory"
/ "2026-05-12.md"
)
assert original.read_text(encoding="utf-8") == "OpenClaw daily memory\n"
def test_migrated_workspace_and_loadable_skills_are_consumed_by_opensquilla(
tmp_path: Path,
monkeypatch,
) -> None:
source = _make_source(tmp_path)
home = tmp_path / "opensquilla-home"
isolated_home = tmp_path / "isolated-home"
isolated_home.mkdir()
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
monkeypatch.setenv("HOME", str(isolated_home))
monkeypatch.setenv("USERPROFILE", str(isolated_home))
OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "config.toml",
apply=True,
preset="user-data",
)
).migrate()
context = load_context_files(str(home / "workspace"))
assert context.soul == "opensquilla soul\n"
assert context.agents == "agent rules\n"
assert context.user == "user profile\n"
assert "core memory" in (context.memory or "")
assert "daily note" in (context.memory or "")
loader = SkillLoader(
bundled_dir=None,
workspace_dir=None,
managed_dir=None,
personal_agents_dir=None,
project_agents_dir=None,
extra_dirs=[home / "skills" / "openclaw-imports"],
snapshot_path=tmp_path / "skills_snapshot.json",
)
skills = loader.load_all()
assert [(skill.name, skill.layer) for skill in skills] == [("demo", SkillLayer.EXTRA)]
assert skills[0].description == "Demo skill"