Files
hermes-agent/hermes_cli/profile_channels.py
teknium1 b47fb2eba3 fix(profiles): clone builds in a hidden staging dir, never writes through symlinks, refuses --clone-channels in core; channel inventory is ownership-based
Post-merge review of #109502 (gaoanze888) on current main, findings 1-3 and 5-11
(finding 4, the migrate manifest ordering, was already fixed by f9e47aa6fe).

Why:
- `--clone-all` used copytree(symlinks=True); a symlinked source `.env` was then
  edited THROUGH the link by the channel strip, deleting the SOURCE's bot token.
  Root files the clone edits (.env, config.yaml, auth.json, SOUL.md) are now
  materialized as private copies before any write.
- The final profiles/<name> existed during the copy; the multiplexer rescans
  profiles/ on create (#109239) and could adopt the half-copied tree and start
  adapters on credentials not yet stripped. Clones are built in
  profiles/.<name>.staging-<pid> (a leading dot never matches _PROFILE_ID_RE, so
  profiles_to_serve never lists it) and published with one os.rename after the
  strip; a failed create removes the staging tree.
- The live-multiplexer refusal for --clone-channels lived only in the CLI; REST
  (POST /api/profiles) and the TUI (profiles.create) bypassed it. It now lives in
  create_profile as profile_channels.clone_channels_refusal, raising ValueError
  which every surface already maps to a 400 / 4062. --clone-channels without a
  clone flag is an error instead of a silent no-op.
- Channel inventory is ownership-based, evaluated in the SOURCE profile's plugin
  scope: private platform plugins under <source>/plugins/ contribute their keys
  (previously discovered under the ambient HERMES_HOME); GATEWAY_ALLOW_ALL_USERS /
  GATEWAY_ALLOWED_USERS and GATEWAY_RELAY_ID/SECRET/DELIVERY_KEY are channel
  settings; alias prefixes SUPPLEMENT the canonical <PLATFORM>_ prefix
  (WECOM_DM_POLICY, SMS_WEBHOOK_PORT now stripped). Prefixes shared with tools
  (HASS_, TWILIO_, EMAIL_) are stripped only when the source's gateway would run
  that adapter (enabled in config, or complete credentials and not explicitly
  disabled); their allowlist/port keys are always channel-only.
- --clone-all state removal handled files only; Google Chat's directory-shaped
  google_chat_user_tokens/ survived. Directories are removed too, without
  following a copied symlink.
2026-09-13 14:33:11 -07:00

496 lines
23 KiB
Python

"""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 inventory is OWNERSHIP-based and evaluated in the SOURCE profile's plugin scope: every adapter
(built-in ``Platform`` member or plugin registered under the source's ``HERMES_HOME``) owns the env
keys it declares outright (``required_env``, allowlist / allow-all / home-channel names, the gateway
env-override table ``gateway.config_env._ENV_STEPS`` / ``_ENV_ENABLE_CREDENTIALS``) plus every key
under its canonical ``<PLATFORM>_`` prefix and its historical alias prefixes. Gateway-wide channel
policy (``GATEWAY_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOWED_USERS``) and relay enrollment identity
(``GATEWAY_RELAY_*``) are channel settings too. A prefix an adapter SHARES with a non-channel
capability (``HASS_*`` is also the Home Assistant tool, ``TWILIO_*`` the telephony skill, ``EMAIL_*``
mail-sending scripts) is stripped only when the source actually runs that adapter — the credential is
then the bot's identity; otherwise it is a tool key and survives. 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__)
# Historical env prefixes that do not match a platform's config id. They SUPPLEMENT the canonical
# ``<PLATFORM>_`` prefix (``WECOM_DM_POLICY`` and ``SMS_WEBHOOK_PORT`` are wecom / sms keys too).
_PLATFORM_ENV_PREFIX_ALIASES: dict[str, tuple[str, ...]] = {
"email": ("EMAIL_",),
"homeassistant": ("HASS_",),
"qqbot": ("QQ_",),
"relay": ("GATEWAY_RELAY_",),
"sms": ("TWILIO_",),
}
# Pseudo-platform for gateway-wide channel policy: not an adapter, but a clone that inherits the
# source's allow-all / allowlist boundary hands a freshly configured bot the source's authorization.
GATEWAY_POLICY_ID = "gateway"
_GATEWAY_POLICY_KEYS = ("GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS")
# Prefixes a messaging adapter shares with a NON-channel capability. Their credentials belong to the
# channel only while the source runs that adapter; the adapter's policy keys are channel-only always.
_SHARED_WITH_TOOLS: dict[str, tuple[str, ...]] = {
"homeassistant": ("HASS_",), # tools/homeassistant_tool.py reads HASS_TOKEN / HASS_URL
"sms": ("TWILIO_",), # telephony skill reads TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN
"email": ("EMAIL_",), # mail-sending skills read EMAIL_ADDRESS / EMAIL_PASSWORD / EMAIL_SMTP_*
}
_POLICY_MARKERS = ("_ALLOWED_USERS", "_ALLOW_ALL_USERS", "_ALLOWED_CHATS", "_HOME_CHANNEL", "_HOME_ADDRESS")
# 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*=")
@contextlib.contextmanager
def _plugin_scope(source_dir: Optional[Path]):
"""Evaluate adapter discovery in ``source_dir``'s plugin scope (its private ``plugins/``), not the
ambient profile's. ``None`` keeps the ambient scope."""
if source_dir is None:
yield
return
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = set_hermes_home_override(str(source_dir))
try:
yield
finally:
reset_hermes_home_override(token)
def platform_env_prefixes(platform_id: str) -> tuple[str, ...]:
"""Env-var prefixes owned by one messaging platform: canonical ``<PLATFORM>_`` plus aliases."""
canonical = platform_id.upper().replace("-", "_") + "_"
return (canonical, *_PLATFORM_ENV_PREFIX_ALIASES.get(platform_id, ()))
def _registry_entries() -> list:
with contextlib.suppress(Exception):
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent per profile scope
from gateway.platform_registry import platform_registry
return list(platform_registry.all_entries())
return []
def platform_ids(source_dir: Optional[Path] = None) -> List[str]:
"""Every messaging platform id: built-in ``Platform`` members plus the plugin adapters registered
in ``source_dir``'s scope (ambient scope when ``None``)."""
from gateway.config import Platform
ids = {m.value for m in Platform.__members__.values() if m.value != "local"}
with _plugin_scope(source_dir):
ids.update(entry.name for entry in _registry_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(source_dir: Optional[Path] = None) -> Dict[str, str]:
"""``{ENV_KEY: platform_id}`` for every env name an adapter declares outright (registry entry
fields, the gateway env-override table) plus gateway-wide channel policy. Prefix matching covers
the rest."""
keys: Dict[str, str] = dict.fromkeys(_GATEWAY_POLICY_KEYS, GATEWAY_POLICY_ID)
with _plugin_scope(source_dir):
for entry in _registry_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
def _policy_env_keys(source_dir: Optional[Path] = None) -> Set[str]:
"""Allowlist / allow-all / home-channel names adapters declare — channel-only by nature."""
names: Set[str] = set(_GATEWAY_POLICY_KEYS)
with _plugin_scope(source_dir):
for entry in _registry_entries():
names.update(n for n in (entry.allowed_users_env, entry.allow_all_env, entry.cron_deliver_env_var) if n)
with contextlib.suppress(Exception):
from gateway import config_env
for step in config_env._ENV_STEPS:
if isinstance(step, config_env._Cred) and step.home:
names.update({step.home, f"{step.home}_NAME", f"{step.home}_THREAD_ID"})
elif isinstance(step, partial) and step.keywords.get("env_base"):
base = step.keywords["env_base"]
names.update({base, f"{base}_NAME", f"{base}_THREAD_ID"})
return names
_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] = {}
for entry in _registry_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)}
def _env_values(env_path: Path, wanted: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Non-blank assignments in ``env_path`` (restricted to ``wanted`` keys when given)."""
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 (wanted is None or key in wanted) and value and value.strip():
values[key] = value.strip()
return values
def _explicit_enabled(raw: dict, pid: str) -> Optional[bool]:
"""``platforms.<pid>.enabled`` (either nesting) when the source config states it, else None."""
gateway = raw["gateway"] if isinstance(raw.get("gateway"), dict) else {}
for section in (raw.get("platforms"), gateway.get("platforms")):
block = section.get(pid) if isinstance(section, dict) else None
if isinstance(block, dict) and "enabled" in block:
return bool(block["enabled"])
return None
def _shared_adapters_active(source_dir: Optional[Path]) -> Set[str]:
"""Shared-prefix platforms the SOURCE runs as a channel: explicitly enabled in its config.yaml, or
auto-enabled by a complete credential set in its ``.env`` and not explicitly disabled — the same
gate ``gateway.config_env._Cred`` applies at gateway start."""
if source_dir is None:
return set(_SHARED_WITH_TOOLS) # no source to consult: the historical (strip) behaviour
raw: dict = {}
if (source_dir / "config.yaml").is_file():
from hermes_cli.config import read_user_config_raw
with contextlib.suppress(Exception):
raw = read_user_config_raw(source_dir / "config.yaml") or {}
env = _env_values(source_dir / ".env")
creds_by_platform: Dict[str, Set[str]] = {}
with contextlib.suppress(Exception):
from gateway import config_env
for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items():
creds_by_platform[platform.value] = set(names)
with _plugin_scope(source_dir):
for entry in _registry_entries():
creds_by_platform.setdefault(entry.name, set(entry.required_env))
active: Set[str] = set()
for pid in _SHARED_WITH_TOOLS:
explicit = _explicit_enabled(raw, pid)
if explicit is not None:
if explicit:
active.add(pid)
continue
creds = creds_by_platform.get(pid) or set()
if creds and all(env.get(name) for name in creds):
active.add(pid)
return active
class ChannelKeyIndex:
"""Resolves an env key to the messaging platform that owns it (``None`` = not a channel key),
evaluated in ``source_dir``'s plugin scope with the ownership rule for shared prefixes."""
def __init__(self, source_dir: Optional[Path] = None) -> None:
self.source_dir = source_dir
self.platforms = platform_ids(source_dir)
self.declared = declared_channel_env_keys(source_dir)
self.policy = _policy_env_keys(source_dir)
self.shared_active = _shared_adapters_active(source_dir)
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]:
pid = self.declared.get(key) or next((pid for prefix, pid in self._prefixes if key.startswith(prefix)), None)
if pid is None:
return None
shared = _SHARED_WITH_TOOLS.get(pid)
if shared and key.startswith(shared) and pid not in self.shared_active and not self._is_policy(key):
return None # a tool credential the source never used as a bot: keep it
return pid
def _is_policy(self, key: str) -> bool:
return key in self.policy or any(marker in key for marker in _POLICY_MARKERS)
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 ``<platform>:`` block, ``gateway.platforms`` / ``gateway.<platform>``, 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 ``<platform>_*`` file OR directory an adapter writes beside
config.yaml (Google Chat keeps user tokens in ``google_chat_user_tokens/``)."""
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.name.startswith(prefixes))
def _remove_entry(entry: Path) -> None:
"""Remove a state entry without following symlinks (``rmtree`` refuses a symlinked dir and would
otherwise leave the copied link in place; unlinking the link never touches the source)."""
import shutil
if entry.is_symlink() or not entry.is_dir():
entry.unlink(missing_ok=True)
else:
shutil.rmtree(entry, ignore_errors=True)
def strip_channel_settings(profile_dir: Path, *, include_state: bool, source_dir: Optional[Path] = None) -> Dict[str, List[str]]:
"""Strip channel credentials/identity from a freshly cloned profile, judged in ``source_dir``'s
plugin scope. ``include_state`` also drops the runtime state ``--clone-all`` copied. Returns
``{platform|"config"|"state": [what]}``."""
index = ChannelKeyIndex(source_dir)
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):
_remove_entry(entry)
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, in ``profile_dir``'s plugin scope."""
index = ChannelKeyIndex(profile_dir)
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 and platform != GATEWAY_POLICY_ID:
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 clone_channels_refusal(source_dir: Path, source_label: str) -> Optional[str]:
"""Why ``--clone-channels`` must be refused for ``source_dir``: a live multiplexer already serves
the source, so the copied bot would be parked as a duplicate credential at once (the same finding
the migrate preflight reports). ``None`` when the copy is allowed. Shared by CLI, REST and TUI
through :func:`hermes_cli.profiles.create_profile`."""
from hermes_cli.gateway_multiplex_served import recorded_served_profiles
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
platforms = channel_platforms_configured(source_dir)
if not platforms:
return None
return (
f"--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 <name> setup), or route its chats with gateway.profile_routes instead."
)
def _config_platform_tokens(config_path: Path) -> Dict[str, str]:
"""``{platform: token}`` from ``platforms.<p>.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",
]