From acbecf588a83e2f54125b7853dd4d466b9a19094 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:10:40 -0700 Subject: [PATCH] fix(profiles): --clone leaves messaging channels behind; --clone-channels opts in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/AGENTS.md | 4 +- hermes_cli/profile_channels.py | 354 ++++++++++++++++++ hermes_cli/profile_cmd.py | 85 ++++- hermes_cli/profiles.py | 10 + hermes_cli/subcommands/profile.py | 10 +- hermes_cli/web_models.py | 3 + hermes_cli/web_routers/profiles.py | 3 +- hermes_cli/web_server_messaging.py | 15 +- .../hermes_cli/test_profile_clone_channels.py | 122 ++++++ tui_gateway/methods_profiles.py | 10 +- website/docs/user-guide/profiles.md | 28 ++ 11 files changed, 626 insertions(+), 18 deletions(-) create mode 100644 hermes_cli/profile_channels.py create mode 100644 tests/hermes_cli/test_profile_clone_channels.py diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index abbaf5e942..b9896c080e 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -128,7 +128,9 @@ matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87 `_apply_profile_override()` in `hermes_cli/main.py` sets `HERMES_HOME` before any module import, so every `get_hermes_home()` scopes to the active profile (rules in root). Profiles are independent -islands by design — no live config inheritance; `--clone` copies at creation. Multiplex +islands by design — no live config inheritance; `--clone` copies at creation, minus messaging +channels (`profile_channels.py` derives the token/allowlist/platform-section key set from the adapter +registry + `gateway/config_env._ENV_STEPS`, never a hand list; `--clone-channels` opts in). Multiplex (`gateway.multiplex_profiles`) secret-scope rules: `gateway/AGENTS.md`. The served set is `profiles.py::profiles_to_serve(multiplex=True)` = default + every live (non-tombstoned) dir under `profiles/` — there is no allowlist (`gateway.multiplex_profile_allowlist` was retired in config v43). diff --git a/hermes_cli/profile_channels.py b/hermes_cli/profile_channels.py new file mode 100644 index 0000000000..dbf81f8872 --- /dev/null +++ b/hermes_cli/profile_channels.py @@ -0,0 +1,354 @@ +"""Messaging-channel settings a profile clone must NOT inherit. + +A ``--clone``d profile that keeps the source's bot tokens, allowlists and platform state makes two +gateways fight over one bot (standalone) or blocks ``hermes gateway migrate --multiplex`` with a +duplicate-credential finding per platform. The key set is DERIVED from the platform adapters — the +``Platform`` enum + plugin registry (``required_env``, allowlist/allow-all/home-channel env names), +the gateway env-override table (``gateway.config_env._ENV_STEPS`` / ``_ENV_ENABLE_CREDENTIALS``) and +the ``_`` env prefix every adapter's keys share — so a new adapter is covered without a +hand-written list. Model/provider keys, tool keys, memory and general config are never touched. +""" + +from __future__ import annotations + +import contextlib +import logging +import re +from functools import partial +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +logger = logging.getLogger(__name__) + +# Platforms whose env names do not share the ``_`` prefix of their config id. The +# dashboard Channels page uses the same table to decide which Keys-page fields a card owns. +_PLATFORM_ENV_PREFIX_ALIASES: dict[str, tuple[str, ...]] = { + "email": ("EMAIL_",), + "homeassistant": ("HASS_",), + "qqbot": ("QQ_", "QQBOT_"), + "sms": ("TWILIO_",), + "wecom": ("WECOM_BOT_", "WECOM_SECRET"), + "wecom_callback": ("WECOM_CALLBACK_",), +} + +# Multiplexer-owner settings: a clone of the default that inherits them and is then started +# standalone tries to be a second multiplexer for every profile on the host. +_GATEWAY_OWNER_KEYS = ("multiplex_profiles", "profile_routes") + +_ENV_LINE_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=") + + +def platform_env_prefixes(platform_id: str) -> tuple[str, ...]: + """Env-var prefixes owned by one messaging platform.""" + return _PLATFORM_ENV_PREFIX_ALIASES.get(platform_id, (platform_id.upper().replace("-", "_") + "_",)) + + +def platform_ids() -> List[str]: + """Every messaging platform id: built-in ``Platform`` members plus registered plugin adapters.""" + from gateway.config import Platform + ids = {m.value for m in Platform.__members__.values() if m.value != "local"} + with contextlib.suppress(Exception): + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent + from gateway.platform_registry import platform_registry + ids.update(entry.name for entry in platform_registry.all_entries()) + return sorted(ids) + + +def _cred_row_envs(row) -> Set[str]: + """Every env name a ``gateway.config_env._Cred`` row reads.""" + names: Set[str] = set() + + def _flatten(spec) -> None: + if isinstance(spec, str): + names.add(spec) + elif isinstance(spec, (tuple, list)): + for item in spec: + _flatten(item) + + _flatten(row.creds) + if row.token: + names.add(row.token) + for key_env in (*row.fixed, *row.optional, *row.optional_stripped): + _flatten(key_env[1]) + if row.warn_missing: + names.add(row.warn_missing[0]) + if row.home: + names.update({row.home, f"{row.home}_NAME", f"{row.home}_THREAD_ID"}) + return names + + +def declared_channel_env_keys() -> Dict[str, str]: + """``{ENV_KEY: platform_id}`` for every env name an adapter declares outright (registry entry + fields, the gateway env-override table). Prefix matching covers the rest.""" + keys: Dict[str, str] = {} + with contextlib.suppress(Exception): + from hermes_cli.plugins import discover_plugins + discover_plugins() + from gateway.platform_registry import platform_registry + for entry in platform_registry.all_entries(): + for name in (*entry.required_env, entry.allowed_users_env, entry.allow_all_env, entry.cron_deliver_env_var): + if name: + keys[name] = entry.name + with contextlib.suppress(Exception): + from gateway import config_env + for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items(): + keys.update(dict.fromkeys(names, platform.value)) + for step in config_env._ENV_STEPS: + if isinstance(step, config_env._Cred): + keys.update(dict.fromkeys(_cred_row_envs(step), step.platform.value)) + elif isinstance(step, partial): + platform = step.keywords.get("platform") + for kw in ("env", "env_base"): + if step.keywords.get(kw) and platform is not None: + keys[step.keywords[kw]] = platform.value + return keys + + +_CREDENTIAL_SUFFIXES = ( + "_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_APP_ID", "_CLIENT_ID", "_BOT_ID", "_ACCOUNT_SID", + "_SERVICE_ACCOUNT_JSON", "_PROJECT_ID", +) + + +def credential_env_keys() -> Dict[str, str]: + """``{ENV_KEY: platform_id}`` for the keys that make an adapter CONNECT AS a bot (token / app id / + client id / secret — the shape ``GatewayRunner._adapter_credential_fingerprint`` hashes). Enable + flags, URLs and hosts are excluded: two profiles pointing at one Mattermost server collide only + when they also share the token.""" + keys: Dict[str, str] = {} + with contextlib.suppress(Exception): + from hermes_cli.plugins import discover_plugins + discover_plugins() + from gateway.platform_registry import platform_registry + for entry in platform_registry.all_entries(): + keys.update(dict.fromkeys(entry.required_env, entry.name)) + with contextlib.suppress(Exception): + from gateway import config_env + for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items(): + keys.update(dict.fromkeys(names, platform.value)) + for step in config_env._ENV_STEPS: + if isinstance(step, config_env._Cred): + creds: Set[str] = set() + for group in step.creds: + creds.update((group,) if isinstance(group, str) else group) + if step.token: + creds.add(step.token) + keys.update(dict.fromkeys(creds, step.platform.value)) + return {key: pid for key, pid in keys.items() if key.endswith(_CREDENTIAL_SUFFIXES)} + + +class ChannelKeyIndex: + """Resolves an env key to the messaging platform that owns it (``None`` = not a channel key).""" + + def __init__(self) -> None: + self.platforms = platform_ids() + self.declared = declared_channel_env_keys() + self._prefixes: List[Tuple[str, str]] = sorted( + ((prefix, pid) for pid in self.platforms for prefix in platform_env_prefixes(pid)), + key=lambda item: -len(item[0]), # longest prefix wins: WECOM_CALLBACK_ before WECOM_ + ) + + def platform_for(self, key: str) -> Optional[str]: + if key in self.declared: + return self.declared[key] + return next((pid for prefix, pid in self._prefixes if key.startswith(prefix)), None) + + +def _env_key_of_line(line: str) -> Optional[str]: + match = _ENV_LINE_RE.match(line) + return match.group(1) if match else None + + +def strip_channel_env_file(env_path: Path, index: Optional[ChannelKeyIndex] = None) -> Dict[str, List[str]]: + """Drop every messaging-channel assignment from ``env_path`` in place; comments, blank lines and + every other key survive verbatim. Returns ``{platform: [keys removed]}``.""" + if not env_path.is_file(): + return {} + index = index or ChannelKeyIndex() + removed: Dict[str, List[str]] = {} + kept: List[str] = [] + text = env_path.read_text(encoding="utf-8-sig", errors="replace") + for line in text.splitlines(): + key = _env_key_of_line(line) + platform = index.platform_for(key) if key else None + if key is None or platform is None: + kept.append(line) + else: + removed.setdefault(platform, []).append(key) + if removed: + env_path.write_text("\n".join(kept) + ("\n" if text.endswith("\n") or kept else ""), encoding="utf-8") + return removed + + +def _channel_config_paths(raw: dict, platforms: Iterable[str]) -> List[Tuple[str, ...]]: + """Dotted paths in a raw config.yaml mapping that hold platform identity: ``platforms``, every + top-level ``:`` block, ``gateway.platforms`` / ``gateway.``, and the + multiplexer-owner keys (both spellings the gateway loader accepts).""" + paths: List[Tuple[str, ...]] = [] + gateway: dict = raw["gateway"] if isinstance(raw.get("gateway"), dict) else {} + if "platforms" in raw: + paths.append(("platforms",)) + if "platforms" in gateway: + paths.append(("gateway", "platforms")) + for key in _GATEWAY_OWNER_KEYS: + if key in raw: + paths.append((key,)) + if key in gateway: + paths.append(("gateway", key)) + for pid in platforms: + if pid in raw: + paths.append((pid,)) + if pid in gateway: + paths.append(("gateway", pid)) + return paths + + +def strip_channel_config(config_path: Path, index: Optional[ChannelKeyIndex] = None) -> List[str]: + """Remove platform sections from a raw ``config.yaml`` in place. Returns the dotted paths removed.""" + if not config_path.is_file(): + return [] + from hermes_cli.config import read_user_config_raw + from utils import atomic_yaml_write + index = index or ChannelKeyIndex() + raw = read_user_config_raw(config_path) + paths = _channel_config_paths(raw, index.platforms) + if not paths: + return [] + for path in paths: + node = raw + for seg in path[:-1]: + node = node[seg] + node.pop(path[-1], None) + if isinstance(raw.get("gateway"), dict) and not raw["gateway"]: + raw.pop("gateway") + atomic_yaml_write(config_path, raw, sort_keys=False) + return [".".join(path) for path in paths] + + +def channel_state_entries(root: Path, index: Optional[ChannelKeyIndex] = None) -> List[Path]: + """Root entries of a profile that hold per-bot runtime identity: pairing approvals and the + WhatsApp device session (``platforms/`` + legacy dirs), the gateway's per-platform ledgers, + channel directories and every ``_*`` state file an adapter writes beside config.yaml.""" + if not root.is_dir(): + return [] + index = index or ChannelKeyIndex() + fixed = {"platforms", "pairing", "whatsapp", "gateway", "channel_directory.json", "channel_aliases.json"} + prefixes = tuple(f"{pid}_" for pid in index.platforms) + return sorted( + entry for entry in root.iterdir() + if entry.name in fixed or (entry.is_file() and entry.name.startswith(prefixes)) + ) + + +def strip_channel_settings(profile_dir: Path, *, include_state: bool) -> Dict[str, List[str]]: + """Strip channel credentials/identity from a freshly cloned profile. ``include_state`` also + drops the runtime state ``--clone-all`` copied. Returns ``{platform|"config"|"state": [what]}``.""" + import shutil + index = ChannelKeyIndex() + stripped: Dict[str, List[str]] = dict(strip_channel_env_file(profile_dir / ".env", index)) + config_paths = strip_channel_config(profile_dir / "config.yaml", index) + if config_paths: + stripped["config"] = config_paths + if include_state: + dropped = [] + for entry in channel_state_entries(profile_dir, index): + shutil.rmtree(entry, ignore_errors=True) if entry.is_dir() else entry.unlink(missing_ok=True) + dropped.append(entry.name) + if dropped: + stripped["state"] = dropped + return stripped + + +def channel_platforms_configured(profile_dir: Path) -> List[str]: + """Platform ids with any channel setting in ``profile_dir`` (.env keys or config.yaml sections) — + what a channel-less clone of it leaves behind. Pure read.""" + index = ChannelKeyIndex() + found: Set[str] = set() + env_path = profile_dir / ".env" + if env_path.is_file(): + for line in env_path.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + key = _env_key_of_line(line) + platform = index.platform_for(key) if key else None + if platform: + found.add(platform) + config_path = profile_dir / "config.yaml" + if config_path.is_file(): + from hermes_cli.config import read_user_config_raw + raw = read_user_config_raw(config_path) + for path in _channel_config_paths(raw, index.platforms): + node = raw + for seg in path: + node = node[seg] + if path[-1] == "platforms" and isinstance(node, dict): + found.update(str(k) for k in node) + elif path[-1] in index.platforms: + found.add(path[-1]) + return sorted(found) + + +def _env_values(env_path: Path, wanted: Dict[str, str]) -> Dict[str, str]: + values: Dict[str, str] = {} + if not env_path.is_file(): + return values + from dotenv import dotenv_values + with contextlib.suppress(Exception): + for key, value in (dotenv_values(env_path, encoding="utf-8-sig") or {}).items(): + if key in wanted and value and value.strip(): + values[key] = value.strip() + return values + + +def _config_platform_tokens(config_path: Path) -> Dict[str, str]: + """``{platform: token}`` from ``platforms.

