fix(profiles): --clone leaves messaging channels behind; --clone-channels opts in

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.
This commit is contained in:
teknium1
2026-09-12 18:10:40 -07:00
committed by Teknium
parent de2d6a1b93
commit acbecf588a
11 changed files with 626 additions and 18 deletions
+84 -1
View File
@@ -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 <name> 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