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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user