Files
hermes-agent/tui_gateway/methods_profiles.py
John Paul Soliva c3e78fcb4e fix(profiles): copy launch ${VAR} refs into new profiles, not their values
profiles.create ("New bot" in the TUI/Desktop) mirrored the launch
profile's stt/tts/voice sections from load_config_readonly(), which
returns the env-expanded config, so every ${VAR} ref in those sections
landed in the new profile's config.yaml as the launch profile's
plaintext secret. Template preservation on save cannot help: the new
profile's raw file has no templates to restore. The secret then
overrides the new profile's own .env (rotating the key there is
ignored) and travels with config.yaml if the profile is published.

Read the launch config.yaml raw at every site that copies launch config
into a profile, as _seed_model_config already does:
- _mirror_voice_sections (stt/tts/voice)
- _inherit_launch_model (the custom providers: gateway seed)
- _configure_cfg_sections (an MCP server copied from the launch catalog
  when the editor enables it on a profile that lacks it)
2026-09-29 18:50:36 -05:00

725 lines
38 KiB
Python

"""Profile JSON-RPC handlers — the ws twin of the dashboard's /api/profiles (desktop plugins
only have the ws door), on the same `hermes_cli.profiles` primitives. Bodies are rebound onto
server.py's globals (method_ctx.bind_module) and use them bare; module-level names are published
onto server.py, so they must not collide with its globals.
"""
import contextlib
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
# ext -> mime; iteration order is the on-disk lookup order for assets.
_ASSET_EXTS = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
# ext -> [(start, end, magic bytes)]; format is sniffed, the declared mime is never trusted.
_ASSET_MAGIC = {"png": [(0, 8, b"\x89PNG\r\n\x1a\n")], "jpg": [(0, 3, b"\xff\xd8\xff")],
"webp": [(0, 4, b"RIFF"), (8, 12, b"WEBP")]}
def _profile_handler(name: str, code: int):
"""``@method(name)`` whose body's uncaught exception becomes ``_err(rid, code, str(e))``."""
def deco(fn):
def handler(rid, params: dict) -> dict:
try:
return fn(rid, params)
except Exception as e:
return _err(rid, code, str(e))
return method(name)(handler)
return deco
def _lazy(module, name):
"""Late-bound attribute lookup; ``__import__`` builtin on purpose (rebound bodies see only
server.py globals, not this module's imports)."""
return getattr(__import__(module, fromlist=[name]), name)
def _pin_profile_model(profile_dir, provider, model) -> None:
_lazy("hermes_cli.web_routers.profiles", "_write_profile_model")(profile_dir, provider, model)
def _model_provider_params(params) -> tuple:
return str(params.get("model") or "").strip(), str(params.get("provider") or "").strip()
def _try(fn, default):
"""``fn()`` or ``default`` on any exception (best-effort sections must never fail each other)."""
try:
return fn()
except Exception:
return default
def _best_effort(fn) -> bool:
return _try(lambda: (fn(), True)[1], False)
def _hermes_home_scope(path):
"""Bind ``path``'s full runtime scope (home + secrets + terminal) for the block — the same
composition ``@_profile_scoped`` binds. Home alone is half-bound: under multi-profile hosting
every credential read raised ``UnscopedSecretError``, which a best-effort ``_try`` turned into
a plausible wrong answer (the toolset snapshot's ``XAI_API_KEY`` probe → "every toolset off",
#120726). The launch home maps to None so it keeps its frozen launch env (systemd / ``op run``
keys). No external-source hydration: these bodies read and write config, never call a provider."""
launch = Path(path).resolve() == Path(_hermes_home).resolve()
return _session_profile_runtime_scope({"profile_home": None if launch else str(path)}, hydrate_secrets=False)
def _resolve_profile(rid, params):
"""``(name, profile_dir, err)``; err = 4063 (name required) / 4064 (not found) response."""
name = str(params.get("name") or "").strip()
if not name:
return name, None, _err(rid, 4063, "name required")
from hermes_cli.profiles import get_profile_dir
try:
profile_dir = Path(get_profile_dir(name))
except ValueError:
return name, None, _err(rid, 4064, f"profile '{name}' not found")
if not profile_dir.is_dir():
return name, None, _err(rid, 4064, f"profile '{name}' not found")
return name, profile_dir, None
def _read_profile_yaml(profile_dir) -> dict:
"""profile.yaml as a mapping; ``{}`` when missing, unreadable, unparseable, or not a mapping."""
def load():
import hermes_yaml as yaml
meta_path = profile_dir / "profile.yaml"
return (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {}
loaded = _try(load, {})
return loaded if isinstance(loaded, dict) else {}
def _yaml_scalar_to_json(value):
"""``json.dumps`` default for YAML-only scalars (datetime/date → ISO 8601, else ``str``)."""
isoformat = getattr(value, "isoformat", None)
return isoformat() if callable(isoformat) else str(value)
def _clean_revisions(raw: dict) -> dict:
"""Normalise a ``_ui_meta_revisions`` map: str keys, non-bool ints clamped at 0."""
return {str(k): max(0, int(v)) for k, v in raw.items() if isinstance(v, int) and not isinstance(v, bool)}
def _latest_message_preview(db, session_id):
"""≤80-char excerpt of the NEWEST active user/assistant message, or "" (roster semantics).
Same query shape as ``SessionDB.latest_message_row_id``; keep them in step."""
try:
with db._lock:
row = db._conn.execute(
"SELECT content FROM messages"
" WHERE session_id = ? AND role IN ('user', 'assistant')"
" AND active = 1 AND content IS NOT NULL AND TRIM(content) != ''"
" ORDER BY id DESC LIMIT 1",
(session_id,)).fetchone()
except Exception:
return ""
text = " ".join(str(row[0] or "").split()).strip() if row else ""
return text[:80] + "..." if len(text) > 80 else text
def _resurrect_recoverable_canonical(db, profile_path, session_id):
"""Un-archive an accidentally archived canonical row (judged read-only, written via a
short-lived writable handle); False otherwise.
See #92687.
"""
try:
row = db.get_session(session_id)
if not row or not row.get("archived"):
return False
tip_id = _try(lambda: db.get_compression_tip(session_id), None) or session_id
tip = (_try(lambda: db.get_session(tip_id), None) or row) if tip_id != session_id else row
from hermes_state import SessionDB
from hermes_state_registry import acquire
if (tip.get("end_reason") or "") not in SessionDB.RECOVERABLE_END_REASONS:
return False
wdb = acquire(Path(profile_path) / "state.db")
try:
return bool(wdb.unarchive_recoverable_session(session_id))
finally:
_best_effort(lambda: _lazy("hermes_state_registry", "release_or_close")(wdb))
except Exception:
return False
def _canonical_session_row(db, profile_path):
"""Summary of the profile's canonical "Bot Chat" row (identity is the NAME), or None.
Lineages via ``get_compression_tip`` (NOT the resume walker's unmarked-child fallback);
worker sources count as absent. ``id`` is the registry row, ``resolved_id`` the live tip.
The canonical chat's identity is the NAME: the session titled exactly "Bot Chat" on this profile (core
UNIQUE(title) makes it a registry of at most one row). Complements ``last_session``: that field answers
"what is the newest conversation", this answers "where is the forever-chat" — so a roster row's preview
and its click target describe the same session (hermes-agent#88200) with no client-side pointer
involved.
"""
try:
row = db.get_session_by_title("Bot Chat")
session_id = str((row or {}).get("id") or "").strip()
if not session_id or _denied_source(row):
return None
# Archived = retired (absent), except accidental reaper archives: resurrect those.
# An archived canonical row usually means the user deliberately retired it — report absent. But the
# ws-orphan reaper / older agent cleanup can archive it by accident (#92687): resurrect those. Judge
# recoverability READ-ONLY first so the writable open (20s write-lock patience, the very stall this
# refactor removes from the 5s poll) is paid only in the rare accidental-archive case, then run the
# real predicate through unarchive_recoverable_session on a short-lived writable handle.
if row.get("archived") and not _resurrect_recoverable_canonical(db, profile_path, session_id):
return None
tip = _try(lambda: db.get_compression_tip(session_id), None) or session_id
tip_row = db.get_session(tip) or row
started = row.get("started_at") or 0
return {
"id": session_id, "resolved_id": tip, "root_title": row.get("title") or "",
"title": tip_row.get("title") or "", "preview": _latest_message_preview(db, tip),
"started_at": tip_row.get("started_at") or started,
"last_active": tip_row.get("last_activity_at") or tip_row.get("started_at") or started,
"message_count": tip_row.get("message_count") or 0}
except Exception:
return None
def _latest_profile_session_rows(db):
"""(newest human-facing session, newest worker session); the worker row lets rosters show a
profile as working (workers heartbeat ``last_activity_at`` every ≤60s).
First element mirrors session.list's deny-list (drops ``tool`` sub-agent rows and ``kanban`` dispatcher
workers). Second element is the newest DENIED row — the freshest kanban/tool worker — so roster UIs can
show that a profile is actively working even though worker sessions never surface in conversation lists
(hermes-agent#90268). Workers heartbeat ``last_activity_at`` every ≤60s while running (#72016), so a
live worker's ``last_active`` stays fresh and the client can apply its own liveness window. Best-effort:
any failure (missing state.db, locked db, older schema) degrades to (None, None) rather than failing the
whole profiles.list call.
"""
try:
human = worker = None
for s in db.list_sessions_rich(source=None, limit=20, order_by_last_active=True, compact_rows=True):
title, last_active = s.get("title") or "", s.get("last_active") or s.get("started_at") or 0
if _denied_source(s):
if worker is None:
worker = {"id": s["id"], "source": (s.get("source") or "").strip().lower(),
"title": title, "last_active": last_active}
elif human is None: # rosters want "where the conversation IS": prefer the newest text
human = {"id": s["id"], "title": title,
"preview": _latest_message_preview(db, s["id"]) or s.get("preview") or "",
"started_at": s.get("started_at") or 0, "last_active": last_active,
"message_count": s.get("message_count") or 0}
if human is not None and worker is not None:
break
return human, worker
except Exception:
return None, None
def _profile_session_fields(row, profile_path):
"""Attach last_session / worker_session / canonical_session to a roster row. The DB is a
read-only attach (a writable ``SessionDB()`` waits up to 20s for the write lock + runs DDL
and stalled the 5s roster poll); no/unreadable DB -> every field None (the readers swallow)."""
def _read() -> dict:
db_path = Path(profile_path) / "state.db"
db = None
if _try(db_path.exists, False):
db = _try(lambda: _lazy("hermes_state", "SessionDB")(db_path=db_path, read_only=True), None)
try:
last, worker = _latest_profile_session_rows(db)
# Resolved server-side on every listing so no client carries a session pointer.
return {"last_session": last, "worker_session": worker,
"canonical_session": _canonical_session_row(db, profile_path)}
finally:
if db is not None:
_best_effort(db.close)
# These three are a pure function of the profile's session store, and the roster re-asks every
# 5s per connection — so they are reused while that store has not moved (#117257).
from tui_gateway.profile_roster_cache import cached_session_fields
row.update(cached_session_fields(profile_path, _read))
def _profile_ui_meta_fields(row: dict, profile_dir) -> None:
"""Attach ``ui_meta`` / ``ui_meta_revisions`` / ``has_avatar`` from profile.yaml + assets.
``ui_meta_revisions`` is always present: it feature-detects gateway-owned CAS for a new profile."""
def _read() -> dict:
raw_meta = _read_profile_yaml(profile_dir)
ui_meta, revisions = raw_meta.get("ui_meta"), raw_meta.get("_ui_meta_revisions")
# Key order is wire-visible: ui_meta_revisions precedes ui_meta.
fields = {"ui_meta_revisions":
_try(lambda: _clean_revisions(revisions), {}) if isinstance(revisions, dict) else {}}
if isinstance(ui_meta, dict) and ui_meta:
# YAML promotes unquoted timestamps to datetime/date; the handler's contract is JSON, so
# coerce YAML-only scalars to their ISO string at the boundary (#92506).
fields["ui_meta"] = json.loads(json.dumps(ui_meta, default=_yaml_scalar_to_json))
return fields
# Second parse of this profile.yaml in the same request (``read_profile_meta`` already read it
# for the row's description/display name) — reused while the file has not moved (#117383). The
# ui_meta CAS writer below keeps reading it raw and uncached: it writes the document back.
from tui_gateway.profile_roster_cache import cached_ui_meta_fields
row.update(cached_ui_meta_fields(profile_dir, _read))
# Cheap existence flag so rosters skip a get_asset probe per paint.
row["has_avatar"] = _try(lambda: any((profile_dir / "assets" / f"avatar.{e}").is_file() for e in _ASSET_EXTS), False)
@_profile_handler("profiles.list", 5061)
def _(rid, params: dict) -> dict:
"""List Hermes profiles. ``include_sessions`` (default true) adds ``last_session`` /
``worker_session`` / ``canonical_session`` so a roster paints previews without N calls."""
from hermes_cli.profiles import list_profiles
include_sessions = is_truthy_value(params.get("include_sessions", True))
out = []
# Roster polls this every 5s: ``skill_count`` is the last known value, refreshed off-request.
for p in list_profiles(lazy_skill_count=True):
row = {"name": p.name, "path": str(p.path), "is_default": bool(p.is_default), "model": p.model,
"provider": p.provider, "description": p.description or "",
"display_name": p.display_name or "", "skill_count": p.skill_count or 0,
"previous_names": list(p.previous_names or []), "role": p.role}
if include_sessions:
_profile_session_fields(row, p.path)
_profile_ui_meta_fields(row, Path(str(p.path)))
out.append(row)
# bot_mode_protocol: this backend injects the Bot Mode teammate-messaging protocol into every
# session, so clients must not append it to SOUL.md.
return _ok(rid, {"profiles": out, "bot_mode_protocol": True})
@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``, ``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()
if not name:
return _err(rid, 4061, "name required")
try:
from hermes_cli import profiles as profiles_mod
clone_from = str(params.get("clone_from") or "").strip() or None
clone_all = is_truthy_value(params.get("clone_all", False))
path = profiles_mod.create_profile(
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,
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:
return _err(rid, 5062, str(e))
# CLI/REST create flow: bundled skills for fresh profiles, then the alias wrapper.
if not clone_from:
_best_effort(lambda: profiles_mod.seed_profile_skills(path, quiet=True))
if not is_truthy_value(params.get("no_alias", False)):
_best_effort(lambda: profiles_mod.check_alias_collision(name) or profiles_mod.create_wrapper_script(name))
soul = params.get("soul")
soul_written = isinstance(soul, str) and bool(soul.strip()) and _best_effort(
lambda: (path / "SOUL.md").write_text(soul, encoding="utf-8"))
mirrored = _mirror_launch_credentials(path, params)
model, provider = _model_provider_params(params)
model_set = False
if model and provider:
model_set = _best_effort(lambda: _pin_profile_model(path, provider, model))
elif is_truthy_value(params.get("mirror_credentials", True)):
mirrored["model_inherited"] = _try(lambda: _inherit_launch_model(path), False)
return _ok(rid, {"ok": True, "name": name, "path": str(path), "soul_written": soul_written,
"model_set": model_set, "mirrored": mirrored})
@_profile_handler("profiles.describe", 5063)
def _(rid, params: dict) -> dict:
"""Editor snapshot; installed skills are enabled unless in ``skills.disabled``; ``mcp_servers``
is ``[{name, enabled, transport}]`` (best-effort)."""
name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
with _hermes_home_scope(profile_dir):
from agent.skill_utils import iter_skill_index_files
from hermes_cli.config import load_config
from hermes_cli.skills_config import get_disabled_skills
cfg = load_config() or {}
disabled = {s.lower() for s in get_disabled_skills(cfg)}
skills_root = profile_dir / "skills"
installed = [
{"name": md.parent.name, "enabled": md.parent.name.lower() not in disabled}
for md in (iter_skill_index_files(skills_root, "SKILL.md") if skills_root.is_dir() else ())]
toolsets_out, pinned_set = _describe_toolsets(cfg)
soul_path = profile_dir / "SOUL.md"
soul = _try(lambda: soul_path.read_text(encoding="utf-8", errors="replace") if soul_path.is_file() else "", "")
mcp_cfg = cfg.get("mcp_servers")
mcp_out = _try(lambda: [
{"name": str(srv_name), "enabled": _mcp_entry_enabled(entry),
"transport": str(entry.get("transport") or "http") if entry.get("url") else "stdio"}
for srv_name in sorted(mcp_cfg.keys()) for entry in (mcp_cfg[srv_name],)
if isinstance(entry, dict)
], []) if isinstance(mcp_cfg, dict) else []
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
meta = _try(lambda: _lazy("hermes_cli.profiles", "read_profile_meta")(profile_dir), {})
return _ok(rid, {
"name": name, "description": str(meta.get("description") or ""), "soul": soul,
"model": {"provider": str(model_cfg.get("provider") or ""),
"default": str(model_cfg.get("default") or "")},
"skills": installed, "toolsets": toolsets_out,
"toolsets_pinned": pinned_set is not None, "mcp_servers": mcp_out})
@_profile_handler("profiles.configure", 5064)
def _(rid, params: dict) -> dict:
"""Editor Save: ``name`` plus any of ``ui_meta`` (+ ``ui_meta_expected_revisions``), ``soul``,
``description``, ``model`` + ``provider`` (+ ``confirm_expensive_model``), ``disabled_skills``,
``enabled_toolsets``, ``enabled_mcp_servers``; sections are independent, ``applied`` reports each."""
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
applied = {}
if isinstance(params.get("ui_meta"), dict):
_configure_ui_meta(profile_dir, params, applied)
if isinstance(params.get("soul"), str):
applied["soul"] = _best_effort(lambda: (profile_dir / "SOUL.md").write_text(params["soul"], encoding="utf-8"))
if isinstance(params.get("description"), str):
write_meta = _lazy("hermes_cli.profiles", "write_profile_meta")
applied["description"] = _best_effort(lambda: write_meta(
profile_dir, description=params["description"].strip(), description_auto=False))
confirm_message = _configure_model(profile_dir, params, applied)
if any(isinstance(params.get(k), list) for k in ("disabled_skills", "enabled_toolsets", "enabled_mcp_servers")):
_configure_cfg_sections(profile_dir, params, applied)
# confirm_* is the shape config.set returns, so clients reuse one confirm handler.
return _ok(rid, {"ok": all(applied.values()) if applied else True, "applied": applied,
**({"confirm_required": True, "confirm_message": confirm_message}
if confirm_message is not None else {})})
@_profile_handler("profiles.set_asset", 5065)
def _(rid, params: dict) -> dict:
"""Store ``assets/<asset>.<ext>`` atomically. Params: ``name``, ``asset`` (``"avatar"`` only),
``data`` (data URL or base64; PNG/JPEG/WebP ≤2MB, format sniffed) or ``clear: true``."""
asset = str(params.get("asset") or "avatar").strip().lower()
if not str(params.get("name") or "").strip():
return _err(rid, 4063, "name required")
if asset != "avatar":
return _err(rid, 4066, f"unknown asset '{asset}' (supported: avatar)")
import base64
import re
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
assets_dir = profile_dir / "assets"
if is_truthy_value(params.get("clear", False)):
return _ok(rid, {"ok": True, "asset": asset, "size": 0, "removed": _unlink_asset_files(assets_dir, asset)})
data = str(params.get("data") or "")
if not data:
return _err(rid, 4067, "data required (data URL or base64)")
match = re.match(r"^data:(image/(?:png|jpeg|webp));base64,(.*)$", data, re.DOTALL)
try:
blob = base64.b64decode(match.group(2) if match else data, validate=True)
except Exception:
return _err(rid, 4068, "data is not valid base64")
if len(blob) > 2_000_000:
return _err(rid, 4069, f"asset too large ({len(blob)} bytes; max 2MB)")
ext = next((e for e, magic in _ASSET_MAGIC.items() if all(blob[a:b] == m for a, b, m in magic)), None)
if ext is None:
return _err(rid, 4070, "unsupported image format (PNG/JPEG/WebP only)")
assets_dir.mkdir(parents=True, exist_ok=True)
_unlink_asset_files(assets_dir, asset) # one canonical file per asset
tmp = assets_dir / f"{asset}.{ext}.tmp"
tmp.write_bytes(blob)
tmp.replace(assets_dir / f"{asset}.{ext}")
return _ok(rid, {"ok": True, "asset": asset, "size": len(blob)})
@_profile_handler("profiles.get_asset", 5066)
def _(rid, params: dict) -> dict:
"""Profile asset as a data URL; absent is ``found: false``, not an error."""
asset = str(params.get("asset") or "avatar").strip().lower()
import base64
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
for ext, mime in _ASSET_EXTS.items():
target = profile_dir / "assets" / f"{asset}.{ext}"
if target.is_file():
blob = target.read_bytes()
return _ok(rid, {"found": True, "mime": mime, "size": len(blob),
"data": f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}"})
return _ok(rid, {"found": False})
@_profile_handler("profiles.remember_onboarding", 5067)
def _(rid, params: dict) -> dict:
from tui_gateway.onboarding_personalization import remember_onboarding
return _ok(rid, remember_onboarding(params.get("answers")))
def _mirror_secret(path, launch_home, name: str, wanted) -> bool:
"""Copy the launch ``name`` file into the profile (0600) when it exists and ``wanted(src, dst)``."""
src, dst = launch_home / name, path / name
if not (src.is_file() and wanted(src, dst)):
return False
import shutil
shutil.copy2(src, dst)
with contextlib.suppress(OSError):
os.chmod(str(dst), 0o600)
return True
def _env_has_content(env_path) -> bool:
lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines()
return any(s and not s.startswith("#") for s in map(str.strip, lines))
def _mirror_voice_sections(path) -> bool:
"""Copy stt/tts/voice sections from the launch profile (a fresh profile has only ``model``,
so voice fell back to defaults); True if written."""
try:
from hermes_cli.config import read_user_config_raw, save_config
# Launch file RAW too: the loaded config has ${VAR} refs expanded, and the new profile
# must get the ref (resolved against its own .env), never the launch profile's secret.
src_cfg = read_user_config_raw()
sections = {k: src_cfg[k] for k in ("stt", "tts", "voice") if src_cfg.get(k)}
if not sections:
return False
with _hermes_home_scope(path):
# RAW file: load_config() merges DEFAULT_CONFIG (every section would look present).
dst_cfg = read_user_config_raw() or {}
missing = {k: v for k, v in sections.items() if k not in dst_cfg}
if missing:
save_config({**dst_cfg, **missing})
return bool(missing)
except Exception:
return False
def _inherit_launch_model(path) -> bool:
"""Inherit launch model.provider/default when the new profile has none. Gate on the MODEL
SECTION, not config.yaml existing: voice mirroring creates the file first."""
# Gate on the MODEL SECTION being absent, not on config.yaml existing — earlier mirroring steps (voice
# sections, #85755) legitimately create the file first, and a file-existence gate silently skipped
# inheritance for every non-clone bot ("No inference provider configured" on first message, tester
# report). Clones bring their own model section and stay untouched.
from hermes_cli.config import load_config_readonly, read_user_config_raw
with _hermes_home_scope(path):
dst_model = (read_user_config_raw() or {}).get("model") or {}
if dst_model.get("provider") and dst_model.get("default"):
return False
launch_cfg = load_config_readonly() or {}
model_cfg = launch_cfg.get("model") or {}
if not (model_cfg.get("provider") and model_cfg.get("default")):
return False
# A custom `providers:` gateway travels with the model it backs (same seed as the CLI path). It is
# written BEFORE the pin: the pin validates the pick inside the new profile, and an empty profile
# rejects a provider it has not been told about ("Unknown provider").
# Seeded from the RAW launch file so a ${VAR} api_key travels as the ref, not its value.
custom = _lazy("hermes_cli.profiles", "launch_model_seed")(read_user_config_raw()).get("providers")
if custom:
from hermes_cli.config import load_config, save_config
with _hermes_home_scope(path):
cfg = load_config()
cfg["providers"] = {**(cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}), **custom}
save_config(cfg)
_pin_profile_model(path, str(model_cfg["provider"]), str(model_cfg["default"]))
return True
def _mirror_launch_credentials(path, params: dict) -> dict:
"""Copy launch .env / auth.json / voice sections into a new profile (best-effort per item).
``share_auth`` reports ``auth: "shared"`` and skips the auth copy; ``mirror_credentials``
false skips everything. ``model_inherited`` is filled in by the caller."""
share_auth = is_truthy_value(params.get("share_auth", False))
mirrored = {"env": False, "auth": "shared" if share_auth else False, "model_inherited": False,
"voice": False}
if not is_truthy_value(params.get("mirror_credentials", True)):
return mirrored
launch_home = get_hermes_home()
# .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)
if mirrored["auth"]:
# Drop single-use OAuth grants (first refresh strands every sibling); they read from the
# root grant via the pool fallback. API keys stay.
_best_effort(lambda: _lazy("hermes_cli.auth", "strip_cloned_single_use_oauth_grants")(path))
mirrored["voice"] = _mirror_voice_sections(path)
return mirrored
def _describe_toolsets(cfg):
"""``(toolsets, pinned_set)`` as the `hermes tools` checklist presents them (the raw registry
leaks platform composites and reports everything enabled without a pin)."""
from hermes_cli.tools_config import (
_coerce_platform_toolsets_value, _get_effective_configurable_toolsets, _get_platform_tools,
_toolset_allowed_for_platform)
from toolsets import resolve_toolset
pinned = _coerce_platform_toolsets_value((cfg.get("platform_toolsets") or {}).get("cli"), "cli")
pinned_set = _clean_names(pinned) if isinstance(pinned, list) else None
platform_enabled = _try(lambda: set(_get_platform_tools(cfg, "cli", include_default_mcp_servers=False)), set())
default_off = _try(lambda: _lazy("hermes_cli.tools_config", "_DEFAULT_OFF_TOOLSETS"), set())
toolsets_out = []
for ts_name, ts_label, ts_desc in _get_effective_configurable_toolsets():
enabled = ts_name in (pinned_set if pinned_set is not None else platform_enabled)
# Default-off integrations (+ opt-in yuanbao) are noise unless already enabled.
if not _toolset_allowed_for_platform(ts_name, "cli") or (
(ts_name in default_off or ts_name == "yuanbao") and not enabled):
continue
toolsets_out.append({"name": ts_name, "label": ts_label, "description": ts_desc or "",
"tool_count": _try(lambda: len(set(resolve_toolset(ts_name))), 0),
"enabled": enabled})
return toolsets_out, pinned_set
def _configure_ui_meta(profile_dir, params, applied) -> None:
"""Merge ``params["ui_meta"]`` key-wise into profile.yaml (None deletes). 64KB cap (rides
every roster paint). ``ui_meta_expected_revisions``: per-key CAS, any mismatch rejects the
whole write; revisions survive deletion so a stale client cannot recreate a removed key."""
applied["ui_meta"] = False
try:
incoming = params["ui_meta"]
if len(json.dumps(incoming)) > 65536:
return
expected = params.get("ui_meta_expected_revisions")
if expected is not None and not isinstance(expected, dict):
raise ValueError("ui_meta_expected_revisions must be an object")
with _profile_ui_meta_lock:
existing = _read_profile_yaml(profile_dir)
raw_revisions = existing.get("_ui_meta_revisions")
revisions = _clean_revisions(raw_revisions if isinstance(raw_revisions, dict) else {})
conflicts = {}
for key in incoming if isinstance(expected, dict) else ():
wanted, actual = expected.get(key), revisions.get(key, 0)
if not isinstance(wanted, int) or isinstance(wanted, bool) or wanted < 0 or wanted != actual:
conflicts[key] = {"expected": wanted, "actual": actual}
if conflicts:
applied["ui_meta_conflicts"] = conflicts
applied["ui_meta_revisions"] = {key: revisions.get(key, 0) for key in incoming}
return
current = existing.get("ui_meta")
current = current if isinstance(current, dict) else {}
for key, value in incoming.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
revisions[key] = revisions.get(key, 0) + 1
if current:
existing["ui_meta"] = current
else:
existing.pop("ui_meta", None)
existing["_ui_meta_revisions"] = revisions
from utils import atomic_yaml_write
atomic_yaml_write(profile_dir / "profile.yaml", existing, sort_keys=False)
applied["ui_meta"] = True
applied["ui_meta_revisions"] = {key: revisions[key] for key in incoming}
except Exception:
applied["ui_meta"] = False
def _configure_model(profile_dir, params, applied):
"""Apply a ``model`` + ``provider`` pin, or return a confirm message and write NOTHING (client
resends with ``confirm_expensive_model``). A failing guard = no warning (as _apply_model_switch)."""
model, provider = _model_provider_params(params)
if not (model and provider):
return None
confirm_message = None
# #95293 remainder: this is the Bots editor's model-switch path, and it used to write guarded
# (data-policy / expensive) models silently — the ONE surface that bypassed the selection guard every
# other switch path enforces. Same handshake contract as ``config.set model``: without
# ``confirm_expensive_model`` a guarded pick answers ``confirm_required`` + ``confirm_message`` and
# writes NOTHING; the client resends with ``confirm_expensive_model: true`` once the user confirms. A
# misbehaving guard must never break the save (treated as "no warning"), matching
# ``_apply_model_switch``.
if not is_truthy_value(params.get("confirm_expensive_model", False)):
warn = _lazy("hermes_cli.model_selection_guards", "combined_selection_warning")
confirm_message = _try(lambda: getattr(warn(model, provider=provider or None), "message", None), None)
if confirm_message is None:
applied["model"] = _best_effort(lambda: _pin_profile_model(profile_dir, provider, model))
return confirm_message
def _clean_names(values) -> set:
return {str(v).strip() for v in values if str(v).strip()}
def _save_toolset_pin(cfg, enabled, save_config) -> None:
"""Pin ``platform_toolsets.cli``: the key ``_load_enabled_toolsets`` reads and ``hermes tools`` writes.
An empty selection clears the pin so the platform default applies again."""
from hermes_cli.tools_config import _save_platform_tools
wanted = _clean_names(enabled)
if wanted:
_save_platform_tools(cfg, "cli", wanted)
elif isinstance(cfg.get("platform_toolsets"), dict):
cfg["platform_toolsets"].pop("cli", None)
save_config(cfg)
def _mcp_entry_enabled(entry: dict) -> bool:
"""The runtime's ``enabled`` reader; a legacy ``disabled: true`` (what older editors wrote,
migrated by config v46) still reads as off."""
from hermes_cli.tools_config import _parse_enabled_flag
from tools.mcp_tool_common import mcp_server_enabled
return mcp_server_enabled(entry) and not _parse_enabled_flag(entry.get("disabled", False), default=False)
def _save_mcp_toggles(cfg, enabled, launch_mcp, save_config) -> None:
"""Write the ``enabled`` flag every runtime resolver reads (``enabled_mcp_server_names``,
coding_context, oneshot); the legacy ``disabled`` key is dropped so old configs migrate."""
wanted = _clean_names(enabled)
mcp_cfg = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {}
for srv in wanted:
if not isinstance(mcp_cfg.get(srv), dict) and isinstance(launch_mcp.get(srv), dict):
mcp_cfg[srv] = dict(launch_mcp[srv])
for srv, entry in mcp_cfg.items():
if isinstance(entry, dict):
entry["enabled"] = srv in wanted
entry.pop("disabled", None)
if mcp_cfg:
cfg["mcp_servers"] = mcp_cfg
save_config(cfg)
def _configure_cfg_sections(profile_dir, params, applied) -> None:
"""Apply ``disabled_skills`` / ``enabled_toolsets`` / ``enabled_mcp_servers`` (replace
semantics; empty toolsets clears the pin). An undefined MCP server is copied from the LAUNCH
catalog (unknown names skipped); credentials stay in .env/auth."""
want_mcp = isinstance(params.get("enabled_mcp_servers"), list)
launch_mcp = {}
if want_mcp: # launch catalog read BEFORE the home override flips config resolution
# RAW: a copied entry keeps its ${VAR} refs instead of the launch profile's expanded secrets.
load_launch = _lazy("hermes_cli.config", "read_user_config_raw")
launch_mcp = _try(lambda: (load_launch() or {}).get("mcp_servers"), {})
launch_mcp = launch_mcp if isinstance(launch_mcp, dict) else {}
with _hermes_home_scope(profile_dir):
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
if isinstance(params.get("disabled_skills"), list):
try:
from hermes_cli.skills_config import save_disabled_skills
save_disabled_skills(cfg, _clean_names(params["disabled_skills"]))
applied["skills"] = True
cfg = load_config() or {}
except Exception:
applied["skills"] = False
if isinstance(params.get("enabled_toolsets"), list):
applied["toolsets"] = _best_effort(lambda: _save_toolset_pin(cfg, params["enabled_toolsets"], save_config))
if want_mcp:
applied["mcp_servers"] = _best_effort(lambda: _save_mcp_toggles(
load_config() or {}, params["enabled_mcp_servers"], launch_mcp, save_config))
def _unlink_asset_files(assets_dir, asset) -> int:
"""Delete every ``<asset>.<ext>`` in ``assets_dir``; returns how many existed."""
present = [t for t in (assets_dir / f"{asset}.{ext}" for ext in _ASSET_EXTS) if t.is_file()]
return len([t.unlink() for t in present])
def register(server) -> None:
bind_module(globals(), server)