.token|api_key`` (both nesting spellings).""" + tokens: Dict[str, str] = {} + if not config_path.is_file(): + return tokens + from hermes_cli.config import read_user_config_raw + raw = read_user_config_raw(config_path) + gateway: dict = raw["gateway"] if isinstance(raw.get("gateway"), dict) else {} + for section in (raw.get("platforms"), gateway.get("platforms")): + if not isinstance(section, dict): + continue + for pid, block in section.items(): + if isinstance(block, dict): + token = block.get("token") or block.get("api_key") + if isinstance(token, str) and token.strip(): + tokens[str(pid)] = token.strip() + return tokens + + +def shared_channel_credentials(profile_dir: Path, source_dir: Path) -> List[str]: + """Platforms whose CONNECTING credential (bot token / app id / account) in ``profile_dir`` is + byte-identical to ``source_dir``'s — the bots that will collide. Pure file reads: no secret + manager, no gateway config load, so ``hermes profile list`` can afford it per profile.""" + wanted = credential_env_keys() + mine = _env_values(profile_dir / ".env", wanted) + theirs = _env_values(source_dir / ".env", wanted) + shared = {wanted[key] for key in mine if theirs.get(key) == mine[key]} + mine_cfg = _config_platform_tokens(profile_dir / "config.yaml") + theirs_cfg = _config_platform_tokens(source_dir / "config.yaml") + shared.update(pid for pid, token in mine_cfg.items() if theirs_cfg.get(pid) == token) + return sorted(shared) + + +def shared_credential_warning(profile: str, platforms: List[str], source: str = "default") -> str: + return ( + f"⚠ Profile '{profile}' shares its {', '.join(platforms)} credential with {source}: the bot can " + f"only belong to one profile. Give '{profile}' its own bot (hermes -p {profile} setup, or the " + f"dashboard Messaging page) or remove the token from '{profile}'; a multiplexed gateway parks " + f"the duplicate and `hermes gateway migrate --multiplex` refuses until it is gone." + ) + + +def format_stripped_notice(profile: str, platforms: List[str], clone_flag: str = "--clone") -> List[str]: + """Lines printed after a channel-less clone so the user knows what was left behind and how to + configure the new profile's own bots.""" + if not platforms: + return [] + return [ + f"Messaging channels were NOT cloned ({', '.join(platforms)}): a copied bot token or allowlist " + "would make two gateways fight over one bot.", + f" Configure this profile's own bots: hermes -p {profile} setup (or the dashboard Messaging page)", + f" To copy the source's channels anyway: hermes profile create {profile} {clone_flag} --clone-channels", + ] diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index faf46f2dcc..45b67323a9 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -9,6 +9,7 @@ from __future__ import annotations from pathlib import Path import os import sys +from typing import Optional def _die(msg: str, code: int = 1, *, err: bool = False) -> None: @@ -132,6 +133,29 @@ def _profile_list(args): dist = f"{p.distribution_name}@{p.distribution_version or '?'}"[:30] if p.distribution_name else "—" print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}") print() + for line in _shared_credential_warnings(profiles): + print(line) + + +def _shared_credential_warnings(profiles) -> list: + """One warning per named profile whose bot credential is byte-identical to the default's + (typically an old ``--clone`` that copied .env): the collision that parks a multiplexed + adapter or makes two standalone gateways fight over one bot.""" + from hermes_cli.profile_channels import shared_channel_credentials, shared_credential_warning + default = next((p for p in profiles if p.is_default), None) + if default is None: + return [] + lines = [] + for p in profiles: + if p.is_default: + continue + try: + shared = shared_channel_credentials(p.path, default.path) + except Exception: + continue + if shared: + lines.append(shared_credential_warning(p.name, shared)) + return lines + ([""] if lines else []) def _profile_use(args): @@ -144,6 +168,58 @@ def _profile_use(args): _die(f"Error: {e}") +def _source_profile_dir(source_label: str) -> Path: + from hermes_cli.profiles import get_profile_dir + source_dir = get_profile_dir(source_label) + if not source_dir.is_dir(): + raise FileNotFoundError(source_dir) + return source_dir + + +def _clone_channels_refusal(source_label: str) -> Optional[str]: + """``--clone-channels`` is refused when a live multiplexer already serves the source: the + duplicate adapter would be parked at once (same explanation the migrate preflight gives).""" + from hermes_cli.gateway_multiplex_served import recorded_served_profiles + from hermes_cli.profile_channels import channel_platforms_configured + from hermes_cli.profiles import normalize_profile_name + served = recorded_served_profiles() + if not served or len(served) < 2 or normalize_profile_name(source_label) not in { + normalize_profile_name(p) for p in served + }: + return None + try: + platforms = channel_platforms_configured(_source_profile_dir(source_label)) + except FileNotFoundError: + return None + if not platforms: + return None + return ( + f"Error: --clone-channels would copy {', '.join(platforms)} from '{source_label}', which the running " + "multiplexed gateway already serves: the bot can only belong to one profile, so the copy would be " + "parked as a duplicate credential. Clone without --clone-channels and give the new profile its own bot " + "(hermes -p setup), or route its chats with gateway.profile_routes instead." + ) + + +def _print_channel_clone_notice(name: str, source_label: str, clone_channels: bool, clone_flag: str) -> None: + from hermes_cli.profile_channels import ( + channel_platforms_configured, format_stripped_notice, shared_channel_credentials, + shared_credential_warning, + ) + from hermes_cli.profiles import get_profile_dir + try: + source_dir = _source_profile_dir(source_label) + except FileNotFoundError: + return + if not clone_channels: + for line in format_stripped_notice(name, channel_platforms_configured(source_dir), clone_flag): + print(line) + return + shared = shared_channel_credentials(get_profile_dir(name), source_dir) + if shared: + print(shared_credential_warning(name, shared, source_label)) + + def _profile_create(args): from hermes_cli.profiles import ( _get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_profile, @@ -155,22 +231,29 @@ def _profile_create(args): no_alias = getattr(args, "no_alias", False) no_skills = getattr(args, "no_skills", False) clone_from = getattr(args, "clone_from", None) + clone_channels = getattr(args, "clone_channels", False) clone_config = clone or clone_from is not None cloned = clone_config or clone_all + source_label = clone_from or get_active_profile_name() + if clone_channels and cloned: + refusal = _clone_channels_refusal(source_label) + if refusal: + _die(refusal) try: profile_dir = create_profile( name=name, clone_from=clone_from, clone_all=clone_all, clone_config=clone_config, no_alias=no_alias, no_skills=no_skills, description=getattr(args, "description", None), + clone_channels=clone_channels, ) except (ValueError, FileExistsError, FileNotFoundError) as e: _die(f"Error: {e}") print(f"\nProfile '{name}' created at {profile_dir}") if cloned: - source_label = clone_from or get_active_profile_name() if clone_all: print(f"Full copy from {source_label} (excluding session history, cron jobs, backups, and snapshots).") else: print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.") + _print_channel_clone_notice(name, source_label, clone_channels, "--clone-all" if clone_all else "--clone") # Auto-clone Honcho config for the new profile (only with clone operations) try: from plugins.memory.honcho.cli import clone_honcho_for_profile diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 4cce218876..12c98d1048 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -800,11 +800,16 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path]) -> Non def create_profile( name: str, clone_from: Optional[str] = None, clone_all: bool = False, clone_config: bool = False, no_alias: bool = False, no_skills: bool = False, description: Optional[str] = None, + clone_channels: bool = False, ) -> Path: """Create a new profile directory and return its path. ``clone_from`` defaults to the active profile when cloning. ``clone_all`` copies all state; ``clone_config`` copies config.yaml/.env/SOUL.md, installed skills, and identity files. + Either clone strips the source's messaging channels — bot tokens, allowlists, platform + sections, pairing/session state — unless ``clone_channels`` opts in: a copied bot credential + makes two gateways fight over one bot (``hermes_cli.profile_channels``; callers list what + was left behind with ``channel_platforms_configured(source_dir)``). ``no_skills`` creates an empty profile and writes a marker so ``hermes update`` skips re-seeding its skills; it is mutually exclusive with the clone options, which copy skills.""" if no_skills and (clone_from is not None or clone_config or clone_all): @@ -832,6 +837,11 @@ def create_profile( _clone_all_into(source_dir, profile_dir, canon) else: _bootstrap_profile_dir(profile_dir, source_dir) + if source_dir is not None and not clone_channels: + from hermes_cli.profile_channels import strip_channel_settings + stripped = strip_channel_settings(profile_dir, include_state=clone_all) + if stripped: + logger.info("profile %s: cloned without messaging channels %s", canon, stripped) # Seed an empty .env so the profile owns a credentials file from day one. Without it, # profile-scoped env writes (dashboard Channels/Keys pages, `hermes -p auth add`) diff --git a/hermes_cli/subcommands/profile.py b/hermes_cli/subcommands/profile.py index 35fbc4b4ec..16c83b2de2 100644 --- a/hermes_cli/subcommands/profile.py +++ b/hermes_cli/subcommands/profile.py @@ -19,13 +19,19 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None: profile_create.add_argument("profile_name", help="Profile name (lowercase, alphanumeric)") profile_create.add_argument( "--clone", action="store_true", - help="Copy config.yaml, .env, SOUL.md, and skills from active profile") + help="Copy config.yaml, .env, SOUL.md, and skills from active profile " + "(messaging bot tokens/allowlists are left behind; see --clone-channels)") profile_create.add_argument( "--clone-all", action="store_true", - help="Full copy of active profile (all state, excluding per-profile history)") + help="Full copy of active profile (all state, excluding per-profile history and messaging channels)") profile_create.add_argument( "--clone-from", metavar="SOURCE", help="Source profile to clone from; implies --clone unless --clone-all is set") + profile_create.add_argument( + "--clone-channels", action="store_true", + help="Also copy the source's messaging channels (bot tokens, allowlists, platform sections). " + "Two profiles holding one bot token collide; refused when the source is served by a live " + "multiplexed gateway.") profile_create.add_argument( "--no-alias", action="store_true", help="Skip wrapper script creation") profile_create.add_argument( diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index 8aeacb378b..208e4730e1 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -407,6 +407,9 @@ class ProfileCreate(BaseModel): clone_from: Optional[str] = None clone_from_default: bool = False # legacy clients; new ones send clone_from explicitly clone_all: bool = False + # Opt-in: also copy the source's messaging channels (bot tokens, allowlists, platform sections). + # Default False — a copied bot credential makes two profiles collide over one bot. + clone_channels: bool = False no_skills: bool = False description: Optional[str] = None provider: Optional[str] = None diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index fe76f32ea8..565e1f2f55 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -667,7 +667,8 @@ async def create_profile_endpoint(body: ProfileCreate): bad_request=(ValueError, FileExistsError, FileNotFoundError)): path = profiles_mod.create_profile( name=body.name, clone_from=clone_from, clone_all=body.clone_all, - clone_config=clone_config, no_skills=body.no_skills, description=body.description) + clone_config=clone_config, no_skills=body.no_skills, description=body.description, + clone_channels=body.clone_channels) # Match the CLI flow: fresh named profiles get the bundled skills (cloning already # copied the source's; no_skills wrote the opt-out marker so seeding no-ops) and a # ~/.local/bin wrapper when the alias is safe. diff --git a/hermes_cli/web_server_messaging.py b/hermes_cli/web_server_messaging.py index 6e88d7bbc8..3aba26be48 100644 --- a/hermes_cli/web_server_messaging.py +++ b/hermes_cli/web_server_messaging.py @@ -281,18 +281,11 @@ _MESSAGING_KEYS_PAGE_KEYS = frozenset({ "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_PROXY_KEY", "GATEWAY_PROXY_URL"}) -_PLATFORM_ENV_PREFIX_ALIASES: dict[str, tuple[str, ...]] = { - "email": ("EMAIL_",), - "homeassistant": ("HASS_",), - "qqbot": ("QQ_", "QQBOT_"), - "sms": ("TWILIO_",), - "wecom": ("WECOM_BOT_", "WECOM_SECRET"), - "wecom_callback": ("WECOM_CALLBACK_",)} - - def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]: - """Env-var prefixes owned by a messaging platform card.""" - return _PLATFORM_ENV_PREFIX_ALIASES.get(platform_id, (platform_id.upper().replace("-", "_") + "_",)) + """Env-var prefixes owned by a messaging platform card (shared with the profile-clone + channel stripper so a card and a clone agree on which keys belong to a platform).""" + from hermes_cli.profile_channels import platform_env_prefixes + return platform_env_prefixes(platform_id) def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]: diff --git a/tests/hermes_cli/test_profile_clone_channels.py b/tests/hermes_cli/test_profile_clone_channels.py new file mode 100644 index 0000000000..f26a5e4d19 --- /dev/null +++ b/tests/hermes_cli/test_profile_clone_channels.py @@ -0,0 +1,122 @@ +"""``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" diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index f9ec13cf53..9f0a06cbdd 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -323,6 +323,10 @@ def _mirror_launch_credentials(path, params: dict) -> dict: # .env: only over the seeded comment-only stub (never a clone's secrets). mirrored["env"] = _try(lambda: _mirror_secret(path, launch_home, ".env", lambda src, dst: ( _env_has_content(src) and not _try(lambda: _env_has_content(dst), False))), False) + if mirrored["env"] and not is_truthy_value(params.get("clone_channels", False)): + # Provider/tool keys are what "mirror credentials" means; the launch profile's bot tokens + # and allowlists would make the new bot collide with it over one Telegram/Discord bot. + _best_effort(lambda: _lazy("hermes_cli.profile_channels", "strip_channel_env_file")(path / ".env")) if not share_auth: # a copy forks token state: the first refresh in either store strands the other mirrored["auth"] = _try(lambda: _mirror_secret(path, launch_home, "auth.json", lambda src, dst: not dst.exists()), False) @@ -337,7 +341,8 @@ def _mirror_launch_credentials(path, params: dict) -> dict: @method("profiles.create") def _(rid, params: dict) -> dict: """Create a profile (ws twin of POST /api/profiles). Params: ``name``, ``description``, - ``clone_from`` (omitted = fresh + bundled skills), ``clone_all``, ``no_skills``, ``soul``, + ``clone_from`` (omitted = fresh + bundled skills), ``clone_all``, ``clone_channels`` (opt-in: keep the + source's bot tokens/allowlists — default strips them so two profiles never hold one bot), ``no_skills``, ``soul``, ``model`` + ``provider``, ``share_auth``, ``no_alias``, ``mirror_credentials`` (default true: a bare ``create_profile()`` seeds a comment-only .env and no auth.json = NO provider headless).""" name = str(params.get("name") or "").strip() @@ -351,7 +356,8 @@ def _(rid, params: dict) -> dict: name=name, clone_from=clone_from, clone_all=clone_all, clone_config=bool(clone_from) and not clone_all, no_skills=is_truthy_value(params.get("no_skills", False)), - description=str(params.get("description") or "").strip() or None) + description=str(params.get("description") or "").strip() or None, + clone_channels=is_truthy_value(params.get("clone_channels", False))) except (ValueError, FileExistsError, FileNotFoundError) as e: return _err(rid, 4062, str(e)) except Exception as e: diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index e5e084f7a1..e2d5da0d16 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -80,6 +80,34 @@ hermes profile create work --clone-from coder hermes profile create work-backup --clone-from coder --clone-all ``` +### Messaging channels are never cloned (`--clone-channels` to opt in) + +Every clone — `--clone`, `--clone-from`, `--clone-all`, and the dashboard / Desktop / TUI +"clone from profile" option — copies the source **without its messaging channels**: bot tokens +and allowlists (`TELEGRAM_BOT_TOKEN`, `DISCORD_ALLOWED_USERS`, `WHATSAPP_ENABLED`, +`API_SERVER_KEY`, `WEBHOOK_SECRET`, …), the `platforms:` / `telegram:` / `discord:` sections of +`config.yaml`, `gateway.multiplex_profiles` / `profile_routes`, and (for `--clone-all`) the +pairing store, WhatsApp session and other per-bot state. Provider and tool API keys, the model +block, memory settings, skills and `SOUL.md` are copied as before. The command prints which +platforms were left behind. + +The reason is that a bot can only belong to one profile: two standalone gateways holding the +same token fight over its long-poll, and a [multiplexed gateway](./multi-profile-gateways.md) +parks the duplicate adapter (and `hermes gateway migrate --multiplex` refuses with one +duplicate-credential blocker per platform). Configure the new profile's own bots with +`hermes -p setup` or the dashboard Messaging page. + +```bash +hermes profile create twin --clone --clone-channels # keep the source's bots anyway +``` + +`--clone-channels` is refused when a running multiplexed gateway already serves the source +(the copy would be parked immediately) and otherwise prints a warning naming the platforms +now shared with the source. `hermes profile list` prints the same warning for any existing +profile whose bot credential is byte-identical to the default's, so older clones surface +before they bite. The key set is derived from the platform adapters themselves (registry +entries and the gateway's env table), so a newly added platform is covered automatically. + :::tip Honcho memory + profiles When Honcho is enabled, clone operations automatically create a dedicated AI peer for the new profile while sharing the same user workspace. Each profile builds its own observations and identity. See [Honcho -- Multi-agent / Profiles](./features/memory-providers.md#honcho) for details. :::