acbecf588a
A cloned profile carried the source's TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, allowlists, WHATSAPP_ENABLED, API_SERVER_KEY and the platforms:/telegram:/ discord: config sections byte-for-byte. Standalone, that made two gateways fight over one bot's long-poll; under multiplex it blocked `hermes gateway migrate --multiplex` with one duplicate-credential finding per platform per clone (18 on a real 10-profile install). Every clone entry point (CLI --clone/--clone-from/--clone-all, dashboard POST /api/profiles, TUI/Desktop profiles.create incl. its mirror_credentials .env copy) now strips channel settings after the copy. The key set is derived from the adapters — Platform enum + plugin registry (required_env, allowed_users_env, allow_all_env, cron_deliver_env_var), the gateway env table (gateway.config_env._ENV_STEPS / _ENV_ENABLE_CREDENTIALS) and each platform's env prefix — so a new adapter is covered without a hand list. --clone-all also drops pairing/WhatsApp-session/gateway ledgers. Provider and tool keys, the model block, memory, skills and SOUL.md are untouched. `--clone-channels` (REST/RPC: clone_channels) keeps them; it is refused when a live multiplexer already serves the source and otherwise warns which platforms are now shared. `hermes profile list` prints the same warning for existing clones whose bot credential is byte-identical to the default's. The dashboard's per-platform env-prefix table moves into profile_channels so Channels-page cards and the clone stripper share one definition.
123 lines
5.8 KiB
Python
123 lines
5.8 KiB
Python
"""``hermes profile create --clone`` leaves messaging channels behind (``hermes_cli.profile_channels``).
|
|
|
|
Invariant, not snapshot: the clone's credential fingerprint set — computed by the gateway's own
|
|
``_adapter_credential_fingerprint`` through the migrate preflight — is DISJOINT from the source's,
|
|
while provider/tool keys and general config survive; ``--clone-channels`` restores the copy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
import hermes_constants
|
|
from hermes_cli import gateway_migrate as gm
|
|
from hermes_cli.profile_channels import (
|
|
channel_platforms_configured, shared_channel_credentials, strip_channel_env_file,
|
|
)
|
|
from hermes_cli.profiles import create_profile
|
|
|
|
_SOURCE_ENV = (
|
|
"OPENAI_API_KEY=sk-model-key\n"
|
|
"FIRECRAWL_API_KEY=fc-tool-key\n"
|
|
"# telegram\n"
|
|
"TELEGRAM_BOT_TOKEN=111111:default-telegram-token\n"
|
|
"TELEGRAM_ALLOWED_USERS=12345\n"
|
|
"TELEGRAM_GROUP_ALLOWED_CHATS=-100999\n"
|
|
"DISCORD_BOT_TOKEN=default-discord-token-abcdef\n"
|
|
"DISCORD_ALLOWED_USERS=777\n"
|
|
"WHATSAPP_ENABLED=true\n"
|
|
"API_SERVER_KEY=default-api-server-key-0123456789\n"
|
|
)
|
|
_SOURCE_CONFIG = {
|
|
"model": {"default": "gpt-5", "provider": "openai"},
|
|
"memory": {"provider": "builtin"},
|
|
"platforms": {"telegram": {"enabled": True, "token": "111111:default-telegram-token"},
|
|
"discord": {"enabled": True}},
|
|
"telegram": {"reactions": True, "allowed_chats": "-100999"},
|
|
"discord": {"require_mention": False, "dm_role_auth_guild": "42"},
|
|
"gateway": {"multiplex_profiles": True, "profile_routes": [{"profile": "x", "platform": "telegram"}],
|
|
"platform_connect_timeout": 45},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def home(tmp_path, monkeypatch):
|
|
root = tmp_path / ".hermes"
|
|
root.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
|
|
for name in ("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "API_SERVER_KEY", "WHATSAPP_ENABLED",
|
|
"GATEWAY_MULTIPLEX_PROFILES", "TELEGRAM_ALLOWED_USERS"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
(root / ".env").write_text(_SOURCE_ENV, encoding="utf-8")
|
|
(root / "config.yaml").write_text(yaml.safe_dump(_SOURCE_CONFIG), encoding="utf-8")
|
|
(root / "SOUL.md").write_text("Be helpful.", encoding="utf-8")
|
|
monkeypatch.setattr(gm, "_installed_service", lambda home: None)
|
|
monkeypatch.setattr(gm, "_live_gateway_pid", lambda home: None)
|
|
return root
|
|
|
|
|
|
def _fingerprints(profile_home: Path) -> set:
|
|
"""``(platform, fingerprint)`` claims exactly as the migrate preflight / multiplexer see them."""
|
|
with gm._multiplex_read_mode():
|
|
return set(gm._credential_claims(gm._profile_gateway_config(profile_home)))
|
|
|
|
|
|
def test_clone_strips_every_channel_credential_but_keeps_model_and_tool_keys(home):
|
|
source_claims = _fingerprints(home)
|
|
assert {p for p, _ in source_claims} >= {"telegram", "discord"}
|
|
|
|
profile_dir = create_profile("bot2", clone_config=True, no_alias=True)
|
|
|
|
assert _fingerprints(profile_dir).isdisjoint(source_claims)
|
|
assert shared_channel_credentials(profile_dir, home) == []
|
|
env_text = (profile_dir / ".env").read_text(encoding="utf-8")
|
|
assert "OPENAI_API_KEY=sk-model-key" in env_text and "FIRECRAWL_API_KEY=fc-tool-key" in env_text
|
|
assert "TELEGRAM" not in env_text and "DISCORD" not in env_text
|
|
assert "WHATSAPP_ENABLED" not in env_text and "API_SERVER_KEY" not in env_text
|
|
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text(encoding="utf-8"))
|
|
assert cfg["model"] == _SOURCE_CONFIG["model"] and cfg["memory"] == _SOURCE_CONFIG["memory"]
|
|
assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == "Be helpful."
|
|
for section in ("platforms", "telegram", "discord"):
|
|
assert section not in cfg
|
|
# The clone must not think it is the host's multiplexer, but unrelated gateway knobs survive.
|
|
assert "multiplex_profiles" not in cfg["gateway"] and "profile_routes" not in cfg["gateway"]
|
|
assert cfg["gateway"]["platform_connect_timeout"] == 45
|
|
# The migrate preflight, which blocked with a duplicate finding per platform, is now clean.
|
|
plan = gm.build_migration_plan()
|
|
assert not plan.blocked, plan.blockers
|
|
|
|
|
|
def test_clone_channels_opt_in_keeps_the_source_channels(home):
|
|
profile_dir = create_profile("twin", clone_config=True, no_alias=True, clone_channels=True)
|
|
assert _fingerprints(profile_dir) == _fingerprints(home)
|
|
assert set(shared_channel_credentials(profile_dir, home)) >= {"telegram", "discord"}
|
|
assert set(channel_platforms_configured(profile_dir)) >= {"telegram", "discord", "whatsapp", "api_server"}
|
|
assert gm.build_migration_plan().blocked
|
|
|
|
|
|
def test_clone_all_drops_pairing_and_platform_state(home):
|
|
(home / "platforms" / "pairing").mkdir(parents=True)
|
|
(home / "platforms" / "pairing" / "telegram_approved.json").write_text("{}", encoding="utf-8")
|
|
(home / "discord_threads.json").write_text("{}", encoding="utf-8")
|
|
(home / "memories").mkdir()
|
|
(home / "memories" / "MEMORY.md").write_text("remember", encoding="utf-8")
|
|
|
|
profile_dir = create_profile("full", clone_all=True, no_alias=True)
|
|
|
|
assert not (profile_dir / "platforms").exists() and not (profile_dir / "discord_threads.json").exists()
|
|
assert (profile_dir / "memories" / "MEMORY.md").read_text(encoding="utf-8") == "remember"
|
|
assert _fingerprints(profile_dir) == set()
|
|
|
|
|
|
def test_strip_env_file_keeps_comments_and_unknown_keys_verbatim(tmp_path):
|
|
env = tmp_path / ".env"
|
|
env.write_text("# header\nexport OPENAI_API_KEY=abc\n\nTELEGRAM_BOT_TOKEN=1:x\nMY_CUSTOM_THING=1\n", encoding="utf-8")
|
|
removed = strip_channel_env_file(env)
|
|
assert removed == {"telegram": ["TELEGRAM_BOT_TOKEN"]}
|
|
assert env.read_text(encoding="utf-8") == "# header\nexport OPENAI_API_KEY=abc\n\nMY_CUSTOM_THING=1\n"
|