Merge remote-tracking branch 'origin/main' into pr48275-rebase

# Conflicts:
#	cron/scheduler.py
This commit is contained in:
teknium1 2026-06-19 07:40:29 -07:00
commit a58287afcb
No known key found for this signature in database
162 changed files with 8521 additions and 634 deletions

View file

@ -34,14 +34,38 @@ logger = logging.getLogger(__name__)
# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``
# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``
# are not accidentally excluded.
#
# The dependency/cache entries below matter for more than tidiness: without
# them a single plugin venv, MCP-server install, or pip/uv cache living under
# HERMES_HOME gets walked file-by-file, ballooning a backup to hundreds of
# thousands of entries that crawl for hours — the exact "backup stuck for
# days / 426543 files" symptom users hit. The dependency/test-env names mostly
# mirror ``agent.skill_utils.EXCLUDED_SKILL_DIRS`` (the project's canonical
# "regeneratable dir" set); ``.cache`` is an additional backup-only entry, as
# it names a broad regeneratable cache convention (pip/uv/etc.) that the skill
# scanner doesn't need to prune but a backup walk does. We deliberately do NOT
# exclude ``.archive`` here because the curator's ``skills/.archive/`` holds
# restorable user skills that must survive a backup.
_EXCLUDED_DIRS = {
"hermes-agent", # the codebase repo — re-clone instead
"__pycache__", # bytecode caches — regenerated on import
".git", # nested git dirs (profiles shouldn't have these, but safety)
"node_modules", # js deps if website/ somehow leaks in
"node_modules", # js deps — reinstalled on demand
"backups", # prior auto-backups — don't nest backups exponentially
"checkpoints", # session-local trajectory caches — regenerated per-session,
# session-hash-keyed so they don't port to another machine anyway
# Python dependency trees (plugin / MCP-server venvs under HERMES_HOME) —
# regenerated by reinstalling; never irreplaceable state.
".venv",
"venv",
"site-packages",
# Tool / build caches — all regeneratable.
".cache",
".tox",
".nox",
".pytest_cache",
".mypy_cache",
".ruff_cache",
}
# File-name suffixes to skip

View file

@ -123,8 +123,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
# Configuration
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration",
args_hint="[model] [--provider name] [--global] [--refresh]"),
CommandDef("model", "Switch model (persists by default)", "Configuration",
args_hint="[model] [--provider name] [--global|--session] [--refresh]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),

View file

@ -1581,6 +1581,14 @@ DEFAULT_CONFIG = {
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
# spinner), or ascii. Live-swappable via `/indicator <style>`.
"tui_status_indicator": "kaomoji",
# Seconds between prompt_toolkit redraws in the classic CLI when idle.
# Default 1.0 keeps the wall-clock status-bar read-outs (idle-since-
# last-turn) ticking and keeps the bottom chrome alive during idle —
# without it prompt_toolkit stops repainting the status bar after a
# turn and it can go stale/disappear (#45592).
# Set 0 to disable the background refresh if it fights terminal
# auto-scroll in non-fullscreen mode on some emulators (#48309).
"cli_refresh_interval": 1.0,
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
"first_lines": 2,
"last_lines": 2,
@ -3453,6 +3461,7 @@ OPTIONAL_ENV_VARS = {
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
"im:history, im:read, im:write, users:read, files:read, files:write",
"prompt": "Slack Bot Token (xoxb-...)",
"help": "In your Slack app, add the required bot scopes, install the app to the workspace, then copy OAuth & Permissions > Bot User OAuth Token.",
"url": "https://api.slack.com/apps",
"password": True,
"category": "messaging",
@ -3462,10 +3471,19 @@ OPTIONAL_ENV_VARS = {
"App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
"message.channels, message.groups, app_mention",
"prompt": "Slack App Token (xapp-...)",
"help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.",
"url": "https://api.slack.com/apps",
"password": True,
"category": "messaging",
},
"SLACK_ALLOWED_USERS": {
"description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.",
"prompt": "Allowed Slack member IDs",
"help": "In Slack, open your profile, choose More or the three-dot menu, then Copy member ID. Add multiple IDs comma-separated.",
"url": "https://api.slack.com/apps",
"password": False,
"category": "messaging",
},
"MATTERMOST_URL": {
"description": "Mattermost server URL (e.g. https://mm.example.com)",
"prompt": "Mattermost server URL",

View file

@ -25,7 +25,7 @@ _GATEWAY_LIFECYCLE_PATTERNS = re.compile(
r"(?i)"
r"(hermes\s+gateway\s+(restart|stop|start))"
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
r"|(systemctl\s+(-\S+\s+)*(restart|stop|start)\s+.*hermes)"
r"|(p?kill\s+.*hermes.*gateway)"
)

View file

@ -191,10 +191,10 @@ _PRIVACY_NOTICE = """\
This will upload the following to a public paste service:
System info (OS, Python version, Hermes version, provider, which API keys
are configured NOT the actual keys)
Recent log lines (agent.log, errors.log, gateway.log, desktop.log may
contain conversation fragments and file paths)
Full agent.log, gateway.log, and desktop.log (up to 512 KB each likely
contains conversation content, tool outputs, and file paths)
Recent log lines (agent.log, errors.log, gateway.log, gui.log, desktop.log
may contain conversation fragments and file paths)
Full agent.log, gateway.log, gui.log, and desktop.log (up to 512 KB each
likely contains conversation content, tool outputs, and file paths)
Pastes auto-delete after 6 hours.
"""
@ -503,6 +503,9 @@ def _capture_default_log_snapshots(
"gateway": _capture_log_snapshot(
"gateway", tail_lines=errors_lines, redact=redact
),
"gui": _capture_log_snapshot(
"gui", tail_lines=errors_lines, redact=redact
),
"desktop": _capture_log_snapshot(
"desktop", tail_lines=errors_lines, redact=redact
),
@ -574,6 +577,10 @@ def collect_debug_report(
buf.write(log_snapshots["gateway"].tail_text)
buf.write("\n\n")
buf.write(f"--- gui.log (last {errors_lines} lines) ---\n")
buf.write(log_snapshots["gui"].tail_text)
buf.write("\n\n")
buf.write(f"--- desktop.log (last {errors_lines} lines) ---\n")
buf.write(log_snapshots["desktop"].tail_text)
buf.write("\n")
@ -639,6 +646,7 @@ def build_debug_share(
)
agent_log = log_snapshots["agent"].full_text
gateway_log = log_snapshots["gateway"].full_text
gui_log = log_snapshots["gui"].full_text
desktop_log = log_snapshots["desktop"].full_text
# Prepend dump header to each full log so every paste is self-contained.
@ -646,6 +654,8 @@ def build_debug_share(
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
if gui_log:
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
if desktop_log:
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
@ -657,6 +667,8 @@ def build_debug_share(
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if gui_log:
gui_log = _REDACTION_BANNER + gui_log
if desktop_log:
desktop_log = _REDACTION_BANNER + desktop_log
@ -670,6 +682,7 @@ def build_debug_share(
for label, content in (
("agent.log", agent_log),
("gateway.log", gateway_log),
("gui.log", gui_log),
("desktop.log", desktop_log),
):
if not content:
@ -712,11 +725,14 @@ def run_debug_share(args):
)
agent_log = log_snapshots["agent"].full_text
gateway_log = log_snapshots["gateway"].full_text
gui_log = log_snapshots["gui"].full_text
desktop_log = log_snapshots["desktop"].full_text
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
if gui_log:
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
if desktop_log:
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
if redact:
@ -725,12 +741,15 @@ def run_debug_share(args):
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if gui_log:
gui_log = _REDACTION_BANNER + gui_log
if desktop_log:
desktop_log = _REDACTION_BANNER + desktop_log
print(report)
for title, body in (
("FULL agent.log", agent_log),
("FULL gateway.log", gateway_log),
("FULL gui.log", gui_log),
("FULL desktop.log", desktop_log),
):
if body:

View file

@ -319,23 +319,12 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
# gateway. See #13242.
exclude_pids = exclude_pids | _get_ancestor_pids()
pids: list[int] = []
patterns = [
"hermes_cli.main gateway",
"hermes_cli.main --profile",
"hermes_cli.main -p",
"hermes_cli/main.py gateway",
"hermes_cli/main.py --profile",
"hermes_cli/main.py -p",
"hermes gateway",
# Windows: only match invocations that actually carry the ``gateway``
# subcommand or the gateway-dedicated console-script shim. Bare
# ``hermes.exe --profile`` / ``hermes.exe -p`` would also match
# ``hermes.exe --profile foo dashboard`` and other CLI subcommands,
# producing false-positive gateway PIDs (Copilot review).
"hermes.exe gateway",
"hermes-gateway.exe",
"gateway/run.py",
]
# Strict command-line matcher shared with gateway.status: requires the
# actual ``gateway run`` subcommand (or the dedicated entrypoints), so this
# scan no longer false-matches ``gateway status``/``dashboard`` siblings or
# unrelated processes like ``python -m tui_gateway``. Lazy import mirrors the
# circular-import avoidance used elsewhere in this module.
from gateway.status import looks_like_gateway_command_line
current_home = str(get_hermes_home().resolve())
current_home_lc = current_home.lower()
current_profile_arg = _profile_arg(current_home)
@ -430,8 +419,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
current_cmd = line[len("CommandLine=") :]
elif line.startswith("ProcessId="):
pid_str = line[len("ProcessId=") :]
current_cmd_lc = current_cmd.lower()
if any(p in current_cmd_lc for p in patterns) and (
if looks_like_gateway_command_line(current_cmd) and (
all_profiles or _matches_current_profile(current_cmd)
):
try:
@ -456,8 +444,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
with open(f"/proc/{pid}/cmdline", "rb") as _f:
cmdline = _f.read().decode("utf-8", errors="replace")
cmdline = cmdline.replace("\x00", " ")
cmdline_lc = cmdline.lower()
if any(p in cmdline_lc for p in patterns) and (
if looks_like_gateway_command_line(cmdline) and (
all_profiles or _matches_current_profile(cmdline)
):
_append_unique_pid(pids, pid, exclude_pids)
@ -500,8 +487,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
if pid is None:
continue
command_lc = command.lower()
if any(pattern in command_lc for pattern in patterns) and (
if looks_like_gateway_command_line(command) and (
all_profiles or _matches_current_profile(command)
):
_append_unique_pid(pids, pid, exclude_pids)
@ -3865,6 +3851,86 @@ def _running_under_gateway_supervisor() -> bool:
return False
def _guard_named_profile_under_multiplexer(force: bool = False) -> None:
"""Refuse a named-profile gateway when a multiplexer is already serving it.
When the default profile's gateway runs with gateway.multiplex_profiles=on,
it is the sole inbound process for EVERY profile on the host. Starting a
separate gateway for a named profile would double-bind that profile's
platforms (two pollers on one bot token, port fights). In that mode a
named-profile ``hermes gateway run`` is always a misconfiguration, so we
hard-error with a pointer to the multiplexer. ``--force`` overrides.
Inert unless ALL of: (a) this invocation is a named profile, (b) a default-
profile gateway is running, (c) that gateway's config has multiplexing on.
"""
if force:
return
# (a) Are we a named profile? Default/custom-hash homes return "".
try:
suffix = _profile_suffix()
except Exception:
return
if not suffix:
return # default profile (or unrecognized) — this guard doesn't apply
try:
from hermes_constants import get_default_hermes_root
default_root = get_default_hermes_root()
# (b) Is the default-profile gateway running?
from gateway.status import get_running_pid as _default_running_pid # noqa
except Exception:
return
try:
import yaml as _yaml
from gateway.status import _read_pid_record # type: ignore
# (b) default gateway PID file present + alive
default_pid_path = default_root / "gateway.pid"
rec = _read_pid_record(default_pid_path)
if not rec:
return
from gateway.status import _pid_exists, _pid_from_record
pid = _pid_from_record(rec)
if not pid or not _pid_exists(pid):
return
# (c) default config has multiplexing on
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return
with open(cfg_path, encoding="utf-8") as f:
cfg = _yaml.safe_load(f) or {}
multiplex = bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
)
if not multiplex:
return
except Exception:
logger.debug("Multiplexer-conflict probe failed", exc_info=True)
return
print_error(
f"The default gateway is running as a profile multiplexer and already "
f"serves profile '{suffix}'."
)
print(
" When gateway.multiplex_profiles is on, the default gateway is the\n"
" single inbound process for every profile. Starting a separate\n"
" gateway for this profile would double-bind its platforms (two\n"
" pollers on one bot token, port conflicts).\n"
)
print(" Manage the multiplexer instead (from the default profile):")
print()
print(" hermes gateway restart")
print()
print(" Pass --force to start a separate profile gateway anyway (not")
print(" recommended while the multiplexer is running).")
sys.exit(1)
def _guard_supervised_gateway_conflict(force: bool = False) -> None:
"""Refuse a foreground gateway when a service manager already supervises one.
@ -3977,6 +4043,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo
systemd/launchd service is already supervising this profile.
"""
_guard_official_docker_root_gateway()
_guard_named_profile_under_multiplexer(force=force)
_guard_supervised_gateway_conflict(force=force)
_guard_existing_gateway_process_conflict(replace=replace)
sys.path.insert(0, str(PROJECT_ROOT))

View file

@ -1302,10 +1302,54 @@ def stop() -> None:
print("✗ No gateway was running")
def _wait_for_gateway_absent(timeout_s: float = 30.0, interval_s: float = 0.5) -> bool:
"""Block until no gateway process is detectable, or the timeout elapses.
``stop()`` can return while the previous gateway is still draining
in-flight agents (the drain runs up to the restart-drain timeout). Uses the
authoritative ``get_running_pid()`` (lock + liveness + start-time +
gateway-shape) plus the now-strict ``_gateway_pids()`` scan so a relaunch
never races a still-alive old process.
"""
from gateway.status import get_running_pid
deadline = time.monotonic() + max(timeout_s, interval_s)
while time.monotonic() < deadline:
if get_running_pid() is None and not _gateway_pids():
return True
time.sleep(interval_s)
return get_running_pid() is None and not _gateway_pids()
def restart() -> None:
"""Stop the gateway then start it again."""
"""Stop the gateway then start it again.
Waits for the old gateway to be authoritatively gone before relaunching --
otherwise ``start()``'s "already running" guard sees the still-draining old
process and no-ops, and when that process later exits nothing replaces it (a
silent outage). Fails loudly if the process can't be cleared or the relaunch
doesn't produce a running gateway.
"""
_assert_windows()
from hermes_cli.gateway import kill_gateway_processes
stop()
if not _wait_for_gateway_absent(timeout_s=30.0):
print("⚠ Gateway still present after stop; forcing termination before restart...")
kill_gateway_processes(all_profiles=False, force=True)
if not _wait_for_gateway_absent(timeout_s=10.0):
raise RuntimeError(
"Gateway process still detected after force kill; refusing to "
"start a duplicate. Investigate stray PIDs before retrying."
)
# Give Windows a moment to release the listening port.
time.sleep(1.0)
start()
if not _wait_for_gateway_ready(timeout_s=15.0):
raise RuntimeError(
"Gateway restart did not produce a running gateway process. "
"Check logs/gateway.log and run `hermes gateway status`."
)

View file

@ -121,6 +121,16 @@ DEFAULT_CLAIM_TTL_SECONDS = 15 * 60
# effect of normal API traffic.
DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS = 60 * 60
# Grace added to a claim when a reclaim is deferred because the previous
# host-local worker is still alive after a termination attempt. Releasing the
# claim in that state would spawn a duplicate alongside the surviving worker —
# the runaway seen when a cgroup memory.high throttle parks a worker in
# uninterruptible (D) state, where a pending SIGKILL cannot be delivered until
# the throttle lifts. Holding the claim a short grace and retrying next tick
# stops the duplication; once no duplicate is spawned the pressure eases, the
# signal lands, and the following tick reclaims cleanly.
RECLAIM_DEFER_GRACE_SECONDS = 120
def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int:
"""Return the effective claim TTL, honoring the kanban env override.
@ -3286,6 +3296,14 @@ def release_stale_claims(
termination = _terminate_reclaimed_worker(
row["worker_pid"], row["claim_lock"], signal_fn=signal_fn,
)
# Never release a claim while our own worker is still alive: that would
# spawn a duplicate beside it. Hold the claim and retry next tick.
if _worker_survived_termination(termination):
_defer_reclaim_for_live_worker(
conn, row["id"], row["claim_lock"], now, termination,
reason="ttl_expired_worker_alive",
)
continue
with write_txn(conn):
cur = conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
@ -5113,7 +5131,13 @@ def _terminate_reclaimed_worker(
info["termination_attempted"] = True
try:
kill(int(pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
except ProcessLookupError:
# Process is already gone — that's a successful termination, not a
# survival. Leaving terminated=False here would make the reclaim guard
# misread a dead worker as still-alive and defer forever.
info["terminated"] = True
return info
except OSError:
return info
for _ in range(10):
@ -5136,6 +5160,63 @@ def _terminate_reclaimed_worker(
return info
def _worker_survived_termination(termination: dict) -> bool:
"""True when we tried to kill our own host-local worker and it is still alive.
Reclaiming in this state would release the claim and let the dispatcher
spawn a second worker while the first is still running the duplication
loop. Only host-local workers we actually signalled count: a non-local
claim lock or a no-op attempt (no ``os.kill`` available) must fall through
to the normal release path, since we cannot manage that worker anyway.
"""
return bool(
termination.get("termination_attempted")
and termination.get("host_local")
and not termination.get("terminated")
)
def _defer_reclaim_for_live_worker(
conn: sqlite3.Connection,
task_id: str,
claim_lock: Optional[str],
now: int,
termination: dict,
*,
reason: str,
) -> None:
"""Hold a claim whose worker survived termination instead of releasing it.
Extends ``claim_expires`` by ``RECLAIM_DEFER_GRACE_SECONDS`` so the task
stays ``running`` (no duplicate spawn) and records a ``reclaim_deferred``
event so the hold is visible in ``hermes kanban tail``. The next dispatch
tick retries the kill; this is self-correcting because not spawning a
duplicate is what lets the throttled worker finally die.
"""
grace = now + RECLAIM_DEFER_GRACE_SECONDS
with write_txn(conn):
cur = conn.execute(
"UPDATE tasks SET claim_expires = ? "
"WHERE id = ? AND status = 'running' AND claim_lock IS ?",
(grace, task_id, claim_lock),
)
if cur.rowcount != 1:
return
run_id = _current_run_id(conn, task_id)
if run_id is not None:
conn.execute(
"UPDATE task_runs SET claim_expires = ? WHERE id = ?",
(grace, run_id),
)
payload = {
"reason": reason,
"claim_lock": claim_lock,
"claim_expires_now": grace,
}
payload.update(termination)
_append_event(conn, task_id, "reclaim_deferred", payload, run_id=run_id)
def heartbeat_worker(
conn: sqlite3.Connection,
task_id: str,
@ -5374,6 +5455,15 @@ def detect_stale_running(
pid, lock, signal_fn=signal_fn,
)
# Never release a claim while our own worker is still alive: that would
# spawn a duplicate beside it. Hold the claim and retry next tick.
if _worker_survived_termination(termination):
_defer_reclaim_for_live_worker(
conn, tid, lock, now, termination,
reason="heartbeat_stale_worker_alive",
)
continue
with write_txn(conn):
cur = conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "

View file

@ -299,34 +299,46 @@ class ModelSwitchResult:
# Flag parsing
# ---------------------------------------------------------------------------
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
"""Parse --provider, --global, and --refresh flags from /model command args.
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]:
"""Parse --provider, --global, --session, and --refresh flags from /model command args.
Returns (model_input, explicit_provider, is_global, force_refresh).
Returns ``(model_input, explicit_provider, is_global, force_refresh, is_session)``.
``is_global`` and ``is_session`` are independent flag presences; the
*effective* persistence decision is resolved by
:func:`resolve_persist_behavior` so the config-gated default
(``model.persist_switch_by_default``) is applied in one place.
Examples::
"sonnet" -> ("sonnet", "", False, False)
"sonnet --global" -> ("sonnet", "", True, False)
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False)
"--provider my-ollama" -> ("", "my-ollama", False, False)
"--refresh" -> ("", "", False, True)
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False)
"sonnet" -> ("sonnet", "", False, False, False)
"sonnet --global" -> ("sonnet", "", True, False, False)
"sonnet --session" -> ("sonnet", "", False, False, True)
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False, False)
"--provider my-ollama" -> ("", "my-ollama", False, False, False)
"--refresh" -> ("", "", False, True, False)
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False, False)
"""
is_global = False
explicit_provider = ""
force_refresh = False
is_session = False
# Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash)
# A single Unicode dash before a flag keyword becomes "--"
import re as _re
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|refresh)', r'--\1', raw_args)
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|session|refresh)', r'--\1', raw_args)
# Extract --global
if "--global" in raw_args:
is_global = True
raw_args = raw_args.replace("--global", "").strip()
# Extract --session (explicit session-only; overrides the persist default)
if "--session" in raw_args:
is_session = True
raw_args = raw_args.replace("--session", "").strip()
# Extract --refresh (bust the model picker disk cache before listing)
if "--refresh" in raw_args:
force_refresh = True
@ -345,7 +357,37 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
i += 1
model_input = " ".join(filtered).strip()
return (model_input, explicit_provider, is_global, force_refresh)
return (model_input, explicit_provider, is_global, force_refresh, is_session)
def resolve_persist_behavior(is_global: bool, is_session: bool) -> bool:
"""Decide whether a ``/model`` switch should persist to ``config.yaml``.
Resolution order:
1. ``--session`` explicitly opts out ``False`` (this session only).
2. ``--global`` explicitly opts in ``True``.
3. Otherwise defer to ``model.persist_switch_by_default`` in
``config.yaml`` (defaults to ``True``, so a plain ``/model <name>``
survives across sessions the behavior users expect).
The config read is defensive: on a fresh install ``model`` may be a
flat string rather than a dict, in which case the built-in default
(``True``) applies.
"""
if is_session:
return False
if is_global:
return True
try:
from hermes_cli.config import load_config
model_cfg = load_config().get("model")
if isinstance(model_cfg, dict):
return bool(model_cfg.get("persist_switch_by_default", True))
except Exception:
pass
return True
# ---------------------------------------------------------------------------

View file

@ -29,7 +29,7 @@ import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, Optional
from typing import List, Optional, Tuple
from agent.skill_utils import is_excluded_skill_path
@ -781,6 +781,47 @@ def list_profiles() -> List[ProfileInfo]:
return profiles
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
"""Return the ``(profile_name, hermes_home)`` pairs a gateway should serve.
This is the single chokepoint for "which profiles does the inbound gateway
handle" so later multiplexing phases never re-derive the set.
- ``multiplex=False`` (default): returns exactly one entry for the *active*
profile byte-for-byte the single-profile behavior the gateway has
always had. The name is ``"default"`` for the default profile or the
active named profile's id.
- ``multiplex=True``: returns the default profile plus every valid named
profile under ``profiles/``, each paired with its own HERMES_HOME.
Intentionally lightweight (a directory scan + name validation only): no
per-profile config reads, gateway-running probes, or skill counts like
:func:`list_profiles`. It runs on gateway startup and must stay cheap.
The returned ``hermes_home`` is the path to pass to
``set_hermes_home_override`` when scoping a turn to that profile.
"""
active = get_active_profile_name() or "default"
if not multiplex:
return [(active, get_profile_dir(active))]
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
profiles_root = _get_profiles_root()
if profiles_root.is_dir():
for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir():
continue
name = entry.name
if name == "default":
continue # default is the built-in entry already added above
if not _PROFILE_ID_RE.match(name):
continue
serve.append((name, entry))
return serve
def create_profile(
name: str,
clone_from: Optional[str] = None,

View file

@ -0,0 +1,170 @@
"""Unified provider catalog — one source of truth for the provider universe.
The provider list shown by ``hermes model`` (CLI/TUI) and the desktop Settings
Providers tabs (Accounts + API keys) **must be the same set**. Historically
they were not: the CLI picker read :data:`hermes_cli.models.CANONICAL_PROVIDERS`
(which auto-extends from ``plugins/model-providers/<name>/``), while the desktop
tabs read separate hand-maintained lists (``_OAUTH_PROVIDER_CATALOG``,
``OPTIONAL_ENV_VARS`` + ``PROVIDER_GROUPS``) that nobody kept in sync. Every
provider added after those lists were written silently went missing from the
GUI e.g. GitHub Copilot showing up only under "tools", or ``openai-api`` being
configurable from the CLI but not the desktop app.
This module fixes that at the root: it derives ONE descriptor per provider from
the same universe ``hermes model`` renders (``CANONICAL_PROVIDERS``), joining:
* ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var`` from
:data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth), and
* ``display_name`` / ``description`` / ``signup_url`` from the provider's
:class:`providers.base.ProviderProfile` when one exists, falling back to the
``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the
``OPTIONAL_ENV_VARS`` signup URL otherwise (many profiles leave these blank,
and four canonical providers have no profile at all lmstudio, openai-api,
tencent-tokenhub, xai-oauth so the fallbacks are load-bearing).
Each descriptor is tagged with the ``tab`` it belongs on (``keys`` vs
``accounts``) based purely on how the provider authenticates. The desktop
``/api/env`` and ``/api/providers/oauth`` endpoints derive their MEMBERSHIP from
this catalog; the old hand lists are demoted to presentation/override overlays
(bespoke OAuth flow + status resolvers, richer copy, icons, ordering) and no
longer decide which providers exist.
Parity contract (locked by tests): the union of the two tabs equals the
``CANONICAL_PROVIDERS`` universe, i.e. exactly what ``hermes model`` shows.
"""
from __future__ import annotations
from dataclasses import dataclass
# Auth types that authenticate via an account / sign-in flow rather than a
# pasted API key. These route to the desktop "Accounts" tab; everything else
# (api_key, and aws_sdk which is configured via AWS_REGION/AWS_PROFILE) routes
# to the "API keys" tab. Mirrors the auth_type strings used in
# hermes_cli.auth.PROVIDER_REGISTRY and providers.base.ProviderProfile.
_ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset(
{
"oauth_device_code",
"oauth_external",
"oauth_minimax",
"external_process", # copilot-acp: spawns `copilot --acp --stdio`
"copilot", # GitHub Copilot token / gh auth
}
)
@dataclass(frozen=True)
class ProviderDescriptor:
"""One provider, as seen by every surface (CLI picker + both GUI tabs)."""
slug: str # canonical id, e.g. "google-gemini-cli"
label: str # human display name
description: str # one-line description
auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk
tab: str # "keys" | "accounts"
api_key_env_vars: tuple[str, ...] # credential env vars (may be empty)
base_url_env_var: str # base-URL override env var (may be "")
signup_url: str # signup / console URL (may be "")
order: int # CANONICAL_PROVIDERS index — mirrors `hermes model`
def tab_for_auth_type(auth_type: str) -> str:
"""Return the desktop tab ("keys"|"accounts") a provider's auth maps to."""
return "accounts" if auth_type in _ACCOUNTS_AUTH_TYPES else "keys"
def _split_env_vars(env_vars: tuple[str, ...]) -> tuple[tuple[str, ...], str]:
"""Split a profile's ``env_vars`` into (api_key_vars, base_url_var)."""
keys = tuple(v for v in env_vars if not (v.endswith("_BASE_URL") or v.endswith("_URL")))
base = next((v for v in env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), "")
return keys, base
def provider_catalog() -> list[ProviderDescriptor]:
"""Return one descriptor per provider in the ``hermes model`` universe.
Membership is :data:`CANONICAL_PROVIDERS` (the list the CLI/TUI picker
renders, which auto-extends from provider plugins). Auth + env come from
``PROVIDER_REGISTRY``; display metadata from ``ProviderProfile`` with
canonical/env fallbacks so providers without a profile (or with blank
profile metadata) still resolve sensibly.
"""
from hermes_cli.models import CANONICAL_PROVIDERS
# PROVIDER_REGISTRY / list_providers are imported lazily and defensively:
# this module is on the import path of the web server and the CLI, and we
# never want a provider-plugin import error to blank the whole catalog.
try:
from hermes_cli.auth import PROVIDER_REGISTRY
except Exception:
PROVIDER_REGISTRY = {}
try:
from providers import list_providers
profiles = {p.name: p for p in list_providers()}
except Exception:
profiles = {}
try:
from hermes_cli.config import OPTIONAL_ENV_VARS
except Exception:
OPTIONAL_ENV_VARS = {}
out: list[ProviderDescriptor] = []
for order, entry in enumerate(CANONICAL_PROVIDERS):
slug = entry.slug
cfg = PROVIDER_REGISTRY.get(slug)
prof = profiles.get(slug)
# auth_type: registry is authoritative; fall back to profile, then api_key.
auth_type = (
(getattr(cfg, "auth_type", "") if cfg else "")
or (getattr(prof, "auth_type", "") if prof else "")
or "api_key"
)
# Credential env vars: registry first (it already normalizes these),
# else derive from the profile's env_vars tuple.
if cfg and getattr(cfg, "api_key_env_vars", ()):
api_key_vars = tuple(cfg.api_key_env_vars)
base_url_var = getattr(cfg, "base_url_env_var", "") or ""
elif prof and getattr(prof, "env_vars", ()):
api_key_vars, base_url_var = _split_env_vars(tuple(prof.env_vars))
else:
api_key_vars, base_url_var = (), ""
label = (
(getattr(prof, "display_name", "") if prof else "")
or entry.label
or slug
)
description = (
(getattr(prof, "description", "") if prof else "")
or entry.tui_desc
or label
)
signup_url = (getattr(prof, "signup_url", "") if prof else "") or ""
if not signup_url and api_key_vars:
info = OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {}
signup_url = info.get("url") or ""
out.append(
ProviderDescriptor(
slug=slug,
label=label,
description=description,
auth_type=auth_type,
tab=tab_for_auth_type(auth_type),
api_key_env_vars=api_key_vars,
base_url_env_var=base_url_var,
signup_url=signup_url,
order=order,
)
)
return out
def provider_catalog_by_slug() -> dict[str, ProviderDescriptor]:
"""Convenience: the catalog keyed by slug."""
return {d.slug: d for d in provider_catalog()}

View file

@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
from hermes_cli import auth as auth_mod
from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
from agent.secret_scope import get_secret as _get_secret
from hermes_cli.auth import (
AuthError,
DEFAULT_CODEX_BASE_URL,
@ -35,6 +36,19 @@ from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_int
def _getenv(name: str, default: str = "") -> str:
"""Profile-scoped replacement for ``os.getenv`` on credential/provider reads.
Routes through the secret scope (Workstream A): identical to ``os.getenv``
when multiplexing is off, scope-aware (and fail-closed on an unscoped read)
when on. Genuinely-global vars are handled inside ``get_secret`` and still
read ``os.environ``. Keeps the ``(name, default) -> str`` contract every
call site here already relies on.
"""
val = _get_secret(name, default)
return val if val is not None else default
def _normalize_custom_provider_name(value: str) -> str:
return value.strip().lower().replace(" ", "-")
@ -156,7 +170,7 @@ def _host_derived_api_key(base_url: str) -> str:
if sanitized in ("OPENAI", "OPENROUTER", "OLLAMA"):
return ""
env_name = f"{sanitized}_API_KEY"
return (os.getenv(env_name, "") or "").strip()
return (_getenv(env_name, "") or "").strip()
def _auto_detect_local_model(base_url: str) -> str:
@ -437,7 +451,7 @@ def resolve_requested_provider(requested: Optional[str] = None) -> str:
# Prefer the persisted config selection over any stale shell/.env
# provider override so chat uses the endpoint the user last saved.
env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
env_provider = _getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
if env_provider:
return env_provider
@ -542,7 +556,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
name_norm = _normalize_custom_provider_name(ep_name)
# Resolve the API key from the env var name stored in key_env
key_env = str(entry.get("key_env", "") or "").strip()
resolved_api_key = os.getenv(key_env, "").strip() if key_env else ""
resolved_api_key = _getenv(key_env, "").strip() if key_env else ""
# Fall back to inline api_key when key_env is absent or unresolvable
if not resolved_api_key:
resolved_api_key = str(entry.get("api_key", "") or "").strip()
@ -824,8 +838,8 @@ def _resolve_named_custom_runtime(
api_key_candidates = [
(explicit_api_key or "").strip(),
# Gate env key fallbacks on authoritative hosts (#28660)
(os.getenv("OPENAI_API_KEY", "").strip() if _da_is_openai_url else ""),
(os.getenv("OPENROUTER_API_KEY", "").strip() if _da_is_openrouter else ""),
(_getenv("OPENAI_API_KEY", "").strip() if _da_is_openai_url else ""),
(_getenv("OPENROUTER_API_KEY", "").strip() if _da_is_openrouter else ""),
# Bonus (#28660): derive `<VENDOR>_API_KEY` from the host so users
# who set DEEPSEEK_API_KEY / GROQ_API_KEY / MISTRAL_API_KEY get the
# intuitive match without configuring `custom_providers` first.
@ -878,11 +892,11 @@ def _resolve_named_custom_runtime(
api_key_candidates = [
(explicit_api_key or "").strip(),
str(custom_provider.get("api_key", "") or "").strip(),
os.getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(),
_getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(),
# Gate provider env keys on their authoritative hosts — sending
# OPENAI_API_KEY to a local-llm endpoint leaks credentials (#28660).
(os.getenv("OPENAI_API_KEY", "").strip() if _cp_is_openai_url else ""),
(os.getenv("OPENROUTER_API_KEY", "").strip() if _cp_is_openrouter else ""),
(_getenv("OPENAI_API_KEY", "").strip() if _cp_is_openai_url else ""),
(_getenv("OPENROUTER_API_KEY", "").strip() if _cp_is_openrouter else ""),
# Bonus (#28660): derive `<VENDOR>_API_KEY` from the host as a final
# fallback when key_env wasn't set explicitly.
_host_derived_api_key(base_url),
@ -941,8 +955,8 @@ def _resolve_openrouter_runtime(
except Exception:
pass
env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
env_custom_base_url = os.getenv("CUSTOM_BASE_URL", "").strip()
env_openrouter_base_url = _getenv("OPENROUTER_BASE_URL", "").strip()
env_custom_base_url = _getenv("CUSTOM_BASE_URL", "").strip()
# Use config base_url when available and the provider context matches.
# OPENAI_BASE_URL env var is no longer consulted — config.yaml is
@ -982,8 +996,8 @@ def _resolve_openrouter_runtime(
if _is_openrouter_context:
api_key_candidates = [
explicit_api_key,
os.getenv("OPENROUTER_API_KEY"),
os.getenv("OPENAI_API_KEY"),
_getenv("OPENROUTER_API_KEY"),
_getenv("OPENAI_API_KEY"),
]
else:
# Custom endpoint: use api_key from config when using config base_url (#1760).
@ -1003,9 +1017,9 @@ def _resolve_openrouter_runtime(
api_key_candidates = [
explicit_api_key,
(cfg_api_key if use_config_base_url else ""),
(os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
(os.getenv("OPENAI_API_KEY") if (_is_openai_url or _is_openai_azure) else ""),
(os.getenv("OPENROUTER_API_KEY") if _is_openrouter_url else ""),
(_getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
(_getenv("OPENAI_API_KEY") if (_is_openai_url or _is_openai_azure) else ""),
(_getenv("OPENROUTER_API_KEY") if _is_openrouter_url else ""),
# Bonus (#28660): derive `<VENDOR>_API_KEY` from the host so users
# who set DEEPSEEK_API_KEY / GROQ_API_KEY / MISTRAL_API_KEY get the
# intuitive match. Helper returns "" for IPs/loopback and for env
@ -1108,7 +1122,7 @@ def _resolve_azure_foundry_runtime(
if inferred:
cfg_api_mode = inferred
env_base_url = os.getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/")
env_base_url = _getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/")
base_url = explicit_base_url_clean or cfg_base_url or env_base_url
if not base_url:
raise AuthError(
@ -1197,7 +1211,7 @@ def _resolve_azure_foundry_runtime(
except Exception:
api_key = ""
if not api_key:
api_key = os.getenv("AZURE_FOUNDRY_API_KEY", "").strip()
api_key = _getenv("AZURE_FOUNDRY_API_KEY", "").strip()
if not api_key:
raise AuthError(
"Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in "
@ -1297,7 +1311,7 @@ def _resolve_explicit_runtime(
expires_at = state.get("agent_key_expires_at") or state.get("expires_at")
if not api_key:
creds = resolve_nous_runtime_credentials(
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
timeout_seconds=float(_getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
)
api_key = creds.get("api_key", "")
expires_at = creds.get("expires_at")
@ -1326,7 +1340,7 @@ def _resolve_explicit_runtime(
if pconfig and pconfig.auth_type == "api_key":
env_url = ""
if pconfig.base_url_env_var:
env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
env_url = _getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
base_url = explicit_base_url
if not base_url:
@ -1398,8 +1412,8 @@ def resolve_runtime_provider(
if requested_provider == "anthropic" and "azure.com" in _eff_base:
_azure_key = (
(explicit_api_key or "").strip()
or os.getenv("AZURE_ANTHROPIC_KEY", "").strip()
or os.getenv("ANTHROPIC_API_KEY", "").strip()
or _getenv("AZURE_ANTHROPIC_KEY", "").strip()
or _getenv("ANTHROPIC_API_KEY", "").strip()
)
return {
"provider": "anthropic",
@ -1454,8 +1468,8 @@ def resolve_runtime_provider(
if provider == "openrouter":
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
cfg_base_url = str(model_cfg.get("base_url") or "").strip()
env_openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip()
env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
env_openai_base_url = _getenv("OPENAI_BASE_URL", "").strip()
env_openrouter_base_url = _getenv("OPENROUTER_BASE_URL", "").strip()
has_custom_endpoint = bool(
explicit_base_url
or env_openai_base_url
@ -1511,7 +1525,7 @@ def resolve_runtime_provider(
if provider == "nous":
try:
creds = resolve_nous_runtime_credentials(
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
timeout_seconds=float(_getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
)
return {
"provider": "nous",
@ -1664,7 +1678,7 @@ def resolve_runtime_provider(
for hint_key in ("key_env", "api_key_env"):
env_var = str(model_cfg.get(hint_key) or "").strip()
if env_var:
token = os.getenv(env_var, "").strip()
token = _getenv(env_var, "").strip()
if token:
break
# Next: an inline api_key on the model config (useful in multi-profile
@ -1674,8 +1688,8 @@ def resolve_runtime_provider(
# Finally fall back to the historical fixed names.
if not token:
token = (
os.getenv("AZURE_ANTHROPIC_KEY", "").strip()
or os.getenv("ANTHROPIC_API_KEY", "").strip()
_getenv("AZURE_ANTHROPIC_KEY", "").strip()
or _getenv("ANTHROPIC_API_KEY", "").strip()
)
if not token:
raise AuthError(

View file

@ -1554,6 +1554,7 @@ async def upload_managed_file_stream(
)
tmp_path = Path(tmp_name)
total = 0
renamed = False
try:
with os.fdopen(tmp_fd, "wb") as out:
while True:
@ -1565,16 +1566,21 @@ async def upload_managed_file_stream(
raise HTTPException(status_code=413, detail="File is too large")
out.write(chunk)
os.replace(tmp_path, target)
renamed = True
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
except PermissionError:
tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
finally:
# Clean up the temp file on every non-success exit, including
# BaseException paths the `except` clauses above don't catch — most
# importantly asyncio.CancelledError when a browser aborts a large
# upload mid-stream (the exact NS-501 scenario). os.replace clears
# tmp_path on success, so only unlink when the rename didn't happen.
if not renamed:
tmp_path.unlink(missing_ok=True)
await file.close()
return {
@ -2316,6 +2322,43 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str:
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
# Slack member IDs (users U..., Enterprise Grid W...). Kept in sync with the
# frontend SLACK_MEMBER_ID_RE in web/src/pages/ChannelsPage.tsx.
_SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}")
def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None:
"""Reject platform credentials that are clearly in the wrong field."""
if platform_id != "slack" or not value:
return
if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"):
raise HTTPException(
status_code=400,
detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.",
)
if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"):
raise HTTPException(
status_code=400,
detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
)
if key == "SLACK_ALLOWED_USERS":
# Mirror the gateway's parse (gateway/platforms/slack.py): split on comma,
# strip, and drop empty entries so a trailing/interior comma isn't rejected
# here when the runtime would accept it. "*" is the allow-all wildcard.
user_ids = [part.strip() for part in value.split(",") if part.strip()]
invalid = [
user_id
for user_id in user_ids
if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id)
]
if invalid:
raise HTTPException(
status_code=400,
detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.",
)
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
@ -3925,28 +3968,135 @@ async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
raise HTTPException(status_code=500, detail="Internal server error")
def _catalog_provider_env_metadata() -> dict:
"""Map provider env vars → desktop card metadata, derived from the catalog.
Returns ``{env_var: {provider, provider_label, description, url, is_password,
advanced}}`` for every API-key provider in the unified ``provider_catalog()``
(i.e. the ``hermes model`` universe). This is what lets the desktop Keys tab
render a card for a provider even when its env var was never hand-added to
``OPTIONAL_ENV_VARS`` closing the drift where CLI-configurable providers
(openai-api, kilocode, novita, tencent-tokenhub, copilot, ) were missing
from the GUI.
Hand ``OPTIONAL_ENV_VARS`` prose is layered ON TOP of this in the endpoint;
this only supplies membership + grouping + sensible fallbacks.
"""
try:
from hermes_cli.provider_catalog import provider_catalog
except Exception:
return {}
# Env vars already declared with a NON-provider category (e.g. the shared
# GITHUB_TOKEN, which is a Skills-Hub "tool" credential) must not be
# promoted into a provider card. Copilot lists GITHUB_TOKEN among its auth
# aliases, but its provider card uses the provider-owned COPILOT_GITHUB_TOKEN.
try:
from hermes_cli.config import OPTIONAL_ENV_VARS as _OPT
except Exception:
_OPT = {}
_non_provider_keys = {
k for k, v in _OPT.items()
if (v or {}).get("category") and (v or {}).get("category") != "provider"
}
meta: dict = {}
for d in provider_catalog():
if d.tab != "keys":
continue
# API-key vars: the first is the primary (password) field; any aliases
# are kept as additional password fields so users can clear them too.
for env_var in d.api_key_env_vars:
if env_var in _non_provider_keys:
continue # don't hijack a shared tool/messaging credential
meta.setdefault(
env_var,
{
"provider": d.slug,
"provider_label": d.label,
"description": d.description,
"url": d.signup_url or None,
"is_password": True,
"advanced": False,
"category": "provider",
},
)
# Base-URL override is an advanced, non-secret field for the same card.
if d.base_url_env_var:
meta.setdefault(
d.base_url_env_var,
{
"provider": d.slug,
"provider_label": d.label,
"description": f"{d.label} base URL override",
"url": None,
"is_password": False,
"advanced": True,
"category": "provider",
},
)
# AWS-SDK providers (Bedrock) authenticate via the AWS credential chain
# rather than a pasted API key, so they have no api_key_env_vars. Tag
# their AWS_* settings to the provider card so they still appear on the
# Keys tab (otherwise Bedrock — a `hermes model` provider — would be
# invisible in the desktop app).
if d.auth_type == "aws_sdk":
for aws_var in ("AWS_REGION", "AWS_PROFILE"):
existing = meta.get(aws_var, {})
meta[aws_var] = {
"provider": d.slug,
"provider_label": d.label,
"description": existing.get("description") or f"{d.label} ({aws_var})",
"url": existing.get("url"),
"is_password": False,
"advanced": existing.get("advanced", True),
"category": "provider",
}
return meta
@app.get("/api/env")
async def get_env_vars(profile: Optional[str] = None):
with _profile_scope(profile):
env_on_disk = load_env()
channel_keys = _channel_managed_env_keys()
result = {}
for var_name, info in OPTIONAL_ENV_VARS.items():
catalog_meta = _catalog_provider_env_metadata()
def _row(var_name: str, info: dict) -> dict:
value = env_on_disk.get(var_name)
result[var_name] = {
cat_meta = catalog_meta.get(var_name) or {}
# Hand OPTIONAL_ENV_VARS prose wins where present; the catalog fills any
# gaps (description/url) and always supplies provider grouping hints.
return {
"is_set": bool(value),
"redacted_value": redact_key(value) if value else None,
"description": info.get("description", ""),
"url": info.get("url"),
"category": info.get("category", ""),
"is_password": info.get("password", False),
"description": info.get("description") or cat_meta.get("description", ""),
"url": info.get("url") if info.get("url") is not None else cat_meta.get("url"),
"category": info.get("category") or cat_meta.get("category", ""),
"is_password": info.get("password", cat_meta.get("is_password", False)),
"tools": info.get("tools", []),
"advanced": info.get("advanced", False),
"advanced": info.get("advanced", cat_meta.get("advanced", False)),
# True when this var is a messaging-platform credential owned by a
# Channels page card. The Keys/Env page uses this to hide it and
# avoid duplicating the (richer) Channels configuration UI.
"channel_managed": var_name in channel_keys,
# Provider grouping hints derived from the unified provider catalog
# so the desktop Keys tab groups by the SAME provider identity the
# CLI `hermes model` picker uses (not desktop-only prefix guesses).
"provider": cat_meta.get("provider", ""),
"provider_label": cat_meta.get("provider_label", ""),
}
result = {}
for var_name, info in OPTIONAL_ENV_VARS.items():
result[var_name] = _row(var_name, info)
# Synthesize rows for catalog provider env vars that have no hand entry in
# OPTIONAL_ENV_VARS — these are the providers that were CLI-configurable but
# invisible in the desktop app until now.
for var_name in catalog_meta:
if var_name not in result:
result[var_name] = _row(var_name, {})
return result
@ -4146,9 +4296,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
},
"slack": {
"name": "Slack",
"description": "Use Hermes from Slack via Socket Mode.",
"description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.",
"docs_url": "https://api.slack.com/apps",
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"),
"required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
},
"mattermost": {
@ -4633,6 +4783,7 @@ def _messaging_env_info(key: str) -> dict[str, Any]:
return {
"description": info.get("description", ""),
"prompt": info.get("prompt", key),
"help": info.get("help", ""),
"url": info.get("url"),
"is_password": info.get("password", False),
"advanced": info.get("advanced", False),
@ -5212,6 +5363,7 @@ async def update_messaging_platform(
)
trimmed = value.strip()
if trimmed:
_validate_messaging_env_value(platform_id, key, trimmed)
save_env_value(key, trimmed)
if body.enabled is not None:
@ -5413,13 +5565,53 @@ def _claude_code_only_status() -> Dict[str, Any]:
return {"logged_in": False, "source": None}
# Provider catalog. The order matters — it's how we render the UI list.
# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard
# fallback while Phase 2 (in-browser flows) isn't built yet.
# ``flow`` describes the OAuth shape so the future modal can pick the
# right UI: ``pkce`` = open URL + paste callback code, ``device_code`` =
# show code + verification URL + poll, ``external`` = read-only (delegated
# to a third-party CLI like Claude Code or Qwen).
def _gemini_cli_status() -> Dict[str, Any]:
"""Status for the google-gemini-cli OAuth provider (Code Assist login)."""
try:
from hermes_cli import auth as hauth
raw = hauth.get_gemini_oauth_auth_status()
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or "google_oauth",
"source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist",
"token_preview": _truncate_token(raw.get("api_key")),
"expires_at": None,
"has_refresh_token": True,
}
def _copilot_acp_status() -> Dict[str, Any]:
"""Status for copilot-acp — credentials are owned by the Copilot CLI.
There is no cheap programmatic credential probe for the ACP subprocess, so
this is a read-only "managed by the Copilot CLI" card (like claude-code):
Hermes never claims a login state it can't verify.
"""
return {
"logged_in": False,
"source": "copilot_cli",
"source_label": "Managed by the GitHub Copilot CLI",
"token_preview": None,
"expires_at": None,
"has_refresh_token": False,
}
# Explicit, hand-tuned OAuth/account provider cards. These carry the bits that
# can't be derived from the unified provider catalog: the OAuth ``flow`` shape,
# the per-provider ``status_fn``, the ``cli_command`` fallback, and curated
# display order. They are the OVERRIDE BASE for ``_build_oauth_catalog()``,
# which unions them with every accounts-tab provider in ``provider_catalog()``
# so newly-added OAuth/external providers appear automatically (no hand edit).
# This tuple also still includes two entries that are NOT catalog providers but
# must show on the Accounts tab: the api-key Anthropic PKCE card and the
# synthetic ``claude-code`` subscription row.
# ``flow`` describes the OAuth shape so the modal can pick the right UI:
# ``pkce`` = open URL + paste callback code, ``device_code`` = show code +
# verification URL + poll, ``external`` = read-only (delegated to a third-party
# CLI like Claude Code or Qwen), ``loopback`` = 127.0.0.1 callback listener.
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
{
"id": "nous",
@ -5469,6 +5661,22 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
},
{
"id": "google-gemini-cli",
"name": "Google Gemini (OAuth + Code Assist)",
"flow": "external",
"cli_command": "hermes auth add google-gemini-cli",
"docs_url": "https://ai.google.dev/gemini-api/docs",
"status_fn": _gemini_cli_status,
},
{
"id": "copilot-acp",
"name": "GitHub Copilot (ACP)",
"flow": "external",
"cli_command": "copilot /login",
"docs_url": "https://docs.github.com/en/copilot",
"status_fn": _copilot_acp_status,
},
# ── Anthropic / Claude entries sit at the bottom: the API-key path
# first, then the subscription OAuth path (which only works with extra
# usage credits on top of a Claude Max plan — see disclaimer in name).
@ -5555,6 +5763,31 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
"has_refresh_token": True,
"last_refresh": raw.get("last_refresh"),
}
# No hand-written branch for this provider id: fall through to the
# canonical slug-driven dispatcher so accounts-tab providers derived
# from the unified catalog (which carry status_fn=None) still reflect
# real login state instead of rendering permanently logged-out. This
# closes the membership-auto-extends-but-status-doesn't gap: add an
# OAuth/account provider plugin and its card shows the right state.
raw = hauth.get_auth_status(provider_id)
if isinstance(raw, dict) and "logged_in" in raw:
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or raw.get("provider") or provider_id,
"source_label": (
raw.get("source_label")
or raw.get("auth_store")
or raw.get("auth_store_path")
or raw.get("base_url")
or raw.get("name")
or ""
),
"token_preview": _truncate_token(
raw.get("access_token") or raw.get("api_key")
),
"expires_at": raw.get("expires_at") or raw.get("access_expires_at"),
"has_refresh_token": bool(raw.get("has_refresh_token")),
}
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {"logged_in": False}
@ -5598,6 +5831,56 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
return None
def _build_oauth_catalog() -> list[Dict[str, Any]]:
"""Build the Accounts-tab provider list.
MEMBERSHIP is the union of:
1. ``_OAUTH_PROVIDER_CATALOG`` the explicit, hand-tuned cards that carry
bespoke flow / status_fn / cli_command (including the api-key Anthropic
PKCE card and the synthetic claude-code subscription row, which are not
catalog providers), and
2. every accounts-tab provider in the unified ``provider_catalog()`` (the
``hermes model`` universe) so any OAuth/external provider added as a
plugin appears automatically, with sensible defaults, even if no
explicit card was written for it.
The explicit catalog wins on metadata; the unified catalog guarantees we
never silently drop a provider the CLI picker offers. Order: explicit cards
first (their curated order), then any catalog-only providers appended in
``hermes model`` order.
"""
rows: list[Dict[str, Any]] = []
seen: set[str] = set()
# 1. Explicit hand-tuned cards (authoritative metadata + curated order).
for entry in _OAUTH_PROVIDER_CATALOG:
if entry["id"] in seen:
continue
seen.add(entry["id"])
rows.append(dict(entry))
# 2. Catalog accounts-providers not already covered — keeps the Accounts tab
# in lockstep with the `hermes model` universe (zero-edit for new plugins).
try:
from hermes_cli.provider_catalog import provider_catalog
for d in provider_catalog():
if d.tab != "accounts" or d.slug in seen:
continue
seen.add(d.slug)
rows.append({
"id": d.slug,
"name": d.label,
"flow": "external",
"cli_command": f"hermes auth add {d.slug}",
"docs_url": d.signup_url or "",
"status_fn": None,
})
except Exception:
pass
return rows
@app.get("/api/providers/oauth")
async def list_oauth_providers(profile: Optional[str] = None):
"""Enumerate every OAuth-capable LLM provider with current status.
@ -5617,10 +5900,14 @@ async def list_oauth_providers(profile: Optional[str] = None):
token_preview last N chars of the token, never the full token
expires_at ISO timestamp string or null
has_refresh_token bool
Membership is derived from the unified provider_catalog() so this stays in
sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider
flow/status/cli metadata.
"""
with _profile_scope(profile):
providers = []
for p in _OAUTH_PROVIDER_CATALOG:
for p in _build_oauth_catalog():
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
@ -5647,7 +5934,7 @@ async def disconnect_oauth_provider(
_require_token(request)
with _profile_scope(profile):
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(
@ -10914,6 +11201,7 @@ def _resolve_chat_argv(
# the dashboard PTY path.
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
env.setdefault("HERMES_TUI_INLINE", "1")
env["HERMES_TUI_DASHBOARD"] = "1"
if profile_dir is not None:
env["HERMES_HOME"] = str(profile_dir)