Merge commit '6110aed9b' into feat/whatsapp-cloud-api
This commit is contained in:
commit
bfcc9f92b4
3038 changed files with 499128 additions and 63841 deletions
|
|
@ -14,34 +14,79 @@ Provides subcommands for:
|
|||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.14.0"
|
||||
__release_date__ = "2026.5.16"
|
||||
__version__ = "0.16.0"
|
||||
__release_date__ = "2026.6.5"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
|
||||
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
|
||||
|
||||
Windows services and terminals default to cp1252, which cannot encode
|
||||
box-drawing characters used in CLI output. This causes unhandled
|
||||
UnicodeEncodeError crashes on gateway startup.
|
||||
Several environments select a legacy, non-UTF-8 encoding for the standard
|
||||
streams:
|
||||
|
||||
- Windows services and terminals default to cp1252.
|
||||
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
|
||||
installs and Raspberry Pi) select latin-1 or ASCII.
|
||||
|
||||
The CLI prints box-drawing characters (┌│├└─) and the ⚕ glyph in the setup
|
||||
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
|
||||
raises an unhandled UnicodeEncodeError that crashes the command before it
|
||||
can even start — e.g. `hermes setup` on a fresh Pi.
|
||||
|
||||
This runs at import time so it protects every CLI subcommand, on any
|
||||
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
|
||||
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
|
||||
stream object is fixed in place (cached `sys.stdout` references keep
|
||||
working) and falling back to reopening the file descriptor with
|
||||
closefd=False (the CPython-recommended safe variant).
|
||||
|
||||
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
|
||||
stream change and no environment mutation.
|
||||
|
||||
Note: this is intentionally the earliest, platform-agnostic guard.
|
||||
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
|
||||
points and layers on the Windows-only extras (console code-page flip,
|
||||
EDITOR default, PATH augmentation); its stream reconfiguration is a
|
||||
harmless idempotent no-op once we have already repaired the streams here.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
repaired = False
|
||||
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, stream_name, None)
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
except (AttributeError, OSError):
|
||||
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
||||
if encoding == "utf8":
|
||||
continue
|
||||
|
||||
# Preferred: reconfigure the existing TextIOWrapper in place. This
|
||||
# preserves object identity so any code already holding a reference
|
||||
# to the old sys.stdout benefits from the repair too.
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
repaired = True
|
||||
continue
|
||||
|
||||
# Fallback: reopen the underlying file descriptor as UTF-8. Used
|
||||
# for streams that don't expose reconfigure() (e.g. some wrapped
|
||||
# or replaced streams). closefd=False keeps the original fd open.
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
errors="replace", buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
repaired = True
|
||||
except (AttributeError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Only nudge child processes toward UTF-8 when we actually detected a
|
||||
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
|
||||
# locale already, so leave the environment untouched (minimal footprint).
|
||||
if repaired:
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
|
||||
_ensure_utf8()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ _EPILOGUE = """
|
|||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes --tui Launch the modern TUI (or set display.interface: tui)
|
||||
hermes --cli Force the classic REPL (overrides display.interface: tui)
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
|
|
@ -129,7 +131,8 @@ def build_top_level_parser():
|
|||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
"Applies to -z/--oneshot and --tui. The persistent provider lives in config.yaml "
|
||||
"under model.provider — use `hermes setup` or edit the file to change it."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
|
|
@ -217,6 +220,13 @@ def build_top_level_parser():
|
|||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--cli",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
|
|
@ -268,7 +278,11 @@ def build_top_level_parser():
|
|||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Verbose output",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
|
|
@ -364,6 +378,13 @@ def build_top_level_parser():
|
|||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--cli",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
|
|
|
|||
|
|
@ -27,16 +27,15 @@ guarantee.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Sequence
|
||||
from typing import Sequence
|
||||
|
||||
__all__ = [
|
||||
"IS_WINDOWS",
|
||||
"resolve_node_command",
|
||||
"windows_detach_flags",
|
||||
"windows_detach_flags_without_breakaway",
|
||||
"windows_hide_flags",
|
||||
"windows_detach_popen_kwargs",
|
||||
]
|
||||
|
|
@ -99,6 +98,16 @@ def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]:
|
|||
_CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||
_DETACHED_PROCESS = 0x00000008
|
||||
_CREATE_NO_WINDOW = 0x08000000
|
||||
# Escape any Win32 job object the parent process belongs to. Without this,
|
||||
# a detached child still inherits its parent's job object membership, and
|
||||
# when that parent (Electron, Tauri, Windows Terminal, the Desktop GUI's
|
||||
# bootstrap-installer) dies, the OS tears down the whole job — taking the
|
||||
# "detached" child with it. Critical for the post-update gateway watcher:
|
||||
# Electron spawns the Tauri updater inside its own job, the updater spawns
|
||||
# the watcher subprocess; without BREAKAWAY the watcher dies the instant
|
||||
# Electron exits, so the gateway never gets respawned after a `hermes
|
||||
# update` triggered from the GUI. See fix/windows-gateway-reliability.
|
||||
_CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
|
||||
|
||||
def windows_detach_flags() -> int:
|
||||
|
|
@ -118,6 +127,56 @@ def windows_detach_flags() -> int:
|
|||
- ``CREATE_NO_WINDOW`` — suppress the brief cmd flash that would
|
||||
otherwise appear when launching a console app. Redundant with
|
||||
DETACHED_PROCESS but explicit for clarity.
|
||||
- ``CREATE_BREAKAWAY_FROM_JOB`` — escape any job object the parent is
|
||||
in. Electron (Desktop app) and Tauri (bootstrap installer) wrap
|
||||
their children in job objects; without breakaway, those children
|
||||
die when the parent process exits even if they were spawned with
|
||||
DETACHED_PROCESS. This was the missing flag that made the
|
||||
post-update gateway respawn watcher silently die alongside the
|
||||
Tauri updater after the Electron Desktop's update flow finished.
|
||||
|
||||
If a process is in a job that disallows breakaway (rare —
|
||||
JOB_OBJECT_LIMIT_BREAKAWAY_OK isn't set), CreateProcess returns
|
||||
ERROR_ACCESS_DENIED. Python surfaces that as ``PermissionError``
|
||||
on the ``subprocess.Popen`` call. Callers in this codebase already
|
||||
wrap detached spawns in ``try/except OSError`` and fall back to a
|
||||
cmd.exe wrapper, so the breakaway-denied case degrades gracefully
|
||||
rather than crashing.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return 0
|
||||
return (
|
||||
_CREATE_NEW_PROCESS_GROUP
|
||||
| _DETACHED_PROCESS
|
||||
| _CREATE_NO_WINDOW
|
||||
| _CREATE_BREAKAWAY_FROM_JOB
|
||||
)
|
||||
|
||||
|
||||
def windows_detach_flags_without_breakaway() -> int:
|
||||
"""Same as :func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``.
|
||||
|
||||
The docstring on :func:`windows_detach_flags` notes that a process in
|
||||
a job which disallows breakaway (no ``JOB_OBJECT_LIMIT_BREAKAWAY_OK``)
|
||||
will see ``ERROR_ACCESS_DENIED`` from CreateProcess, surfacing as
|
||||
``OSError`` (``PermissionError``) on the ``subprocess.Popen`` call.
|
||||
Callers that want to recover — by retrying without the breakaway
|
||||
bit — can pair the two helpers symbolically rather than coding the
|
||||
``& ~0x01000000`` magic at every site:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
try:
|
||||
subprocess.Popen(argv, creationflags=windows_detach_flags(), …)
|
||||
except OSError:
|
||||
subprocess.Popen(
|
||||
argv,
|
||||
creationflags=windows_detach_flags_without_breakaway(),
|
||||
…,
|
||||
)
|
||||
|
||||
See ``gateway_windows.py::_spawn_detached`` for the canonical
|
||||
implementation of this pattern. Returns 0 on non-Windows.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return 0
|
||||
|
|
|
|||
320
hermes_cli/active_sessions.py
Normal file
320
hermes_cli/active_sessions.py
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
"""Cross-process active chat session leases.
|
||||
|
||||
The session database records persisted conversations. This module records
|
||||
currently open chat surfaces, including idle CLI/TUI sessions that have not
|
||||
written a transcript row yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def coerce_max_concurrent_sessions(value: Any, key: str = "max_concurrent_sessions") -> Optional[int]:
|
||||
"""Return a positive integer cap, or None when disabled/invalid."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, float):
|
||||
if not value.is_integer():
|
||||
raise ValueError(value)
|
||||
parsed = int(value)
|
||||
elif isinstance(value, str):
|
||||
parsed = int(value.strip(), 10)
|
||||
else:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
if parsed <= 0:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def resolve_max_concurrent_sessions(config: Any) -> Optional[int]:
|
||||
"""Resolve top-level max_concurrent_sessions with gateway.* fallback."""
|
||||
raw: Any = None
|
||||
key = "max_concurrent_sessions"
|
||||
if isinstance(config, dict):
|
||||
if "max_concurrent_sessions" in config:
|
||||
raw = config.get("max_concurrent_sessions")
|
||||
else:
|
||||
gateway_cfg = config.get("gateway")
|
||||
if isinstance(gateway_cfg, dict):
|
||||
raw = gateway_cfg.get("max_concurrent_sessions")
|
||||
key = "gateway.max_concurrent_sessions"
|
||||
else:
|
||||
raw = getattr(config, "max_concurrent_sessions", None)
|
||||
return coerce_max_concurrent_sessions(raw, key=key)
|
||||
|
||||
|
||||
def active_session_limit_message(active_count: int, max_sessions: int) -> str:
|
||||
return (
|
||||
f"Hermes is at the active session limit ({active_count}/{max_sessions}). "
|
||||
"Try again when another session finishes."
|
||||
)
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
return get_hermes_home() / "runtime"
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
return _state_dir() / "active_sessions.json"
|
||||
|
||||
|
||||
def _lock_path() -> Path:
|
||||
return _state_dir() / "active_sessions.lock"
|
||||
|
||||
|
||||
class _FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fh = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = open(self.path, "a+b")
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
self._fh.seek(0)
|
||||
msvcrt.locking(self._fh.fileno(), msvcrt.LK_LOCK, 1)
|
||||
except Exception as exc:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
raise RuntimeError("active session file lock unavailable") from exc
|
||||
else:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
|
||||
except Exception as exc:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
raise RuntimeError("active session file lock unavailable") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self._fh is None:
|
||||
return
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
self._fh.seek(0)
|
||||
msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._fh.close()
|
||||
finally:
|
||||
self._fh = None
|
||||
|
||||
|
||||
def _read_entries(path: Path) -> list[dict[str, Any]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception:
|
||||
logger.warning("Ignoring corrupt active session registry at %s", path)
|
||||
return []
|
||||
entries = data.get("entries") if isinstance(data, dict) else data
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [entry for entry in entries if isinstance(entry, dict)]
|
||||
|
||||
|
||||
def _write_entries(path: Path, entries: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump({"entries": entries}, fh, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _process_start_time(pid: int) -> Optional[float]:
|
||||
# Pair pid with process create_time when psutil can read it, so a recycled
|
||||
# pid does not keep a stale lease alive indefinitely.
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
return float(psutil.Process(pid).create_time())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive(pid: Any, process_start_time: Any = None) -> bool:
|
||||
try:
|
||||
pid_int = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid_int <= 0:
|
||||
return False
|
||||
try:
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
exists = bool(_pid_exists(pid_int))
|
||||
except Exception:
|
||||
return False
|
||||
if not exists:
|
||||
return False
|
||||
expected_start = _optional_float(process_start_time)
|
||||
if expected_start is None:
|
||||
return True
|
||||
current_start = _process_start_time(pid_int)
|
||||
if current_start is None:
|
||||
return True
|
||||
return abs(current_start - expected_start) < 0.001
|
||||
|
||||
|
||||
def _prune_dead(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
entry
|
||||
for entry in entries
|
||||
if _pid_alive(entry.get("pid"), entry.get("process_start_time"))
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveSessionLease:
|
||||
lease_id: str
|
||||
session_id: str
|
||||
surface: str
|
||||
enabled: bool = True
|
||||
released: bool = False
|
||||
|
||||
def release(self) -> None:
|
||||
if self.released or not self.enabled:
|
||||
return
|
||||
release_active_session(self)
|
||||
|
||||
|
||||
def try_acquire_active_session(
|
||||
*,
|
||||
session_id: str,
|
||||
surface: str,
|
||||
config: Any,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> tuple[Optional[ActiveSessionLease], Optional[str]]:
|
||||
"""Acquire an active-session slot.
|
||||
|
||||
Returns ``(lease, None)`` on success. When the cap is disabled, the lease is
|
||||
a no-op object so callers can unconditionally call ``release()``.
|
||||
"""
|
||||
max_sessions = resolve_max_concurrent_sessions(config)
|
||||
lease_id = uuid.uuid4().hex
|
||||
if max_sessions is None:
|
||||
return ActiveSessionLease(
|
||||
lease_id=lease_id,
|
||||
session_id=session_id,
|
||||
surface=surface,
|
||||
enabled=False,
|
||||
), None
|
||||
|
||||
now = time.time()
|
||||
entry = {
|
||||
"lease_id": lease_id,
|
||||
"session_id": str(session_id),
|
||||
"surface": str(surface),
|
||||
"pid": os.getpid(),
|
||||
"process_start_time": _process_start_time(os.getpid()),
|
||||
"started_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if metadata:
|
||||
entry["metadata"] = {
|
||||
str(k): v for k, v in metadata.items() if isinstance(k, str)
|
||||
}
|
||||
|
||||
state_path = _state_path()
|
||||
with _FileLock(_lock_path()):
|
||||
raw_entries = _read_entries(state_path)
|
||||
entries = _prune_dead(raw_entries)
|
||||
pruned = len(raw_entries) - len(entries)
|
||||
if pruned:
|
||||
logger.info("Pruned %d stale active session lease(s)", pruned)
|
||||
active_count = len(entries)
|
||||
if active_count >= max_sessions:
|
||||
_write_entries(state_path, entries)
|
||||
logger.info(
|
||||
"Active session limit reached: active=%d max=%d surface=%s",
|
||||
active_count,
|
||||
max_sessions,
|
||||
surface,
|
||||
)
|
||||
return None, active_session_limit_message(active_count, max_sessions)
|
||||
entries.append(entry)
|
||||
_write_entries(state_path, entries)
|
||||
|
||||
return ActiveSessionLease(
|
||||
lease_id=lease_id,
|
||||
session_id=str(session_id),
|
||||
surface=str(surface),
|
||||
), None
|
||||
|
||||
|
||||
def release_active_session(lease: ActiveSessionLease) -> None:
|
||||
state_path = _state_path()
|
||||
try:
|
||||
with _FileLock(_lock_path()):
|
||||
entries = _prune_dead(_read_entries(state_path))
|
||||
kept = [
|
||||
entry
|
||||
for entry in entries
|
||||
if str(entry.get("lease_id") or "") != lease.lease_id
|
||||
]
|
||||
if len(kept) != len(entries):
|
||||
_write_entries(state_path, kept)
|
||||
finally:
|
||||
lease.released = True
|
||||
|
||||
|
||||
def active_session_registry_snapshot() -> list[dict[str, Any]]:
|
||||
"""Return the pruned active-session registry for diagnostics/tests."""
|
||||
state_path = _state_path()
|
||||
with _FileLock(_lock_path()):
|
||||
entries = _prune_dead(_read_entries(state_path))
|
||||
_write_entries(state_path, entries)
|
||||
return entries
|
||||
1656
hermes_cli/auth.py
1656
hermes_cli/auth.py
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from getpass import getpass
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -14,6 +13,7 @@ from agent.credential_pool import (
|
|||
AUTH_TYPE_OAUTH,
|
||||
CUSTOM_POOL_PREFIX,
|
||||
SOURCE_MANUAL,
|
||||
SOURCE_MANUAL_DEVICE_CODE,
|
||||
STATUS_EXHAUSTED,
|
||||
STRATEGY_FILL_FIRST,
|
||||
STRATEGY_ROUND_ROBIN,
|
||||
|
|
@ -30,6 +30,7 @@ from agent.credential_pool import (
|
|||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
|
||||
# Providers that support OAuth login in addition to API keys.
|
||||
|
|
@ -196,7 +197,7 @@ def auth_add_command(args) -> None:
|
|||
if requested_type == AUTH_TYPE_API_KEY:
|
||||
token = (getattr(args, "api_key", None) or "").strip()
|
||||
if not token:
|
||||
token = getpass("Paste your API key: ").strip()
|
||||
token = masked_secret_prompt("Paste your API key: ").strip()
|
||||
if not token:
|
||||
raise SystemExit("No API key provided.")
|
||||
default_label = _api_key_default_label(len(pool.entries()) + 1)
|
||||
|
|
@ -272,9 +273,6 @@ def auth_add_command(args) -> None:
|
|||
print("Rehydrating Nous session from shared credentials...")
|
||||
rehydrated = auth_mod._try_import_shared_nous_state(
|
||||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
min_key_ttl_seconds=max(
|
||||
60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))
|
||||
),
|
||||
)
|
||||
if rehydrated is not None:
|
||||
custom_label = (getattr(args, "label", None) or "").strip() or None
|
||||
|
|
@ -297,7 +295,6 @@ def auth_add_command(args) -> None:
|
|||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
insecure=bool(getattr(args, "insecure", False)),
|
||||
ca_bundle=getattr(args, "ca_bundle", None),
|
||||
min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))),
|
||||
)
|
||||
# Honor `--label <name>` so nous matches other providers' UX. The
|
||||
# helper embeds this into providers.nous so that label_from_token
|
||||
|
|
@ -311,27 +308,39 @@ def auth_add_command(args) -> None:
|
|||
return
|
||||
|
||||
if provider == "openai-codex":
|
||||
# Clear any existing suppression marker so a re-link after `hermes auth
|
||||
# remove openai-codex` works without the new tokens being skipped.
|
||||
auth_mod.unsuppress_credential_source(provider, "device_code")
|
||||
creds = auth_mod._codex_device_code_login()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
# Add a distinct, self-contained pool entry per account (matching the
|
||||
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
|
||||
# routing through the singleton ``_save_codex_tokens`` save path.
|
||||
# The singleton round-trip collapsed every added account into the
|
||||
# latest login: a second ``hermes auth add openai-codex`` overwrote
|
||||
# the first account's singleton-mirrored ``device_code`` entry rather
|
||||
# than creating an independent one (#39236). ``manual:device_code``
|
||||
# entries refresh from their own token pair, so they need no singleton
|
||||
# shadow.
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:device_code",
|
||||
source=SOURCE_MANUAL_DEVICE_CODE,
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
)
|
||||
first_credential = not pool.entries()
|
||||
pool.add_entry(entry)
|
||||
# Adding the first Codex credential should make it the active provider
|
||||
# (the old singleton save path did this implicitly via
|
||||
# _save_provider_state). Subsequent adds leave the active provider as-is.
|
||||
if first_credential:
|
||||
auth_mod.mark_provider_active_if_unset(provider)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
|
|
@ -341,30 +350,25 @@ def auth_add_command(args) -> None:
|
|||
open_browser=not getattr(args, "no_browser", False),
|
||||
manual_paste=bool(getattr(args, "manual_paste", False)),
|
||||
)
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:xai_pkce",
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
auth_mod._save_xai_oauth_tokens(
|
||||
creds["tokens"],
|
||||
discovery=creds.get("discovery"),
|
||||
redirect_uri=creds.get("redirect_uri", ""),
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
pool = load_pool(provider)
|
||||
entry = next((e for e in pool.entries() if getattr(e, "source", "") == "loopback_pkce"), None)
|
||||
shown_label = entry.label if entry is not None else label_from_token(
|
||||
creds["tokens"]["access_token"], _oauth_default_label(provider, 1)
|
||||
)
|
||||
print(f'Saved {provider} OAuth credentials: "{shown_label}"')
|
||||
return
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
from agent.google_oauth import run_gemini_oauth_login_pure
|
||||
|
||||
creds = run_gemini_oauth_login_pure()
|
||||
auth_mod._mark_google_gemini_cli_active(creds)
|
||||
label = (getattr(args, "label", None) or "").strip() or (
|
||||
creds.get("email") or _oauth_default_label(provider, len(pool.entries()) + 1)
|
||||
)
|
||||
|
|
@ -384,6 +388,7 @@ def auth_add_command(args) -> None:
|
|||
|
||||
if provider == "qwen-oauth":
|
||||
creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
auth_mod._mark_qwen_oauth_active(creds)
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["api_key"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
|
|
|
|||
|
|
@ -85,6 +85,22 @@ def _should_exclude(rel_path: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _should_skip_backup_file(abs_path: Path, rel_path: Path, out_path: Path) -> bool:
|
||||
"""Return True when a candidate file should not be written to a backup zip."""
|
||||
if _should_exclude(rel_path):
|
||||
return True
|
||||
|
||||
# zipfile.write() follows file symlinks, so skip links before any archive
|
||||
# write can copy data from outside HERMES_HOME.
|
||||
if abs_path.is_symlink():
|
||||
return True
|
||||
|
||||
try:
|
||||
return abs_path.resolve() == out_path.resolve()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite safe copy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -173,16 +189,9 @@ def run_backup(args) -> None:
|
|||
fpath = dp / fname
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
|
||||
if _should_exclude(rel):
|
||||
if _should_skip_backup_file(fpath, rel, out_path):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it happens to be inside hermes root
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
|
||||
if not files_to_add:
|
||||
|
|
@ -503,6 +512,7 @@ def _quick_snapshot_root(hermes_home: Optional[Path] = None) -> Path:
|
|||
def create_quick_snapshot(
|
||||
label: Optional[str] = None,
|
||||
hermes_home: Optional[Path] = None,
|
||||
keep: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
"""Create a quick state snapshot of critical files.
|
||||
|
||||
|
|
@ -576,8 +586,10 @@ def create_quick_snapshot(
|
|||
with open(snap_dir / "manifest.json", "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
# Auto-prune
|
||||
_prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP)
|
||||
# Auto-prune. Defaults preserve historical manual /snapshot behavior; callers
|
||||
# with known high-churn safety snapshots (for example pre-update) can pass a
|
||||
# smaller keep value so large state.db copies do not accumulate indefinitely.
|
||||
_prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP if keep is None else keep)
|
||||
|
||||
logger.info("State snapshot created: %s (%d files)", snap_id, len(manifest))
|
||||
return snap_id
|
||||
|
|
@ -658,6 +670,105 @@ def restore_quick_snapshot(
|
|||
return restored > 0
|
||||
|
||||
|
||||
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
|
||||
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
|
||||
_CRON_JOBS_REL = "cron/jobs.json"
|
||||
|
||||
|
||||
def _count_cron_jobs(path: Path) -> Optional[int]:
|
||||
"""Return the number of cron jobs stored in ``path``.
|
||||
|
||||
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
|
||||
A legacy bare-list shape (``[...]``) is also honoured.
|
||||
|
||||
Returns:
|
||||
The job count for any *valid, readable* JSON document, or ``None`` if
|
||||
the file is missing or cannot be parsed. ``None`` means "unknown" —
|
||||
callers must not treat it as "zero jobs", because acting on an
|
||||
unreadable file could mask a real corruption the user needs to see.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
return len(jobs) if isinstance(jobs, list) else None
|
||||
if isinstance(data, list):
|
||||
return len(data)
|
||||
return None
|
||||
|
||||
|
||||
def restore_cron_jobs_if_emptied(
|
||||
snapshot_id: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Safety net for silent cron-job loss across ``hermes update``.
|
||||
|
||||
Config-version migrations have been observed to leave ``cron/jobs.json``
|
||||
valid-but-empty after an update, silently dropping every scheduled job
|
||||
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
|
||||
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
|
||||
|
||||
This compares the *current* job count against the pre-update snapshot. If
|
||||
the live file now has **zero** jobs while the snapshot captured **one or
|
||||
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
|
||||
|
||||
The check is deliberately conservative — it only ever restores when there
|
||||
is unambiguous evidence of loss (snapshot had jobs, live file has none),
|
||||
so a user who genuinely deleted all their jobs during/after the update is
|
||||
never second-guessed, and an unreadable live file (count ``None``) is left
|
||||
untouched so real corruption still surfaces.
|
||||
|
||||
Args:
|
||||
snapshot_id: The pre-update quick-snapshot id (from
|
||||
:func:`create_quick_snapshot`).
|
||||
hermes_home: Override for the Hermes home directory (tests).
|
||||
|
||||
Returns:
|
||||
``None`` when no action was taken (the common, healthy path). On a
|
||||
successful restore, a dict ``{"restored": True, "job_count": N,
|
||||
"snapshot_id": ...}`` so the caller can warn the user.
|
||||
"""
|
||||
if not snapshot_id:
|
||||
return None
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
live_path = home / _CRON_JOBS_REL
|
||||
|
||||
live_count = _count_cron_jobs(live_path)
|
||||
# Only act when the live file is readable AND empty. ``None`` (missing or
|
||||
# unparseable) is intentionally left alone — that's a different failure
|
||||
# mode the user should see rather than have papered over.
|
||||
if live_count is None or live_count > 0:
|
||||
return None
|
||||
|
||||
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
|
||||
snap_count = _count_cron_jobs(snap_path)
|
||||
if not snap_count: # None or 0 — nothing worth restoring
|
||||
return None
|
||||
|
||||
try:
|
||||
live_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(snap_path, live_path)
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.error(
|
||||
"Cron jobs were emptied during update but auto-restore failed: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Restored %d cron job(s) from pre-update snapshot %s "
|
||||
"(cron/jobs.json was emptied during migration)",
|
||||
snap_count,
|
||||
snapshot_id,
|
||||
)
|
||||
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
|
||||
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
|
||||
if not root.exists():
|
||||
|
|
@ -726,16 +837,9 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
|
|||
except ValueError:
|
||||
continue
|
||||
|
||||
if _should_exclude(rel):
|
||||
if _should_skip_backup_file(fpath, rel, out_path):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it already exists inside root.
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
except OSError as exc:
|
||||
logger.warning("Full-zip backup: walk failed: %s", exc)
|
||||
|
|
|
|||
|
|
@ -12,14 +12,16 @@ import threading
|
|||
import time
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
# rich and prompt_toolkit are imported lazily (inside the functions that use
|
||||
# them) rather than at module level. Importing this module is on the TUI
|
||||
# gateway's critical startup path purely to reach the lightweight update-check
|
||||
# helpers (``prefetch_update_check``); pulling rich.console + prompt_toolkit
|
||||
# eagerly added ~50ms of wasted imports before ``gateway.ready`` could fire.
|
||||
# Keep the type-only reference available to checkers without the runtime cost.
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -36,6 +38,8 @@ _RST = "\033[0m"
|
|||
|
||||
def cprint(text: str):
|
||||
"""Print ANSI-colored text through prompt_toolkit's renderer."""
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
_pt_print(_PT_ANSI(text))
|
||||
|
||||
|
||||
|
|
@ -50,17 +54,6 @@ def _skin_color(key: str, fallback: str) -> str:
|
|||
return get_active_skin().get_color(key, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _skin_branding(key: str, fallback: str) -> str:
|
||||
"""Get a branding string from the active skin, or return fallback."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
return get_active_skin().get_branding(key, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# ASCII Art & Branding
|
||||
# =========================================================================
|
||||
|
|
@ -232,7 +225,30 @@ def check_for_updates() -> Optional[int]:
|
|||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
# Docker images have no working tree to count commits against — the
|
||||
# published image excludes `.git` (see .dockerignore) and sets no
|
||||
# HERMES_REVISION (that's nix-only). Without this guard the checks below
|
||||
# fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1)
|
||||
# then gets rendered by the CLI banner and the TUI badge as a phantom
|
||||
# "1 commit behind" — even though no git repo or commit math is involved,
|
||||
# and `hermes update` correctly refuses to run in-place inside the
|
||||
# container anyway. The dashboard's REST `/api/hermes/update/check`
|
||||
# endpoint already short-circuits docker the same way (web_server.py);
|
||||
# mirror that here so the banner/TUI surfaces agree. Returning None makes
|
||||
# both the Rich banner (build_welcome_banner) and the Ink badge
|
||||
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "docker":
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read cache — invalidate if the embedded rev OR installed version has
|
||||
# changed since the last check. The version guard matters for pip installs:
|
||||
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
|
||||
# changes VERSION but leaves rev unchanged (both None), and without this
|
||||
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
|
|
@ -240,6 +256,7 @@ def check_for_updates() -> Optional[int]:
|
|||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
and cached.get("ver") == VERSION
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
|
|
@ -260,7 +277,9 @@ def check_for_updates() -> Optional[int]:
|
|||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -300,14 +319,42 @@ def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
|
|||
|
||||
|
||||
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
|
||||
"""Return upstream/local git hashes for the startup banner."""
|
||||
"""Return upstream/local git hashes for the startup banner.
|
||||
|
||||
For source installs and dev images this runs ``git rev-parse`` against
|
||||
the active checkout. When no checkout is available — the canonical case
|
||||
is the published Docker image, which excludes ``.git`` from the build
|
||||
context — we fall back to the baked-in build SHA (see
|
||||
``hermes_cli/build_info.py``) and return it as a frozen
|
||||
``upstream == local`` state with ``ahead=0``. A built image is by
|
||||
definition pinned to one commit, so "ahead" is always zero and the
|
||||
banner correctly shows ``· upstream <sha>`` with no carried-commits
|
||||
annotation.
|
||||
"""
|
||||
repo_dir = repo_dir or _resolve_repo_dir()
|
||||
if repo_dir is None:
|
||||
# No git checkout — try the baked build SHA (Docker image path).
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return {"upstream": baked, "local": baked, "ahead": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
upstream = _git_short_hash(repo_dir, "origin/main")
|
||||
local = _git_short_hash(repo_dir, "HEAD")
|
||||
if not upstream or not local:
|
||||
# Live-git lookup failed (e.g. shallow clone without origin/main).
|
||||
# Fall back to the baked build SHA if available.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return {"upstream": baked, "local": baked, "ahead": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
ahead = 0
|
||||
|
|
@ -447,7 +494,7 @@ def _display_toolset_name(toolset_name: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
tools: List[dict] = None,
|
||||
enabled_toolsets: List[str] = None,
|
||||
session_id: str = None,
|
||||
|
|
@ -466,6 +513,8 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
|||
context_length: Model's context window size in tokens.
|
||||
"""
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
if get_toolset_for_tool is None:
|
||||
from model_tools import get_toolset_for_tool
|
||||
|
||||
|
|
@ -596,6 +645,11 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
|||
f"[dim {dim}]{srv['name']}[/] [{text}]({srv['transport']})[/] "
|
||||
f"[dim {dim}]—[/] [{text}]{srv['tools']} tool(s)[/]"
|
||||
)
|
||||
elif srv.get("disabled"):
|
||||
right_lines.append(
|
||||
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
|
||||
f"[dim {dim}]— disabled[/]"
|
||||
)
|
||||
else:
|
||||
right_lines.append(
|
||||
f"[red]{srv['name']}[/] [dim]({srv['transport']})[/] "
|
||||
|
|
@ -674,6 +728,21 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
|||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
right_lines.append(
|
||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
||||
"[dim yellow] — exists for reasons other than user install; "
|
||||
"expect instability and an inability to support issues[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
right_content = "\n".join(right_lines)
|
||||
layout_table.add_row(left_content, right_content)
|
||||
|
||||
|
|
|
|||
51
hermes_cli/build_info.py
Normal file
51
hermes_cli/build_info.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
Baked-in build metadata for Hermes Agent.
|
||||
|
||||
Source installs report their git revision live via ``git rev-parse`` (see
|
||||
``hermes_cli/dump.py`` and ``hermes_cli/banner.py``). That doesn't work inside
|
||||
the published Docker image because ``.dockerignore`` excludes ``.git``, so
|
||||
those callsites fall back to ``"(unknown)"`` / drop the banner suffix entirely.
|
||||
|
||||
To make ``hermes dump`` and the startup banner identify the exact commit the
|
||||
image was built from, the Docker build writes the build-time ``$HERMES_GIT_SHA``
|
||||
arg into ``<project_root>/.hermes_build_sha``. This module is the single
|
||||
read-side helper consumed by both callsites — keeping the lookup in one place
|
||||
so the file path and missing-file behaviour stay consistent.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- Returns ``None`` when the file is absent. Source installs and dev images
|
||||
built without the ``HERMES_GIT_SHA`` build-arg fall through to live-git
|
||||
resolution in the caller, so non-Docker installs are unaffected.
|
||||
- Returns ``None`` on any IO / decoding error. The build-sha is a nice-to-have
|
||||
for support triage; nothing in the CLI is allowed to crash because of it.
|
||||
- Truncates to ``short`` characters (default 8) to match the format used by
|
||||
``git rev-parse --short=8`` throughout the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
"""Return the baked-in build SHA, truncated to ``short`` chars, or None.
|
||||
|
||||
Reads ``<project_root>/.hermes_build_sha`` if present. The file is
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg and contains
|
||||
the full 40-character commit hash on a single line.
|
||||
"""
|
||||
try:
|
||||
if not _BUILD_SHA_FILE.is_file():
|
||||
return None
|
||||
sha = _BUILD_SHA_FILE.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not sha:
|
||||
return None
|
||||
return sha[:short] if short and short > 0 else sha
|
||||
|
|
@ -15,7 +15,7 @@ Subcommands:
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ with the TUI.
|
|||
|
||||
import queue
|
||||
import time as _time
|
||||
import getpass
|
||||
|
||||
from hermes_cli.banner import cprint, _DIM, _RST
|
||||
from hermes_cli.config import save_env_value_secure
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
|||
if not hasattr(cli, "_secret_deadline"):
|
||||
cli._secret_deadline = 0
|
||||
try:
|
||||
value = getpass.getpass(f"{prompt} (hidden, ESC or empty Enter to skip): ")
|
||||
value = masked_secret_prompt(f"{prompt} (hidden, ESC or empty Enter to skip): ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
value = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import argparse
|
|||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
|
|||
"conflicts (Telegram, Discord, and Slack only allow one active "
|
||||
"session per token)."
|
||||
)
|
||||
print_info("Recommendation: stop the gateway first with 'hermes stop'.")
|
||||
print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.")
|
||||
print()
|
||||
if not auto_yes and not prompt_yes_no("Continue anyway?", default=False):
|
||||
print_info("Migration cancelled. Stop the gateway and try again.")
|
||||
|
|
|
|||
681
hermes_cli/cli_agent_setup_mixin.py
Normal file
681
hermes_cli/cli_agent_setup_mixin.py
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
"""Agent-construction and session-resume display methods for ``HermesCLI``.
|
||||
|
||||
Extracted from ``cli.py`` as part of the god-file decomposition campaign
|
||||
(``~/.hermes/plans/god-file-decomposition.md``, Phase 4 step 2). This mixin holds
|
||||
the agent lifecycle/setup cluster: runtime-credential resolution, per-turn agent
|
||||
config, first-use agent construction, and resumed-session preload + history recap.
|
||||
|
||||
Behavior-neutral: every method is lifted verbatim from ``HermesCLI``. ``self.*``
|
||||
calls resolve unchanged via the MRO. Neutral dependencies are imported at module
|
||||
top level; ``cli.py``-internal helpers/constants are imported lazily inside each
|
||||
method (``from cli import ...`` resolves at call time, when ``cli`` is fully
|
||||
loaded) so this module never imports ``cli`` at import time -> no import cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from rich.markup import escape as _escape
|
||||
|
||||
|
||||
class CLIAgentSetupMixin:
|
||||
"""Agent construction + session-resume display methods for ``HermesCLI``."""
|
||||
|
||||
def _ensure_runtime_credentials(self) -> bool:
|
||||
"""
|
||||
Ensure runtime credentials are resolved before agent use.
|
||||
Re-resolves provider credentials so key rotation and token refresh
|
||||
are picked up without restarting the CLI.
|
||||
Returns True if credentials are ready, False on auth failure.
|
||||
"""
|
||||
from cli import ChatConsole, _cprint, logger
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
format_runtime_provider_error,
|
||||
)
|
||||
|
||||
_primary_exc = None
|
||||
runtime = None
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=self.requested_provider,
|
||||
explicit_api_key=self._explicit_api_key,
|
||||
explicit_base_url=self._explicit_base_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
_primary_exc = exc
|
||||
|
||||
# Primary provider auth failed — try fallback providers before giving up.
|
||||
if runtime is None and _primary_exc is not None:
|
||||
from hermes_cli.auth import AuthError
|
||||
if isinstance(_primary_exc, AuthError):
|
||||
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
|
||||
for _fb in _fb_chain:
|
||||
_fb_provider = (_fb.get("provider") or "").strip().lower()
|
||||
_fb_model = (_fb.get("model") or "").strip()
|
||||
if not _fb_provider or not _fb_model:
|
||||
continue
|
||||
try:
|
||||
runtime = resolve_runtime_provider(requested=_fb_provider)
|
||||
logger.warning(
|
||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||
_primary_exc, _fb_provider, _fb_model,
|
||||
)
|
||||
_cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}")
|
||||
self.requested_provider = _fb_provider
|
||||
self.model = _fb_model
|
||||
_primary_exc = None
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if runtime is None:
|
||||
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
|
||||
ChatConsole().print(f"[bold red]{message}[/]")
|
||||
return False
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = runtime.get("base_url")
|
||||
resolved_provider = runtime.get("provider", "openrouter")
|
||||
resolved_api_mode = runtime.get("api_mode", self.api_mode)
|
||||
resolved_acp_command = runtime.get("command")
|
||||
resolved_acp_args = list(runtime.get("args") or [])
|
||||
resolved_credential_pool = runtime.get("credential_pool")
|
||||
# A callable api_key is a bearer-token provider (Azure Foundry
|
||||
# Entra ID — ``azure_identity_adapter.build_token_provider``).
|
||||
# The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and
|
||||
# invokes it before every request. Skip the string-only validation
|
||||
# and placeholder substitution for callables.
|
||||
_is_callable_provider = callable(api_key) and not isinstance(api_key, str)
|
||||
if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):
|
||||
# Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often
|
||||
# don't require authentication. When a base_url IS configured but
|
||||
# no API key was found, use a placeholder so the OpenAI SDK
|
||||
# doesn't reject the request and local servers just ignore it.
|
||||
_source = runtime.get("source", "")
|
||||
_has_custom_base = isinstance(base_url, str) and base_url and "openrouter.ai" not in base_url
|
||||
if _has_custom_base:
|
||||
api_key = "no-key-required"
|
||||
logger.debug(
|
||||
"No API key for custom endpoint %s (source=%s), "
|
||||
"using placeholder — local servers typically ignore auth",
|
||||
base_url, _source,
|
||||
)
|
||||
else:
|
||||
print("\n⚠️ Provider resolver returned an empty API key. "
|
||||
"Set OPENROUTER_API_KEY or run: hermes setup")
|
||||
return False
|
||||
if not isinstance(base_url, str) or not base_url:
|
||||
print("\n⚠️ Provider resolver returned an empty base URL. "
|
||||
"Check your provider config or run: hermes setup")
|
||||
return False
|
||||
|
||||
credentials_changed = api_key != self.api_key or base_url != self.base_url
|
||||
routing_changed = (
|
||||
resolved_provider != self.provider
|
||||
or resolved_api_mode != self.api_mode
|
||||
or resolved_acp_command != self.acp_command
|
||||
or resolved_acp_args != self.acp_args
|
||||
)
|
||||
self.provider = resolved_provider
|
||||
self.api_mode = resolved_api_mode
|
||||
self.acp_command = resolved_acp_command
|
||||
self.acp_args = resolved_acp_args
|
||||
self._credential_pool = resolved_credential_pool
|
||||
self._provider_source = runtime.get("source")
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
# When a custom_provider entry carries an explicit `model` field,
|
||||
# use it as the effective model name. Without this, running
|
||||
# `hermes chat --model <provider-name>` sends the provider name
|
||||
# (e.g. "my-provider") as the model string to the API instead of
|
||||
# the configured model (e.g. "qwen3.6-plus"), causing 400 errors.
|
||||
runtime_model = runtime.get("model")
|
||||
if runtime_model and isinstance(runtime_model, str):
|
||||
# Only use runtime model if: model is unset, or model equals provider name
|
||||
should_use_runtime_model = (
|
||||
not self.model or # No model configured yet
|
||||
self.model == self.provider or # Model is the provider slug
|
||||
self.model == runtime.get("name") # Model matches provider display name
|
||||
)
|
||||
if should_use_runtime_model:
|
||||
self.model = runtime_model
|
||||
|
||||
# If model is still empty (e.g. user ran `hermes auth add openai-codex`
|
||||
# without `hermes model`), fall back to the provider's first catalog
|
||||
# model so the API call doesn't fail with "model must be non-empty".
|
||||
if not self.model and resolved_provider:
|
||||
try:
|
||||
from hermes_cli.models import get_default_model_for_provider
|
||||
_default = get_default_model_for_provider(resolved_provider)
|
||||
if _default:
|
||||
self.model = _default
|
||||
logger.info(
|
||||
"No model configured — defaulting to %s for provider %s",
|
||||
_default, resolved_provider,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Normalize model for the resolved provider (e.g. swap non-Codex
|
||||
# models when provider is openai-codex). Fixes #651.
|
||||
model_changed = self._normalize_model_for_provider(resolved_provider)
|
||||
|
||||
# AIAgent/OpenAI client holds auth at init time, so rebuild if key,
|
||||
# routing, or the effective model changed.
|
||||
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
|
||||
self.agent = None
|
||||
self._active_agent_route_signature = None
|
||||
|
||||
return True
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str) -> dict:
|
||||
"""Build the effective model/runtime config for a single user turn.
|
||||
|
||||
Always uses the session's primary model/provider. If the user has
|
||||
toggled `/fast` on and the current model supports Priority
|
||||
Processing / Anthropic fast mode, attach `request_overrides` so the
|
||||
API call is marked accordingly.
|
||||
"""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
runtime = {
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
"provider": self.provider,
|
||||
"api_mode": self.api_mode,
|
||||
"command": self.acp_command,
|
||||
"args": list(self.acp_args or []),
|
||||
"credential_pool": getattr(self, "_credential_pool", None),
|
||||
}
|
||||
route = {
|
||||
"model": self.model,
|
||||
"runtime": runtime,
|
||||
"signature": (
|
||||
self.model,
|
||||
runtime["provider"],
|
||||
runtime["base_url"],
|
||||
runtime["api_mode"],
|
||||
runtime["command"],
|
||||
tuple(runtime["args"]),
|
||||
),
|
||||
}
|
||||
|
||||
service_tier = getattr(self, "service_tier", None)
|
||||
if not service_tier:
|
||||
route["request_overrides"] = None
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(route["model"])
|
||||
except Exception:
|
||||
overrides = None
|
||||
route["request_overrides"] = overrides
|
||||
return route
|
||||
|
||||
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
||||
"""
|
||||
Initialize the agent on first use.
|
||||
When resuming a session, restores conversation history from SQLite.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
from cli import AIAgent, ChatConsole, _DIM, _RST, _accent_hex, _cprint, _prepare_deferred_agent_startup, logger
|
||||
if self.agent is not None:
|
||||
return True
|
||||
|
||||
_prepare_deferred_agent_startup()
|
||||
self._install_tool_callbacks()
|
||||
self._ensure_tirith_security()
|
||||
|
||||
if not self._ensure_runtime_credentials():
|
||||
return False
|
||||
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
|
||||
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
|
||||
if self._session_db is None:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
|
||||
|
||||
# If resuming, validate the session exists and load its history.
|
||||
# _preload_resumed_session() may have already loaded it (called from
|
||||
# run() for immediate display). In that case, conversation_history
|
||||
# is non-empty and we skip the DB round-trip.
|
||||
if self._resumed and self._session_db and not self.conversation_history:
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
# In quiet mode (`hermes chat -Q` / --quiet, surfaced via
|
||||
# tool_progress_mode == "off"), resume status lines go to stderr
|
||||
# so stdout stays machine-readable for automation wrappers that
|
||||
# do `$(hermes chat -Q --resume <id> -q "...")`. Without this,
|
||||
# the resume banner pollutes captured stdout. See #11793.
|
||||
_quiet_mode = getattr(self, "tool_progress_mode", "full") == "off"
|
||||
if not session_meta:
|
||||
if _quiet_mode:
|
||||
print(f"Session not found: {self.session_id}", file=sys.stderr)
|
||||
print(
|
||||
"Use a session ID from a previous CLI run (hermes sessions list).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
|
||||
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
|
||||
return False
|
||||
# If the requested session is the (empty) head of a compression
|
||||
# chain, walk to the descendant that actually holds the messages.
|
||||
# See #15000 and SessionDB.resolve_resume_session_id.
|
||||
try:
|
||||
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
|
||||
except Exception:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
ChatConsole().print(
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(resolved_id)}; resuming the descendant with your "
|
||||
f"transcript.[/dim]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
if resolved_meta:
|
||||
session_meta = resolved_meta
|
||||
restored = self._session_db.get_messages_as_conversation(self.session_id)
|
||||
if restored:
|
||||
restored = [m for m in restored if m.get("role") != "session_meta"]
|
||||
self.conversation_history = restored
|
||||
msg_count = len([m for m in restored if m.get("role") == "user"])
|
||||
title_part = ""
|
||||
if session_meta.get("title"):
|
||||
title_part = f" \"{session_meta['title']}\""
|
||||
if _quiet_mode:
|
||||
print(
|
||||
f"↻ Resumed session {self.session_id}{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]↻ Resumed session[/] "
|
||||
f"[bold]{_escape(self.session_id)}[/]"
|
||||
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
|
||||
)
|
||||
self._restore_session_cwd(session_meta, quiet=_quiet_mode)
|
||||
else:
|
||||
if _quiet_mode:
|
||||
print(
|
||||
f"Session {self.session_id} found but has no messages. Starting fresh.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
|
||||
)
|
||||
# Re-open the session (clear ended_at so it's active again)
|
||||
try:
|
||||
self._session_db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
|
||||
(self.session_id,),
|
||||
)
|
||||
self._session_db._conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
runtime = runtime_override or {
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
"provider": self.provider,
|
||||
"api_mode": self.api_mode,
|
||||
"command": self.acp_command,
|
||||
"args": list(self.acp_args or []),
|
||||
"credential_pool": getattr(self, "_credential_pool", None),
|
||||
}
|
||||
effective_model = model_override or self.model
|
||||
self.agent = AIAgent(
|
||||
model=effective_model,
|
||||
api_key=runtime.get("api_key"),
|
||||
base_url=runtime.get("base_url"),
|
||||
provider=runtime.get("provider"),
|
||||
api_mode=runtime.get("api_mode"),
|
||||
acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"),
|
||||
credential_pool=runtime.get("credential_pool"),
|
||||
max_tokens=self.max_tokens,
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets,
|
||||
verbose_logging=self.verbose,
|
||||
quiet_mode=not self.verbose,
|
||||
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
|
||||
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
|
||||
prefill_messages=self.prefill_messages or None,
|
||||
reasoning_config=self.reasoning_config,
|
||||
service_tier=self.service_tier,
|
||||
request_overrides=request_overrides,
|
||||
providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore,
|
||||
providers_order=self._providers_order,
|
||||
provider_sort=self._provider_sort,
|
||||
provider_require_parameters=self._provider_require_params,
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
openrouter_min_coding_score=self._openrouter_min_coding_score,
|
||||
session_id=self.session_id,
|
||||
platform="cli",
|
||||
session_db=self._session_db,
|
||||
clarify_callback=self._clarify_callback,
|
||||
reasoning_callback=self._current_reasoning_callback(),
|
||||
|
||||
fallback_model=self._fallback_model,
|
||||
thinking_callback=self._on_thinking,
|
||||
checkpoints_enabled=self.checkpoints_enabled,
|
||||
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
||||
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
|
||||
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
|
||||
pass_session_id=self.pass_session_id,
|
||||
skip_context_files=self.ignore_rules,
|
||||
skip_memory=self.ignore_rules,
|
||||
tool_progress_callback=self._on_tool_progress,
|
||||
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
|
||||
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
||||
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
|
||||
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
||||
notice_callback=self._on_notice,
|
||||
notice_clear_callback=self._on_notice_clear,
|
||||
)
|
||||
# Store reference for atexit memory provider shutdown
|
||||
global _active_agent_ref
|
||||
_active_agent_ref = self.agent
|
||||
# Route agent status output through prompt_toolkit so ANSI escape
|
||||
# sequences aren't garbled by patch_stdout's StdoutProxy (#2262).
|
||||
self.agent._print_fn = _cprint
|
||||
# Hydrate credits notices at session OPEN (parity with the TUI), so a
|
||||
# depletion / usage-band warning shows before the first message. The
|
||||
# notice_callback is bound above → _on_notice renders the line. Idempotent
|
||||
# + fail-open inside the helper; harmless for non-Nous providers.
|
||||
try:
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
seed_credits_at_session_start(self.agent)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_agent_route_signature = (
|
||||
effective_model,
|
||||
runtime.get("provider"),
|
||||
runtime.get("base_url"),
|
||||
runtime.get("api_mode"),
|
||||
runtime.get("command"),
|
||||
tuple(runtime.get("args") or ()),
|
||||
)
|
||||
|
||||
# Force-create DB row on /title intent, then apply title.
|
||||
if self._pending_title and self._session_db and self.agent:
|
||||
try:
|
||||
self.agent._ensure_db_session()
|
||||
if self.agent._session_db_created:
|
||||
self._session_db.set_session_title(self.session_id, self._pending_title)
|
||||
_cprint(f" Session title applied: {self._pending_title}")
|
||||
self._pending_title = None
|
||||
# else: row creation failed transiently — keep _pending_title for retry
|
||||
except (ValueError, Exception) as e:
|
||||
_cprint(f" Could not apply pending title: {e}")
|
||||
# Keep _pending_title so it can be retried after row creation succeeds
|
||||
return True
|
||||
except Exception as e:
|
||||
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||
return False
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
"""Load a resumed session's history from the DB early (before first chat).
|
||||
|
||||
Called from run() so the conversation history is available for display
|
||||
before the user sends their first message. Sets
|
||||
``self.conversation_history`` and prints the one-liner status. Returns
|
||||
True if history was loaded, False otherwise.
|
||||
|
||||
The corresponding block in ``_init_agent()`` checks whether history is
|
||||
already populated and skips the DB round-trip.
|
||||
"""
|
||||
from cli import _accent_hex
|
||||
if not self._resumed or not self._session_db:
|
||||
return False
|
||||
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
if not session_meta:
|
||||
self._console_print(
|
||||
f"[bold red]Session not found: {self.session_id}[/]"
|
||||
)
|
||||
self._console_print(
|
||||
"[dim]Use a session ID from a previous CLI run "
|
||||
"(hermes sessions list).[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
# If the requested session is the (empty) head of a compression chain,
|
||||
# walk to the descendant that actually holds the messages. See #15000.
|
||||
try:
|
||||
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
|
||||
except Exception:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
self._console_print(
|
||||
f"[dim]Session {self.session_id} was compressed into "
|
||||
f"{resolved_id}; resuming the descendant with your transcript.[/]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
if resolved_meta:
|
||||
session_meta = resolved_meta
|
||||
|
||||
restored = self._session_db.get_messages_as_conversation(self.session_id)
|
||||
if restored:
|
||||
restored = [m for m in restored if m.get("role") != "session_meta"]
|
||||
self.conversation_history = restored
|
||||
msg_count = len([m for m in restored if m.get("role") == "user"])
|
||||
title_part = ""
|
||||
if session_meta.get("title"):
|
||||
title_part = f' "{session_meta["title"]}"'
|
||||
accent_color = _accent_hex()
|
||||
self._console_print(
|
||||
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
|
||||
f"{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages)[/]"
|
||||
)
|
||||
self._restore_session_cwd(session_meta)
|
||||
else:
|
||||
accent_color = _accent_hex()
|
||||
self._console_print(
|
||||
f"[{accent_color}]Session {self.session_id} found but has no "
|
||||
f"messages. Starting fresh.[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
# Re-open the session (clear ended_at so it's active again)
|
||||
try:
|
||||
self._session_db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = NULL, end_reason = NULL "
|
||||
"WHERE id = ?",
|
||||
(self.session_id,),
|
||||
)
|
||||
self._session_db._conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def _display_resumed_history(self):
|
||||
"""Render a compact recap of previous conversation messages.
|
||||
|
||||
Uses Rich markup with dim/muted styling so the recap is visually
|
||||
distinct from the active conversation. Caps the display at the
|
||||
last ``MAX_DISPLAY_EXCHANGES`` user/assistant exchanges and shows
|
||||
an indicator for earlier hidden messages.
|
||||
"""
|
||||
from cli import CLI_CONFIG, _record_output_history_entry, _strip_reasoning_tags, _suspend_output_history
|
||||
if not self.conversation_history:
|
||||
return
|
||||
|
||||
# Check config: resume_display setting
|
||||
if self.resume_display == "minimal":
|
||||
return
|
||||
|
||||
# Read limits from config (with hardcoded defaults)
|
||||
_disp = CLI_CONFIG.get("display", {})
|
||||
MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10))
|
||||
MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300))
|
||||
MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200))
|
||||
MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3))
|
||||
SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True)
|
||||
|
||||
# Collect displayable entries (skip system, tool-result messages)
|
||||
entries = [] # list of (role, display_text)
|
||||
_last_asst_idx = None # index of last assistant entry
|
||||
_last_asst_full = None # un-truncated display text for last assistant
|
||||
for msg in self.conversation_history:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
|
||||
if role == "system":
|
||||
continue
|
||||
if role == "tool":
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
text = "" if content is None else str(content)
|
||||
# Handle multimodal content (list of dicts)
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
parts.append(part.get("text", ""))
|
||||
elif isinstance(part, dict) and part.get("type") == "image_url":
|
||||
parts.append("[image]")
|
||||
text = " ".join(parts)
|
||||
if len(text) > MAX_USER_LEN:
|
||||
text = text[:MAX_USER_LEN] + "..."
|
||||
entries.append(("user", text))
|
||||
|
||||
elif role == "assistant":
|
||||
text = "" if content is None else str(content)
|
||||
text = _strip_reasoning_tags(text)
|
||||
parts = []
|
||||
full_parts = [] # un-truncated version
|
||||
if text:
|
||||
full_parts.append(text)
|
||||
lines = text.splitlines()
|
||||
if len(lines) > MAX_ASST_LINES:
|
||||
text = "\n".join(lines[:MAX_ASST_LINES]) + " ..."
|
||||
if len(text) > MAX_ASST_LEN:
|
||||
text = text[:MAX_ASST_LEN] + "..."
|
||||
parts.append(text)
|
||||
if tool_calls:
|
||||
tc_count = len(tool_calls)
|
||||
# Extract tool names
|
||||
names = []
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
names_str = ", ".join(names[:4])
|
||||
if len(names) > 4:
|
||||
names_str += ", ..."
|
||||
noun = "call" if tc_count == 1 else "calls"
|
||||
tc_summary = f"[{tc_count} tool {noun}: {names_str}]"
|
||||
parts.append(tc_summary)
|
||||
full_parts.append(tc_summary)
|
||||
if not parts:
|
||||
# Skip pure-reasoning messages that have no visible output
|
||||
continue
|
||||
# Skip tool-call-only entries when SKIP_TOOL_ONLY is enabled
|
||||
has_text = bool(text)
|
||||
if SKIP_TOOL_ONLY and not has_text and tool_calls:
|
||||
continue
|
||||
entries.append(("assistant", " ".join(parts)))
|
||||
_last_asst_idx = len(entries) - 1
|
||||
_last_asst_full = " ".join(full_parts)
|
||||
|
||||
if not entries:
|
||||
return
|
||||
|
||||
# Determine if we need to truncate
|
||||
skipped = 0
|
||||
if len(entries) > MAX_DISPLAY_EXCHANGES * 2:
|
||||
skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2
|
||||
entries = entries[skipped:]
|
||||
|
||||
# Replace last assistant entry with full (un-truncated) text
|
||||
# so the user can see where they left off without wasting tokens.
|
||||
if _last_asst_idx is not None and _last_asst_full:
|
||||
adj_idx = _last_asst_idx - skipped
|
||||
if 0 <= adj_idx < len(entries):
|
||||
entries[adj_idx] = ("assistant_last", _last_asst_full)
|
||||
|
||||
# Build the display using Rich
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
_history_text_c = _skin.get_color("banner_text", "#FFF8DC")
|
||||
_session_label_c = _skin.get_color("session_label", "#DAA520")
|
||||
_session_border_c = _skin.get_color("session_border", "#8B8682")
|
||||
_assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
|
||||
except Exception:
|
||||
_history_text_c = "#FFF8DC"
|
||||
_session_label_c = "#DAA520"
|
||||
_session_border_c = "#8B8682"
|
||||
_assistant_label_c = "#8FBC8F"
|
||||
|
||||
lines = Text()
|
||||
if skipped:
|
||||
lines.append(
|
||||
f" ... {skipped} earlier messages ...\n\n",
|
||||
style="dim italic",
|
||||
)
|
||||
|
||||
for i, (role, text) in enumerate(entries):
|
||||
if role == "user":
|
||||
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
|
||||
# Show first line inline, indent rest
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="dim")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="dim")
|
||||
elif role == "assistant_last":
|
||||
# Last assistant response shown in full, non-dim
|
||||
lines.append(" ◆ Hermes: ", style=f"bold {_assistant_label_c}")
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="")
|
||||
else:
|
||||
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="dim")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="dim")
|
||||
if i < len(entries) - 1:
|
||||
lines.append("") # small gap
|
||||
|
||||
panel = Panel(
|
||||
lines,
|
||||
title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||
border_style=f"dim {_session_border_c}",
|
||||
padding=(0, 1),
|
||||
style=_history_text_c,
|
||||
)
|
||||
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
||||
with _suspend_output_history():
|
||||
self._console_print(panel)
|
||||
2212
hermes_cli/cli_commands_mixin.py
Normal file
2212
hermes_cli/cli_commands_mixin.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,9 +5,8 @@ functions previously duplicated across setup.py, tools_config.py,
|
|||
mcp_config.py, and memory_setup.py.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
|
||||
# ─── Print Helpers ────────────────────────────────────────────────────────────
|
||||
|
|
@ -59,7 +58,7 @@ def prompt(
|
|||
|
||||
try:
|
||||
if password:
|
||||
value = getpass.getpass(display)
|
||||
value = masked_secret_prompt(display)
|
||||
else:
|
||||
value = input(display)
|
||||
value = value.strip()
|
||||
|
|
|
|||
|
|
@ -29,21 +29,29 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
|||
# curated fallback so Pro users still see Spark in `/model` when live
|
||||
# discovery is unavailable (offline first run, transient API failure).
|
||||
"gpt-5.3-codex-spark",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
# NOTE: gpt-5.2-codex / gpt-5.1-codex-max / gpt-5.1-codex-mini were
|
||||
# previously listed here but the chatgpt.com Codex backend returns
|
||||
# HTTP 400 "The '<model>' model is not supported when using Codex with
|
||||
# a ChatGPT account." for all three on every ChatGPT Pro account we've
|
||||
# tested (verified live 2026-05-27). Keeping them in the fallback list
|
||||
# leaked dead slugs into /model when live discovery was unavailable
|
||||
# (transient API failure, first-run before refresh) and surfaced HTTP 400
|
||||
# crashes on selection. The Codex CLI public catalog still references
|
||||
# these slugs, which is why they survived previously — but those entries
|
||||
# describe the public OpenAI API, not the OAuth-backed Codex backend
|
||||
# Hermes uses. Removed here. If OpenAI re-enables them on Codex backend,
|
||||
# live discovery will pick them up automatically via _fetch_models_from_api.
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")),
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex",)),
|
||||
("gpt-5.4", ("gpt-5.3-codex",)),
|
||||
# Surface Spark whenever any compatible Codex template is present so
|
||||
# accounts hitting the live endpoint with an older lineup still see
|
||||
# Spark in the picker. Backend gates real availability by ChatGPT Pro
|
||||
# entitlement; Hermes does not.
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex",)),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ class CommandDef:
|
|||
|
||||
COMMAND_REGISTRY: list[CommandDef] = [
|
||||
# Session
|
||||
CommandDef("start", "Acknowledge platform start pings without a reply", "Session",
|
||||
gateway_only=True),
|
||||
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
||||
aliases=("reset",), args_hint="[name]"),
|
||||
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
|
||||
|
|
@ -76,15 +78,16 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("save", "Save the current conversation", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
||||
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
||||
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
|
||||
args_hint="[N]"),
|
||||
CommandDef("title", "Set a title for the current session", "Session",
|
||||
args_hint="[name]"),
|
||||
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
||||
args_hint="<platform>", cli_only=True),
|
||||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||||
aliases=("fork",), args_hint="[name]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||||
args_hint="[focus topic]"),
|
||||
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session",
|
||||
args_hint="[here [N] | focus topic]"),
|
||||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||||
args_hint="[number]"),
|
||||
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
||||
|
|
@ -121,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration",
|
||||
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
|
||||
args_hint="[model] [--provider name] [--global] [--refresh]"),
|
||||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
|
|
@ -164,7 +167,13 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
cli_only=True),
|
||||
CommandDef("skills", "Search, install, inspect, or manage skills",
|
||||
"Tools & Skills", cli_only=True,
|
||||
subcommands=("search", "browse", "inspect", "install")),
|
||||
gateway_config_gate="skills.write_approval",
|
||||
subcommands=("search", "browse", "inspect", "install", "audit",
|
||||
"pending", "approve", "reject", "diff", "approval")),
|
||||
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
|
||||
"Tools & Skills",
|
||||
args_hint="[pending|approve|reject|approval] [id|on|off]",
|
||||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
|
|
@ -213,6 +222,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
|
||||
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
|
|
@ -346,6 +356,7 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
|||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -449,7 +460,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
|||
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave
|
||||
like ``CommandDef`` entries for gateway surfacing: they appear in the
|
||||
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
|
||||
(via :func:`gateway.platforms.discord._register_slash_commands`) in
|
||||
(via :func:`plugins.platforms.discord.adapter._register_slash_commands`) in
|
||||
Discord's native slash command picker.
|
||||
|
||||
Lookup is lazy so importing this module never forces plugin discovery
|
||||
|
|
@ -1145,41 +1156,6 @@ def slack_subcommand_map() -> dict[str, str]:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
|
||||
# every keystroke would block the UI; a short TTL keeps it live without
|
||||
# hammering the server.
|
||||
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
|
||||
|
||||
|
||||
def _lmstudio_completion_models() -> list[str]:
|
||||
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
|
||||
global _LMSTUDIO_COMPLETION_CACHE
|
||||
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
|
||||
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store() or {}
|
||||
if "lmstudio" not in (store.get("providers") or {}) \
|
||||
and "lmstudio" not in (store.get("credential_pool") or {}):
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
now = time.time()
|
||||
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
|
||||
return _LMSTUDIO_COMPLETION_CACHE[1]
|
||||
try:
|
||||
from hermes_cli.models import fetch_lmstudio_models
|
||||
models = fetch_lmstudio_models(
|
||||
api_key=os.environ.get("LM_API_KEY", ""),
|
||||
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
|
||||
timeout=0.8,
|
||||
)
|
||||
except Exception:
|
||||
models = []
|
||||
_LMSTUDIO_COMPLETION_CACHE = (now, models)
|
||||
return models
|
||||
|
||||
|
||||
class SlashCommandCompleter(Completer):
|
||||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||||
|
||||
|
|
@ -1596,52 +1572,6 @@ class SlashCommandCompleter(Completer):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||||
seen = set()
|
||||
# Config-based direct aliases (preferred — include provider info)
|
||||
try:
|
||||
from hermes_cli.model_switch import (
|
||||
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
||||
)
|
||||
_ensure_direct_aliases()
|
||||
for name, da in DIRECT_ALIASES.items():
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
seen.add(name)
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{da.model} ({da.provider})",
|
||||
)
|
||||
# Built-in catalog aliases not already covered
|
||||
for name in sorted(MODEL_ALIASES.keys()):
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
identity = MODEL_ALIASES[name]
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{identity.vendor}/{identity.family}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# LM Studio: surface locally-loaded models. Gated on the user actually
|
||||
# having LM Studio configured (env var or auth-store entry) so we
|
||||
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
|
||||
for name in _lmstudio_completion_models():
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta="LM Studio",
|
||||
)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
if not text.startswith("/"):
|
||||
|
|
@ -1665,9 +1595,6 @@ class SlashCommandCompleter(Completer):
|
|||
|
||||
# Dynamic completions for commands with runtime lists
|
||||
if " " not in sub_text:
|
||||
if base_cmd == "/model":
|
||||
yield from self._model_completions(sub_text, sub_lower)
|
||||
return
|
||||
if base_cmd == "/skin":
|
||||
yield from self._skin_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
|
@ -1785,7 +1712,7 @@ class SlashCommandAutoSuggest(AutoSuggest):
|
|||
return Suggestion(cmd_name[len(word):])
|
||||
return None
|
||||
|
||||
# Command is complete — suggest subcommands or model names
|
||||
# Command is complete — suggest subcommands
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@ _hermes_profiles() {{
|
|||
local profiles_dir="$HOME/.hermes/profiles"
|
||||
local profiles="default"
|
||||
if [ -d "$profiles_dir" ]; then
|
||||
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
|
||||
for f in "$profiles_dir"/*/; do
|
||||
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
echo "$profiles"
|
||||
}}
|
||||
|
|
@ -206,7 +208,7 @@ _hermes_profiles() {{
|
|||
local -a profiles
|
||||
profiles=(default)
|
||||
if [[ -d "$HOME/.hermes/profiles" ]]; then
|
||||
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
|
||||
profiles+=($HOME/.hermes/profiles/*(N/:t))
|
||||
fi
|
||||
_describe 'profile' profiles
|
||||
}}
|
||||
|
|
@ -260,7 +262,9 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
|
|||
"function __hermes_profiles",
|
||||
" echo default",
|
||||
" if test -d $HOME/.hermes/profiles",
|
||||
" ls $HOME/.hermes/profiles 2>/dev/null",
|
||||
" for d in $HOME/.hermes/profiles/*/",
|
||||
" basename $d",
|
||||
" end",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
|
|
|
|||
1118
hermes_cli/config.py
1118
hermes_cli/config.py
File diff suppressed because it is too large
Load diff
395
hermes_cli/container_boot.py
Normal file
395
hermes_cli/container_boot.py
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"""Container-boot reconciliation of per-profile gateway s6 services.
|
||||
|
||||
Service directories under /run/service/ live on **tmpfs** and are wiped
|
||||
on every container restart. Profile directories under
|
||||
``$HERMES_HOME/profiles/<name>/`` live on the persistent VOLUME, and
|
||||
each one records its gateway's last state in ``gateway_state.json``.
|
||||
This module bridges the two: on every container boot, walk the
|
||||
persistent profiles, recreate the s6 service slots, and auto-start
|
||||
only those whose last recorded state was ``running``.
|
||||
|
||||
Wired into the image as /etc/cont-init.d/02-reconcile-profiles by the
|
||||
Dockerfile (Phase 4 Task 4.0). Runs as root after 01-hermes-setup
|
||||
(the stage2 hook) has chowned the volume and seeded $HERMES_HOME, but
|
||||
before s6-rc starts user services.
|
||||
|
||||
Without this module, every ``docker restart`` would silently wipe
|
||||
every per-profile gateway, even though the user's profiles still
|
||||
exist on disk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, Sequence
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Only this prior state triggers automatic restart. Everything else
|
||||
# (startup_failed, starting, stopped, missing) registers the slot in
|
||||
# the down state and waits for explicit user action — this avoids the
|
||||
# crash-loop where a broken gateway keeps being restarted across
|
||||
# `docker restart` cycles.
|
||||
_AUTOSTART_STATES = frozenset({"running"})
|
||||
|
||||
# Stale runtime files we sweep before recreating service slots. These
|
||||
# all hold container-namespaced state (PIDs, process tables) that's
|
||||
# garbage post-restart — a numerically-equal PID in the new container
|
||||
# is a different process. See the Risk Register in the plan.
|
||||
_STALE_RUNTIME_FILES = ("gateway.pid", "processes.json")
|
||||
|
||||
ReconcileActionLabel = Literal["started", "registered", "skipped"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileAction:
|
||||
"""One profile's outcome from a single reconciliation pass."""
|
||||
profile: str
|
||||
prior_state: str | None
|
||||
action: ReconcileActionLabel
|
||||
|
||||
|
||||
def reconcile_profile_gateways(
|
||||
*,
|
||||
hermes_home: Path,
|
||||
scandir: Path,
|
||||
dry_run: bool = False,
|
||||
container_argv: Sequence[str] | None = None,
|
||||
) -> list[ReconcileAction]:
|
||||
"""Recreate s6 service registrations for every persistent profile.
|
||||
|
||||
Always registers a ``gateway-default`` slot for the root profile
|
||||
(the implicit profile that lives at the top of ``$HERMES_HOME``,
|
||||
not under ``profiles/``). The dispatcher in ``hermes_cli.gateway``
|
||||
maps an empty profile suffix to ``gateway-default``, so this slot
|
||||
is what ``hermes gateway start`` (no ``-p``) targets. Without it,
|
||||
bare ``hermes gateway start`` inside the container would land on
|
||||
``s6-svc -u /run/service/gateway-default`` → uncaught
|
||||
``CalledProcessError`` → traceback to the user (PR #30136 review).
|
||||
|
||||
The default slot's prior state is read from
|
||||
``$HERMES_HOME/gateway_state.json`` (sibling to the profile root,
|
||||
not under ``profiles/``); stale runtime files there are swept the
|
||||
same way as for named profiles.
|
||||
|
||||
Args:
|
||||
hermes_home: The container's HERMES_HOME (typically /opt/data).
|
||||
Profiles live under ``<hermes_home>/profiles/<name>/``;
|
||||
the default profile lives at ``<hermes_home>`` itself.
|
||||
scandir: The s6 dynamic scandir (typically /run/service). Service
|
||||
directories are created at ``<scandir>/gateway-<profile>/``.
|
||||
dry_run: When True, walk and return the action list without
|
||||
touching the filesystem. For tests and `--dry-run` debug.
|
||||
container_argv: Optional container PID 1 argv override. Production
|
||||
reads ``/proc/1/cmdline``; tests inject it directly.
|
||||
|
||||
Returns:
|
||||
One :class:`ReconcileAction` per profile, in this order:
|
||||
``default`` first, then named profiles in directory order.
|
||||
"""
|
||||
actions: list[ReconcileAction] = []
|
||||
|
||||
# Default profile — always register, even if nothing has ever
|
||||
# populated the root profile dir. The slot exists so
|
||||
# ``hermes gateway start`` (no ``-p``) has somewhere to land;
|
||||
# auto-up only when the prior state was "running" (same rule as
|
||||
# named profiles). If the container was launched with the legacy
|
||||
# `gateway run` command and no state exists yet, seed that intent
|
||||
# as `running` so the s6 reconciler preserves the pre-s6 behavior.
|
||||
legacy_default_state = _maybe_migrate_legacy_gateway_run_state(
|
||||
hermes_home,
|
||||
container_argv=container_argv,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
default_prior_state = legacy_default_state or _read_prior_state(hermes_home)
|
||||
default_should_start = default_prior_state in _AUTOSTART_STATES
|
||||
if not dry_run:
|
||||
_cleanup_stale_runtime_files(hermes_home)
|
||||
_register_service(scandir, "default", start=default_should_start)
|
||||
actions.append(ReconcileAction(
|
||||
profile="default",
|
||||
prior_state=default_prior_state,
|
||||
action="started" if default_should_start else "registered",
|
||||
))
|
||||
|
||||
profiles_root = hermes_home / "profiles"
|
||||
if profiles_root.is_dir():
|
||||
for entry in sorted(profiles_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
# SOUL.md is always seeded by `hermes profile create` (config.yaml
|
||||
# is not — that comes later via `hermes setup`). Use it as the
|
||||
# "real profile" marker so stray dirs (backups, manual mkdir)
|
||||
# aren't picked up.
|
||||
if not (entry / "SOUL.md").exists():
|
||||
continue
|
||||
# The "default" service name is reserved for the root
|
||||
# profile (above) — if a user has somehow created a
|
||||
# ``profiles/default/`` directory, skip it to avoid the
|
||||
# slot collision. Their gateway would still be reachable
|
||||
# via ``hermes -p default-named gateway start`` if they
|
||||
# rename the directory; we don't try to disambiguate here.
|
||||
if entry.name == "default":
|
||||
log.warning(
|
||||
"profiles/default/ exists — skipping to avoid colliding "
|
||||
"with the reserved root-profile s6 slot",
|
||||
)
|
||||
continue
|
||||
|
||||
prior_state = _read_prior_state(entry)
|
||||
should_start = prior_state in _AUTOSTART_STATES
|
||||
|
||||
if not dry_run:
|
||||
_cleanup_stale_runtime_files(entry)
|
||||
_register_service(scandir, entry.name, start=should_start)
|
||||
|
||||
actions.append(ReconcileAction(
|
||||
profile=entry.name,
|
||||
prior_state=prior_state,
|
||||
action="started" if should_start else "registered",
|
||||
))
|
||||
|
||||
if not dry_run:
|
||||
_write_reconcile_log(hermes_home, actions)
|
||||
return actions
|
||||
|
||||
|
||||
def _maybe_migrate_legacy_gateway_run_state(
|
||||
hermes_home: Path,
|
||||
*,
|
||||
container_argv: Sequence[str] | None,
|
||||
dry_run: bool,
|
||||
) -> str | None:
|
||||
"""Seed root gateway_state for pre-s6 `gateway run` containers.
|
||||
|
||||
The tini image let Docker users run the gateway as the container
|
||||
command (`docker run ... gateway run`). After the s6 migration,
|
||||
profile gateways are restored from persisted gateway_state.json; a
|
||||
legacy container with no state file would therefore register the
|
||||
default service down and never start. Only synthesize state when no
|
||||
root gateway_state.json exists so explicit stopped/failed states keep
|
||||
winning across restarts.
|
||||
"""
|
||||
state_file = hermes_home / "gateway_state.json"
|
||||
if state_file.exists():
|
||||
return None
|
||||
|
||||
if os.environ.get("HERMES_GATEWAY_NO_SUPERVISE", "").lower() in ("1", "true", "yes"):
|
||||
return None
|
||||
|
||||
argv = tuple(container_argv) if container_argv is not None else _read_container_argv()
|
||||
if not _is_legacy_gateway_run_request(argv):
|
||||
return None
|
||||
|
||||
if not dry_run:
|
||||
import time
|
||||
state_file.write_text(json.dumps({
|
||||
"gateway_state": "running",
|
||||
"timestamp": int(time.time()),
|
||||
"migrated_from": "legacy-container-cmd",
|
||||
}) + "\n")
|
||||
return "running"
|
||||
|
||||
|
||||
def _read_container_argv() -> tuple[str, ...]:
|
||||
"""Best-effort read of the container PID 1 argv."""
|
||||
try:
|
||||
raw = Path("/proc/1/cmdline").read_bytes()
|
||||
except OSError:
|
||||
return ()
|
||||
return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
|
||||
|
||||
|
||||
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
||||
args = list(argv)
|
||||
if args and Path(args[0]).name == "init":
|
||||
args = args[1:]
|
||||
if args and args[0].endswith("main-wrapper.sh"):
|
||||
args = args[1:]
|
||||
if args and Path(args[0]).name == "hermes":
|
||||
args = args[1:]
|
||||
if "--no-supervise" in args:
|
||||
return False
|
||||
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
|
||||
|
||||
|
||||
def _read_prior_state(profile_dir: Path) -> str | None:
|
||||
"""Read gateway_state.json's ``gateway_state`` field, or None if
|
||||
missing or unparseable. Unparseable counts as "no prior state" so
|
||||
we don't bork the whole reconciliation on a corrupt file."""
|
||||
state_file = profile_dir / "gateway_state.json"
|
||||
if not state_file.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(state_file.read_text()).get("gateway_state")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
log.warning(
|
||||
"could not read %s; treating as no prior state", state_file,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_stale_runtime_files(profile_dir: Path) -> None:
|
||||
"""Remove gateway.pid and processes.json — they reference PIDs in
|
||||
the dead container's process namespace and would otherwise confuse
|
||||
the newly-started gateway's process-mismatch checks."""
|
||||
for name in _STALE_RUNTIME_FILES:
|
||||
(profile_dir / name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _register_service(scandir: Path, profile: str, *, start: bool) -> None:
|
||||
"""Recreate the s6 service slot for one profile.
|
||||
|
||||
Mirrors the rendering in :func:`S6ServiceManager.register_profile_gateway`,
|
||||
but here we control the start state directly via the ``down`` marker
|
||||
file (s6-svscan honors it on rescan). Cannot use the manager
|
||||
directly because the cont-init.d phase runs as root before
|
||||
s6-svscan starts scanning the dynamic scandir — the manager's
|
||||
``s6-svscanctl -a`` call would fail with no control socket.
|
||||
|
||||
Atomicity: build the new layout in a sibling temp directory and
|
||||
rename it into place via :meth:`Path.replace`. This matches
|
||||
:meth:`S6ServiceManager.register_profile_gateway` (PR #30136
|
||||
review item O4) — even though cont-init.d runs before s6-svscan
|
||||
starts scanning, an atomic publication keeps the contract uniform
|
||||
between the two registration paths and protects against a
|
||||
half-populated dir if the script is interrupted mid-write.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
from hermes_cli.service_manager import (
|
||||
S6ServiceManager,
|
||||
_seed_supervise_skeleton,
|
||||
validate_profile_name,
|
||||
)
|
||||
|
||||
validate_profile_name(profile)
|
||||
service_dir = scandir / f"gateway-{profile}"
|
||||
tmp_dir = service_dir.with_name(service_dir.name + ".tmp")
|
||||
|
||||
# Wipe any leftover tmp from a previous interrupted run.
|
||||
if tmp_dir.exists():
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
tmp_dir.mkdir(parents=True)
|
||||
|
||||
try:
|
||||
(tmp_dir / "type").write_text("longrun\n")
|
||||
|
||||
# Reuse the manager's run-script rendering — single source of
|
||||
# truth so register_profile_gateway and reconcile_profile_gateways
|
||||
# stay consistent. extra_env is empty here; users who need
|
||||
# per-profile env can set it via the profile's config.yaml
|
||||
# (which the gateway itself loads).
|
||||
run = tmp_dir / "run"
|
||||
run.write_text(S6ServiceManager._render_run_script(profile, extra_env={}))
|
||||
run.chmod(0o755)
|
||||
|
||||
# Persistent log rotation (OQ8-C).
|
||||
log_subdir = tmp_dir / "log"
|
||||
log_subdir.mkdir()
|
||||
log_run = log_subdir / "run"
|
||||
log_run.write_text(S6ServiceManager._render_log_run(profile))
|
||||
log_run.chmod(0o755)
|
||||
|
||||
# The presence of a `down` file tells s6-supervise to NOT
|
||||
# start the service when s6-svscan picks it up. User brings
|
||||
# it up explicitly with `hermes -p <profile> gateway start`
|
||||
# (which routes through the Phase 4
|
||||
# _dispatch_via_service_manager_if_s6 helper to `s6-svc -u`).
|
||||
if not start:
|
||||
(tmp_dir / "down").touch()
|
||||
|
||||
# Pre-create the supervise/ skeleton with hermes ownership
|
||||
# BEFORE we publish the slot. Mirrors the same pre-creation
|
||||
# step in S6ServiceManager.register_profile_gateway — when
|
||||
# s6-svscan picks the published slot up, the s6-supervise it
|
||||
# spawns will EEXIST our dirs/FIFOs and inherit hermes
|
||||
# ownership, so runtime s6-svc / s6-svstat / s6-svwait calls
|
||||
# (all dispatched as the hermes user) won't hit EACCES. See
|
||||
# ``_seed_supervise_skeleton`` in service_manager.py for the
|
||||
# full rationale.
|
||||
_seed_supervise_skeleton(tmp_dir)
|
||||
|
||||
# Publish atomically. Path.replace handles the existing-target
|
||||
# case the same way os.rename does on POSIX: the target is
|
||||
# silently replaced, so a previous reconcile pass's slot is
|
||||
# cleanly overwritten in one operation.
|
||||
if service_dir.exists():
|
||||
shutil.rmtree(service_dir)
|
||||
tmp_dir.replace(service_dir)
|
||||
except Exception:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _write_reconcile_log(
|
||||
hermes_home: Path, actions: list[ReconcileAction],
|
||||
) -> None:
|
||||
"""Append one line per profile to $HERMES_HOME/logs/container-boot.log.
|
||||
|
||||
Operators inspect this to debug "why didn't my profile come back
|
||||
up". Keeping a separate log file (vs. mixing into agent.log) lets
|
||||
troubleshooters grep for "profile=foo" without wading through
|
||||
unrelated activity.
|
||||
|
||||
Size-bounded: when the file exceeds ``_LOG_ROTATE_BYTES``
|
||||
(defaults to 256 KiB ≈ 3000 reconcile lines), the current file
|
||||
is renamed to ``container-boot.log.1`` (replacing any previous
|
||||
rotation) before the new entries are appended. This gives long-
|
||||
lived containers a soft cap of ~512 KiB across the two files
|
||||
without pulling in logrotate or s6-log machinery just for this
|
||||
one append-only file (PR #30136 review item O3).
|
||||
"""
|
||||
import time
|
||||
log_dir = hermes_home / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "container-boot.log"
|
||||
|
||||
# Rotate before opening to append, so the new entries always land
|
||||
# in a fresh file when we crossed the threshold last time.
|
||||
try:
|
||||
if log_path.exists() and log_path.stat().st_size >= _LOG_ROTATE_BYTES:
|
||||
log_path.replace(log_dir / "container-boot.log.1")
|
||||
except OSError as exc:
|
||||
# Rotation failure is non-fatal — keep appending to the
|
||||
# existing file rather than losing the entry entirely.
|
||||
log.warning("could not rotate %s: %s", log_path, exc)
|
||||
|
||||
ts = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
with log_path.open("a", encoding="utf-8") as f:
|
||||
for a in actions:
|
||||
f.write(
|
||||
f"{ts} profile={a.profile} prior_state={a.prior_state} "
|
||||
f"action={a.action}\n"
|
||||
)
|
||||
|
||||
|
||||
# 256 KiB soft cap on container-boot.log; rotated to .1 when crossed.
|
||||
# At ~80 B per reconcile-action line this is ~3000 lines, or about a
|
||||
# year of daily reboots on a 5-profile container. Two files = ~512 KiB
|
||||
# worst case. Tuned for visibility (small enough to grep / cat without
|
||||
# scrolling forever) more than space (the persistent volume has GB).
|
||||
_LOG_ROTATE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point invoked from /etc/cont-init.d/02-reconcile-profiles."""
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=hermes_home, scandir=scandir,
|
||||
)
|
||||
for a in actions:
|
||||
print(
|
||||
f"reconcile: profile={a.profile} "
|
||||
f"prior_state={a.prior_state} action={a.action}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -6,6 +6,7 @@ pause/resume/run/remove, status, and tick.
|
|||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
|
|
@ -15,6 +16,24 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
# Patterns that indicate a cron job targets the gateway lifecycle.
|
||||
# Matches commands that restart/stop the gateway or its service manager.
|
||||
# Deliberately specific — a bare "gateway ... restart" catch-all would block
|
||||
# legitimate prompts that merely mention an unrelated gateway (e.g. "summarize
|
||||
# the API gateway logs and report restart events").
|
||||
_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"|(p?kill\s+.*hermes.*gateway)"
|
||||
)
|
||||
|
||||
|
||||
def _contains_gateway_lifecycle_command(text: str) -> bool:
|
||||
"""Return True if *text* contains a gateway lifecycle command pattern."""
|
||||
return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))
|
||||
|
||||
|
||||
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
|
||||
if skills is None:
|
||||
|
|
@ -62,7 +81,10 @@ def cron_list(show_all: bool = False):
|
|||
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
|
||||
next_run = job.get("next_run_at", "?")
|
||||
|
||||
repeat_info = job.get("repeat", {})
|
||||
# `repeat` may be present-but-null in the job record (e.g. a one-shot
|
||||
# job persisted with "repeat": null), so coalesce to {} rather than
|
||||
# relying on the dict-default, which only applies to a missing key.
|
||||
repeat_info = job.get("repeat") or {}
|
||||
repeat_times = repeat_info.get("times")
|
||||
repeat_completed = repeat_info.get("completed", 0)
|
||||
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞"
|
||||
|
|
@ -166,6 +188,28 @@ def cron_status():
|
|||
|
||||
|
||||
def cron_create(args):
|
||||
# Defense: reject cron jobs that contain gateway lifecycle commands.
|
||||
# Prevents agents from scheduling their own restart/stop, which creates
|
||||
# SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).
|
||||
prompt = getattr(args, "prompt", None) or ""
|
||||
script = getattr(args, "script", None)
|
||||
combined = prompt
|
||||
if script:
|
||||
try:
|
||||
script_text = Path(script).read_text(encoding="utf-8")
|
||||
combined = f"{combined}\n{script_text}"
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
if _contains_gateway_lifecycle_command(combined):
|
||||
print(color(
|
||||
"Blocked: cron job contains a gateway lifecycle command "
|
||||
"(restart/stop/kill).\n"
|
||||
"This is blocked to prevent restart loops (#30719).\n"
|
||||
"Use `hermes gateway restart` from a shell outside the gateway.",
|
||||
Colors.RED,
|
||||
))
|
||||
return 1
|
||||
|
||||
result = _cron_api(
|
||||
action="create",
|
||||
schedule=args.schedule,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
42
hermes_cli/dashboard_auth/__init__.py
Normal file
42
hermes_cli/dashboard_auth/__init__.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Dashboard authentication provider framework.
|
||||
|
||||
The dashboard auth gate engages only when the dashboard binds to a
|
||||
non-loopback host without ``--insecure``. In that mode, every request must
|
||||
carry a verified session from one of the registered ``DashboardAuthProvider``
|
||||
plugins.
|
||||
|
||||
The Nous provider lives in ``plugins/dashboard-auth-nous/`` and is the
|
||||
default. Third parties register their own providers via the plugin hook
|
||||
``ctx.register_dashboard_auth_provider``.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth.base import (
|
||||
DashboardAuthProvider,
|
||||
Session,
|
||||
LoginStart,
|
||||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.registry import (
|
||||
register_provider,
|
||||
get_provider,
|
||||
list_providers,
|
||||
clear_providers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DashboardAuthProvider",
|
||||
"Session",
|
||||
"LoginStart",
|
||||
"InvalidCodeError",
|
||||
"InvalidCredentialsError",
|
||||
"ProviderError",
|
||||
"RefreshExpiredError",
|
||||
"assert_protocol_compliance",
|
||||
"register_provider",
|
||||
"get_provider",
|
||||
"list_providers",
|
||||
"clear_providers",
|
||||
]
|
||||
87
hermes_cli/dashboard_auth/audit.py
Normal file
87
hermes_cli/dashboard_auth/audit.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Audit log for dashboard-auth events.
|
||||
|
||||
Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``.
|
||||
Format: one JSON object per line. Token-like fields are stripped before
|
||||
serialisation to avoid leaking refresh tokens or JWTs to disk.
|
||||
|
||||
This module deliberately keeps a minimal dependency surface — no imports
|
||||
from ``hermes_constants`` or other hermes_cli modules — so it can be
|
||||
imported safely from middleware code that loads early in the startup
|
||||
sequence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import enum
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
# Field names that must never appear in the log raw. Any kwarg matching
|
||||
# these is silently dropped.
|
||||
_REDACTED_FIELDS: frozenset = frozenset({
|
||||
"access_token", "refresh_token", "code", "code_verifier",
|
||||
"state", "ticket", "cookie", "Authorization", "authorization",
|
||||
})
|
||||
|
||||
|
||||
class AuditEvent(enum.Enum):
|
||||
"""Event types written to dashboard-auth.log.
|
||||
|
||||
Values are the literal ``event`` field on the JSON line.
|
||||
"""
|
||||
|
||||
LOGIN_START = "login_start"
|
||||
LOGIN_SUCCESS = "login_success"
|
||||
LOGIN_FAILURE = "login_failure"
|
||||
LOGOUT = "logout"
|
||||
REFRESH_SUCCESS = "refresh_success"
|
||||
REFRESH_FAILURE = "refresh_failure"
|
||||
REVOKE = "revoke"
|
||||
SESSION_VERIFY_FAILURE = "session_verify_failure"
|
||||
WS_TICKET_MINTED = "ws_ticket_minted"
|
||||
WS_TICKET_REJECTED = "ws_ticket_rejected"
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
"""``$HERMES_HOME/logs/dashboard-auth.log`` with the standard fallback.
|
||||
|
||||
Mirrors ``hermes_constants.get_hermes_home`` semantics: env var wins,
|
||||
else ``~/.hermes``. A local copy avoids an import cycle with the
|
||||
middleware which lives below ``hermes_cli``.
|
||||
"""
|
||||
home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
|
||||
return Path(home) / "logs" / "dashboard-auth.log"
|
||||
|
||||
|
||||
def audit_log(event: AuditEvent, **fields: Any) -> None:
|
||||
"""Append one event to the audit log.
|
||||
|
||||
Token-like fields are dropped. Missing log directory is created.
|
||||
Write failures are logged at WARNING but never raise — auth must not
|
||||
fail because the audit logger broke.
|
||||
"""
|
||||
safe_fields = {
|
||||
k: v for k, v in fields.items()
|
||||
if k not in _REDACTED_FIELDS
|
||||
}
|
||||
entry = {
|
||||
"ts": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
||||
"event": event.value,
|
||||
**safe_fields,
|
||||
}
|
||||
line = json.dumps(entry, separators=(",", ":")) + "\n"
|
||||
path = _resolve_log_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _write_lock:
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception as e:
|
||||
_log.warning("dashboard-auth audit log write failed: %s", e)
|
||||
220
hermes_cli/dashboard_auth/base.py
Normal file
220
hermes_cli/dashboard_auth/base.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Abstract base + dataclasses + exceptions for dashboard auth providers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
"""A verified identity. Returned by ``complete_login`` and ``verify_session``.
|
||||
|
||||
All fields are mandatory. Providers that don't have a concept of orgs
|
||||
should set ``org_id`` to an empty string. ``access_token`` and
|
||||
``refresh_token`` are opaque to Hermes — provider-specific.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
org_id: str
|
||||
provider: str
|
||||
expires_at: int # unix seconds; the access_token's exp claim
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginStart:
|
||||
"""First leg of the OAuth round trip.
|
||||
|
||||
``redirect_url`` is the URL the browser must navigate to (e.g. the
|
||||
Portal's ``/oauth/authorize``). ``cookie_payload`` is a dict of cookie
|
||||
name → serialised value that the auth route will ``Set-Cookie`` on the
|
||||
response. Used for PKCE state, CSRF nonces, etc. Cookies set here MUST
|
||||
be HttpOnly + Secure (when over HTTPS) + SameSite=Lax with a TTL ≤ 10
|
||||
minutes (the login lifetime).
|
||||
"""
|
||||
|
||||
redirect_url: str
|
||||
cookie_payload: dict[str, str]
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""IDP unreachable, network error, or other transient failure.
|
||||
|
||||
Middleware translates this to HTTP 503.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidCodeError(Exception):
|
||||
"""The OAuth callback ``code`` / ``state`` failed validation.
|
||||
|
||||
Middleware translates this to HTTP 400.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidCredentialsError(Exception):
|
||||
"""A username/password pair was rejected by a password provider.
|
||||
|
||||
Raised by :meth:`DashboardAuthProvider.complete_password_login`. The
|
||||
``/auth/password-login`` route translates this to HTTP 401 with a
|
||||
deliberately generic detail (never distinguishing "unknown user" from
|
||||
"wrong password") so the endpoint can't be used as a username oracle.
|
||||
"""
|
||||
|
||||
|
||||
class RefreshExpiredError(Exception):
|
||||
"""The refresh token is dead.
|
||||
|
||||
Middleware clears cookies and forces re-login (302 → ``/login``).
|
||||
"""
|
||||
|
||||
|
||||
class DashboardAuthProvider(ABC):
|
||||
"""Protocol every dashboard-auth provider plugin implements.
|
||||
|
||||
Lifecycle:
|
||||
1. ``start_login`` — user clicks "Log in with X" on the login page.
|
||||
Provider returns a redirect URL and any PKCE/CSRF state to stash
|
||||
in short-lived cookies.
|
||||
2. Browser bounces through the OAuth IDP and lands at /auth/callback.
|
||||
3. ``complete_login`` — exchange the code + verifier for a Session.
|
||||
4. ``verify_session`` — called on every request to validate the
|
||||
access token in the cookie. Returns ``None`` if the token is
|
||||
expired or invalid (middleware then triggers refresh or logout).
|
||||
5. ``refresh_session`` — called when the access token is near expiry.
|
||||
Returns a new Session with rotated tokens.
|
||||
6. ``revoke_session`` — called on /auth/logout. Best-effort.
|
||||
|
||||
Failure semantics:
|
||||
* ``start_login`` may raise ``ProviderError`` if the IDP is
|
||||
unreachable.
|
||||
* ``complete_login`` raises ``InvalidCodeError`` on bad code/state;
|
||||
``ProviderError`` if the IDP is unreachable.
|
||||
* ``verify_session`` returns ``None`` on expiry / unknown token;
|
||||
raises ``ProviderError`` if the IDP is unreachable. Middleware
|
||||
treats expiry and unreachable differently (expiry → refresh;
|
||||
unreachable → 503).
|
||||
* ``refresh_session`` raises ``RefreshExpiredError`` when the
|
||||
refresh token is also invalid; middleware then forces re-login.
|
||||
Raises ``ProviderError`` on network failure.
|
||||
* ``revoke_session`` is best-effort and must not raise.
|
||||
|
||||
Subclasses MUST set ``name`` (lowercase identifier, stable forever)
|
||||
and ``display_name`` (user-facing label on the login page).
|
||||
|
||||
Password (non-redirect) providers:
|
||||
A provider that authenticates with a username + password instead of
|
||||
an OAuth redirect sets ``supports_password = True`` and implements
|
||||
``complete_password_login``. The login page then renders a
|
||||
credential form (POSTing to ``/auth/password-login``) instead of a
|
||||
"Log in with X" redirect button. Everything downstream of login —
|
||||
``verify_session`` / ``refresh_session`` / ``revoke_session``, the
|
||||
session cookies, the WS-ticket mint — is identical to the OAuth
|
||||
path, because a password session is just a :class:`Session` with
|
||||
provider-minted opaque tokens. The OAuth methods (``start_login`` /
|
||||
``complete_login``) remain abstract; a pure-password provider that
|
||||
will never be reached via the redirect flow may implement them as
|
||||
stubs that raise ``NotImplementedError``.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
display_name: str = ""
|
||||
|
||||
# When True, this provider authenticates via username + password
|
||||
# (``complete_password_login``) rather than (or in addition to) the
|
||||
# OAuth redirect flow. The login page renders a credential form for
|
||||
# such providers; the ``/auth/password-login`` route dispatches to
|
||||
# ``complete_password_login``. OAuth-only providers leave this False
|
||||
# and are completely unaffected.
|
||||
supports_password: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart: ...
|
||||
|
||||
@abstractmethod
|
||||
def complete_login(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
state: str,
|
||||
code_verifier: str,
|
||||
redirect_uri: str,
|
||||
) -> Session: ...
|
||||
|
||||
@abstractmethod
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]: ...
|
||||
|
||||
@abstractmethod
|
||||
def refresh_session(self, *, refresh_token: str) -> Session: ...
|
||||
|
||||
@abstractmethod
|
||||
def revoke_session(self, *, refresh_token: str) -> None: ...
|
||||
|
||||
def complete_password_login(
|
||||
self, *, username: str, password: str
|
||||
) -> "Session":
|
||||
"""Verify a username/password pair and mint a :class:`Session`.
|
||||
|
||||
Only called when ``supports_password`` is True (the
|
||||
``/auth/password-login`` route guards on the flag). The default
|
||||
raises ``NotImplementedError`` so an OAuth-only provider that
|
||||
forgets to set the flag fails loudly rather than silently
|
||||
accepting credentials.
|
||||
|
||||
The returned ``Session`` carries provider-minted opaque
|
||||
``access_token`` / ``refresh_token`` exactly like the OAuth path,
|
||||
so all downstream session handling (cookies, verify, refresh,
|
||||
ws-tickets, logout) is identical.
|
||||
|
||||
Failure semantics:
|
||||
* ``InvalidCredentialsError`` — username/password rejected. The
|
||||
route surfaces a generic 401 (no user-vs-password
|
||||
distinction). Implementations SHOULD spend constant time on
|
||||
unknown users (dummy hash verify) to avoid a timing oracle.
|
||||
* ``ProviderError`` — the backing credential store is
|
||||
unreachable (LDAP/DB down); the route surfaces 503.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support password login "
|
||||
"(set supports_password = True and override "
|
||||
"complete_password_login)"
|
||||
)
|
||||
|
||||
|
||||
def assert_protocol_compliance(cls: type) -> None:
|
||||
"""Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol.
|
||||
|
||||
Call this in every provider plugin's unit tests::
|
||||
|
||||
def test_protocol_compliance():
|
||||
assert_protocol_compliance(MyProvider)
|
||||
|
||||
Returns ``None`` on success so callers can assert it explicitly.
|
||||
"""
|
||||
required_methods = (
|
||||
"start_login",
|
||||
"complete_login",
|
||||
"verify_session",
|
||||
"refresh_session",
|
||||
"revoke_session",
|
||||
)
|
||||
required_attrs = ("name", "display_name")
|
||||
|
||||
for attr in required_attrs:
|
||||
val = getattr(cls, attr, "")
|
||||
if not val:
|
||||
raise TypeError(
|
||||
f"{cls.__name__} missing or empty attribute: {attr!r}"
|
||||
)
|
||||
for method in required_methods:
|
||||
if not callable(getattr(cls, method, None)):
|
||||
raise TypeError(f"{cls.__name__} missing method: {method}")
|
||||
# Also catch the ABC-not-overridden case.
|
||||
if getattr(cls, "__abstractmethods__", None):
|
||||
raise TypeError(
|
||||
f"{cls.__name__} has unimplemented abstract methods: "
|
||||
f"{sorted(cls.__abstractmethods__)}"
|
||||
)
|
||||
247
hermes_cli/dashboard_auth/cookies.py
Normal file
247
hermes_cli/dashboard_auth/cookies.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""Cookie helpers for dashboard auth.
|
||||
|
||||
Three cookies in play:
|
||||
- hermes_session_at: the OAuth access token
|
||||
(HttpOnly, lifetime = token TTL, ~15 min)
|
||||
- hermes_session_rt: the OAuth refresh token
|
||||
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
|
||||
Nous Portal issues a rotating refresh token for the
|
||||
dashboard auth-code grant (Portal NAS #293 / hermes
|
||||
#37247). ``set_session_cookies`` writes this cookie
|
||||
whenever the provider returns a non-empty
|
||||
``refresh_token``; the middleware uses it to rotate a
|
||||
fresh access token transparently on AT expiry. A
|
||||
provider that omits the refresh token (empty string)
|
||||
degrades gracefully to access-token-only sessions —
|
||||
the RT cookie is simply not written.
|
||||
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
|
||||
hint (HttpOnly, lifetime = 10 minutes)
|
||||
|
||||
All three are ``SameSite=Lax`` (browser will send on cross-site GET
|
||||
top-level navigation, which we need for the IDP redirect back to
|
||||
``/auth/callback``) and live under the prefix's Path. ``Secure`` is set
|
||||
ONLY when the dashboard was reached over HTTPS — detected via the
|
||||
request URL scheme, which honours ``X-Forwarded-Proto`` upstream of
|
||||
Fly's TLS terminator when uvicorn is configured with
|
||||
``proxy_headers=True``. Loopback dev traffic is always HTTP so
|
||||
``Secure`` would lock the cookies out of the browser.
|
||||
|
||||
Cookie prefix selection (browser hardening per
|
||||
https://datatracker.ietf.org/doc/html/draft-west-cookie-prefixes):
|
||||
|
||||
* Loopback HTTP — bare name. ``__Host-`` / ``__Secure-`` require
|
||||
``Secure``, which is incompatible with HTTP.
|
||||
* Gated HTTPS, direct deploy (Path=/) — ``__Host-`` prefix. Binds the
|
||||
cookie to the exact origin (no Domain attribute) — strongest spec
|
||||
guarantee.
|
||||
* Gated HTTPS, behind a reverse-proxy prefix (Path=/hermes) —
|
||||
``__Secure-`` prefix. ``__Host-`` is disallowed when Path != "/";
|
||||
``__Secure-`` keeps the Secure-required hardening without the
|
||||
Path constraint, and the explicit ``Path=/hermes`` covers
|
||||
same-origin app isolation.
|
||||
|
||||
The setters and readers BOTH consult the active prefix because the
|
||||
cookie *name* changes — a reader that looked up the bare name when the
|
||||
setter wrote ``__Secure-hermes_session_at`` would never find the value.
|
||||
|
||||
Refresh-token handling:
|
||||
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
|
||||
it) and silently skips writing the RT cookie in that case, so a
|
||||
refresh-token-less provider degrades to access-token-only sessions.
|
||||
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
|
||||
cookie on logout / session expiry so a stale cookie from an earlier
|
||||
deployment gets cleared. The transparent rotation flow ("expired AT +
|
||||
live RT → rotate server-side, else 401 → /login") lives in
|
||||
``middleware._attempt_refresh``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
# Bare cookie names — the request-scoped ``_resolved_name`` helper
|
||||
# decides whether to prepend ``__Host-`` / ``__Secure-`` based on the
|
||||
# request's HTTPS + prefix combination.
|
||||
SESSION_AT_COOKIE = "hermes_session_at"
|
||||
SESSION_RT_COOKIE = "hermes_session_rt"
|
||||
PKCE_COOKIE = "hermes_session_pkce"
|
||||
|
||||
# Possible name variants we may have to read back. Sorted so most-strict
|
||||
# wins on iteration when both happen to be present (shouldn't happen in
|
||||
# practice — a single request emits exactly one variant).
|
||||
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
|
||||
|
||||
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
|
||||
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
|
||||
# real authority — once the RT itself expires/rotates out, a refresh attempt
|
||||
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
|
||||
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
|
||||
# lifetime to a server-side TTL that can change independently; revisit if the
|
||||
# stale-cookie refresh churn ever matters.)
|
||||
_RT_MAX_AGE = 30 * 24 * 60 * 60
|
||||
_PKCE_MAX_AGE = 10 * 60
|
||||
|
||||
|
||||
def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str:
|
||||
"""Pick the cookie-prefix variant for the active request shape.
|
||||
|
||||
See module docstring for the prefix selection rules. Mismatch
|
||||
between setter and reader would silently break sessions, so this
|
||||
function is the single source of truth for naming.
|
||||
"""
|
||||
if not use_https:
|
||||
return bare
|
||||
if prefix:
|
||||
# Path != "/" forbids __Host-; fall back to __Secure-.
|
||||
return f"__Secure-{bare}"
|
||||
return f"__Host-{bare}"
|
||||
|
||||
|
||||
def _cookie_path(prefix: str) -> str:
|
||||
"""Cookie ``Path`` attribute for the active deploy shape.
|
||||
|
||||
Under ``X-Forwarded-Prefix: /hermes`` we want ``Path=/hermes`` so:
|
||||
a) the browser sends the cookie back on requests under the prefix
|
||||
(browsers omit the cookie if request path doesn't start with
|
||||
Path);
|
||||
b) the cookie doesn't leak to other apps on the same origin
|
||||
(``mission-control.tilos.com/billing/...``).
|
||||
|
||||
Direct-deploy (no proxy prefix) gets ``Path=/``.
|
||||
"""
|
||||
return prefix if prefix else "/"
|
||||
|
||||
|
||||
def _common_attrs(*, use_https: bool, prefix: str) -> dict:
|
||||
attrs: dict = {
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"path": _cookie_path(prefix),
|
||||
}
|
||||
if use_https:
|
||||
attrs["secure"] = True
|
||||
return attrs
|
||||
|
||||
|
||||
def set_session_cookies(
|
||||
response: Response,
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
access_token_expires_in: int,
|
||||
use_https: bool,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
"""Set the session cookies on the response.
|
||||
|
||||
``access_token_expires_in`` is in seconds. Use the provider's reported
|
||||
TTL for the access token.
|
||||
|
||||
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
|
||||
issues a 24h rotating refresh token (hermes #37247); a provider that
|
||||
omits it returns ``Session.refresh_token == ""`` and we simply don't
|
||||
persist the RT cookie — the session then behaves as access-token-only
|
||||
until the AT expires. No other branch changes between the two cases.
|
||||
|
||||
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
|
||||
or ``""`` for a direct deploy. It influences both the cookie name
|
||||
(``__Host-`` vs ``__Secure-`` vs bare) and the ``Path`` attribute.
|
||||
"""
|
||||
response.set_cookie(
|
||||
_resolved_name(SESSION_AT_COOKIE, use_https=use_https, prefix=prefix),
|
||||
access_token,
|
||||
max_age=access_token_expires_in,
|
||||
**_common_attrs(use_https=use_https, prefix=prefix),
|
||||
)
|
||||
# Contract v1: empty refresh token means "don't persist RT cookie".
|
||||
# Keeping a literal empty-value cookie around would be dead state at
|
||||
# best, attack surface at worst.
|
||||
if refresh_token:
|
||||
response.set_cookie(
|
||||
_resolved_name(SESSION_RT_COOKIE, use_https=use_https, prefix=prefix),
|
||||
refresh_token,
|
||||
max_age=_RT_MAX_AGE,
|
||||
**_common_attrs(use_https=use_https, prefix=prefix),
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
|
||||
"""Emit Max-Age=0 deletions for both session cookies.
|
||||
|
||||
To delete a cookie reliably the deletion's ``Path`` must match the
|
||||
set path AND the cookie name must match the variant the setter used.
|
||||
We don't know which variant was originally set (cookie prefix
|
||||
depends on the request that set it), so we emit deletions for every
|
||||
plausible variant under the active path.
|
||||
"""
|
||||
path = _cookie_path(prefix)
|
||||
for variant in _NAME_VARIANTS:
|
||||
response.set_cookie(
|
||||
f"{variant}{SESSION_AT_COOKIE}", "", max_age=0,
|
||||
path=path, httponly=True, samesite="lax",
|
||||
)
|
||||
response.set_cookie(
|
||||
f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
|
||||
path=path, httponly=True, samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def set_pkce_cookie(
|
||||
response: Response, *, payload: str, use_https: bool, prefix: str = "",
|
||||
) -> None:
|
||||
response.set_cookie(
|
||||
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
|
||||
payload,
|
||||
max_age=_PKCE_MAX_AGE,
|
||||
**_common_attrs(use_https=use_https, prefix=prefix),
|
||||
)
|
||||
|
||||
|
||||
def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None:
|
||||
path = _cookie_path(prefix)
|
||||
for variant in _NAME_VARIANTS:
|
||||
response.set_cookie(
|
||||
f"{variant}{PKCE_COOKIE}", "", max_age=0,
|
||||
path=path, httponly=True, samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _read_with_fallback(
|
||||
request: Request, bare_name: str,
|
||||
) -> Optional[str]:
|
||||
"""Read a cookie by checking every prefix variant in order.
|
||||
|
||||
The setter chooses one variant based on the active request shape;
|
||||
the reader doesn't know which one fired (the request that READS
|
||||
the cookie may not be the same shape as the request that SET it
|
||||
in pathological cases). Trying all three guarantees we find it.
|
||||
"""
|
||||
for variant in _NAME_VARIANTS:
|
||||
value = request.cookies.get(f"{variant}{bare_name}")
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Returns (access_token, refresh_token), either may be None."""
|
||||
at = _read_with_fallback(request, SESSION_AT_COOKIE)
|
||||
rt = _read_with_fallback(request, SESSION_RT_COOKIE)
|
||||
return at, rt
|
||||
|
||||
|
||||
def read_pkce_cookie(request: Request) -> Optional[str]:
|
||||
return _read_with_fallback(request, PKCE_COOKIE)
|
||||
|
||||
|
||||
def detect_https(request: Request) -> bool:
|
||||
"""Decide whether to set the ``Secure`` cookie flag.
|
||||
|
||||
Reads ``request.url.scheme`` — under uvicorn's ``proxy_headers=True``
|
||||
(which start_server enables when the gate is active), this honours
|
||||
``X-Forwarded-Proto`` from Fly's TLS terminator. Loopback traffic is
|
||||
always HTTP so this returns False there.
|
||||
"""
|
||||
return request.url.scheme == "https"
|
||||
534
hermes_cli/dashboard_auth/login_page.py
Normal file
534
hermes_cli/dashboard_auth/login_page.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
"""Server-rendered /login page.
|
||||
|
||||
No React, no JavaScript dependency. Listed providers come from the
|
||||
registry; clicking a provider sends a GET to
|
||||
``/auth/login?provider=<name>``.
|
||||
|
||||
Visual styling mirrors the Nous Research design system (the
|
||||
``@nous-research/ui`` package the React dashboard uses): the same
|
||||
``Collapse`` / ``Rules Compressed`` typeface, amber-on-dark colour
|
||||
tokens (``#170d02`` / ``#ffac02`` / ``#fff``), uppercase + wide-tracking
|
||||
brand chrome, and the inset-bevel button shadow. Fonts are served
|
||||
out of the SPA's ``/fonts/`` directory which the dashboard-auth gate
|
||||
already allowlists pre-auth (see ``_GATE_PUBLIC_PREFIXES`` in
|
||||
``middleware.py``), so the page renders without needing the React
|
||||
bundle loaded.
|
||||
|
||||
Test-stable class names: the existing test suite extracts the
|
||||
``class="provider-btn"`` anchor href to walk the OAuth flow. That
|
||||
class name MUST NOT change without updating
|
||||
``tests/hermes_cli/test_dashboard_auth_401_reauth.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from hermes_cli.dashboard_auth import list_providers
|
||||
|
||||
# Inline minimal CSS. The dashboard's full skin lives in the React
|
||||
# bundle, which we deliberately do NOT load here — the login page must
|
||||
# not depend on the SPA build being present or on the injected session
|
||||
# token.
|
||||
#
|
||||
# Single curly braces are placeholders for ``str.format``; CSS curlies
|
||||
# are doubled (``{{`` / ``}}``).
|
||||
_LOGIN_HTML_TEMPLATE = """\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in — Hermes Agent</title>
|
||||
<style>
|
||||
/* Brand fonts shipped by @nous-research/ui — same files the SPA loads. */
|
||||
@font-face {{
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Bold.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Regular.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
|
||||
}}
|
||||
|
||||
:root {{
|
||||
--background-base: #170d02;
|
||||
--background: #170d02;
|
||||
--midground: #ffac02;
|
||||
--foreground: #ffffff;
|
||||
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
|
||||
--hairline-strong: color-mix(in srgb, #ffac02 35%, transparent);
|
||||
}}
|
||||
|
||||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||||
|
||||
html, body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--background-base);
|
||||
color: var(--foreground);
|
||||
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}}
|
||||
|
||||
/* Subtle dot-grid backdrop — DS idiom (see `.dither` in globals.css). */
|
||||
body {{
|
||||
background-image:
|
||||
radial-gradient(
|
||||
ellipse at top,
|
||||
color-mix(in srgb, var(--midground) 6%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
repeating-conic-gradient(
|
||||
color-mix(in srgb, var(--midground) 4%, transparent) 0% 25%,
|
||||
transparent 0% 50%
|
||||
);
|
||||
background-size: auto, 3px 3px;
|
||||
background-attachment: fixed;
|
||||
}}
|
||||
|
||||
/* Layout: vertically center on tall screens, top-anchor on short. */
|
||||
body {{
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
|
||||
}}
|
||||
|
||||
main {{
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
position: relative;
|
||||
animation: slide-up 0.6s ease-out both;
|
||||
}}
|
||||
|
||||
@keyframes slide-up {{
|
||||
from {{ opacity: 0; transform: translateY(6px); }}
|
||||
to {{ opacity: 1; transform: translateY(0); }}
|
||||
}}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
main {{ animation: none; }}
|
||||
}}
|
||||
|
||||
/* Brand wordmark above the card — same uppercase + wide-tracking
|
||||
idiom DS Buttons use. */
|
||||
.brand {{
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--midground);
|
||||
}}
|
||||
.brand .dot {{
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--midground);
|
||||
margin: 0 0.55em 0.18em;
|
||||
vertical-align: middle;
|
||||
border-radius: 1px;
|
||||
}}
|
||||
|
||||
.card {{
|
||||
position: relative;
|
||||
padding: 2.25rem 2rem 2rem;
|
||||
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
|
||||
border: 1px solid var(--hairline);
|
||||
/* Hairline highlight + bevel shadow — matches DS Button SHADOW_DEFAULT
|
||||
(`inset -1px -1px 0 #00000080, inset 1px 1px 0 #ffffff80`) at panel scale. */
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.6);
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
margin: 0 0 0.4rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
}}
|
||||
|
||||
.subtitle {{
|
||||
margin: 0 0 1.75rem;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
font-size: 0.95rem;
|
||||
}}
|
||||
|
||||
.provider-list {{
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}}
|
||||
|
||||
/* Provider button — mirrors DS Button (default variant):
|
||||
amber surface, dark text, uppercase + wide tracking, inset bevel. */
|
||||
.provider-btn {{
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.95rem 1rem;
|
||||
text-align: center;
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
font-family: 'Collapse', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
border-radius: 0; /* DS Button is squared — no rounded corners. */
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.5);
|
||||
transition: filter 0.12s ease-out;
|
||||
}}
|
||||
.provider-btn:hover {{
|
||||
filter: brightness(1.08);
|
||||
}}
|
||||
.provider-btn:active {{
|
||||
/* DS Button uses `active:invert` on the default surface. */
|
||||
filter: invert(1);
|
||||
}}
|
||||
.provider-btn:focus-visible {{
|
||||
outline: 2px solid var(--midground);
|
||||
outline-offset: 3px;
|
||||
}}
|
||||
|
||||
/* Password provider form — same visual language as the OAuth buttons:
|
||||
squared inputs, hairline borders, amber focus ring. */
|
||||
.provider-form {{
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
}}
|
||||
.form-title {{
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}}
|
||||
.field {{
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}}
|
||||
.field-label {{
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}}
|
||||
.field-input {{
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: color-mix(in srgb, #000000 25%, var(--background-base));
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 0;
|
||||
font-family: 'Collapse', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
}}
|
||||
.field-input:focus-visible {{
|
||||
outline: none;
|
||||
border-color: var(--midground);
|
||||
box-shadow: 0 0 0 1px var(--midground);
|
||||
}}
|
||||
.form-error {{
|
||||
color: #ff6b6b;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.02em;
|
||||
}}
|
||||
.provider-form .provider-btn {{
|
||||
margin-top: 0.25rem;
|
||||
}}
|
||||
|
||||
footer {{
|
||||
margin-top: 1.75rem;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.7;
|
||||
}}
|
||||
footer .sep {{
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
background: var(--hairline-strong);
|
||||
vertical-align: middle;
|
||||
margin: 0 0.6em 0.2em;
|
||||
}}
|
||||
|
||||
/* Selection — DS uses midground bg + background text. */
|
||||
::selection {{
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand">Nous<span class="dot"></span>Research</div>
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<p class="subtitle">Choose a sign-in method to continue to the Hermes Agent dashboard.</p>
|
||||
<div class="provider-list">
|
||||
{provider_buttons}
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<span class="sep"></span>Public bind · Auth required<span class="sep"></span>
|
||||
</footer>
|
||||
</main>
|
||||
{password_script}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
_EMPTY_HTML = """\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign-in unavailable — Hermes Agent</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
|
||||
}
|
||||
:root {
|
||||
--background-base: #170d02;
|
||||
--midground: #ffac02;
|
||||
--foreground: #ffffff;
|
||||
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; min-height: 100%;
|
||||
background: var(--background-base);
|
||||
color: var(--foreground);
|
||||
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px; line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body {
|
||||
display: grid; place-items: center;
|
||||
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
|
||||
}
|
||||
main {
|
||||
width: 100%; max-width: 32rem;
|
||||
padding: 2.25rem 2rem;
|
||||
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600; font-size: 1.5rem;
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
color: var(--midground);
|
||||
}
|
||||
p { margin: 0 0 1rem; }
|
||||
code {
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Sign-in unavailable</h1>
|
||||
<p>This dashboard is bound to a non-loopback host but no authentication
|
||||
providers are installed.</p>
|
||||
<p>Install <code>plugins/dashboard-auth-nous</code> (default) or another
|
||||
auth provider, or restart with <code>--insecure</code> to bypass the
|
||||
auth gate (not recommended on untrusted networks).</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# Inline script that wires every password provider form to POST JSON to
|
||||
# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
|
||||
# least one ``supports_password`` provider is listed (OAuth-only login
|
||||
# pages stay script-free, preserving the no-JS contract for that case).
|
||||
#
|
||||
# Plain string (NOT run through ``str.format``), so braces are literal —
|
||||
# do not double them. A single delegated submit handler covers all forms;
|
||||
# the provider name is read from the form's ``data-provider`` attribute.
|
||||
_PASSWORD_FORM_SCRIPT = """\
|
||||
<script>
|
||||
(function () {
|
||||
function handle(form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
var err = form.querySelector('.form-error');
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
if (err) { err.hidden = true; err.textContent = ''; }
|
||||
if (btn) { btn.disabled = true; }
|
||||
var body = {
|
||||
provider: form.getAttribute('data-provider') || '',
|
||||
username: (form.querySelector('input[name=username]') || {}).value || '',
|
||||
password: (form.querySelector('input[name=password]') || {}).value || '',
|
||||
next: (form.querySelector('input[name=next]') || {}).value || ''
|
||||
};
|
||||
fetch('/auth/password-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (resp) {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(function (data) {
|
||||
window.location.assign((data && data.next) || '/');
|
||||
});
|
||||
}
|
||||
var msg = resp.status === 429
|
||||
? 'Too many attempts. Please wait and try again.'
|
||||
: (resp.status === 401 ? 'Invalid username or password.'
|
||||
: 'Sign-in failed. Please try again.');
|
||||
if (err) { err.textContent = msg; err.hidden = false; }
|
||||
if (btn) { btn.disabled = false; }
|
||||
}).catch(function () {
|
||||
if (err) { err.textContent = 'Network error. Please try again.'; err.hidden = false; }
|
||||
if (btn) { btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
var forms = document.querySelectorAll('form.provider-form');
|
||||
for (var i = 0; i < forms.length; i++) { handle(forms[i]); }
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def render_login_html(*, next_path: str = "") -> str:
|
||||
"""Return the full HTML for ``GET /login``.
|
||||
|
||||
``next_path`` — when set, the post-login landing path the user
|
||||
originally requested. Threaded into each provider button's ``href``
|
||||
as a ``next=`` query parameter so the OAuth round trip carries it
|
||||
end-to-end. The caller (``routes.login_page``) is responsible for
|
||||
validating ``next_path`` against the same-origin rules before we
|
||||
emit it; we still HTML-escape it as defence in depth.
|
||||
"""
|
||||
providers = list_providers()
|
||||
if not providers:
|
||||
return _EMPTY_HTML
|
||||
|
||||
if next_path:
|
||||
# URL-encode then HTML-escape. The URL-encode step matches the
|
||||
# gate's ``_safe_next_target`` output shape (also URL-encoded),
|
||||
# so a value that round-tripped from /login?next=... back into
|
||||
# the button href is byte-identical.
|
||||
from urllib.parse import quote
|
||||
next_qs = f"&next={html.escape(quote(next_path, safe=''), quote=True)}"
|
||||
else:
|
||||
next_qs = ""
|
||||
|
||||
buttons = []
|
||||
needs_password_script = False
|
||||
for p in providers:
|
||||
if getattr(p, "supports_password", False):
|
||||
needs_password_script = True
|
||||
buttons.append(_render_password_form(p, next_path))
|
||||
else:
|
||||
buttons.append(
|
||||
f' <a class="provider-btn" '
|
||||
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
|
||||
f'Sign in with {html.escape(p.display_name)}</a>'
|
||||
)
|
||||
script = _PASSWORD_FORM_SCRIPT if needs_password_script else ""
|
||||
return _LOGIN_HTML_TEMPLATE.format(
|
||||
provider_buttons="\n".join(buttons),
|
||||
password_script=script,
|
||||
)
|
||||
|
||||
|
||||
def _render_password_form(provider, next_path: str) -> str:
|
||||
"""Render a username/password form for a ``supports_password`` provider.
|
||||
|
||||
The form is wired by :data:`_PASSWORD_FORM_SCRIPT` (a single delegated
|
||||
submit handler) to POST JSON to ``/auth/password-login`` and navigate
|
||||
on success. ``next_path`` is carried in a hidden field; it has already
|
||||
been validated same-origin by the caller and is HTML-escaped here as
|
||||
defence in depth. The provider ``name`` is emitted in a ``data-``
|
||||
attribute (not a hidden input) so the script reads it without trusting
|
||||
form-field ordering.
|
||||
"""
|
||||
pname = html.escape(provider.name, quote=True)
|
||||
plabel = html.escape(provider.display_name)
|
||||
safe_next = html.escape(next_path, quote=True) if next_path else ""
|
||||
return (
|
||||
f' <form class="provider-form" data-provider="{pname}" '
|
||||
f'autocomplete="on">\n'
|
||||
f' <div class="form-title">Sign in with {plabel}</div>\n'
|
||||
f' <input type="hidden" name="next" value="{safe_next}">\n'
|
||||
f' <label class="field">\n'
|
||||
f' <span class="field-label">Username</span>\n'
|
||||
f' <input class="field-input" type="text" name="username" '
|
||||
f'autocomplete="username" autocapitalize="none" '
|
||||
f'autocorrect="off" spellcheck="false" required>\n'
|
||||
f' </label>\n'
|
||||
f' <label class="field">\n'
|
||||
f' <span class="field-label">Password</span>\n'
|
||||
f' <input class="field-input" type="password" name="password" '
|
||||
f'autocomplete="current-password" required>\n'
|
||||
f' </label>\n'
|
||||
f' <div class="form-error" role="alert" hidden></div>\n'
|
||||
f' <button class="provider-btn" type="submit">Sign in</button>\n'
|
||||
f' </form>'
|
||||
)
|
||||
368
hermes_cli/dashboard_auth/middleware.py
Normal file
368
hermes_cli/dashboard_auth/middleware.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"""Auth-gate middleware for the dashboard.
|
||||
|
||||
Engaged when ``app.state.auth_required is True``. The gate's job:
|
||||
|
||||
1. Allow a small set of routes through unauthenticated (login page,
|
||||
``/auth/*`` OAuth round trip, ``/api/auth/providers``, static
|
||||
assets).
|
||||
2. For everything else, demand a valid session cookie and attach the
|
||||
verified :class:`Session` to ``request.state.session``.
|
||||
3. On HTML routes, redirect missing/invalid cookies to ``/login``.
|
||||
On ``/api/*`` routes, return 401 JSON.
|
||||
|
||||
The middleware is a no-op when ``auth_required`` is False (loopback
|
||||
mode); the legacy ``_SESSION_TOKEN`` ``auth_middleware`` handles those
|
||||
binds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
|
||||
from hermes_cli.dashboard_auth import list_providers
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
|
||||
from hermes_cli.dashboard_auth.cookies import read_session_cookies
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Prefixes that bypass the auth gate. Match via ``path == prefix`` or
|
||||
# ``path.startswith(prefix)`` — so ``/assets/`` (with trailing slash)
|
||||
# matches ``/assets/foo.css`` but not ``/assetsleak``. Auth-bootstrap
|
||||
# (login page, OAuth round trip, provider listing) and static asset
|
||||
# mounts go here.
|
||||
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/auth/login",
|
||||
"/auth/callback",
|
||||
"/auth/password-login",
|
||||
"/auth/logout",
|
||||
"/login",
|
||||
"/api/auth/providers",
|
||||
"/assets/",
|
||||
"/favicon.ico",
|
||||
"/ds-assets/",
|
||||
"/fonts/",
|
||||
"/fonts-terminal/",
|
||||
)
|
||||
|
||||
|
||||
def _path_is_public(path: str) -> bool:
|
||||
"""True if ``path`` bypasses the OAuth auth gate.
|
||||
|
||||
Two sources of public-ness:
|
||||
|
||||
* :data:`PUBLIC_API_PATHS` — the shared ``/api/*`` allowlist that
|
||||
the legacy ``_SESSION_TOKEN`` middleware also honours. Matched
|
||||
exactly (no prefix expansion) so adding ``/api/status`` doesn't
|
||||
accidentally expose ``/api/status/secret-extension``.
|
||||
* :data:`_GATE_PUBLIC_PREFIXES` — auth-bootstrap routes and static
|
||||
mounts. Prefix-matched so ``/assets/foo.css`` lights up via
|
||||
``/assets/``.
|
||||
"""
|
||||
if path in PUBLIC_API_PATHS:
|
||||
return True
|
||||
return any(
|
||||
path == prefix or path.startswith(prefix)
|
||||
for prefix in _GATE_PUBLIC_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def _unauth_response(request: Request, *, reason: str) -> Response:
|
||||
"""API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login.
|
||||
|
||||
The JSON envelope carries a ``login_url`` field with a ``next=`` query
|
||||
string so the SPA's global 401 handler can drop the user back where
|
||||
they were after re-auth. The contract is intentionally simple so any
|
||||
fetch-wrapper can implement the redirect without parsing details:
|
||||
|
||||
if response.status === 401 && body.error in ("unauthenticated",
|
||||
"session_expired"):
|
||||
window.location.assign(body.login_url);
|
||||
|
||||
HTML redirects also carry the ``next=`` query string so direct
|
||||
navigation to ``/sessions`` (etc.) without a cookie comes back to
|
||||
``/sessions`` after login.
|
||||
|
||||
Under a reverse proxy with ``X-Forwarded-Prefix: /hermes``, the
|
||||
``login_url`` is prefixed (``/hermes/login?next=...``) so the
|
||||
browser's window.location.assign / Location: follow lands on the
|
||||
proxied login page rather than the bare ``/login`` (which the
|
||||
proxy doesn't route to the dashboard).
|
||||
"""
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
|
||||
path = request.url.path
|
||||
next_param = _safe_next_target(request)
|
||||
prefix = prefix_from_request(request)
|
||||
login_url = (
|
||||
f"{prefix}/login?next={next_param}" if next_param
|
||||
else f"{prefix}/login"
|
||||
)
|
||||
|
||||
if path.startswith("/api/"):
|
||||
# API routes never get redirects: the browser fetch() API would
|
||||
# follow a 302 into the cross-origin OAuth dance opaquely. Return
|
||||
# 401 with a structured envelope so the SPA can full-page-navigate
|
||||
# to login_url.
|
||||
error_code = (
|
||||
"session_expired"
|
||||
if reason == "invalid_or_expired_session"
|
||||
else "unauthenticated"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": error_code,
|
||||
"detail": "Unauthorized",
|
||||
"reason": reason,
|
||||
"login_url": login_url,
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
|
||||
|
||||
def _safe_next_target(request: Request) -> str:
|
||||
"""Build the URL-encoded ``next`` query value, or empty string.
|
||||
|
||||
Only same-origin relative paths are accepted; absolute URLs or
|
||||
``//evil.com`` open-redirect attempts are silently dropped. The empty
|
||||
string return means the caller produces a bare ``/login`` URL — fine,
|
||||
user lands at the dashboard root after re-auth.
|
||||
"""
|
||||
path = request.url.path
|
||||
# Reject anything that doesn't start with "/" or starts with "//"
|
||||
# (protocol-relative URL — would open-redirect to an attacker host).
|
||||
if not path or not path.startswith("/") or path.startswith("//"):
|
||||
return ""
|
||||
# Don't redirect back to the auth routes themselves — that loops.
|
||||
if any(
|
||||
path == p or path.startswith(p)
|
||||
for p in ("/login", "/auth/", "/api/auth/")
|
||||
):
|
||||
return ""
|
||||
# Reject ALL ``/api/*`` paths. The 401-envelope code path fires for
|
||||
# any unauthenticated SPA fetch (e.g. ``GET /api/analytics/models``
|
||||
# from ModelsPage), and the SPA's global 401 handler full-page
|
||||
# navigates to ``login_url``. After the OAuth round trip the user
|
||||
# would land on the API URL and see raw JSON instead of the
|
||||
# dashboard. SPA routes survive (they don't start with ``/api/``);
|
||||
# the SPA's own ``sessionStorage["hermes.lastLocation"]`` fallback
|
||||
# in ``web/src/lib/api.ts`` covers the deep-link case.
|
||||
if path == "/api" or path.startswith("/api/"):
|
||||
return ""
|
||||
# Preserve query string if present (e.g. /sessions?page=2).
|
||||
query = request.url.query
|
||||
target = f"{path}?{query}" if query else path
|
||||
# urlencode the whole thing as a single value.
|
||||
from urllib.parse import quote
|
||||
return quote(target, safe="")
|
||||
|
||||
|
||||
async def gated_auth_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""Engaged only when ``app.state.auth_required is True``.
|
||||
|
||||
No-op pass-through in loopback mode so the legacy auth_middleware can
|
||||
handle those binds via ``_SESSION_TOKEN``.
|
||||
"""
|
||||
if not getattr(request.app.state, "auth_required", False):
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if _path_is_public(path):
|
||||
return await call_next(request)
|
||||
|
||||
at, _rt = read_session_cookies(request)
|
||||
if not at and not _rt:
|
||||
# Neither token present — no session at all. Nothing to verify or
|
||||
# refresh; force login.
|
||||
return _unauth_response(request, reason="no_cookie")
|
||||
|
||||
# Try every registered provider's verify_session in turn. Providers
|
||||
# MUST return None for tokens they don't recognise (not raise). This
|
||||
# lets multiple providers stack — the first one that recognises a
|
||||
# token wins.
|
||||
#
|
||||
# When the access-token cookie is absent but a refresh-token cookie is
|
||||
# present, skip verification and go straight to the refresh path below.
|
||||
# This is the COMMON expiry case, not an edge case: the access-token
|
||||
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
|
||||
# the browser EVICTS it the moment the token lapses, while the
|
||||
# refresh-token cookie lives for 30 days. From that point the browser
|
||||
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
|
||||
# bounce the user to /login on every expiry despite holding a perfectly
|
||||
# good refresh token — defeating the whole transparent-refresh feature.
|
||||
session = None
|
||||
if at:
|
||||
# Try every registered provider's verify_session in turn. A provider
|
||||
# that doesn't recognise the token returns None and we move on; the
|
||||
# first provider that returns a Session wins.
|
||||
#
|
||||
# A provider may instead raise ProviderError (its IDP/JWKS is
|
||||
# unreachable, so it can neither confirm nor deny the token). With
|
||||
# multiple providers stacked, that MUST NOT abort the chain — the
|
||||
# token may belong to a *different*, reachable provider. (Concretely:
|
||||
# a self-hosted-OIDC session hits the `nous` provider first, which
|
||||
# tries to reach Nous Portal's JWKS; if that's unreachable it raises,
|
||||
# but the `self-hosted` provider can still verify the token.) So we
|
||||
# remember the unreachable error and keep going. Only if NO provider
|
||||
# verifies the token AND at least one was unreachable do we surface a
|
||||
# 503 — distinguishing "transient IDP outage" (don't force re-login)
|
||||
# from "token genuinely invalid" (fall through to refresh/relogin).
|
||||
unreachable_provider: str | None = None
|
||||
for provider in list_providers():
|
||||
try:
|
||||
session = provider.verify_session(access_token=at)
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
if unreachable_provider is None:
|
||||
unreachable_provider = provider.name
|
||||
continue
|
||||
if session is not None:
|
||||
break
|
||||
if session is None and unreachable_provider is not None:
|
||||
# No provider could verify the token and at least one couldn't be
|
||||
# reached — treat as a transient outage rather than forcing a
|
||||
# re-login through a (possibly also-unreachable) refresh.
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {unreachable_provider!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
if session is None:
|
||||
# Access token is expired/invalid. Before forcing re-login, try to
|
||||
# rotate it using the refresh token (if the session cookie carries
|
||||
# one). On success we re-set the rotated cookies on the response and
|
||||
# serve the request transparently; on RefreshExpiredError (RT dead /
|
||||
# revoked / reuse-detected) we fall through to clear-and-relogin.
|
||||
refreshed = _attempt_refresh(request, refresh_token=_rt)
|
||||
if refreshed is not None:
|
||||
new_session, refreshing_provider = refreshed
|
||||
request.state.session = new_session
|
||||
response = await call_next(request)
|
||||
# Persist the ROTATED tokens. Portal rotates the refresh token on
|
||||
# every refresh and runs reuse-detection, so writing the new RT
|
||||
# back is mandatory: a stale RT cookie would replay a rotated
|
||||
# token on the next refresh and (outside Portal's grace) revoke
|
||||
# the whole session. Bind cookie Secure/Path to the request shape.
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
detect_https,
|
||||
set_session_cookies,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
|
||||
set_session_cookies(
|
||||
response,
|
||||
access_token=new_session.access_token,
|
||||
refresh_token=new_session.refresh_token,
|
||||
access_token_expires_in=_expires_in_seconds(new_session),
|
||||
use_https=detect_https(request),
|
||||
prefix=prefix_from_request(request),
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_SUCCESS,
|
||||
provider=refreshing_provider,
|
||||
user_id=new_session.user_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return response
|
||||
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
reason="no_provider_recognises",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
response = _unauth_response(request, reason="invalid_or_expired_session")
|
||||
# Clear the dead cookies so the browser doesn't keep sending them.
|
||||
# Refresh already failed (or there was no RT), so the only correct
|
||||
# next step is full re-auth via /login. Importing locally avoids a
|
||||
# cycle with cookies → middleware at module load. Pass the active
|
||||
# prefix so the deletion's Path matches the set-Path (otherwise
|
||||
# the browser ignores it).
|
||||
from hermes_cli.dashboard_auth.cookies import clear_session_cookies
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
clear_session_cookies(response, prefix=prefix_from_request(request))
|
||||
return response
|
||||
|
||||
request.state.session = session
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _expires_in_seconds(session) -> int:
|
||||
"""Seconds until the access token's ``exp``, floored at 60.
|
||||
|
||||
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
|
||||
cookie's Max-Age tracks the token lifetime even on a slightly skewed
|
||||
clock. ``time`` imported locally to keep the module's import surface
|
||||
minimal.
|
||||
"""
|
||||
import time
|
||||
|
||||
return max(60, int(session.expires_at) - int(time.time()))
|
||||
|
||||
|
||||
def _attempt_refresh(request: Request, *, refresh_token):
|
||||
"""Try to rotate an expired session via the refresh token.
|
||||
|
||||
Returns ``(new_session, provider_name)`` on success, or ``None`` if
|
||||
there's no RT or every provider's ``refresh_session`` failed with
|
||||
``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login).
|
||||
|
||||
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
|
||||
here — re-raising would 500 the request; instead we log and return None so
|
||||
the caller forces a clean re-login, which is the safer UX than a hard
|
||||
error on a transient network blip during the narrow refresh window.
|
||||
"""
|
||||
if not refresh_token:
|
||||
return None
|
||||
for provider in list_providers():
|
||||
try:
|
||||
new_session = provider.refresh_session(refresh_token=refresh_token)
|
||||
except RefreshExpiredError:
|
||||
# This provider owns the RT but it's dead — stop trying others
|
||||
# (an RT belongs to exactly one provider) and force re-login.
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="refresh_expired",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during refresh: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
if new_session is not None:
|
||||
return new_session, provider.name
|
||||
return None
|
||||
|
||||
201
hermes_cli/dashboard_auth/prefix.py
Normal file
201
hermes_cli/dashboard_auth/prefix.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Helpers for X-Forwarded-Prefix support.
|
||||
|
||||
Mission-control style deploys reverse-proxy the dashboard at a path
|
||||
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> dashboard on
|
||||
:9119), injecting ``X-Forwarded-Prefix: /hermes`` so the backend can
|
||||
reconstruct prefixed URLs (Location: headers, OAuth redirect_uri,
|
||||
cookie Path attributes, SPA asset URLs).
|
||||
|
||||
This module is also the home of the ``HERMES_DASHBOARD_PUBLIC_URL`` /
|
||||
``dashboard.public_url`` resolution — when the operator declares a
|
||||
complete public URL (scheme + host + optional path prefix), we use
|
||||
that directly for the OAuth ``redirect_uri`` and skip the
|
||||
X-Forwarded-Prefix reconstruction. Relief valve for deploys where the
|
||||
proxy header chain isn't reliable.
|
||||
|
||||
The single source of truth for both helpers lives here so the gate
|
||||
middleware, the OAuth routes, the cookie helpers, and the SPA mount
|
||||
all agree on validation rules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Characters that, if present in a public_url or prefix value, indicate
|
||||
# either a typo or a header-injection attempt. Reject the whole value
|
||||
# rather than try to sanitise — the operator can fix their config.
|
||||
_REJECT_CHARS = frozenset(('"', "'", "<", ">", " ", "\n", "\r", "\t"))
|
||||
|
||||
# Remember which (source, value) pairs we've already warned about.
|
||||
# ``resolve_public_url`` runs on every authenticated request, so an
|
||||
# un-deduplicated warning would flood the logs once per request for a
|
||||
# misconfigured deploy. Keyed on the raw value too, so changing the
|
||||
# config and reloading surfaces a fresh warning.
|
||||
_warned_malformed_public_urls: set = set()
|
||||
|
||||
|
||||
def _warn_if_malformed(source: str, raw: str) -> None:
|
||||
"""Warn (once per distinct value) when a non-empty public-url value
|
||||
was rejected by :func:`_normalise_public_url`.
|
||||
|
||||
A non-empty value that normalises to ``""`` is almost always a
|
||||
missing scheme (``hermes.example.com`` instead of
|
||||
``https://hermes.example.com``) — the single most common cause of
|
||||
"I set HERMES_DASHBOARD_PUBLIC_URL but the OAuth callback is still
|
||||
http://". Without this warning the value is silently discarded and
|
||||
the dashboard falls back to reconstructing the redirect URI from
|
||||
request headers, which behind a reverse proxy can yield the wrong
|
||||
scheme. Surfacing it turns a silent footgun into a self-diagnosing
|
||||
one.
|
||||
"""
|
||||
cleaned = raw.strip() if raw else ""
|
||||
if not cleaned:
|
||||
return # empty/unset is a legitimate "no override" — not malformed
|
||||
key = (source, cleaned)
|
||||
if key in _warned_malformed_public_urls:
|
||||
return
|
||||
_warned_malformed_public_urls.add(key)
|
||||
_log.warning(
|
||||
"%s is set to %r but was ignored because it is not a valid "
|
||||
"absolute URL — it must include an http:// or https:// scheme "
|
||||
"(e.g. https://%s). Falling back to reconstructing the OAuth "
|
||||
"redirect URI from request headers, which may produce the wrong "
|
||||
"scheme behind a reverse proxy.",
|
||||
source,
|
||||
cleaned,
|
||||
cleaned.split("://")[-1] or "hermes.example.com",
|
||||
)
|
||||
|
||||
|
||||
def normalise_prefix(raw: Optional[str]) -> str:
|
||||
"""Normalise an X-Forwarded-Prefix header value.
|
||||
|
||||
Returns a string like ``"/hermes"`` (no trailing slash) or ``""``
|
||||
when no prefix is set / the header is malformed. We deliberately
|
||||
reject anything containing ``..`` or non-printable bytes so a
|
||||
hostile proxy can't inject HTML or path-traversal sequences via the
|
||||
prefix.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
p = p.rstrip("/")
|
||||
if (
|
||||
"//" in p
|
||||
or ".." in p
|
||||
or any(c in p for c in _REJECT_CHARS)
|
||||
):
|
||||
return ""
|
||||
if len(p) > 64:
|
||||
return ""
|
||||
return p
|
||||
|
||||
|
||||
def prefix_from_request(request) -> str:
|
||||
"""Convenience wrapper that reads the header off a Starlette/FastAPI
|
||||
Request and normalises it. Returns ``""`` when no prefix.
|
||||
"""
|
||||
return normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalise_public_url(raw: Optional[str]) -> str:
|
||||
"""Normalise a ``dashboard.public_url`` value.
|
||||
|
||||
Returns the cleaned URL (scheme://netloc[/path], trailing slash
|
||||
removed) on success, or ``""`` when the value is empty, malformed,
|
||||
or contains characters that suggest header injection. The caller
|
||||
must treat ``""`` as "fall back to request reconstruction" — never
|
||||
as "the user explicitly chose no public URL", because the two are
|
||||
indistinguishable from an empty env var.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
url = raw.strip()
|
||||
if not url:
|
||||
return ""
|
||||
# Reject control / quote / whitespace characters before trying to
|
||||
# parse — urlparse is permissive enough to accept some hostile
|
||||
# values (e.g. embedded newlines) and we want a hard "no" rather
|
||||
# than a soft "maybe".
|
||||
if any(c in url for c in _REJECT_CHARS):
|
||||
return ""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except ValueError:
|
||||
return ""
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return ""
|
||||
if not parsed.netloc:
|
||||
return ""
|
||||
# Strip a single trailing slash so callers can append paths without
|
||||
# producing ``//`` double-slashes.
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def _load_dashboard_section() -> dict:
|
||||
"""Return the ``dashboard`` block from ``config.yaml`` if it exists
|
||||
and is a dict; otherwise an empty dict.
|
||||
|
||||
Robust to (a) load_config() raising (malformed YAML, IO error,
|
||||
config.yaml absent), and (b) ``dashboard`` being absent or non-dict.
|
||||
Both shapes fall through to ``{}`` so the caller can rely on
|
||||
``.get(...)`` access.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
_log.debug(
|
||||
"dashboard-auth.prefix: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg.get("dashboard") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def resolve_public_url() -> str:
|
||||
"""Resolve the operator-declared dashboard public URL.
|
||||
|
||||
Precedence (mirrors ``dashboard.oauth.client_id``):
|
||||
|
||||
1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var (when non-empty after
|
||||
strip — empty values are treated as unset so a provisioned-but-
|
||||
not-populated Fly secret can't shadow a valid config.yaml entry).
|
||||
2. ``dashboard.public_url`` in ``config.yaml``.
|
||||
3. Empty string — signals "no override, reconstruct from request"
|
||||
to the caller.
|
||||
|
||||
Each candidate value is run through :func:`_normalise_public_url`.
|
||||
A malformed env var falls through to the config.yaml entry; a
|
||||
malformed config entry falls through to ``""``. This means a typo
|
||||
in one surface doesn't prevent the other from working.
|
||||
"""
|
||||
env_raw = os.environ.get("HERMES_DASHBOARD_PUBLIC_URL", "")
|
||||
env_clean = _normalise_public_url(env_raw)
|
||||
if env_clean:
|
||||
return env_clean
|
||||
_warn_if_malformed("HERMES_DASHBOARD_PUBLIC_URL env var", env_raw)
|
||||
cfg_raw = str(_load_dashboard_section().get("public_url", ""))
|
||||
cfg_clean = _normalise_public_url(cfg_raw)
|
||||
if not cfg_clean:
|
||||
_warn_if_malformed("dashboard.public_url in config.yaml", cfg_raw)
|
||||
return cfg_clean
|
||||
49
hermes_cli/dashboard_auth/public_paths.py
Normal file
49
hermes_cli/dashboard_auth/public_paths.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Shared allowlist of ``/api/*`` paths that bypass dashboard auth.
|
||||
|
||||
Two middlewares enforce dashboard auth and previously kept independent
|
||||
copies of this list:
|
||||
|
||||
* ``hermes_cli.web_server.auth_middleware`` — loopback / ``--insecure``
|
||||
mode, gates on the ephemeral ``_SESSION_TOKEN``.
|
||||
* ``hermes_cli.dashboard_auth.middleware.gated_auth_middleware`` —
|
||||
non-loopback mode, gates on the OAuth session cookie.
|
||||
|
||||
When the lists drifted, ``/api/status`` ended up public under the legacy
|
||||
gate but 401'd under the OAuth gate. That broke the portal's wildcard
|
||||
liveness probe (``nous-account-service`` ``fly-provider.ts``
|
||||
``getInstanceRuntimeStatus``), which fetches ``/api/status`` without a
|
||||
cookie as its sole signal of "agent dashboard is alive": every healthy
|
||||
wildcard-subdomain agent surfaced as STARTING/down in the portal UI even
|
||||
though the dashboard was serving correctly.
|
||||
|
||||
Centralising the allowlist here so both middlewares import the same
|
||||
frozenset prevents the next drift. Keep this list minimal — only truly
|
||||
non-sensitive, read-only endpoints belong here. As a sanity check, every
|
||||
entry should be safe to expose to:
|
||||
|
||||
* external uptime probes (Pingdom, Better Stack, NAS),
|
||||
* the dashboard SPA before the user has logged in,
|
||||
* anyone who happens to ``curl`` the hostname.
|
||||
|
||||
If a new endpoint doesn't pass all three tests, it should be gated and
|
||||
the SPA should bootstrap it after login instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_API_PATHS: frozenset[str] = frozenset({
|
||||
# Liveness probe target. Returns version, gateway state, active
|
||||
# session count, and the dashboard auth-gate shape. No bodies, no
|
||||
# session content, no secrets. Documented as the portal's wildcard
|
||||
# liveness probe in
|
||||
# ``docs/agent-dashboard-public-url-contract.md`` (NAS side).
|
||||
"/api/status",
|
||||
# Read-only config-defaults / schema feeds for the SPA's Config page.
|
||||
"/api/config/defaults",
|
||||
"/api/config/schema",
|
||||
# Read-only model metadata (context windows, etc.) — same shape as
|
||||
# provider catalogs already exposed on the public internet.
|
||||
"/api/model/info",
|
||||
# Read-only theme + plugin manifests for the dashboard skin engine.
|
||||
"/api/dashboard/themes",
|
||||
"/api/dashboard/plugins",
|
||||
})
|
||||
58
hermes_cli/dashboard_auth/registry.py
Normal file
58
hermes_cli/dashboard_auth/registry.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Module-level registry for DashboardAuthProvider instances.
|
||||
|
||||
Plugins call ``register_provider`` via the plugin context hook at startup.
|
||||
The auth gate middleware iterates ``list_providers()`` and uses
|
||||
``get_provider`` to dispatch on the session's ``provider`` field.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
from hermes_cli.dashboard_auth.base import (
|
||||
DashboardAuthProvider,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
_lock = threading.Lock()
|
||||
_providers: dict[str, DashboardAuthProvider] = {}
|
||||
|
||||
|
||||
def register_provider(provider: DashboardAuthProvider) -> None:
|
||||
"""Register a provider.
|
||||
|
||||
Raises:
|
||||
TypeError: on protocol violation.
|
||||
ValueError: if a provider with the same name is already registered.
|
||||
"""
|
||||
assert_protocol_compliance(type(provider))
|
||||
with _lock:
|
||||
if provider.name in _providers:
|
||||
raise ValueError(
|
||||
f"dashboard-auth provider already registered: {provider.name!r}"
|
||||
)
|
||||
_providers[provider.name] = provider
|
||||
_log.info(
|
||||
"dashboard-auth: registered provider %r (%s)",
|
||||
provider.name, provider.display_name,
|
||||
)
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[DashboardAuthProvider]:
|
||||
"""Return the registered provider for ``name``, or None if unknown."""
|
||||
with _lock:
|
||||
return _providers.get(name)
|
||||
|
||||
|
||||
def list_providers() -> List[DashboardAuthProvider]:
|
||||
"""All registered providers, in registration order."""
|
||||
with _lock:
|
||||
return list(_providers.values())
|
||||
|
||||
|
||||
def clear_providers() -> None:
|
||||
"""Test-only: drop all registrations."""
|
||||
with _lock:
|
||||
_providers.clear()
|
||||
621
hermes_cli/dashboard_auth/routes.py
Normal file
621
hermes_cli/dashboard_auth/routes.py
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
"""HTTP routes for the dashboard-auth OAuth round trip.
|
||||
|
||||
Mounted at root (no prefix) by ``web_server.py``. The router does not
|
||||
auto-gate; gating is performed by ``gated_auth_middleware``, which
|
||||
allowlists everything under ``/auth/*`` and ``/api/auth/providers``.
|
||||
|
||||
The routes:
|
||||
|
||||
GET /login → server-rendered login page
|
||||
GET /auth/login?provider=N → 302 to IDP, sets PKCE cookie
|
||||
GET /auth/callback?code,state → completes login, sets session cookies
|
||||
POST /auth/logout → clears cookies, best-effort revoke
|
||||
GET /api/auth/providers → list registered providers (login bootstrap)
|
||||
GET /api/auth/me → current Session as JSON (auth-required)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Deque, Dict, Tuple
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
get_provider,
|
||||
list_providers,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import (
|
||||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
clear_pkce_cookie,
|
||||
clear_session_cookies,
|
||||
detect_https,
|
||||
read_pkce_cookie,
|
||||
read_session_cookies,
|
||||
set_pkce_cookie,
|
||||
set_session_cookies,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.login_page import render_login_html
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _redirect_uri(request: Request) -> str:
|
||||
"""Reconstruct the absolute callback URL the IDP redirects back to.
|
||||
|
||||
Three resolution tiers:
|
||||
|
||||
1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var or
|
||||
``dashboard.public_url`` in config.yaml — when set, this is
|
||||
the complete authority (scheme + host + optional path prefix)
|
||||
and we append ``/auth/callback`` verbatim. ``X-Forwarded-Prefix``
|
||||
is IGNORED on this code path because the operator has declared
|
||||
the public URL — we no longer need to guess from proxy headers,
|
||||
and stacking the prefix on top would double-prefix the common
|
||||
case where the prefix is already baked into ``public_url``.
|
||||
Relief valve for deploys behind reverse proxies whose forwarded
|
||||
headers aren't reliable.
|
||||
|
||||
2. ``X-Forwarded-Prefix: /hermes`` (Mission Control deploys) — we
|
||||
prepend the prefix to the path FastAPI's ``url_for`` produces
|
||||
(it doesn't natively honour this header — it isn't part of the
|
||||
Starlette/uvicorn proxy_headers set).
|
||||
|
||||
3. Bare ``request.url_for("auth_callback")`` — under uvicorn's
|
||||
``proxy_headers=True`` this picks up the public https URL from
|
||||
``X-Forwarded-Host`` plus ``X-Forwarded-Proto``. Fly.io's
|
||||
default path.
|
||||
"""
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from hermes_cli.dashboard_auth.prefix import (
|
||||
prefix_from_request,
|
||||
resolve_public_url,
|
||||
)
|
||||
|
||||
# Tier 1: operator-declared public URL.
|
||||
public_url = resolve_public_url()
|
||||
if public_url:
|
||||
# ``public_url`` is the complete authority (possibly with a
|
||||
# path prefix already baked in). Append the auth callback path
|
||||
# verbatim. ``resolve_public_url`` already stripped any trailing
|
||||
# slash so we don't produce ``//auth/callback`` double-slashes.
|
||||
return f"{public_url}/auth/callback"
|
||||
|
||||
# Tier 2 + 3: reconstruct from the request URL, optionally with
|
||||
# X-Forwarded-Prefix layered on top of the path.
|
||||
base = str(request.url_for("auth_callback"))
|
||||
prefix = prefix_from_request(request)
|
||||
if not prefix:
|
||||
return base
|
||||
parsed = urlparse(base)
|
||||
return urlunparse(parsed._replace(path=f"{prefix}{parsed.path}"))
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def _prefix(request: Request) -> str:
|
||||
"""Resolve the X-Forwarded-Prefix header for the active request.
|
||||
|
||||
Local indirection so the routes pass a consistent value to the
|
||||
cookie helpers (cookie name + Path attribute) and the gate's
|
||||
redirect builders (login_url construction). See
|
||||
``hermes_cli.dashboard_auth.prefix`` for the normalisation rules.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
return prefix_from_request(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: login page (server-rendered HTML, no SPA bundle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/login", name="login_page")
|
||||
async def login_page(request: Request) -> HTMLResponse:
|
||||
# Read the ``next=`` query the gate's ``_unauth_response`` set on
|
||||
# the redirect URL. Validate against the same same-origin rules the
|
||||
# callback applies (defence in depth — the gate already filters,
|
||||
# but /login is reachable directly too).
|
||||
next_path = _validate_post_login_target(
|
||||
request.query_params.get("next", "")
|
||||
)
|
||||
return HTMLResponse(
|
||||
render_login_html(next_path=next_path),
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: provider list for the login-page bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/api/auth/providers", name="auth_providers")
|
||||
async def api_auth_providers() -> Any:
|
||||
providers = list_providers()
|
||||
if not providers:
|
||||
# Q13: fail-closed when zero providers are registered.
|
||||
return JSONResponse(
|
||||
{"detail": "no auth providers registered"},
|
||||
status_code=503,
|
||||
)
|
||||
return {
|
||||
"providers": [
|
||||
{
|
||||
"name": p.name,
|
||||
"display_name": p.display_name,
|
||||
"supports_password": bool(
|
||||
getattr(p, "supports_password", False)
|
||||
),
|
||||
}
|
||||
for p in providers
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: OAuth round trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/auth/login", name="auth_login")
|
||||
async def auth_login(request: Request, provider: str, next: str = ""):
|
||||
p = get_provider(provider)
|
||||
if p is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown provider: {provider!r}",
|
||||
)
|
||||
|
||||
try:
|
||||
ls = p.start_login(redirect_uri=_redirect_uri(request))
|
||||
except ProviderError as e:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=provider,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Provider unreachable: {e}",
|
||||
)
|
||||
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_START,
|
||||
provider=provider,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
|
||||
resp = RedirectResponse(url=ls.redirect_url, status_code=302)
|
||||
# Pack the provider name into the PKCE cookie so the callback can
|
||||
# find it without a separate cookie. Provider may or may not have
|
||||
# already included a ``provider=`` segment.
|
||||
pkce = ls.cookie_payload.get("hermes_session_pkce", "")
|
||||
if "provider=" not in pkce:
|
||||
pkce = f"provider={provider};{pkce}" if pkce else f"provider={provider}"
|
||||
# Carry ``next=`` through the round trip in the PKCE cookie. Real
|
||||
# IDPs only echo back ``code`` + ``state`` on the callback URL, so
|
||||
# query-string transport would lose the value — the cookie is the
|
||||
# only server-controlled channel that survives. Validate before we
|
||||
# store it so an attacker who reaches /auth/login directly with
|
||||
# ``next=//evil.example`` can't poison the cookie.
|
||||
safe_next = _validate_post_login_target(next)
|
||||
if safe_next:
|
||||
from urllib.parse import quote
|
||||
pkce = f"{pkce};next={quote(safe_next, safe='')}"
|
||||
set_pkce_cookie(
|
||||
resp, payload=pkce, use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/auth/callback", name="auth_callback")
|
||||
async def auth_callback(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
error: str = "",
|
||||
error_description: str = "",
|
||||
):
|
||||
pkce_raw = read_pkce_cookie(request)
|
||||
if not pkce_raw:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
reason="missing_pkce_cookie",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing PKCE state cookie",
|
||||
)
|
||||
|
||||
# Parse ``provider=...;state=...;verifier=...;next=...`` — the
|
||||
# ``next`` segment is optional (only present when /auth/login was
|
||||
# given a next= query). All keys live in the same flat namespace;
|
||||
# ``next`` carries a URL-encoded path so it never contains ``;``.
|
||||
parts = dict(
|
||||
seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg
|
||||
)
|
||||
provider_name = parts.get("provider", "")
|
||||
expected_state = parts.get("state", "")
|
||||
verifier = parts.get("verifier", "")
|
||||
# Read next= from the cookie ONLY. The IDP doesn't echo next= back
|
||||
# on the callback URL (it only carries ``code`` + ``state``), so any
|
||||
# next= query parameter on the callback URL is attacker-controlled
|
||||
# and MUST be ignored.
|
||||
next_from_cookie = parts.get("next", "")
|
||||
|
||||
p = get_provider(provider_name)
|
||||
if p is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider in cookie: {provider_name!r}",
|
||||
)
|
||||
|
||||
if error:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=provider_name,
|
||||
reason="idp_error",
|
||||
error=error,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"OAuth error from provider: {error} ({error_description})",
|
||||
)
|
||||
|
||||
if not state or state != expected_state:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=provider_name,
|
||||
reason="state_mismatch",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="OAuth state mismatch (CSRF check failed)",
|
||||
)
|
||||
|
||||
try:
|
||||
session = p.complete_login(
|
||||
code=code,
|
||||
state=state,
|
||||
code_verifier=verifier,
|
||||
redirect_uri=_redirect_uri(request),
|
||||
)
|
||||
except InvalidCodeError as e:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=provider_name,
|
||||
reason="invalid_code",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid code: {e}")
|
||||
except ProviderError as e:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=provider_name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Provider unreachable: {e}",
|
||||
)
|
||||
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_SUCCESS,
|
||||
provider=provider_name,
|
||||
user_id=session.user_id,
|
||||
email=session.email,
|
||||
org_id=session.org_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
|
||||
expires_in = max(60, session.expires_at - int(time.time()))
|
||||
# Honour the ``next=`` value the gate's _unauth_response set in the
|
||||
# /login redirect URL and that /auth/login persisted into the PKCE
|
||||
# cookie. We re-validate against the same-origin rules here — the
|
||||
# cookie is server-set so this is defence in depth, but a regression
|
||||
# that lets attacker-controlled bytes into the cookie would otherwise
|
||||
# produce an open redirect.
|
||||
landing = _validate_post_login_target(next_from_cookie) or "/"
|
||||
resp = RedirectResponse(url=landing, status_code=302)
|
||||
set_session_cookies(
|
||||
resp,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
access_token_expires_in=expires_in,
|
||||
use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
clear_pkce_cookie(resp, prefix=_prefix(request))
|
||||
return resp
|
||||
|
||||
|
||||
def _validate_post_login_target(raw: str) -> str:
|
||||
"""Return ``raw`` if it's a safe same-origin path, else empty string.
|
||||
|
||||
The ``next`` query param survives a full OAuth round trip — the gate
|
||||
encodes it into the /login redirect, the login page emits it back into
|
||||
/auth/login, and the IDP preserves it across /authorize/callback. We
|
||||
have to re-validate here because the value came back in via the
|
||||
URL (an attacker could craft a /auth/callback URL with their own
|
||||
``next=https://evil.example``).
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
from urllib.parse import unquote
|
||||
decoded = unquote(raw)
|
||||
if not decoded.startswith("/") or decoded.startswith("//"):
|
||||
return ""
|
||||
# Don't loop back to login pages or auth flow.
|
||||
if any(
|
||||
decoded == p or decoded.startswith(p)
|
||||
for p in ("/login", "/auth/", "/api/auth/")
|
||||
):
|
||||
return ""
|
||||
# Reject any ``/api/*`` target. The gate's ``_safe_next_target``
|
||||
# already filters these out before they reach the cookie, but a
|
||||
# malicious or stale ``next=`` value that re-enters via the
|
||||
# callback URL must not be honoured: a successful redirect to an
|
||||
# API endpoint renders raw JSON in the browser address bar — never
|
||||
# a useful post-login destination, and indistinguishable from an
|
||||
# attacker trying to weaponise the redirect.
|
||||
if decoded == "/api" or decoded.startswith("/api/"):
|
||||
return ""
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: password (non-redirect) login
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Brute-force throttle. The OAuth flow has no guessable secret on our side
|
||||
# (the IDP owns credentials), but ``/auth/password-login`` accepts a
|
||||
# password we verify locally, so it's a credential-stuffing target. A
|
||||
# simple in-process sliding-window limiter per client IP raises the cost
|
||||
# of online guessing without any external dependency. It is intentionally
|
||||
# best-effort: process-local (resets on restart), and behind a trusting
|
||||
# proxy the IP is the proxy's unless X-Forwarded-For is set — which is why
|
||||
# this is defence-in-depth on top of the provider's own constant-time
|
||||
# verify, not the only line of defence.
|
||||
|
||||
_PW_RATE_MAX_ATTEMPTS = 10
|
||||
_PW_RATE_WINDOW_SEC = 60.0
|
||||
_pw_attempts: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
_pw_attempts_lock = threading.Lock()
|
||||
|
||||
|
||||
def _password_rate_limited(ip: str) -> bool:
|
||||
"""True if ``ip`` has exceeded the password-login attempt budget.
|
||||
|
||||
Sliding window: prune attempts older than the window, then check the
|
||||
count. Records the attempt timestamp when allowed. An empty IP (no
|
||||
discernible client) shares a single bucket — fail-safe toward
|
||||
throttling rather than letting unattributable traffic through
|
||||
unmetered.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - _PW_RATE_WINDOW_SEC
|
||||
key = ip or "_unknown_"
|
||||
with _pw_attempts_lock:
|
||||
bucket = _pw_attempts[key]
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
|
||||
return True
|
||||
bucket.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _reset_password_rate_limit() -> None:
|
||||
"""Test-only: clear all rate-limit buckets."""
|
||||
with _pw_attempts_lock:
|
||||
_pw_attempts.clear()
|
||||
|
||||
|
||||
class _PasswordLoginBody(BaseModel):
|
||||
provider: str
|
||||
username: str
|
||||
password: str
|
||||
next: str = ""
|
||||
|
||||
|
||||
@router.post("/auth/password-login", name="auth_password_login")
|
||||
async def auth_password_login(request: Request, body: _PasswordLoginBody):
|
||||
"""Authenticate a username/password against a password provider.
|
||||
|
||||
Mirrors the cookie-minting tail of ``/auth/callback`` but skips the
|
||||
PKCE/state/code machinery (those are OAuth-only). On success sets the
|
||||
session cookies and returns JSON ``{"ok": true, "next": <path>}`` —
|
||||
the credential form POSTs via fetch and navigates client-side, so a
|
||||
302 (which fetch follows opaquely) is the wrong shape here.
|
||||
|
||||
Failure modes, all deliberately generic so the endpoint can't be used
|
||||
as a username oracle or a provider-enumeration oracle:
|
||||
* unknown provider / provider lacks password support → 404
|
||||
* bad credentials → 401 ("Invalid credentials")
|
||||
* backing store unreachable → 503
|
||||
* too many attempts from this IP → 429
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
if _password_rate_limited(ip):
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="rate_limited",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many login attempts. Try again shortly.",
|
||||
)
|
||||
|
||||
p = get_provider(body.provider)
|
||||
if p is None or not getattr(p, "supports_password", False):
|
||||
# Don't leak which providers exist or which support passwords —
|
||||
# same 404 whether the provider is unknown or OAuth-only.
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="unknown_password_provider",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Unknown provider")
|
||||
|
||||
try:
|
||||
session = p.complete_password_login(
|
||||
username=body.username, password=body.password
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="invalid_credentials",
|
||||
ip=ip,
|
||||
)
|
||||
# Generic message — never distinguish unknown-user from wrong-password.
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
except NotImplementedError:
|
||||
# supports_password was True but the method isn't actually
|
||||
# implemented — a provider bug, not a client error.
|
||||
raise HTTPException(status_code=500, detail="Provider misconfigured")
|
||||
except ProviderError as e:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="provider_unreachable",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
|
||||
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_SUCCESS,
|
||||
provider=body.provider,
|
||||
user_id=session.user_id,
|
||||
email=session.email,
|
||||
org_id=session.org_id,
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
expires_in = max(60, session.expires_at - int(time.time()))
|
||||
landing = _validate_post_login_target(body.next) or "/"
|
||||
resp = JSONResponse({"ok": True, "next": landing})
|
||||
set_session_cookies(
|
||||
resp,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
access_token_expires_in=expires_in,
|
||||
use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/auth/logout", name="auth_logout")
|
||||
async def auth_logout(request: Request):
|
||||
_at, rt = read_session_cookies(request)
|
||||
if rt:
|
||||
# Best-effort revoke. Try every provider so a session minted by
|
||||
# any registered provider is revoked correctly. Failures are
|
||||
# logged but never raised.
|
||||
for provider in list_providers():
|
||||
try:
|
||||
provider.revoke_session(refresh_token=rt)
|
||||
except Exception as e: # noqa: BLE001 — best-effort
|
||||
_log.warning(
|
||||
"dashboard-auth: revoke on %r failed: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
sess = getattr(request.state, "session", None)
|
||||
audit_log(
|
||||
AuditEvent.LOGOUT,
|
||||
provider=(sess.provider if sess else "unknown"),
|
||||
user_id=(sess.user_id if sess else ""),
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
|
||||
prefix = _prefix(request)
|
||||
resp = RedirectResponse(url=f"{prefix}/login", status_code=302)
|
||||
clear_session_cookies(resp, prefix=prefix)
|
||||
clear_pkce_cookie(resp, prefix=prefix)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-required: identity probe for the SPA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/api/auth/me", name="auth_me")
|
||||
async def api_auth_me(request: Request):
|
||||
"""Return the verified session as JSON. Auth-required (gate enforces)."""
|
||||
sess = getattr(request.state, "session", None)
|
||||
if sess is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {
|
||||
"user_id": sess.user_id,
|
||||
"email": sess.email,
|
||||
"display_name": sess.display_name,
|
||||
"org_id": sess.org_id,
|
||||
"provider": sess.provider,
|
||||
"expires_at": sess.expires_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-required: WS upgrade ticket (Phase 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/api/auth/ws-ticket", name="auth_ws_ticket")
|
||||
async def api_auth_ws_ticket(request: Request):
|
||||
"""Mint a short-lived single-use ticket for the authenticated session.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade, so in
|
||||
gated mode the SPA POSTs this endpoint to get a ``?ticket=`` value to
|
||||
append to ``/api/pty``, ``/api/ws``, ``/api/pub``, or ``/api/events``.
|
||||
|
||||
The ticket has a 30-second TTL and is single-use. Calling this endpoint
|
||||
multiple times in quick succession (e.g. one ticket per WS) is the
|
||||
expected pattern.
|
||||
"""
|
||||
sess = getattr(request.state, "session", None)
|
||||
if sess is None:
|
||||
# Middleware should already have rejected, but check defensively.
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Import here so the routes module stays usable in test contexts that
|
||||
# don't load the ticket store.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS, mint_ticket
|
||||
|
||||
ticket = mint_ticket(user_id=sess.user_id, provider=sess.provider)
|
||||
audit_log(
|
||||
AuditEvent.WS_TICKET_MINTED,
|
||||
provider=sess.provider,
|
||||
user_id=sess.user_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return {"ticket": ticket, "ttl_seconds": TTL_SECONDS}
|
||||
161
hermes_cli/dashboard_auth/ws_tickets.py
Normal file
161
hermes_cli/dashboard_auth/ws_tickets.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""WS-upgrade auth credentials for gated mode.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
||||
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
||||
token is injected into the SPA bundle. In gated mode there is no injected
|
||||
token — so this module provides two credential shapes:
|
||||
|
||||
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
||||
The SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
||||
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
||||
|
||||
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
||||
``consume_internal_credential``). This authenticates *server-spawned*
|
||||
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
||||
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
||||
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
||||
child reads its attach URL once at startup and **reuses it on every
|
||||
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
||||
The internal credential is minted once per process, never expires, is
|
||||
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
||||
it only ever leaves the process via the spawned child's environment, so
|
||||
browser-side XSS cannot read it. A leaked internal credential grants no
|
||||
more than a single-use ticket already does (the same two internal WS
|
||||
endpoints), and the same Origin / host guards still apply downstream.
|
||||
|
||||
In-memory; the dashboard is a single process so no distributed coordination
|
||||
is needed. The module exposes a small functional API rather than a class so
|
||||
tests can patch ``time.time`` cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
||||
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
||||
#: short enough that a leaked ticket is uninteresting.
|
||||
TTL_SECONDS = 30
|
||||
|
||||
_lock = threading.Lock()
|
||||
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
||||
|
||||
#: The process-lifetime internal credential (see module docstring). Lazily
|
||||
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
||||
#: of the process. Guarded by ``_lock``.
|
||||
_internal_credential: Optional[str] = None
|
||||
|
||||
#: Identity recorded for connections that authenticate via the internal
|
||||
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
||||
INTERNAL_USER_ID = "server-internal"
|
||||
INTERNAL_PROVIDER = "server-internal"
|
||||
|
||||
|
||||
class TicketInvalid(Exception):
|
||||
"""Ticket missing, expired, or already consumed."""
|
||||
|
||||
|
||||
def mint_ticket(*, user_id: str, provider: str) -> str:
|
||||
"""Generate a one-shot ticket bound to this user identity.
|
||||
|
||||
The returned token is base64url, 43 bytes of entropy (32-byte random
|
||||
seed). Stash returns the ``info`` dict to the caller on consume so the
|
||||
WS handler can carry the identity forward into its session log.
|
||||
"""
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
info = {
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
"minted_at": int(time.time()),
|
||||
}
|
||||
with _lock:
|
||||
_tickets[ticket] = (int(time.time()) + TTL_SECONDS, info)
|
||||
_gc_expired_locked()
|
||||
return ticket
|
||||
|
||||
|
||||
def consume_ticket(ticket: str) -> Dict[str, Any]:
|
||||
"""Validate and consume. Raises :class:`TicketInvalid` on missing/expired/used.
|
||||
|
||||
Single-use semantics: a successful consume immediately removes the
|
||||
ticket from the store, so a second call with the same value raises
|
||||
``TicketInvalid("unknown ticket: …")``.
|
||||
"""
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
entry = _tickets.pop(ticket, None)
|
||||
if entry is None:
|
||||
# Truncate ticket value in the error so misuse never logs the
|
||||
# secret in full.
|
||||
truncated = (ticket[:8] + "…") if ticket else "<empty>"
|
||||
raise TicketInvalid(f"unknown ticket: {truncated}")
|
||||
expires_at, info = entry
|
||||
if expires_at < now:
|
||||
raise TicketInvalid("expired")
|
||||
return info
|
||||
|
||||
|
||||
def _gc_expired_locked() -> None:
|
||||
"""Drop expired tickets. Caller must hold ``_lock``."""
|
||||
now = int(time.time())
|
||||
expired = [t for t, (exp, _) in _tickets.items() if exp < now]
|
||||
for t in expired:
|
||||
_tickets.pop(t, None)
|
||||
|
||||
|
||||
def internal_ws_credential() -> str:
|
||||
"""Return the process-lifetime internal WS credential, minting it once.
|
||||
|
||||
Used by the server to authenticate WS clients it spawns itself (the
|
||||
embedded-TUI PTY child). The value is stable for the life of the process,
|
||||
multi-use, and never expires — so a server-spawned child can reconnect
|
||||
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
||||
|
||||
The credential is never injected into the SPA HTML or returned over any
|
||||
REST endpoint; it is only ever passed to a child process via its
|
||||
environment. See the module docstring for the threat-model rationale.
|
||||
"""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
if _internal_credential is None:
|
||||
_internal_credential = secrets.token_urlsafe(32)
|
||||
return _internal_credential
|
||||
|
||||
|
||||
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
||||
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
||||
|
||||
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
||||
not removed on success, so a server-spawned child can present it on every
|
||||
(re)connect. Returns the fixed server-internal identity ``info`` dict
|
||||
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
|
||||
returns, so a caller that wants to record the connecting identity can; the
|
||||
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
|
||||
discards the dict.
|
||||
|
||||
A constant-time compare against the (lazily-minted) credential avoids
|
||||
leaking length / prefix information on mismatch. If no internal
|
||||
credential has been minted yet, any value is rejected.
|
||||
"""
|
||||
with _lock:
|
||||
expected = _internal_credential
|
||||
if not value or expected is None:
|
||||
raise TicketInvalid("no internal credential")
|
||||
if not secrets.compare_digest(value.encode(), expected.encode()):
|
||||
raise TicketInvalid("internal credential mismatch")
|
||||
return {
|
||||
"user_id": INTERNAL_USER_ID,
|
||||
"provider": INTERNAL_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all tickets and the internal credential."""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
_tickets.clear()
|
||||
_internal_credential = None
|
||||
427
hermes_cli/dashboard_register.py
Normal file
427
hermes_cli/dashboard_register.py
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
"""``hermes dashboard register`` — register a self-hosted dashboard OAuth client.
|
||||
|
||||
Automates what a user otherwise does by hand: open the Nous Portal
|
||||
``/local-dashboards`` page in a browser, click "register", copy the
|
||||
resulting ``agent:{id}`` OAuth client ID, and paste it into ``~/.hermes/.env``
|
||||
as ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``.
|
||||
|
||||
This command:
|
||||
1. Resolves a fresh Nous Portal access token from the existing login
|
||||
(``~/.hermes/auth.json``), refreshing it if needed. Fails fast with a
|
||||
"run `hermes setup`" hint when the user isn't logged in.
|
||||
2. POSTs to ``{portal}/api/oauth/self-hosted-client`` with that bearer
|
||||
token, which creates a SELF_HOSTED agent client owned by the caller's
|
||||
org and returns the fully-formed ``agent:{id}`` client_id.
|
||||
3. Writes ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and (if absent)
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` into ``~/.hermes/.env`` idempotently.
|
||||
4. Prints a post-register hint explaining that the OAuth gate only engages
|
||||
on a non-loopback bind.
|
||||
|
||||
The portal endpoint is the NAS half of this feature (POST
|
||||
/api/oauth/self-hosted-client). The ``agent:`` prefix is applied server-side,
|
||||
so this client never needs to know the namespace convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Docker-style name generator. Same vibe as Docker's adjective_surname, but
|
||||
# adjective_noun with a space-free underscore join so it drops cleanly into a
|
||||
# label field. There is NO uniqueness constraint on the portal side (the row
|
||||
# id is the key), so collisions are harmless and we don't retry.
|
||||
_NAME_ADJECTIVES = (
|
||||
"amber", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp",
|
||||
"dreamy", "eager", "electric", "fancy", "gentle", "golden", "happy",
|
||||
"hidden", "jolly", "keen", "lively", "lucid", "lunar", "mellow", "merry",
|
||||
"mighty", "nimble", "noble", "polished", "quiet", "quirky", "rapid",
|
||||
"serene", "sharp", "shiny", "silent", "snappy", "solar", "spry", "stellar",
|
||||
"sunny", "swift", "tidy", "vivid", "vibrant", "witty", "zesty",
|
||||
)
|
||||
|
||||
_NAME_NOUNS = (
|
||||
"albatross", "antelope", "badger", "beacon", "comet", "condor", "cypress",
|
||||
"dolphin", "ember", "falcon", "ferret", "galaxy", "glacier", "harbor",
|
||||
"heron", "ibex", "jaguar", "kestrel", "lantern", "lynx", "meadow", "nebula",
|
||||
"ocelot", "orchid", "otter", "panther", "petrel", "quasar", "raven", "reef",
|
||||
"sparrow", "summit", "tundra", "vortex", "walrus", "willow", "yarrow",
|
||||
# A couple of scientist surnames in the Docker spirit.
|
||||
"kepler", "tesla", "curie", "hopper", "turing", "lovelace",
|
||||
)
|
||||
|
||||
|
||||
def _generate_dashboard_name() -> str:
|
||||
"""Return a human-readable ``adjective_noun`` name (Docker-style)."""
|
||||
return f"{random.choice(_NAME_ADJECTIVES)}_{random.choice(_NAME_NOUNS)}"
|
||||
|
||||
|
||||
def _resolve_portal_base_url(override: Optional[str] = None) -> str:
|
||||
"""Resolve the portal base URL for the registration request.
|
||||
|
||||
Precedence:
|
||||
1. ``override`` — explicit ``--portal-url`` flag or
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` env (used for testing against a
|
||||
preview/staging portal). NOTE: the access token must be valid at
|
||||
this portal — it's minted by whatever portal you logged into, so an
|
||||
override only works if the token's issuer matches (e.g. you logged
|
||||
into the same staging/preview portal).
|
||||
2. The ``portal_base_url`` stored on the Nous login — this is the
|
||||
portal that issued the token, so it's the correct default target.
|
||||
3. The production default.
|
||||
"""
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.rstrip("/")
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL, get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
base = state.get("portal_base_url")
|
||||
if isinstance(base, str) and base.strip():
|
||||
return base.rstrip("/")
|
||||
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def _register_self_hosted_client(
|
||||
*,
|
||||
access_token: str,
|
||||
portal_base_url: str,
|
||||
name: Optional[str],
|
||||
custom_redirect_uri: Optional[str],
|
||||
existing_client_id: Optional[str] = None,
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
|
||||
|
||||
When ``existing_client_id`` is provided (the client_id this install
|
||||
persisted on a prior run), it is sent so the portal updates that existing
|
||||
dashboard record in place instead of minting a duplicate — this is what
|
||||
makes re-running ``hermes dashboard register`` idempotent. The portal
|
||||
falls back to creating a fresh client if the id no longer resolves to a row
|
||||
in the caller's org (stale/deleted), so passing it is always safe.
|
||||
|
||||
``name`` may be ``None`` on the idempotent update path (re-run without an
|
||||
explicit ``--name``): omitting it tells the portal to keep the name it
|
||||
already stored rather than overwriting it. It is required on the create
|
||||
path; the caller guarantees a value there.
|
||||
|
||||
Raises RuntimeError with a user-facing message on any non-2xx response or
|
||||
transport failure.
|
||||
"""
|
||||
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
|
||||
body: dict[str, str] = {}
|
||||
if name:
|
||||
body["name"] = name
|
||||
if custom_redirect_uri:
|
||||
body["custom_redirect_uri"] = custom_redirect_uri
|
||||
if existing_client_id:
|
||||
body["client_id"] = existing_client_id
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The endpoint returns structured JSON errors ({error, error_description}).
|
||||
detail = ""
|
||||
try:
|
||||
err_body = json.loads(exc.read().decode())
|
||||
detail = (
|
||||
err_body.get("error_description")
|
||||
or err_body.get("error")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if exc.code == 401:
|
||||
raise RuntimeError(
|
||||
"Nous Portal rejected the access token (401). "
|
||||
"Try `hermes auth login nous` to re-authenticate."
|
||||
) from exc
|
||||
if exc.code == 403:
|
||||
raise RuntimeError(
|
||||
detail
|
||||
or "Your account is not permitted to register a self-hosted dashboard."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"Portal returned HTTP {exc.code}"
|
||||
+ (f": {detail}" if detail else "")
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict) or not payload.get("client_id"):
|
||||
raise RuntimeError("Portal returned an unexpected response (no client_id).")
|
||||
return payload
|
||||
|
||||
|
||||
def _print_post_register_hint(
|
||||
*,
|
||||
client_id: str,
|
||||
portal_base_url: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
wrote_portal_url: bool,
|
||||
public_url: str = "",
|
||||
) -> None:
|
||||
"""Print the success summary + the gate-engagement caveat."""
|
||||
from hermes_cli.config import get_env_path
|
||||
|
||||
env_path = get_env_path()
|
||||
_cid = client_id
|
||||
print()
|
||||
print(f" Wrote to {env_path}:")
|
||||
print(" HERMES_DASHBOARD_OAUTH_CLIENT_ID=" + str(_cid))
|
||||
if wrote_portal_url:
|
||||
print(" HERMES_DASHBOARD_PORTAL_URL=" + str(portal_base_url))
|
||||
if public_url:
|
||||
print(" HERMES_DASHBOARD_PUBLIC_URL=" + str(public_url))
|
||||
print()
|
||||
print(
|
||||
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
|
||||
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
|
||||
" without auth, which is fine for your own machine."
|
||||
)
|
||||
print()
|
||||
if custom_redirect_uri:
|
||||
# Derive the host the user registered so the example matches it.
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = urlparse(custom_redirect_uri).hostname or "your-host"
|
||||
except Exception:
|
||||
host = "your-host"
|
||||
print(" To require Nous login on your registered host, run the dashboard")
|
||||
print(f" bound publicly (it must be reachable at https://{host}) and log in")
|
||||
print(" at its /login page.")
|
||||
else:
|
||||
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
|
||||
print(" hermes dashboard --host 0.0.0.0")
|
||||
print(" …then log in at the dashboard's /login page.")
|
||||
print()
|
||||
print(
|
||||
" If the dashboard is already running, restart it to pick up the new env."
|
||||
)
|
||||
print(
|
||||
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args) -> None:
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||
from hermes_cli.config import get_env_value, is_managed, save_env_value
|
||||
|
||||
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
|
||||
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# via buildContainerEnvVars). Registering from inside such a container is a
|
||||
# mistake — and save_env_value refuses to write anyway.
|
||||
if is_managed():
|
||||
print(
|
||||
"✗ `hermes dashboard register` is not available in a managed/hosted "
|
||||
"install.\n"
|
||||
" The dashboard OAuth client is provisioned by the hosting platform."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
|
||||
# with a setup hint when the user isn't logged in.
|
||||
try:
|
||||
access_token = resolve_nous_access_token()
|
||||
except AuthError as exc:
|
||||
if getattr(exc, "relogin_required", False):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
||||
else:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# Portal override: explicit --portal-url flag wins, else the
|
||||
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
|
||||
#
|
||||
# We track whether a custom URL was *explicitly supplied* (flag or env)
|
||||
# separately from the resolved value. An explicit custom URL is an
|
||||
# intentional choice the user wants to persist (and update in place if it
|
||||
# already exists in .env); a portal merely inferred from the stored login
|
||||
# keeps the older, more conservative write-only-if-absent behaviour so we
|
||||
# don't clutter .env for the common production case.
|
||||
portal_override = getattr(args, "portal_url", None) or os.environ.get(
|
||||
"HERMES_DASHBOARD_PORTAL_URL"
|
||||
)
|
||||
custom_portal_supplied = bool(
|
||||
isinstance(portal_override, str) and portal_override.strip()
|
||||
)
|
||||
portal_base_url = _resolve_portal_base_url(portal_override)
|
||||
|
||||
# Idempotency: if this install already registered a dashboard, we hold its
|
||||
# client_id locally (HERMES_DASHBOARD_OAUTH_CLIENT_ID). Re-send it so the
|
||||
# portal UPDATES that existing record instead of creating a duplicate. No
|
||||
# stored client_id -> this is a first registration -> create a fresh one
|
||||
# (the original behavior). This mirrors the portal's rule: no client id =
|
||||
# new dashboard; client id present = the stable key of the row to modify.
|
||||
existing_client_id = None
|
||||
try:
|
||||
existing_client_id = get_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID")
|
||||
except Exception:
|
||||
existing_client_id = None
|
||||
if isinstance(existing_client_id, str):
|
||||
existing_client_id = existing_client_id.strip() or None
|
||||
else:
|
||||
existing_client_id = None
|
||||
|
||||
explicit_name = getattr(args, "name", None)
|
||||
# Auto-generate a random name ONLY for a first registration. On a re-run
|
||||
# (we hold a client_id) without an explicit --name, keep the name the
|
||||
# portal already stored rather than churning it to a new random value
|
||||
# every time — so leave `name` unset and let the portal preserve it.
|
||||
if explicit_name:
|
||||
name = explicit_name
|
||||
elif existing_client_id:
|
||||
name = None
|
||||
else:
|
||||
name = _generate_dashboard_name()
|
||||
custom_redirect_uri = getattr(args, "redirect_uri", None)
|
||||
|
||||
# 2. Register with the portal.
|
||||
try:
|
||||
result = _register_self_hosted_client(
|
||||
access_token=access_token,
|
||||
portal_base_url=portal_base_url,
|
||||
name=name,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
existing_client_id=existing_client_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"✗ Registration failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
client_id = str(result["client_id"])
|
||||
registered_name = str(result.get("name") or name or "")
|
||||
|
||||
# Distinguish create vs update for the user: the portal echoes back the
|
||||
# same client_id we sent when it updated in place.
|
||||
updated_existing = bool(
|
||||
existing_client_id and client_id == existing_client_id
|
||||
)
|
||||
if updated_existing:
|
||||
print(f'✓ Updated dashboard "{registered_name}"')
|
||||
else:
|
||||
print(f'✓ Registered dashboard "{registered_name}"')
|
||||
|
||||
# 3. Write env vars idempotently. Always set the client_id.
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
|
||||
except Exception as exc:
|
||||
print(f"✗ Failed to write HERMES_DASHBOARD_OAUTH_CLIENT_ID to .env: {exc}")
|
||||
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
sys.exit(1)
|
||||
|
||||
# Persist the portal URL. Two cases:
|
||||
# a) The user explicitly supplied a custom portal (--portal-url flag or
|
||||
# HERMES_DASHBOARD_PORTAL_URL env). That's an intentional choice we
|
||||
# always persist so it survives across sessions — overwriting any
|
||||
# existing entry in place (save_env_value updates a matching key
|
||||
# rather than appending a duplicate). This is true even when it equals
|
||||
# the production default: the user asked for it explicitly.
|
||||
# b) No custom portal was supplied. Keep the older conservative behaviour:
|
||||
# only write a portal inferred from the stored login when it isn't
|
||||
# already configured AND differs from the production default, so we
|
||||
# don't clutter .env for the common production case and don't alter an
|
||||
# existing entry unexpectedly.
|
||||
wrote_portal_url = False
|
||||
default_portal = "https://portal.nousresearch.com"
|
||||
existing_portal = None
|
||||
try:
|
||||
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
|
||||
except Exception:
|
||||
existing_portal = None
|
||||
|
||||
if custom_portal_supplied:
|
||||
should_write_portal = existing_portal != portal_base_url
|
||||
else:
|
||||
should_write_portal = (
|
||||
not existing_portal and portal_base_url.rstrip("/") != default_portal
|
||||
)
|
||||
|
||||
if should_write_portal:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
|
||||
wrote_portal_url = True
|
||||
except Exception:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# Persist the dashboard public URL derived from the OAuth redirect URI.
|
||||
#
|
||||
# --redirect-uri is the full public HTTPS callback the user registered with
|
||||
# the portal, e.g. https://hermes.example.com/auth/callback. At serve time
|
||||
# the dashboard auth layer (dashboard_auth/routes._redirect_uri) reconstructs
|
||||
# that same callback by taking HERMES_DASHBOARD_PUBLIC_URL and appending
|
||||
# "/auth/callback" verbatim. So the value the runtime actually consumes is
|
||||
# the ORIGIN (scheme://host[:port]), not the full callback path — persisting
|
||||
# the raw redirect URI would double up the path. We derive the origin from
|
||||
# the supplied redirect URI and persist it as HERMES_DASHBOARD_PUBLIC_URL so
|
||||
# the operator doesn't have to re-supply it and the public-URL override is
|
||||
# actually wired (the gate engages and the callback round-trips correctly).
|
||||
#
|
||||
# Like the portal URL, an explicitly supplied value is always written
|
||||
# (updating an existing entry in place rather than appending a duplicate),
|
||||
# a no-op when it already matches, and never written on a localhost-only
|
||||
# install (no --redirect-uri).
|
||||
wrote_public_url = False
|
||||
public_url = ""
|
||||
if custom_redirect_uri:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(custom_redirect_uri)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc:
|
||||
public_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
except Exception:
|
||||
public_url = ""
|
||||
|
||||
if public_url:
|
||||
existing_public_url = None
|
||||
try:
|
||||
existing_public_url = get_env_value("HERMES_DASHBOARD_PUBLIC_URL")
|
||||
except Exception:
|
||||
existing_public_url = None
|
||||
if existing_public_url != public_url:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PUBLIC_URL", public_url)
|
||||
wrote_public_url = True
|
||||
except Exception:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# 4. Hint.
|
||||
_print_post_register_hint(
|
||||
client_id=client_id,
|
||||
portal_base_url=portal_base_url,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
wrote_portal_url=wrote_portal_url,
|
||||
public_url=public_url if wrote_public_url else "",
|
||||
)
|
||||
|
|
@ -14,10 +14,9 @@ Currently supports:
|
|||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -36,6 +35,12 @@ _REDACTION_BANNER = (
|
|||
"run with --no-redact to disable]\n"
|
||||
)
|
||||
|
||||
_EMAIL_ADDRESS_RE = re.compile(
|
||||
r"(?<![A-Za-z0-9._%+-])"
|
||||
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
|
||||
r"(?![A-Za-z0-9._%+-])"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paste services — try paste.rs first, dpaste.com as fallback.
|
||||
|
|
@ -186,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 — may contain
|
||||
conversation fragments and file paths)
|
||||
• Full agent.log and gateway.log (up to 512 KB each — likely contains
|
||||
conversation content, tool outputs, and file paths)
|
||||
• 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)
|
||||
|
||||
Pastes auto-delete after 6 hours.
|
||||
"""
|
||||
|
|
@ -253,15 +258,6 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC
|
|||
_record_pending(urls, delay_seconds=delay_seconds)
|
||||
|
||||
|
||||
def _delete_hint(url: str) -> str:
|
||||
"""Return a one-liner delete command for the given paste URL."""
|
||||
paste_id = _extract_paste_id(url)
|
||||
if paste_id:
|
||||
return f"hermes debug delete {url}"
|
||||
# dpaste.com — no API delete, expires on its own.
|
||||
return "(auto-expires per dpaste.com policy)"
|
||||
|
||||
|
||||
def _upload_paste_rs(content: str) -> str:
|
||||
"""Upload to paste.rs. Returns the paste URL.
|
||||
|
||||
|
|
@ -398,7 +394,8 @@ def _redact_log_text(text: str) -> str:
|
|||
return text
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
return redact_sensitive_text(text, force=True)
|
||||
text = redact_sensitive_text(text, force=True)
|
||||
return _EMAIL_ADDRESS_RE.sub("[REDACTED_EMAIL]", text)
|
||||
|
||||
|
||||
def _capture_log_snapshot(
|
||||
|
|
@ -506,6 +503,9 @@ def _capture_default_log_snapshots(
|
|||
"gateway": _capture_log_snapshot(
|
||||
"gateway", tail_lines=errors_lines, redact=redact
|
||||
),
|
||||
"desktop": _capture_log_snapshot(
|
||||
"desktop", tail_lines=errors_lines, redact=redact
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -572,6 +572,10 @@ def collect_debug_report(
|
|||
|
||||
buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(log_snapshots["gateway"].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")
|
||||
|
||||
return buf.getvalue()
|
||||
|
|
@ -581,20 +585,41 @@ def collect_debug_report(
|
|||
# CLI entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_debug_share(args):
|
||||
"""Collect debug report + full logs, upload each, print URLs."""
|
||||
@dataclass
|
||||
class DebugShareResult:
|
||||
"""Structured outcome of a ``debug share`` upload.
|
||||
|
||||
Returned by :func:`build_debug_share` so non-CLI callers (the dashboard
|
||||
web server, gateway) can render the uploaded paste URLs as real links
|
||||
instead of scraping printed text.
|
||||
"""
|
||||
|
||||
urls: dict # label -> paste URL (e.g. {"Report": "...", "agent.log": "..."})
|
||||
failures: list # human-readable "label: error" strings for optional uploads
|
||||
redacted: bool # whether force-mode redaction was applied before upload
|
||||
auto_delete_seconds: int # how long until the pastes auto-delete
|
||||
report: str = "" # the summary report text (kept for local fallback)
|
||||
|
||||
|
||||
def build_debug_share(
|
||||
*,
|
||||
log_lines: int = 200,
|
||||
expiry: int = 7,
|
||||
redact: bool = True,
|
||||
) -> DebugShareResult:
|
||||
"""Collect the debug report + full logs, upload each, return the URLs.
|
||||
|
||||
This is the shared core behind ``hermes debug share`` (CLI) and the
|
||||
dashboard ``POST /api/ops/debug-share`` endpoint. It performs blocking
|
||||
network I/O (paste uploads) — callers inside an event loop must run it in
|
||||
a worker thread.
|
||||
|
||||
The summary report upload is required: on failure this raises
|
||||
``RuntimeError``. Full-log uploads are best-effort; their errors are
|
||||
collected into ``failures`` rather than raised.
|
||||
"""
|
||||
_best_effort_sweep_expired_pastes()
|
||||
|
||||
log_lines = getattr(args, "lines", 200)
|
||||
expiry = getattr(args, "expire", 7)
|
||||
local_only = getattr(args, "local", False)
|
||||
redact = not getattr(args, "no_redact", False)
|
||||
|
||||
if not local_only:
|
||||
print(_PRIVACY_NOTICE)
|
||||
|
||||
print("Collecting debug report...")
|
||||
|
||||
# Capture dump once — prepended to every paste for context.
|
||||
# The dump is already redacted at extract time via dump.py:_redact;
|
||||
# log_snapshots are redacted by _capture_default_log_snapshots when
|
||||
|
|
@ -614,12 +639,15 @@ def run_debug_share(args):
|
|||
)
|
||||
agent_log = log_snapshots["agent"].full_text
|
||||
gateway_log = log_snapshots["gateway"].full_text
|
||||
desktop_log = log_snapshots["desktop"].full_text
|
||||
|
||||
# Prepend dump header to each full log so every paste is self-contained.
|
||||
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 desktop_log:
|
||||
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
|
||||
|
||||
# Visible banner so reviewers reading the public paste know redaction
|
||||
# was applied at upload time. Banner is omitted under --no-redact.
|
||||
|
|
@ -629,60 +657,115 @@ def run_debug_share(args):
|
|||
agent_log = _REDACTION_BANNER + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = _REDACTION_BANNER + gateway_log
|
||||
if desktop_log:
|
||||
desktop_log = _REDACTION_BANNER + desktop_log
|
||||
|
||||
if local_only:
|
||||
print(report)
|
||||
if agent_log:
|
||||
print(f"\n\n{'=' * 60}")
|
||||
print("FULL agent.log")
|
||||
print(f"{'=' * 60}\n")
|
||||
print(agent_log)
|
||||
if gateway_log:
|
||||
print(f"\n\n{'=' * 60}")
|
||||
print("FULL gateway.log")
|
||||
print(f"{'=' * 60}\n")
|
||||
print(gateway_log)
|
||||
return
|
||||
|
||||
print("Uploading...")
|
||||
urls: dict[str, str] = {}
|
||||
failures: list[str] = []
|
||||
|
||||
# 1. Summary report (required)
|
||||
# 1. Summary report (required — raises on failure so callers can fall back)
|
||||
urls["Report"] = upload_to_pastebin(report, expiry_days=expiry)
|
||||
|
||||
# 2-4. Full logs (optional — failures are collected, not raised)
|
||||
for label, content in (
|
||||
("agent.log", agent_log),
|
||||
("gateway.log", gateway_log),
|
||||
("desktop.log", desktop_log),
|
||||
):
|
||||
if not content:
|
||||
continue
|
||||
try:
|
||||
urls[label] = upload_to_pastebin(content, expiry_days=expiry)
|
||||
except Exception as exc:
|
||||
failures.append(f"{label}: {exc}")
|
||||
|
||||
# Schedule auto-deletion after 6 hours.
|
||||
_schedule_auto_delete(list(urls.values()))
|
||||
|
||||
return DebugShareResult(
|
||||
urls=urls,
|
||||
failures=failures,
|
||||
redacted=redact,
|
||||
auto_delete_seconds=_AUTO_DELETE_SECONDS,
|
||||
report=report,
|
||||
)
|
||||
|
||||
|
||||
def run_debug_share(args):
|
||||
"""Collect debug report + full logs, upload each, print URLs."""
|
||||
log_lines = getattr(args, "lines", 200)
|
||||
expiry = getattr(args, "expire", 7)
|
||||
local_only = getattr(args, "local", False)
|
||||
redact = not getattr(args, "no_redact", False)
|
||||
|
||||
if local_only:
|
||||
# Local-only path never uploads — render the report to stdout and bail
|
||||
# before any network I/O. Mirrors the upload path's collection logic.
|
||||
_best_effort_sweep_expired_pastes()
|
||||
print("Collecting debug report...")
|
||||
dump_text = _capture_dump()
|
||||
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
|
||||
report = collect_debug_report(
|
||||
log_lines=log_lines,
|
||||
dump_text=dump_text,
|
||||
log_snapshots=log_snapshots,
|
||||
)
|
||||
agent_log = log_snapshots["agent"].full_text
|
||||
gateway_log = log_snapshots["gateway"].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 desktop_log:
|
||||
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
|
||||
if redact:
|
||||
report = _REDACTION_BANNER + report
|
||||
if agent_log:
|
||||
agent_log = _REDACTION_BANNER + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = _REDACTION_BANNER + gateway_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 desktop.log", desktop_log),
|
||||
):
|
||||
if body:
|
||||
print(f"\n\n{'=' * 60}")
|
||||
print(title)
|
||||
print(f"{'=' * 60}\n")
|
||||
print(body)
|
||||
return
|
||||
|
||||
print(_PRIVACY_NOTICE)
|
||||
print("Collecting debug report...")
|
||||
print("Uploading...")
|
||||
|
||||
try:
|
||||
urls["Report"] = upload_to_pastebin(report, expiry_days=expiry)
|
||||
result = build_debug_share(
|
||||
log_lines=log_lines,
|
||||
expiry=expiry,
|
||||
redact=redact,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"\nUpload failed: {exc}", file=sys.stderr)
|
||||
print("\nFull report printed below — copy-paste it manually:\n")
|
||||
print(report)
|
||||
print("\nRun `hermes debug share --local` to print the report instead.\n")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Full agent.log (optional)
|
||||
if agent_log:
|
||||
try:
|
||||
urls["agent.log"] = upload_to_pastebin(agent_log, expiry_days=expiry)
|
||||
except Exception as exc:
|
||||
failures.append(f"agent.log: {exc}")
|
||||
|
||||
# 3. Full gateway.log (optional)
|
||||
if gateway_log:
|
||||
try:
|
||||
urls["gateway.log"] = upload_to_pastebin(gateway_log, expiry_days=expiry)
|
||||
except Exception as exc:
|
||||
failures.append(f"gateway.log: {exc}")
|
||||
|
||||
# Print results
|
||||
label_width = max(len(k) for k in urls)
|
||||
label_width = max(len(k) for k in result.urls)
|
||||
print(f"\nDebug report uploaded:")
|
||||
for label, url in urls.items():
|
||||
for label, url in result.urls.items():
|
||||
print(f" {label:<{label_width}} {url}")
|
||||
|
||||
if failures:
|
||||
print(f"\n (failed to upload: {', '.join(failures)})")
|
||||
if result.failures:
|
||||
print(f"\n (failed to upload: {', '.join(result.failures)})")
|
||||
|
||||
# Schedule auto-deletion after 6 hours
|
||||
_schedule_auto_delete(list(urls.values()))
|
||||
print(f"\n⏱ Pastes will auto-delete in 6 hours.")
|
||||
hours = result.auto_delete_seconds // 3600
|
||||
print(f"\n⏱ Pastes will auto-delete in {hours} hours.")
|
||||
|
||||
# Manual delete fallback
|
||||
print(f"To delete now: hermes debug delete <url>")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import os
|
|||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
|
|
@ -25,7 +24,6 @@ load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".en
|
|||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches
|
||||
|
||||
|
|
@ -49,7 +47,6 @@ _PROVIDER_ENV_HINTS = (
|
|||
"DEEPSEEK_API_KEY",
|
||||
"DASHSCOPE_API_KEY",
|
||||
"HF_TOKEN",
|
||||
"AI_GATEWAY_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"XIAOMI_API_KEY",
|
||||
|
|
@ -207,14 +204,123 @@ def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None
|
|||
issues.append(fix)
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read the ``version = "..."`` from ``pyproject.toml`` at the project root.
|
||||
|
||||
Returns None when running from an installed wheel (no pyproject.toml ships
|
||||
with the package) or when the file can't be parsed. Reads only the
|
||||
``[project]`` version, ignoring any version strings that appear in other
|
||||
tables.
|
||||
"""
|
||||
pyproject = PROJECT_ROOT / "pyproject.toml"
|
||||
try:
|
||||
text = pyproject.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
in_project = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
in_project = line == "[project]"
|
||||
continue
|
||||
if in_project and line.startswith("version") and "=" in line:
|
||||
value = line.split("=", 1)[1]
|
||||
value = value.split("#", 1)[0].strip().strip("\"'")
|
||||
return value or None
|
||||
return None
|
||||
|
||||
|
||||
def _check_version_consistency(issues: list[str]) -> None:
|
||||
"""Verify pyproject.toml version matches hermes_cli.__version__.
|
||||
|
||||
A git conflict resolution (reset/merge) can revert one file without the
|
||||
other, leaving ``hermes --version`` reporting a stale version while
|
||||
``pyproject.toml`` is current. Detect that drift so users can re-sync.
|
||||
Silent no-op for installed wheels where pyproject.toml isn't present.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import __version__ as init_version
|
||||
except Exception:
|
||||
return
|
||||
pyproject_version = _read_pyproject_version()
|
||||
if pyproject_version is None:
|
||||
# Installed wheel or unreadable pyproject — nothing to cross-check.
|
||||
return
|
||||
if pyproject_version == init_version:
|
||||
check_ok("Version files consistent", f"({init_version})")
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"Version mismatch between source files",
|
||||
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
|
||||
"Re-sync version files (e.g. run 'hermes update', or set "
|
||||
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
|
||||
issues,
|
||||
)
|
||||
|
||||
|
||||
def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Inside a container under our s6 /init, surface what s6 sees.
|
||||
|
||||
Runs as a counterpart to :func:`_check_gateway_service_linger` for
|
||||
the systemd-on-host case. No-op everywhere except in the s6
|
||||
container so host runs aren't cluttered with irrelevant output.
|
||||
|
||||
Reports:
|
||||
- Whether the main-hermes and dashboard static services are up
|
||||
- How many per-profile gateway slots are registered (via
|
||||
``S6ServiceManager.list_profile_gateways()``) and how many are
|
||||
currently supervised as ``up``
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.service_manager import (
|
||||
S6ServiceManager,
|
||||
detect_service_manager,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if detect_service_manager() != "s6":
|
||||
return
|
||||
|
||||
_section("s6 Supervision")
|
||||
|
||||
mgr = S6ServiceManager()
|
||||
|
||||
# Static services. They live under /run/service/ via s6-rc symlinks,
|
||||
# so the same s6-svstat probe works.
|
||||
for static in ("main-hermes", "dashboard"):
|
||||
if mgr.is_running(static):
|
||||
check_ok(f"{static}: up")
|
||||
else:
|
||||
check_info(f"{static}: down (expected if not enabled via env)")
|
||||
|
||||
profiles = mgr.list_profile_gateways()
|
||||
if not profiles:
|
||||
check_info("No per-profile gateways registered yet — create one with `hermes profile create <name>`")
|
||||
return
|
||||
|
||||
up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}"))
|
||||
check_ok(
|
||||
f"Per-profile gateways: {up_count}/{len(profiles)} supervised up"
|
||||
+ (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "")
|
||||
)
|
||||
|
||||
|
||||
def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
"""Warn when a systemd user gateway service will stop after logout."""
|
||||
"""Warn when a systemd user gateway service will stop after logout.
|
||||
|
||||
Skipped inside a container running under s6 — the linger concept
|
||||
(user-systemd surviving SSH logout) doesn't apply there, and the
|
||||
s6 supervision state is surfaced separately by
|
||||
``_check_s6_supervision``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.gateway import (
|
||||
get_systemd_linger_status,
|
||||
get_systemd_unit_path,
|
||||
is_linux,
|
||||
)
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
except Exception as e:
|
||||
check_warn("Gateway service linger", f"(could not import gateway helpers: {e})")
|
||||
return
|
||||
|
|
@ -222,6 +328,12 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
|
|||
if not is_linux():
|
||||
return
|
||||
|
||||
# Inside a container under our s6 /init, _check_s6_supervision
|
||||
# reports the live supervision state; the linger warning would be
|
||||
# confusing here (no systemd, no logout, no "lingering" concept).
|
||||
if detect_service_manager() == "s6":
|
||||
return
|
||||
|
||||
unit_path = get_systemd_unit_path()
|
||||
if not unit_path.exists():
|
||||
return
|
||||
|
|
@ -263,7 +375,6 @@ def _build_apikey_providers_list() -> list:
|
|||
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
|
||||
# MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
|
||||
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
|
||||
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
|
||||
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
|
||||
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
|
||||
# OpenCode Go has no shared /models endpoint; skip the health check.
|
||||
|
|
@ -279,7 +390,7 @@ def _build_apikey_providers_list() -> list:
|
|||
"Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek",
|
||||
"Hugging Face": "huggingface", "NVIDIA NIM": "nvidia",
|
||||
"Alibaba/DashScope": "alibaba", "MiniMax": "minimax",
|
||||
"MiniMax (China)": "minimax-cn", "Vercel AI Gateway": "ai-gateway",
|
||||
"MiniMax (China)": "minimax-cn",
|
||||
"Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen",
|
||||
"OpenCode Go": "opencode-go",
|
||||
}
|
||||
|
|
@ -452,6 +563,10 @@ def run_doctor(args):
|
|||
check_ok("Virtual environment active")
|
||||
else:
|
||||
check_warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
|
||||
# (a git conflict resolution can silently revert one but not the other).
|
||||
_check_version_consistency(issues)
|
||||
|
||||
_section("Required Packages")
|
||||
required_packages = [
|
||||
|
|
@ -508,6 +623,13 @@ def run_doctor(args):
|
|||
if should_fix:
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.touch()
|
||||
# .env holds API keys — restrict to owner-only access from
|
||||
# creation. touch() obeys umask which is commonly 0o022,
|
||||
# leaving the file world-readable; tighten explicitly.
|
||||
try:
|
||||
os.chmod(str(env_path), 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
check_ok(f"Created empty {_DHH}/.env")
|
||||
check_info("Run 'hermes setup' to configure API keys")
|
||||
fixed_count += 1
|
||||
|
|
@ -616,24 +738,31 @@ def run_doctor(args):
|
|||
issues,
|
||||
)
|
||||
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them.
|
||||
# Vendor/model slugs are valid on aggregator-style providers and on any custom
|
||||
# provider — bare "custom" or a named "custom:<name>" that fronts an OpenAI-compatible
|
||||
# aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.
|
||||
provider_for_policy = runtime_provider or catalog_provider
|
||||
provider_policy_id = str(provider_for_policy or "").strip().lower()
|
||||
providers_accepting_vendor_slugs = {
|
||||
"openrouter",
|
||||
"custom",
|
||||
"auto",
|
||||
"ai-gateway",
|
||||
"kilocode",
|
||||
"opencode-zen",
|
||||
"huggingface",
|
||||
"lmstudio",
|
||||
"nous",
|
||||
}
|
||||
provider_accepts_vendor_slug = (
|
||||
provider_policy_id in providers_accepting_vendor_slugs
|
||||
or provider_policy_id == "custom"
|
||||
or provider_policy_id.startswith("custom:")
|
||||
)
|
||||
if (
|
||||
default_model
|
||||
and "/" in default_model
|
||||
and provider_for_policy
|
||||
and provider_for_policy not in providers_accepting_vendor_slugs
|
||||
and provider_policy_id
|
||||
and not provider_accepts_vendor_slug
|
||||
):
|
||||
check_warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
|
|
@ -744,7 +873,18 @@ def run_doctor(args):
|
|||
"(should be under 'model:' section)"
|
||||
)
|
||||
if should_fix:
|
||||
model_section = raw_config.setdefault("model", {})
|
||||
# Coerce scalar/None ``model:`` into a dict before mutation —
|
||||
# ``setdefault("model", {})`` would return an existing scalar
|
||||
# and then ``model_section[k] = ...`` would raise TypeError.
|
||||
raw_model = raw_config.get("model")
|
||||
if isinstance(raw_model, dict):
|
||||
model_section = raw_model
|
||||
elif isinstance(raw_model, str) and raw_model.strip():
|
||||
model_section = {"default": raw_model.strip()}
|
||||
raw_config["model"] = model_section
|
||||
else:
|
||||
model_section = {}
|
||||
raw_config["model"] = model_section
|
||||
for k in stale_root_keys:
|
||||
if not model_section.get(k):
|
||||
model_section[k] = raw_config.pop(k)
|
||||
|
|
@ -759,6 +899,63 @@ def run_doctor(args):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing
|
||||
# agent.max_turns in config.yaml (issue #17534). The setup wizard
|
||||
# used to dual-write the iteration budget to both stores; users who
|
||||
# later edit only config.yaml are left with a .env ghost. The gateway
|
||||
# bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns
|
||||
# at startup, but if that bridge bails (any earlier config-parse
|
||||
# error), the stale .env value silently wins and the agent runs at the
|
||||
# wrong budget — e.g. config says 400 but the activity line reads N/90.
|
||||
# Read the .env FILE directly (load_env), not get_env_value/os.environ,
|
||||
# which the startup bridge may already have overridden.
|
||||
try:
|
||||
import yaml
|
||||
from hermes_cli.config import load_env, remove_env_value
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_config = yaml.safe_load(f) or {}
|
||||
agent_cfg = raw_config.get("agent")
|
||||
cfg_max_turns = (
|
||||
agent_cfg.get("max_turns")
|
||||
if isinstance(agent_cfg, dict)
|
||||
else None
|
||||
)
|
||||
# Legacy root-level key counts too.
|
||||
if cfg_max_turns is None:
|
||||
cfg_max_turns = raw_config.get("max_turns")
|
||||
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
|
||||
drift = (
|
||||
cfg_max_turns is not None
|
||||
and env_ghost is not None
|
||||
and str(cfg_max_turns).strip() != str(env_ghost).strip()
|
||||
)
|
||||
if drift:
|
||||
check_warn(
|
||||
f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows "
|
||||
f"agent.max_turns={cfg_max_turns} in config.yaml",
|
||||
"(stale ghost from an earlier `hermes setup` run)",
|
||||
)
|
||||
if should_fix:
|
||||
if remove_env_value("HERMES_MAX_ITERATIONS"):
|
||||
check_ok(
|
||||
"Removed stale HERMES_MAX_ITERATIONS from .env "
|
||||
f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)"
|
||||
)
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn("Could not remove HERMES_MAX_ITERATIONS from .env")
|
||||
manual_issues.append(
|
||||
"Manually delete the HERMES_MAX_ITERATIONS line from "
|
||||
f"{_DHH}/.env — config.yaml agent.max_turns is authoritative."
|
||||
)
|
||||
else:
|
||||
issues.append(
|
||||
"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — "
|
||||
"run 'hermes doctor --fix'"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Validate config structure (catches malformed custom_providers, etc.)
|
||||
try:
|
||||
from hermes_cli.config import validate_config_structure
|
||||
|
|
@ -954,7 +1151,53 @@ def run_doctor(args):
|
|||
conn.close()
|
||||
check_ok(f"{_DHH}/state.db exists ({count} sessions)")
|
||||
except Exception as e:
|
||||
check_warn(f"{_DHH}/state.db exists but has issues: {e}")
|
||||
from hermes_state import is_malformed_db_error, repair_state_db_schema
|
||||
|
||||
if is_malformed_db_error(e):
|
||||
# sqlite_master itself is malformed (e.g. duplicate
|
||||
# messages_fts) — every statement fails before it runs, so
|
||||
# this is NOT a plain FTS-index rebuild. Repair sqlite_master
|
||||
# in place (backup first; sessions/messages preserved).
|
||||
check_warn(
|
||||
f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)",
|
||||
f"({e})",
|
||||
)
|
||||
if should_fix:
|
||||
report = repair_state_db_schema(state_db_path)
|
||||
if report.get("repaired"):
|
||||
try:
|
||||
conn = sqlite3.connect(str(state_db_path))
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
except Exception:
|
||||
count = "?"
|
||||
backup_name = (
|
||||
Path(report["backup_path"]).name
|
||||
if report.get("backup_path") else "n/a"
|
||||
)
|
||||
check_ok(
|
||||
f"Repaired state.db schema ({count} sessions recovered)",
|
||||
f"(strategy: {report.get('strategy')}; backup: {backup_name})",
|
||||
)
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn(
|
||||
"state.db schema repair did not recover automatically",
|
||||
f"({report.get('error')}; backup: {report.get('backup_path')})",
|
||||
)
|
||||
issues.append(
|
||||
"state.db schema malformed and auto-repair failed — "
|
||||
"restore from the backup copy beside state.db"
|
||||
)
|
||||
else:
|
||||
issues.append(
|
||||
"state.db schema malformed — run 'hermes doctor --fix' "
|
||||
"(or 'hermes sessions repair') to recover hidden sessions"
|
||||
)
|
||||
else:
|
||||
check_warn(f"{_DHH}/state.db exists but has issues: {e}")
|
||||
else:
|
||||
check_info(f"{_DHH}/state.db not created yet (will be created on first session)")
|
||||
|
||||
|
|
@ -984,6 +1227,7 @@ def run_doctor(args):
|
|||
pass
|
||||
|
||||
_check_gateway_service_linger(issues)
|
||||
_check_s6_supervision(issues)
|
||||
|
||||
if sys.platform != "win32":
|
||||
_section("Command Installation")
|
||||
|
|
@ -1076,6 +1320,26 @@ def run_doctor(args):
|
|||
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
try:
|
||||
from hermes_constants import is_container as _is_container
|
||||
running_in_container = _is_container()
|
||||
except Exception:
|
||||
running_in_container = False
|
||||
|
||||
if running_in_container:
|
||||
# Inside our container the Docker terminal backend is not
|
||||
# configured by default (Docker-in-Docker isn't set up); the
|
||||
# local backend is the intended one. Skip the noisy "docker
|
||||
# not found" warning. If the user has explicitly chosen
|
||||
# TERMINAL_ENV=docker inside the container they likely mounted
|
||||
# /var/run/docker.sock, so fall through to the normal check.
|
||||
if terminal_env != "docker":
|
||||
check_info(
|
||||
"Running inside a container — using local terminal backend "
|
||||
"(docker-in-docker is not configured by default)"
|
||||
)
|
||||
# Skip to next section; Docker isn't relevant here.
|
||||
terminal_env = "local"
|
||||
if terminal_env == "docker":
|
||||
if _safe_which("docker"):
|
||||
# Check if docker daemon is running
|
||||
|
|
@ -1098,6 +1362,8 @@ def run_doctor(args):
|
|||
check_ok("docker", "(optional)")
|
||||
elif _is_termux():
|
||||
check_info("Docker backend is not available inside Termux (expected on Android)")
|
||||
elif running_in_container:
|
||||
pass # already explained above
|
||||
else:
|
||||
check_warn("docker not found", "(optional)")
|
||||
|
||||
|
|
@ -1160,68 +1426,6 @@ def run_doctor(args):
|
|||
issues,
|
||||
)
|
||||
|
||||
# Vercel Sandbox (if using vercel_sandbox backend)
|
||||
if terminal_env == "vercel_sandbox":
|
||||
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
|
||||
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
|
||||
if runtime in _SUPPORTED_VERCEL_RUNTIMES:
|
||||
check_ok("Vercel runtime", f"({runtime})")
|
||||
else:
|
||||
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
|
||||
_fail_and_issue(
|
||||
"Vercel runtime unsupported",
|
||||
f"({runtime}; use {supported})",
|
||||
f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}",
|
||||
issues,
|
||||
)
|
||||
|
||||
disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
|
||||
if disk in {"", "0", "51200"}:
|
||||
check_ok("Vercel disk setting", "(uses platform default)")
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"Vercel custom disk unsupported",
|
||||
"(reset terminal.container_disk to 51200)",
|
||||
"Vercel Sandbox does not support custom container_disk; use the shared default 51200",
|
||||
issues,
|
||||
)
|
||||
|
||||
if importlib.util.find_spec("vercel") is not None:
|
||||
check_ok("vercel SDK", "(installed)")
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"vercel SDK not installed",
|
||||
"(pip install 'hermes-agent[vercel]')",
|
||||
"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'",
|
||||
issues,
|
||||
)
|
||||
|
||||
auth_status = describe_vercel_auth()
|
||||
if auth_status.ok:
|
||||
check_ok("Vercel auth", f"({auth_status.label})")
|
||||
elif auth_status.label.startswith("partial"):
|
||||
_fail_and_issue(
|
||||
"Vercel auth incomplete",
|
||||
f"({auth_status.label})",
|
||||
"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
|
||||
issues,
|
||||
)
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"Vercel auth not configured",
|
||||
f"({auth_status.label})",
|
||||
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
|
||||
issues,
|
||||
)
|
||||
for line in auth_status.detail_lines:
|
||||
check_info(f"Vercel auth {line}")
|
||||
|
||||
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}
|
||||
if persistent:
|
||||
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
|
||||
else:
|
||||
check_info("Vercel persistence: ephemeral filesystem")
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if _safe_which("node"):
|
||||
check_ok("Node.js")
|
||||
|
|
@ -1304,18 +1508,29 @@ def run_doctor(args):
|
|||
# npm audit for all Node.js packages
|
||||
_npm_bin = _safe_which("npm")
|
||||
if _npm_bin:
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
|
||||
# Each entry: (cwd, label, extra_audit_args)
|
||||
# PROJECT_ROOT is audited with --workspaces=false so that the apps/*
|
||||
# glob (which pulls in Electron, node-pty, etc.) is never resolved
|
||||
# for a routine security check. The web and ui-tui workspaces are
|
||||
# audited separately via --workspace flags. See #38772.
|
||||
npm_audit_targets = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
|
||||
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
|
||||
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []),
|
||||
]
|
||||
for npm_dir, label in npm_dirs:
|
||||
if not (npm_dir / "node_modules").exists():
|
||||
for npm_dir, label, audit_extra in npm_audit_targets:
|
||||
# For workspace-scoped audits run from PROJECT_ROOT the
|
||||
# node_modules check must use the workspace root; standalone dirs
|
||||
# (whatsapp-bridge) check their own node_modules.
|
||||
check_dir = PROJECT_ROOT if audit_extra else npm_dir
|
||||
if not (check_dir / "node_modules").exists():
|
||||
continue
|
||||
try:
|
||||
# Use resolved absolute path so Windows can execute
|
||||
# npm.cmd (CreateProcessW can't run bare .cmd names).
|
||||
audit_result = subprocess.run(
|
||||
[_npm_bin, "audit", "--json"],
|
||||
[_npm_bin, "audit", "--json", *audit_extra],
|
||||
cwd=str(npm_dir),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
|
|
@ -1326,12 +1541,20 @@ def run_doctor(args):
|
|||
high = vuln_count.get("high", 0)
|
||||
moderate = vuln_count.get("moderate", 0)
|
||||
total = critical + high + moderate
|
||||
# Determine a scoped fix command for the remediation hint.
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
fix_scope = " ".join(audit_extra)
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
|
||||
elif audit_extra == ["--workspaces=false"]:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
|
||||
else:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix"
|
||||
if total == 0:
|
||||
check_ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
check_warn(
|
||||
f"{label} deps",
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)"
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
|
||||
)
|
||||
issues.append(
|
||||
f"{label} has {total} npm "
|
||||
|
|
@ -1871,7 +2094,15 @@ def run_doctor(args):
|
|||
_honcho_cfg_path = resolve_config_path()
|
||||
|
||||
if not _honcho_cfg_path.exists():
|
||||
check_warn("Honcho config not found", "run: hermes memory setup")
|
||||
# Config file missing — but env var fallback may have resolved it.
|
||||
# Only warn if the config didn't actually resolve from env vars.
|
||||
if hcfg.api_key or hcfg.base_url:
|
||||
check_ok(
|
||||
"Honcho configured via environment variables",
|
||||
f"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var",
|
||||
)
|
||||
else:
|
||||
check_warn("Honcho config not found", "run: hermes memory setup")
|
||||
elif not hcfg.enabled:
|
||||
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
|
||||
elif not (hcfg.api_key or hcfg.base_url):
|
||||
|
|
|
|||
|
|
@ -20,7 +20,15 @@ from agent.skill_utils import is_excluded_skill_path
|
|||
|
||||
|
||||
def _get_git_commit(project_root: Path) -> str:
|
||||
"""Return short git commit hash, or '(unknown)'."""
|
||||
"""Return short git commit hash, or '(unknown)'.
|
||||
|
||||
Source installs and dev images resolve this live via ``git rev-parse``.
|
||||
The published Docker image excludes ``.git`` from the build context, so
|
||||
that lookup always fails — we fall back to the baked-in build SHA written
|
||||
to ``<project_root>/.hermes_build_sha`` by the Dockerfile's
|
||||
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
|
||||
The output format is identical regardless of source.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=8", "HEAD"],
|
||||
|
|
@ -28,9 +36,23 @@ def _get_git_commit(project_root: Path) -> str:
|
|||
cwd=str(project_root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
value = result.stdout.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to the build-time baked SHA (populated in published Docker
|
||||
# images, absent otherwise). Defers the import so the dump module
|
||||
# stays cheap on non-dump code paths.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return baked
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "(unknown)"
|
||||
|
||||
|
||||
|
|
@ -279,7 +301,6 @@ def run_dump(args):
|
|||
("DASHSCOPE_API_KEY", "dashscope"),
|
||||
("HF_TOKEN", "huggingface"),
|
||||
("NVIDIA_API_KEY", "nvidia"),
|
||||
("AI_GATEWAY_API_KEY", "ai_gateway"),
|
||||
("OPENCODE_ZEN_API_KEY", "opencode_zen"),
|
||||
("OPENCODE_GO_API_KEY", "opencode_go"),
|
||||
("KILOCODE_API_KEY", "kilocode"),
|
||||
|
|
@ -297,6 +318,17 @@ def run_dump(args):
|
|||
display = _redact(val)
|
||||
else:
|
||||
display = "set" if val else "not set"
|
||||
# A credential added via `hermes auth add openrouter` lives in the
|
||||
# credential pool, not as an env var — surface it so the dump doesn't
|
||||
# misleadingly read "not set" while `hermes auth list` shows it (#42130).
|
||||
if not val and label == "openrouter":
|
||||
try:
|
||||
from agent.credential_pool import load_pool as _load_pool
|
||||
|
||||
if _load_pool("openrouter").has_credentials():
|
||||
display = "set (auth pool)"
|
||||
except Exception:
|
||||
pass
|
||||
lines.append(f" {label:<20} {display}")
|
||||
|
||||
# Features summary
|
||||
|
|
|
|||
|
|
@ -21,6 +21,68 @@ _CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY")
|
|||
# tests) don't spam the same warning multiple times.
|
||||
_WARNED_KEYS: set[str] = set()
|
||||
|
||||
# Map of env-var name → source label ("bitwarden", etc.) for credentials
|
||||
# that were injected by an external secret source during load_hermes_dotenv().
|
||||
# Used by setup / `hermes model` flows to label detected credentials so
|
||||
# users understand WHERE a key came from when their .env doesn't contain it
|
||||
# directly (otherwise the "credentials detected ✓" line looks identical to
|
||||
# the .env case and they don't know Bitwarden is wired up).
|
||||
_SECRET_SOURCES: dict[str, str] = {}
|
||||
|
||||
# HERMES_HOME paths we've already pulled external secrets for during this
|
||||
# process. ``load_hermes_dotenv()`` is called at module-import time from
|
||||
# several hot modules (cli.py, hermes_cli/main.py, run_agent.py,
|
||||
# trajectory_compressor.py, gateway/run.py, ...), so without this guard the
|
||||
# Bitwarden status line gets printed 3-5x per startup. Bitwarden's own
|
||||
# in-process cache prevents redundant network calls, but the print, the
|
||||
# config re-parse, and the ASCII sanitization sweep still ran every time.
|
||||
_APPLIED_HOMES: set[str] = set()
|
||||
|
||||
|
||||
def get_secret_source(env_var: str) -> str | None:
|
||||
"""Return the label of the secret source that supplied ``env_var``, if any.
|
||||
|
||||
Returns ``"bitwarden"`` for keys pulled from Bitwarden Secrets Manager
|
||||
during the current process's ``load_hermes_dotenv()`` call. Returns
|
||||
``None`` for keys that came from ``.env``, the shell environment, or
|
||||
aren't tracked. The returned label is metadata only: credential-pool
|
||||
persistence may store it to explain the origin of a borrowed secret, but
|
||||
must never treat it as authorization to persist the raw value.
|
||||
"""
|
||||
return _SECRET_SOURCES.get(env_var)
|
||||
|
||||
|
||||
def reset_secret_source_cache() -> None:
|
||||
"""Forget which HERMES_HOME paths have already had external secrets applied.
|
||||
|
||||
The first call to ``_apply_external_secret_sources(home_path)`` in a
|
||||
process pulls from Bitwarden (or other configured backend), records the
|
||||
applied keys in ``_SECRET_SOURCES``, and remembers ``home_path`` so
|
||||
subsequent calls in the same process are no-ops. Call this to force the
|
||||
next call to re-pull — useful for tests, and for long-running processes
|
||||
that want to refresh after a config change.
|
||||
"""
|
||||
_APPLIED_HOMES.clear()
|
||||
|
||||
|
||||
def format_secret_source_suffix(env_var: str) -> str:
|
||||
"""Return a human-readable suffix like ``" (from Bitwarden)"`` or ``""``.
|
||||
|
||||
Use this when printing a detected credential so the user can see where
|
||||
it came from. Empty string when the credential came from ``.env`` or
|
||||
the shell — those are the implicit / "default" cases users already
|
||||
understand.
|
||||
"""
|
||||
source = get_secret_source(env_var)
|
||||
if not source:
|
||||
return ""
|
||||
if source == "bitwarden":
|
||||
return " (from Bitwarden)"
|
||||
# Generic fallback — future-proofing for additional secret sources
|
||||
# (e.g. 1Password, HashiCorp Vault) without having to update every
|
||||
# call site.
|
||||
return f" (from {source})"
|
||||
|
||||
|
||||
def _format_offending_chars(value: str, limit: int = 3) -> str:
|
||||
"""Return a compact 'U+XXXX ('c'), ...' summary of non-ASCII codepoints."""
|
||||
|
|
@ -102,6 +164,10 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
|
|||
This produces mangled values — e.g. a bot token duplicated 8×
|
||||
(see #8908).
|
||||
|
||||
Also strips embedded null bytes which crash ``os.environ[k] = v``
|
||||
with ``ValueError: embedded null byte`` — typically introduced by
|
||||
copy-pasting API keys from terminals or rich-text editors.
|
||||
|
||||
We delegate to ``hermes_cli.config._sanitize_env_lines`` which
|
||||
already knows all valid Hermes env-var names and can split
|
||||
concatenated lines correctly.
|
||||
|
|
@ -117,7 +183,11 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
|
|||
try:
|
||||
with open(path, **read_kw) as f:
|
||||
original = f.readlines()
|
||||
sanitized = _sanitize_env_lines(original)
|
||||
# Strip null bytes before _sanitize_env_lines so they never
|
||||
# reach python-dotenv (which passes them to os.environ and
|
||||
# crashes with ValueError).
|
||||
stripped = [line.replace("\x00", "") for line in original]
|
||||
sanitized = _sanitize_env_lines(stripped)
|
||||
if sanitized != original:
|
||||
import tempfile
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
|
|
@ -184,7 +254,21 @@ def _apply_external_secret_sources(home_path: Path) -> None:
|
|||
locate the access token) but BEFORE the rest of Hermes reads
|
||||
``os.environ`` for credentials. Any failure here is logged and
|
||||
swallowed — external secret sources must never block startup.
|
||||
|
||||
Idempotent within a process: subsequent calls for the same
|
||||
``home_path`` are no-ops. ``load_hermes_dotenv()`` runs at import
|
||||
time from several hot modules (cli.py, hermes_cli/main.py,
|
||||
run_agent.py, trajectory_compressor.py, ...), so without this guard
|
||||
the Bitwarden status line would print 3-5x per CLI startup. Use
|
||||
``reset_secret_source_cache()`` if you need to force a re-pull
|
||||
(tests, future ``hermes secrets bitwarden sync`` from a long-running
|
||||
process).
|
||||
"""
|
||||
home_key = str(Path(home_path).resolve())
|
||||
if home_key in _APPLIED_HOMES:
|
||||
return
|
||||
_APPLIED_HOMES.add(home_key)
|
||||
|
||||
try:
|
||||
cfg = _load_secrets_config(home_path)
|
||||
except Exception: # noqa: BLE001 — config errors must not block startup
|
||||
|
|
@ -206,6 +290,8 @@ def _apply_external_secret_sources(home_path: Path) -> None:
|
|||
override_existing=bool(bw_cfg.get("override_existing", False)),
|
||||
cache_ttl_seconds=float(bw_cfg.get("cache_ttl_seconds", 300)),
|
||||
auto_install=bool(bw_cfg.get("auto_install", True)),
|
||||
server_url=str(bw_cfg.get("server_url", "") or "").strip(),
|
||||
home_path=home_path,
|
||||
)
|
||||
|
||||
if result.applied:
|
||||
|
|
@ -213,6 +299,12 @@ def _apply_external_secret_sources(home_path: Path) -> None:
|
|||
# and might have the same copy-paste corruption as a manually
|
||||
# edited .env (see #6843).
|
||||
_sanitize_loaded_credentials()
|
||||
# Remember where these came from so the setup / `hermes model`
|
||||
# flows can label detected credentials with "(from Bitwarden)" —
|
||||
# otherwise users see "credentials ✓" with no hint that the value
|
||||
# came from BSM rather than .env.
|
||||
for name in result.applied:
|
||||
_SECRET_SOURCES[name] = "bitwarden"
|
||||
print(
|
||||
f" Bitwarden Secrets Manager: applied {len(result.applied)} "
|
||||
f"secret{'s' if len(result.applied) != 1 else ''} "
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ from __future__ import annotations
|
|||
import copy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
|
@ -30,20 +32,11 @@ def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||
"""Return the normalized fallback chain as a list of dicts.
|
||||
|
||||
Accepts both the new list format (``fallback_providers``) and the legacy
|
||||
single-dict format (``fallback_model``). The returned list is always a
|
||||
fresh copy — callers can mutate without touching the config dict.
|
||||
``fallback_model`` format. When both are present, the effective chain is
|
||||
merged with ``fallback_providers`` entries kept first. The returned list is
|
||||
always a fresh copy — callers can mutate without touching the config dict.
|
||||
"""
|
||||
chain = config.get("fallback_providers") or []
|
||||
if isinstance(chain, list):
|
||||
result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")]
|
||||
if result:
|
||||
return result
|
||||
legacy = config.get("fallback_model")
|
||||
if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"):
|
||||
return [dict(legacy)]
|
||||
if isinstance(legacy, list):
|
||||
return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")]
|
||||
return []
|
||||
return get_fallback_chain(config)
|
||||
|
||||
|
||||
def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None:
|
||||
|
|
|
|||
72
hermes_cli/fallback_config.py
Normal file
72
hermes_cli/fallback_config.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Helpers for reading the effective fallback provider chain from config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalized_base_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(raw, dict):
|
||||
candidates = [raw]
|
||||
elif isinstance(raw, list):
|
||||
candidates = raw
|
||||
else:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for entry in candidates:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
provider = str(entry.get("provider") or "").strip()
|
||||
model = str(entry.get("model") or "").strip()
|
||||
if not provider or not model:
|
||||
continue
|
||||
|
||||
normalized = dict(entry)
|
||||
normalized["provider"] = provider
|
||||
normalized["model"] = model
|
||||
|
||||
base_url = _normalized_base_url(entry.get("base_url"))
|
||||
if base_url:
|
||||
normalized["base_url"] = base_url
|
||||
|
||||
entries.append(normalized)
|
||||
return entries
|
||||
|
||||
|
||||
def _entry_identity(entry: dict[str, Any]) -> tuple[str, str, str]:
|
||||
return (
|
||||
str(entry.get("provider") or "").strip().lower(),
|
||||
str(entry.get("model") or "").strip().lower(),
|
||||
_normalized_base_url(entry.get("base_url")).lower(),
|
||||
)
|
||||
|
||||
|
||||
def get_fallback_chain(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Return the effective fallback chain merged across old and new config keys.
|
||||
|
||||
``fallback_providers`` remains the primary source of truth and keeps its
|
||||
order. Legacy ``fallback_model`` entries are appended afterwards unless
|
||||
they target the same provider/model/base_url route as an earlier entry.
|
||||
The returned list always contains fresh dict copies.
|
||||
"""
|
||||
|
||||
config = config or {}
|
||||
chain: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
|
||||
for key in ("fallback_providers", "fallback_model"):
|
||||
for entry in _iter_fallback_entries(config.get(key)):
|
||||
identity = _entry_identity(entry)
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
chain.append(entry)
|
||||
|
||||
return chain
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,7 @@ Design notes
|
|||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
|
@ -52,6 +53,20 @@ _TASK_NAME_DEFAULT = "Hermes_Gateway"
|
|||
_TASK_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
|
||||
|
||||
|
||||
def _schtasks_encoding() -> str:
|
||||
"""Best-effort console encoding for decoding ``schtasks.exe`` output.
|
||||
|
||||
On localized Windows (e.g. Chinese), ``schtasks`` emits text in the OEM/ANSI
|
||||
code page rather than UTF-8. Decoding with the wrong codec raised
|
||||
``UnicodeDecodeError`` inside ``subprocess``' reader threads. Prefer the
|
||||
locale's preferred encoding and fall back to UTF-8.
|
||||
"""
|
||||
try:
|
||||
return locale.getpreferredencoding(False) or "utf-8"
|
||||
except Exception:
|
||||
return "utf-8"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -112,6 +127,12 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]:
|
|||
[schtasks, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# Localized Windows emits schtasks output in the console code page,
|
||||
# not UTF-8. Decode with the locale encoding and replace undecodable
|
||||
# bytes so a non-UTF-8 status line never surfaces a UnicodeDecodeError
|
||||
# traceback from subprocess' reader threads (issue #38172).
|
||||
encoding=_schtasks_encoding(),
|
||||
errors="replace",
|
||||
timeout=_SCHTASKS_TIMEOUT_S,
|
||||
# CREATE_NO_WINDOW avoids a flashing console window when the CLI
|
||||
# is itself hosted in a TUI. See tools/browser_tool.py for the
|
||||
|
|
@ -287,6 +308,29 @@ def get_startup_entry_path() -> Path:
|
|||
return _startup_dir() / f"{_sanitize_filename(get_task_name())}.cmd"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stable working directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stable_gateway_working_dir(project_root: Path) -> str:
|
||||
"""Return a stable cwd for detached/startup gateway runs.
|
||||
|
||||
Mirror the POSIX service invariant: anchor at ``HERMES_HOME`` whenever it
|
||||
exists so Scheduled Task / Startup launches do not fail at the ``cd`` step
|
||||
after a transient checkout or worktree is moved away. Fall back to the
|
||||
source checkout only if ``HERMES_HOME`` cannot be resolved yet.
|
||||
"""
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
try:
|
||||
home = get_hermes_home()
|
||||
if home and Path(home).is_dir():
|
||||
return str(Path(home).resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return str(project_root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -300,7 +344,7 @@ def _build_gateway_cmd_script(
|
|||
"""Build the ``gateway.cmd`` wrapper content (CRLF-terminated).
|
||||
|
||||
The script:
|
||||
- cd's into the project directory
|
||||
- cd's into a stable working directory
|
||||
- exports HERMES_HOME, PYTHONIOENCODING, VIRTUAL_ENV
|
||||
- invokes ``pythonw -m hermes_cli.main [--profile X] gateway run``
|
||||
directly so the wrapper cmd.exe exits without a visible gateway console
|
||||
|
|
@ -336,13 +380,26 @@ def _build_gateway_cmd_script(
|
|||
|
||||
|
||||
def _build_startup_launcher(script_path: Path) -> str:
|
||||
"""The tiny .cmd that goes in the Startup folder. Just minimizes and chains."""
|
||||
"""The tiny .cmd that goes in the Startup folder. Just minimizes and chains.
|
||||
|
||||
Defense-in-depth: bail out silently if the target script is gone. Test
|
||||
fixtures historically wrote Startup entries pointing at pytest tmp_path
|
||||
directories that vanish after the test session. Without the existence
|
||||
guard, every subsequent Windows login flashes a cmd.exe window that
|
||||
fails to find the target. The check + ``exit /b 0`` keeps that case
|
||||
silent.
|
||||
"""
|
||||
quoted_target = _quote_cmd_script_arg(str(script_path))
|
||||
lines = [
|
||||
"@echo off",
|
||||
f"rem {_TASK_DESCRIPTION}",
|
||||
# If the wrapper script is gone (typical for stale entries from
|
||||
# uninstalled/migrated installs), silently no-op instead of
|
||||
# flashing a cmd window with a "file not found" error.
|
||||
f"if not exist {quoted_target} exit /b 0",
|
||||
# ``start "" /min`` detaches with a minimized console window.
|
||||
# ``/d /c`` on cmd.exe skips AUTORUN and runs the target script once.
|
||||
f'start "" /min cmd.exe /d /c {_quote_cmd_script_arg(str(script_path))}',
|
||||
f'start "" /min cmd.exe /d /c {quoted_target}',
|
||||
]
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
|
@ -359,13 +416,15 @@ def _write_task_script() -> Path:
|
|||
)
|
||||
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
working_dir = _stable_gateway_working_dir(PROJECT_ROOT)
|
||||
hermes_home = str(Path(get_hermes_home()).resolve())
|
||||
profile_arg = _profile_arg(hermes_home)
|
||||
|
||||
content = _build_gateway_cmd_script(python_path, working_dir, hermes_home, profile_arg)
|
||||
script_path = get_task_script_path()
|
||||
script_path.write_text(content, encoding="utf-8", newline="")
|
||||
tmp = script_path.with_suffix(".tmp")
|
||||
tmp.write_text(content, encoding="utf-8", newline="")
|
||||
tmp.replace(script_path)
|
||||
return script_path
|
||||
|
||||
|
||||
|
|
@ -432,11 +491,15 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st
|
|||
return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}")
|
||||
|
||||
|
||||
|
||||
|
||||
def _install_startup_entry(script_path: Path) -> Path:
|
||||
"""Write the Startup-folder fallback launcher. Returns its path."""
|
||||
entry = get_startup_entry_path()
|
||||
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="")
|
||||
tmp = entry.with_suffix(".tmp")
|
||||
tmp.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="")
|
||||
tmp.replace(entry)
|
||||
return entry
|
||||
|
||||
|
||||
|
|
@ -522,7 +585,8 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
|
|||
)
|
||||
|
||||
python_exe, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path())
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
project_root = str(PROJECT_ROOT)
|
||||
working_dir = _stable_gateway_working_dir(PROJECT_ROOT)
|
||||
hermes_home = str(Path(get_hermes_home()).resolve())
|
||||
profile_arg = _profile_arg(hermes_home)
|
||||
|
||||
|
|
@ -537,7 +601,7 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
|
|||
"HERMES_GATEWAY_DETACHED": "1",
|
||||
"VIRTUAL_ENV": str(venv_dir),
|
||||
}
|
||||
_prepend_pythonpath(env_overlay, [working_dir, *extra_pythonpath] if extra_pythonpath else [])
|
||||
_prepend_pythonpath(env_overlay, [project_root, *extra_pythonpath] if extra_pythonpath else [project_root])
|
||||
return argv, working_dir, env_overlay
|
||||
|
||||
|
||||
|
|
@ -877,7 +941,10 @@ def uninstall() -> None:
|
|||
else:
|
||||
print(f"⚠ schtasks /Delete returned code {code}: {detail}")
|
||||
|
||||
for path, label in [(startup_entry, "Windows login item"), (script_path, "Task script")]:
|
||||
for path, label in [
|
||||
(startup_entry, "Windows login item"),
|
||||
(script_path, "Task script"),
|
||||
]:
|
||||
try:
|
||||
path.unlink()
|
||||
print(f"✓ Removed {label}: {path}")
|
||||
|
|
@ -935,6 +1002,139 @@ def _gateway_pids() -> list[int]:
|
|||
return list(find_gateway_pids())
|
||||
|
||||
|
||||
def _print_deep_probes() -> None:
|
||||
"""Print PASS/FAIL per individual probe of gateway liveness.
|
||||
|
||||
The default ``status`` output collapses several signals into one
|
||||
✓ / ✗ line, which is great when they agree and confusing when they
|
||||
don't. The deep-probe block shows each underlying check independently
|
||||
so the user can see exactly which signal is wrong.
|
||||
|
||||
Probes:
|
||||
[1] PID file present
|
||||
[2] Lock file present and held by some process
|
||||
[3] gateway.status.get_running_pid() returns a PID
|
||||
[4] _pid_exists(pid) — OS confirms the process is alive
|
||||
[5] gateway_state.json exists and parses (and is fresh-ish)
|
||||
[6] Last lifecycle event in gateway-exit-diag.log
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
home = Path(get_hermes_home()).resolve()
|
||||
pid_path = home / "gateway.pid"
|
||||
lock_path = home / "gateway.lock"
|
||||
state_path = home / "gateway_state.json"
|
||||
diag_path = home / "logs" / "gateway-exit-diag.log"
|
||||
|
||||
print()
|
||||
print("Deep probes:")
|
||||
|
||||
def _mark(ok: bool) -> str:
|
||||
return "PASS" if ok else "FAIL"
|
||||
|
||||
# [1] PID file
|
||||
pid_exists = pid_path.exists()
|
||||
pid_value: int | None = None
|
||||
if pid_exists:
|
||||
try:
|
||||
data = json.loads(pid_path.read_text(encoding="utf-8"))
|
||||
pid_value = int(data.get("pid")) if data.get("pid") is not None else None
|
||||
print(f" [1] {_mark(True):4s} PID file present: {pid_path} (pid={pid_value})")
|
||||
except Exception as exc:
|
||||
print(f" [1] {_mark(False):4s} PID file present but unreadable: {exc}")
|
||||
else:
|
||||
print(f" [1] {_mark(False):4s} PID file missing: {pid_path}")
|
||||
|
||||
# [2] Lock file present + held
|
||||
lock_held = False
|
||||
lock_present = lock_path.exists()
|
||||
if lock_present:
|
||||
try:
|
||||
from gateway.status import is_gateway_runtime_lock_active
|
||||
|
||||
lock_held = is_gateway_runtime_lock_active(lock_path)
|
||||
print(f" [2] {_mark(lock_held):4s} Lock file held by a live process: {lock_path}")
|
||||
except Exception as exc:
|
||||
print(f" [2] {_mark(False):4s} Could not probe lock: {exc}")
|
||||
else:
|
||||
print(f" [2] {_mark(False):4s} Lock file missing: {lock_path}")
|
||||
|
||||
# [3] get_running_pid()
|
||||
running_pid: int | None = None
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
running_pid = get_running_pid(cleanup_stale=False)
|
||||
print(f" [3] {_mark(running_pid is not None):4s} get_running_pid() => {running_pid}")
|
||||
except Exception as exc:
|
||||
print(f" [3] {_mark(False):4s} get_running_pid() raised: {exc!r}")
|
||||
|
||||
# [4] _pid_exists() on the probed PID
|
||||
candidate_pid = running_pid if running_pid is not None else pid_value
|
||||
if candidate_pid is not None:
|
||||
try:
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
alive = bool(_pid_exists(candidate_pid))
|
||||
print(f" [4] {_mark(alive):4s} _pid_exists({candidate_pid}) => {alive}")
|
||||
except Exception as exc:
|
||||
print(f" [4] {_mark(False):4s} _pid_exists raised: {exc!r}")
|
||||
else:
|
||||
print(f" [4] {_mark(False):4s} No candidate PID to verify")
|
||||
|
||||
# [5] runtime status file
|
||||
if state_path.exists():
|
||||
try:
|
||||
state_data = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
gateway_state = state_data.get("gateway_state")
|
||||
updated_at = state_data.get("updated_at")
|
||||
age_str = ""
|
||||
if updated_at:
|
||||
try:
|
||||
updated_dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
age_seconds = int((now - updated_dt).total_seconds())
|
||||
age_str = f" (updated {age_seconds}s ago)"
|
||||
except Exception:
|
||||
pass
|
||||
ok = gateway_state == "running"
|
||||
print(f" [5] {_mark(ok):4s} gateway_state.json state={gateway_state!r}{age_str}")
|
||||
except Exception as exc:
|
||||
print(f" [5] {_mark(False):4s} gateway_state.json present but unreadable: {exc}")
|
||||
else:
|
||||
print(f" [5] {_mark(False):4s} gateway_state.json missing: {state_path}")
|
||||
|
||||
# [6] Last lifecycle event from the exit-diag log
|
||||
if diag_path.exists():
|
||||
try:
|
||||
with open(diag_path, "rb") as fh:
|
||||
# Read last ~4KB; one event is well under 500 bytes.
|
||||
fh.seek(0, 2)
|
||||
size = fh.tell()
|
||||
fh.seek(max(0, size - 4096))
|
||||
tail = fh.read().decode("utf-8", errors="replace").splitlines()
|
||||
last_event = next((ln for ln in reversed(tail) if ln.strip()), "")
|
||||
if last_event:
|
||||
try:
|
||||
event = json.loads(last_event)
|
||||
tag = event.get("tag", "?")
|
||||
pid = event.get("pid", "?")
|
||||
ts = event.get("ts", "?")
|
||||
healthy = tag in ("gateway.start",)
|
||||
print(f" [6] {_mark(healthy):4s} Last lifecycle event: tag={tag} pid={pid} ts={ts}")
|
||||
except Exception:
|
||||
print(f" [6] {_mark(False):4s} Last lifecycle line not JSON: {last_event[:120]}")
|
||||
else:
|
||||
print(f" [6] {_mark(False):4s} exit-diag log empty: {diag_path}")
|
||||
except Exception as exc:
|
||||
print(f" [6] {_mark(False):4s} exit-diag log unreadable: {exc}")
|
||||
else:
|
||||
print(f" [6] {_mark(False):4s} exit-diag log missing: {diag_path}")
|
||||
|
||||
|
||||
def status(deep: bool = False) -> None:
|
||||
"""Print a status report for the Windows gateway service."""
|
||||
_assert_windows()
|
||||
|
|
@ -962,9 +1162,12 @@ def status(deep: bool = False) -> None:
|
|||
|
||||
if deep:
|
||||
print()
|
||||
print(f" Task name: {task_name}")
|
||||
print(f" Task script: {get_task_script_path()}")
|
||||
print(f" Startup entry: {get_startup_entry_path()}")
|
||||
print(f" Task name: {task_name}")
|
||||
print(f" Task script: {get_task_script_path()}")
|
||||
print(f" Startup entry: {get_startup_entry_path()}")
|
||||
# Surface the per-probe truth so the user can see *which* signal
|
||||
# is lying when the high-level summary disagrees with reality.
|
||||
_print_deep_probes()
|
||||
|
||||
if not task_installed and not startup_installed and not pids:
|
||||
print()
|
||||
|
|
@ -1010,12 +1213,70 @@ def start() -> None:
|
|||
_report_gateway_start(f"direct spawn (PID {pid})")
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Stop the gateway. Tries /End on the scheduled task, then kills any stragglers."""
|
||||
_assert_windows()
|
||||
from hermes_cli.gateway import kill_gateway_processes
|
||||
def _drain_gateway_pid(pid: int, drain_timeout: float) -> bool:
|
||||
"""Write the planned-stop marker and wait for the gateway PID to exit.
|
||||
|
||||
stopped_any = False
|
||||
Windows cannot deliver POSIX signals to a Python asyncio loop
|
||||
(``loop.add_signal_handler`` raises NotImplementedError), so writing
|
||||
the marker is the ONLY way to ask a running gateway to drain
|
||||
in-flight agents and persist ``resume_pending`` before exit. The
|
||||
gateway's planned-stop watcher thread (gateway/run.py) polls for
|
||||
the marker and drives the same shutdown path the SIGTERM handler
|
||||
would have on POSIX.
|
||||
|
||||
Returns True if the PID exited within the timeout, False if it
|
||||
didn't (caller should escalate to schtasks /End + taskkill).
|
||||
"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
from gateway.status import write_planned_stop_marker, _pid_exists
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
try:
|
||||
write_planned_stop_marker(pid)
|
||||
except Exception:
|
||||
# Best-effort: if the marker can't be written, we have no choice
|
||||
# but to fall through to a hard kill. Caller decides escalation.
|
||||
pass
|
||||
|
||||
deadline = time.monotonic() + max(drain_timeout, 1.0)
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_exists(pid):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Stop the gateway.
|
||||
|
||||
Writes the planned-stop marker first so the gateway can drain
|
||||
in-flight agents and persist ``resume_pending`` before exit (the
|
||||
gateway's marker-watcher thread picks this up — Windows asyncio
|
||||
can't deliver SIGTERM to the loop, so the marker is our only IPC).
|
||||
Then escalates: ``schtasks /End`` (kills the scheduled-task tree)
|
||||
+ ``kill_gateway_processes(force=True)`` for any strays.
|
||||
"""
|
||||
_assert_windows()
|
||||
from hermes_cli.gateway import kill_gateway_processes, _get_restart_drain_timeout
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
# Phase 1: ask the running gateway (if any) to drain itself by writing
|
||||
# the planned-stop marker, then wait briefly for it to exit cleanly.
|
||||
# On clean exit, sessions land with resume_pending=True and the next
|
||||
# boot will auto-resume them.
|
||||
pid = get_running_pid()
|
||||
drained = False
|
||||
if pid is not None:
|
||||
try:
|
||||
drain_timeout = float(_get_restart_drain_timeout() or 30.0)
|
||||
except Exception:
|
||||
drain_timeout = 30.0
|
||||
drained = _drain_gateway_pid(pid, drain_timeout)
|
||||
|
||||
stopped_any = drained
|
||||
if is_task_registered():
|
||||
code, _out, err = _exec_schtasks(["/End", "/TN", get_task_name()])
|
||||
# schtasks returns nonzero when the task isn't currently running — don't treat that as an error.
|
||||
|
|
@ -1024,12 +1285,19 @@ def stop() -> None:
|
|||
elif "not running" not in (err or "").lower():
|
||||
print(f"⚠ schtasks /End returned code {code}: {err.strip()}")
|
||||
|
||||
killed = kill_gateway_processes(all_profiles=False)
|
||||
# Phase 3: hard-kill any strays. When drain succeeded this is a no-op;
|
||||
# when drain timed out this is the escalation that ensures the PID
|
||||
# actually exits. Use force=True on Windows so taskkill /T /F walks
|
||||
# the descendant tree (browser helpers, etc.).
|
||||
killed = kill_gateway_processes(all_profiles=False, force=not drained)
|
||||
if killed:
|
||||
stopped_any = True
|
||||
print(f"✓ Killed {killed} gateway process(es)")
|
||||
if stopped_any:
|
||||
print("✓ Gateway stopped")
|
||||
if drained:
|
||||
print("✓ Gateway stopped (drained cleanly)")
|
||||
else:
|
||||
print("✓ Gateway stopped")
|
||||
else:
|
||||
print("✗ No gateway was running")
|
||||
|
||||
|
|
|
|||
|
|
@ -747,6 +747,153 @@ class GoalManager:
|
|||
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Kanban worker goal loop
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Continuation prompt fed back to a kanban goal-mode worker that has not
|
||||
# yet completed/blocked its task. The card's own acceptance criteria are
|
||||
# the goal — the worker already has the full task body in its first turn,
|
||||
# so we keep this short and point it back at the lifecycle contract.
|
||||
KANBAN_GOAL_CONTINUATION_TEMPLATE = (
|
||||
"[Continuing toward this kanban task — judge says it is not done yet]\n"
|
||||
"Reason: {reason}\n\n"
|
||||
"Take the next concrete step toward completing the task. When the work "
|
||||
"is genuinely finished, call kanban_complete with a summary. If you are "
|
||||
"blocked and need human input, call kanban_block with a reason. Do not "
|
||||
"stop without calling one of them."
|
||||
)
|
||||
|
||||
# Fed when the judge believes the work is done but the worker never called
|
||||
# kanban_complete / kanban_block. One explicit nudge to terminate the task
|
||||
# the right way before the loop gives up.
|
||||
KANBAN_GOAL_FINALIZE_TEMPLATE = (
|
||||
"[The work looks complete, but the task is still open]\n"
|
||||
"Reason: {reason}\n\n"
|
||||
"If the task is genuinely done, call kanban_complete now with a short "
|
||||
"summary of what you did. If something still blocks completion, call "
|
||||
"kanban_block with the reason instead."
|
||||
)
|
||||
|
||||
|
||||
def run_kanban_goal_loop(
|
||||
*,
|
||||
task_id: str,
|
||||
goal_text: str,
|
||||
run_turn,
|
||||
task_status_fn,
|
||||
block_fn,
|
||||
max_turns: int = DEFAULT_MAX_TURNS,
|
||||
first_response: str = "",
|
||||
log=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Drive a kanban worker through a Ralph-style goal loop.
|
||||
|
||||
The dispatcher spawns a goal-mode worker exactly like a normal worker
|
||||
(``hermes -p <profile> chat -q "work kanban task <id>"``). The worker's
|
||||
first turn has already run by the time this is called; ``first_response``
|
||||
is that turn's reply. From here we:
|
||||
|
||||
1. Check whether the worker already terminated the task (called
|
||||
``kanban_complete`` / ``kanban_block``). If so, stop — nothing to do.
|
||||
2. Otherwise judge the latest response against ``goal_text`` (the card's
|
||||
title + body). ``continue`` → feed a continuation prompt and run
|
||||
another turn IN THE SAME SESSION via ``run_turn``. ``done`` but the
|
||||
task is still open → one explicit "call kanban_complete" nudge.
|
||||
3. When the turn budget is exhausted and the worker still hasn't
|
||||
terminated the task, ``block_fn`` is invoked so the card lands in a
|
||||
sticky ``blocked`` state for human review (NOT a silent exit).
|
||||
|
||||
This function performs NO SessionDB persistence — a worker process is
|
||||
ephemeral, so the turn budget lives in a local counter. It is fully
|
||||
decoupled from the CLI for testability: callers inject ``run_turn``
|
||||
(str -> str), ``task_status_fn`` (() -> str|None), and ``block_fn``
|
||||
(reason: str -> None).
|
||||
|
||||
Returns a decision dict: ``{"outcome", "turns_used", "reason"}`` where
|
||||
outcome is one of ``"completed_by_worker"``, ``"blocked_budget"``,
|
||||
``"blocked_by_worker"``, or ``"stopped"``.
|
||||
"""
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log is not None:
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_turns = int(max_turns or DEFAULT_MAX_TURNS)
|
||||
if max_turns < 1:
|
||||
max_turns = DEFAULT_MAX_TURNS
|
||||
|
||||
last_response = first_response or ""
|
||||
# The first turn already consumed one unit of budget.
|
||||
turns_used = 1
|
||||
nudged_to_finalize = False
|
||||
|
||||
while True:
|
||||
# Did the worker terminate the task itself this turn?
|
||||
try:
|
||||
status = task_status_fn()
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: status check failed ({exc}); stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": "status check failed"}
|
||||
|
||||
if status == "done":
|
||||
_log(f"kanban goal loop: task {task_id} completed by worker after {turns_used} turn(s)")
|
||||
return {"outcome": "completed_by_worker", "turns_used": turns_used, "reason": "worker completed the task"}
|
||||
if status == "blocked":
|
||||
_log(f"kanban goal loop: task {task_id} blocked by worker after {turns_used} turn(s)")
|
||||
return {"outcome": "blocked_by_worker", "turns_used": turns_used, "reason": "worker blocked the task"}
|
||||
if status not in ("running", "ready"):
|
||||
# Reclaimed / archived / unexpected — let the dispatcher own it.
|
||||
_log(f"kanban goal loop: task {task_id} status={status!r}; stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"status={status}"}
|
||||
|
||||
# Still open — judge whether the latest response satisfies the card.
|
||||
verdict, reason, _parse_failed = judge_goal(goal_text, last_response)
|
||||
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
|
||||
|
||||
if verdict == "done":
|
||||
if nudged_to_finalize:
|
||||
# Already asked once to call kanban_complete and it still
|
||||
# didn't — block for review rather than spin.
|
||||
_log(f"kanban goal loop: task {task_id} judged done but worker won't finalize; blocking")
|
||||
try:
|
||||
block_fn(
|
||||
f"Goal-mode worker's output looked complete but it never "
|
||||
f"called kanban_complete after a finalize nudge ({reason})."
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: block_fn failed ({exc})")
|
||||
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "judged done, never finalized"}
|
||||
prompt = KANBAN_GOAL_FINALIZE_TEMPLATE.format(reason=_truncate(reason, 400))
|
||||
nudged_to_finalize = True
|
||||
else:
|
||||
prompt = KANBAN_GOAL_CONTINUATION_TEMPLATE.format(reason=_truncate(reason, 400))
|
||||
|
||||
# Budget check BEFORE spending another turn.
|
||||
if turns_used >= max_turns:
|
||||
_log(f"kanban goal loop: task {task_id} exhausted {turns_used}/{max_turns} turns; blocking")
|
||||
try:
|
||||
block_fn(
|
||||
f"Goal-mode worker exhausted its turn budget "
|
||||
f"({turns_used}/{max_turns}) without completing the task. "
|
||||
f"Last judge verdict: {_truncate(reason, 300)}"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: block_fn failed ({exc})")
|
||||
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "turn budget exhausted"}
|
||||
|
||||
# Run another turn in the same session.
|
||||
try:
|
||||
last_response = run_turn(prompt) or ""
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: run_turn failed ({exc}); stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"run_turn error: {type(exc).__name__}"}
|
||||
turns_used += 1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GoalState",
|
||||
"GoalManager",
|
||||
|
|
@ -754,9 +901,12 @@ __all__ = [
|
|||
"CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE",
|
||||
"JUDGE_USER_PROMPT_TEMPLATE",
|
||||
"JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE",
|
||||
"KANBAN_GOAL_CONTINUATION_TEMPLATE",
|
||||
"KANBAN_GOAL_FINALIZE_TEMPLATE",
|
||||
"DEFAULT_MAX_TURNS",
|
||||
"load_goal",
|
||||
"save_goal",
|
||||
"clear_goal",
|
||||
"judge_goal",
|
||||
"run_kanban_goal_loop",
|
||||
]
|
||||
|
|
|
|||
285
hermes_cli/gui_uninstall.py
Normal file
285
hermes_cli/gui_uninstall.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""
|
||||
Hermes Desktop (Chat GUI) uninstaller.
|
||||
|
||||
The desktop GUI ships in two shapes and this module knows how to find and
|
||||
remove the artifacts of both, on Linux, macOS, and Windows, WITHOUT touching
|
||||
the Python agent or the user's config/data:
|
||||
|
||||
1. Source-built GUI (``hermes desktop`` / ``hermes gui``)
|
||||
Built inside the agent checkout under ``$HERMES_HOME/hermes-agent/``:
|
||||
- ``apps/desktop/dist`` (compiled renderer)
|
||||
- ``apps/desktop/release`` (electron-builder unpacked app + installers)
|
||||
- ``apps/desktop/node_modules`` and the workspace-root ``node_modules``
|
||||
(Electron itself, ~200MB) — only removed on a GUI uninstall because
|
||||
the agent does not need them.
|
||||
- ``$HERMES_HOME/desktop-build-stamp.json`` (the build freshness stamp)
|
||||
|
||||
2. Packaged distributable (DMG / NSIS / AppImage / deb / rpm)
|
||||
Installed by the OS to a standard application location and carrying its
|
||||
own bundled Electron + a per-user Electron ``userData`` directory:
|
||||
- macOS: ``/Applications/Hermes.app`` or ``~/Applications/Hermes.app``
|
||||
- Windows: ``%LOCALAPPDATA%\\Programs\\Hermes`` (NSIS per-user)
|
||||
- Linux: ``~/.local/share/applications`` .desktop entry + AppImage
|
||||
|
||||
In both shapes the Electron runtime keeps a ``userData`` directory keyed on
|
||||
the app name ("Hermes"), separate from ``$HERMES_HOME``:
|
||||
- macOS: ``~/Library/Application Support/Hermes``
|
||||
- Windows: ``%APPDATA%\\Hermes``
|
||||
- Linux: ``$XDG_CONFIG_HOME/Hermes`` (default ``~/.config/Hermes``)
|
||||
|
||||
This holds the desktop's own ``connection.json`` / ``updates.json`` and
|
||||
Chromium cache — pure GUI state, safe to remove on a GUI uninstall.
|
||||
|
||||
The functions here are deliberately import-light and side-effect-free at
|
||||
import time so the Electron main process can shell out to
|
||||
``hermes uninstall --gui`` (and friends) without paying for the full CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
def log_info(msg: str):
|
||||
print(f"{color('→', Colors.CYAN)} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str):
|
||||
print(f"{color('✓', Colors.GREEN)} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str):
|
||||
print(f"{color('⚠', Colors.YELLOW)} {msg}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _agent_root(hermes_home: Path) -> Path:
|
||||
"""The agent checkout root — same layout install.sh / install.ps1 use."""
|
||||
return hermes_home / "hermes-agent"
|
||||
|
||||
|
||||
def desktop_userdata_dir() -> Path:
|
||||
"""Return the Electron ``userData`` directory for the desktop app.
|
||||
|
||||
Mirrors Electron's ``app.getPath('userData')`` for an app named "Hermes"
|
||||
on each platform. This is GUI-only state (connection.json, updates.json,
|
||||
Chromium cache) and never holds agent config or sessions.
|
||||
"""
|
||||
home = Path.home()
|
||||
if sys.platform == "darwin":
|
||||
return home / "Library" / "Application Support" / "Hermes"
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
base = Path(appdata) if appdata else (home / "AppData" / "Roaming")
|
||||
return base / "Hermes"
|
||||
# Linux / other POSIX — XDG config home.
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else (home / ".config")
|
||||
return base / "Hermes"
|
||||
|
||||
|
||||
def source_built_gui_artifacts(hermes_home: Path) -> "list[Path]":
|
||||
"""GUI build artifacts produced by ``hermes desktop`` inside the checkout.
|
||||
|
||||
These are removable on a GUI uninstall without harming the agent: the
|
||||
Python agent runs from ``hermes-agent/`` source + ``venv/`` and never
|
||||
needs the Electron build output or node_modules.
|
||||
"""
|
||||
agent_root = _agent_root(hermes_home)
|
||||
desktop_dir = agent_root / "apps" / "desktop"
|
||||
return [
|
||||
desktop_dir / "dist",
|
||||
desktop_dir / "release",
|
||||
desktop_dir / "node_modules",
|
||||
# Workspace-root node_modules carries Electron (devDependency of the
|
||||
# desktop workspace, ~200MB). The agent does not use any npm package,
|
||||
# so this is GUI tooling — safe to drop on a GUI uninstall.
|
||||
agent_root / "node_modules",
|
||||
hermes_home / "desktop-build-stamp.json",
|
||||
]
|
||||
|
||||
|
||||
def packaged_gui_app_paths() -> "list[Path]":
|
||||
"""Standard install locations of the packaged desktop distributable.
|
||||
|
||||
Returns every candidate for the current OS; the caller filters to those
|
||||
that actually exist. We never glob system-wide — only the well-known
|
||||
electron-builder output locations for the "Hermes" product.
|
||||
"""
|
||||
home = Path.home()
|
||||
paths: list[Path] = []
|
||||
if sys.platform == "darwin":
|
||||
paths += [
|
||||
Path("/Applications/Hermes.app"),
|
||||
home / "Applications" / "Hermes.app",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
local_base = Path(local) if local else (home / "AppData" / "Local")
|
||||
paths += [
|
||||
# NSIS per-user install (perMachine=false → Programs\Hermes).
|
||||
local_base / "Programs" / "Hermes",
|
||||
# Older / alternate layout some builds used.
|
||||
local_base / "hermes-desktop",
|
||||
]
|
||||
program_files = os.environ.get("ProgramFiles")
|
||||
if program_files:
|
||||
# NSIS per-machine fallback (needs admin to remove).
|
||||
paths.append(Path(program_files) / "Hermes")
|
||||
else:
|
||||
# Linux: AppImage is a single file the user placed somewhere; we can
|
||||
# only reliably clean the desktop entry + icon we know the name of.
|
||||
# The AppImage itself lives wherever the user put it, so we surface a
|
||||
# hint rather than guessing. deb/rpm installs are owned by the system
|
||||
# package manager and must be removed via apt/dnf — see the message in
|
||||
# ``uninstall_gui``.
|
||||
data = os.environ.get("XDG_DATA_HOME")
|
||||
data_base = Path(data) if data else (home / ".local" / "share")
|
||||
paths += [
|
||||
data_base / "applications" / "hermes.desktop",
|
||||
data_base / "applications" / "Hermes.desktop",
|
||||
]
|
||||
return paths
|
||||
|
||||
|
||||
def agent_is_installed(hermes_home: Path) -> bool:
|
||||
"""Return True when a usable Python agent install exists under HERMES_HOME.
|
||||
|
||||
Used by the desktop UI to decide which uninstall options to offer: if the
|
||||
agent isn't present (a future "lite" GUI-only client), the "remove agent"
|
||||
options are hidden.
|
||||
"""
|
||||
agent_root = _agent_root(hermes_home)
|
||||
# A real install has the package source + a venv. Either signal alone is
|
||||
# enough — a source checkout without a venv is still "the agent is here".
|
||||
if (agent_root / "hermes_cli").is_dir():
|
||||
return True
|
||||
if (agent_root / "venv").is_dir() or (agent_root / ".venv").is_dir():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def gui_is_installed(hermes_home: Path) -> bool:
|
||||
"""Return True when any desktop GUI artifact exists (built or packaged)."""
|
||||
for p in source_built_gui_artifacts(hermes_home):
|
||||
if p.exists():
|
||||
return True
|
||||
for p in packaged_gui_app_paths():
|
||||
if p.exists():
|
||||
return True
|
||||
if desktop_userdata_dir().exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def gui_install_summary(hermes_home: "Path | None" = None) -> dict:
|
||||
"""Structured snapshot of what's installed, for the desktop UI to render.
|
||||
|
||||
Returns JSON-serializable primitives so the Electron main process can
|
||||
forward it to the renderer via IPC (paths as strings, booleans for the
|
||||
high-level questions the UI gates options on).
|
||||
"""
|
||||
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
|
||||
|
||||
source_artifacts = [p for p in source_built_gui_artifacts(home) if p.exists()]
|
||||
packaged = [p for p in packaged_gui_app_paths() if p.exists()]
|
||||
userdata = desktop_userdata_dir()
|
||||
|
||||
return {
|
||||
"hermes_home": str(home),
|
||||
"agent_installed": agent_is_installed(home),
|
||||
"gui_installed": gui_is_installed(home),
|
||||
"source_built_artifacts": [str(p) for p in source_artifacts],
|
||||
"packaged_app_paths": [str(p) for p in packaged],
|
||||
"userdata_dir": str(userdata),
|
||||
"userdata_exists": userdata.exists(),
|
||||
"platform": sys.platform,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _remove_path(path: Path) -> bool:
|
||||
"""Remove a file or directory tree. Returns True when something was removed."""
|
||||
try:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink()
|
||||
return True
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def uninstall_gui(hermes_home: "Path | None" = None, *, remove_userdata: bool = True) -> "list[Path]":
|
||||
"""Remove the desktop GUI's artifacts, leaving the agent + user data intact.
|
||||
|
||||
Removes:
|
||||
- source-built GUI artifacts (dist/release/node_modules/build-stamp)
|
||||
- the packaged app bundle / install dir (best-effort; deb/rpm need the
|
||||
system package manager and are reported, not force-removed)
|
||||
- the Electron ``userData`` directory (unless ``remove_userdata=False``)
|
||||
|
||||
Never touches ``hermes-agent/hermes_cli`` (agent source), ``venv/``, or any
|
||||
config / sessions / .env under ``$HERMES_HOME``.
|
||||
|
||||
Returns the list of paths actually removed.
|
||||
"""
|
||||
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
|
||||
|
||||
removed: list[Path] = []
|
||||
|
||||
log_info("Removing built GUI artifacts (renderer, release, node_modules)...")
|
||||
for path in source_built_gui_artifacts(home):
|
||||
if path.exists() and _remove_path(path):
|
||||
log_success(f"Removed {path}")
|
||||
removed.append(path)
|
||||
|
||||
log_info("Removing installed desktop app...")
|
||||
found_packaged = False
|
||||
for path in packaged_gui_app_paths():
|
||||
if path.exists():
|
||||
found_packaged = True
|
||||
if _remove_path(path):
|
||||
log_success(f"Removed {path}")
|
||||
removed.append(path)
|
||||
if not found_packaged:
|
||||
log_info("No packaged desktop app found in standard locations")
|
||||
|
||||
if remove_userdata:
|
||||
userdata = desktop_userdata_dir()
|
||||
if userdata.exists():
|
||||
log_info("Removing desktop app data (Electron userData)...")
|
||||
if _remove_path(userdata):
|
||||
log_success(f"Removed {userdata}")
|
||||
removed.append(userdata)
|
||||
|
||||
if not removed:
|
||||
log_info("No desktop GUI artifacts found to remove")
|
||||
|
||||
# Linux deb/rpm installs are owned by the package manager; we can't (and
|
||||
# shouldn't) rmtree files under /usr. Surface the hint so the user can
|
||||
# finish the job. AppImages live wherever the user dropped them.
|
||||
if sys.platform.startswith("linux"):
|
||||
log_info(
|
||||
"If you installed the desktop via a .deb / .rpm package, remove it "
|
||||
"with your package manager (e.g. 'sudo apt remove hermes' or "
|
||||
"'sudo dnf remove hermes'). AppImage builds are a single file you "
|
||||
"can delete from wherever you saved it."
|
||||
)
|
||||
|
||||
return removed
|
||||
|
|
@ -114,6 +114,9 @@ def build_models_payload(
|
|||
include_unconfigured: bool = False,
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
pricing: bool = False,
|
||||
capabilities: bool = False,
|
||||
force_fresh_nous_tier: bool = False,
|
||||
max_models: int = 50,
|
||||
) -> dict:
|
||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||
|
|
@ -128,6 +131,19 @@ def build_models_payload(
|
|||
- ``canonical_order``: reorder canonical-slug rows to
|
||||
``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go
|
||||
last (TUI display order).
|
||||
- ``pricing``: enrich each row with formatted per-model pricing and,
|
||||
for Nous, ``free_tier``/``unavailable_models`` so the GUI picker can
|
||||
show $/Mtok columns and gate paid models on free accounts —
|
||||
mirroring the ``hermes model`` CLI picker. Adds network calls
|
||||
(pricing fetch + Nous tier check); only set for interactive pickers.
|
||||
- ``capabilities``: add a per-row ``capabilities`` map
|
||||
``{model: {fast, reasoning}}`` so pickers can gate the model-options
|
||||
controls (fast toggle / reasoning) to what each model actually
|
||||
supports, instead of offering knobs the backend would reject.
|
||||
- ``force_fresh_nous_tier``: bypass the short Nous free-tier cache when
|
||||
selecting Portal-recommended Nous models and applying tier gating. Keep
|
||||
this false for UI picker opens; explicit auth/model flows can opt in
|
||||
when they need freshly-purchased credits to show up immediately.
|
||||
"""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
|
@ -137,6 +153,7 @@ def build_models_payload(
|
|||
current_model=ctx.current_model,
|
||||
user_providers=ctx.user_providers,
|
||||
custom_providers=ctx.custom_providers,
|
||||
force_fresh_nous_tier=force_fresh_nous_tier,
|
||||
max_models=max_models,
|
||||
)
|
||||
|
||||
|
|
@ -146,6 +163,10 @@ def build_models_payload(
|
|||
_apply_picker_hints(rows)
|
||||
if canonical_order:
|
||||
rows = _reorder_canonical(rows)
|
||||
if pricing:
|
||||
_apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier)
|
||||
if capabilities:
|
||||
_apply_capabilities(rows)
|
||||
|
||||
return {
|
||||
"providers": rows,
|
||||
|
|
@ -154,6 +175,44 @@ def build_models_payload(
|
|||
}
|
||||
|
||||
|
||||
def _apply_capabilities(rows: list[dict]) -> None:
|
||||
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
|
||||
|
||||
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
|
||||
enforces). `reasoning` comes from the models.dev catalog when known and
|
||||
defaults to True otherwise — the effort dial is broadly accepted and a
|
||||
no-op on models that ignore it, whereas hiding it from a capable-but-
|
||||
uncatalogued model is the worse failure.
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
except Exception:
|
||||
get_model_capabilities = None # type: ignore[assignment]
|
||||
|
||||
for row in rows:
|
||||
slug = row.get("slug") or ""
|
||||
caps: dict[str, dict[str, bool]] = {}
|
||||
|
||||
for model in row.get("models") or []:
|
||||
reasoning = True
|
||||
if get_model_capabilities is not None and slug:
|
||||
try:
|
||||
meta = get_model_capabilities(slug, model)
|
||||
if meta is not None:
|
||||
reasoning = bool(meta.supports_reasoning)
|
||||
except Exception:
|
||||
reasoning = True
|
||||
|
||||
caps[model] = {
|
||||
"fast": bool(model_supports_fast_mode(model)),
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
row["capabilities"] = caps
|
||||
|
||||
|
||||
# ─── Internal: row post-processing ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -238,3 +297,91 @@ def _reorder_canonical(rows: list[dict]) -> list[dict]:
|
|||
)
|
||||
extras = [r for r in rows if r["slug"] not in order]
|
||||
return canon + extras
|
||||
|
||||
|
||||
def _apply_pricing(
|
||||
rows: list[dict],
|
||||
*,
|
||||
force_fresh_nous_tier: bool = False,
|
||||
) -> None:
|
||||
"""Enrich each provider row with per-model pricing + Nous tier gating.
|
||||
|
||||
Mutates ``rows`` in-place. For every row whose provider supports live
|
||||
pricing (openrouter / nous / novita) adds::
|
||||
|
||||
row["pricing"] = {model_id: {"input": "$3.00", "output": "$15.00",
|
||||
"cache": "$0.30" | None, "free": bool}}
|
||||
|
||||
For Nous additionally adds::
|
||||
|
||||
row["free_tier"] = bool # current account is free-tier
|
||||
row["unavailable_models"] = [...] # paid models a free user can't pick
|
||||
|
||||
Prices are pre-formatted via ``_format_price_per_mtok`` so the GUI just
|
||||
renders strings — identical formatting to the CLI picker. All failures
|
||||
are swallowed (best-effort): a row simply gets no ``pricing`` key.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
_format_price_per_mtok,
|
||||
check_nous_free_tier,
|
||||
get_pricing_for_provider,
|
||||
partition_nous_models_by_tier,
|
||||
)
|
||||
|
||||
# Resolve Nous free-tier once (cached in models.py for the TTL window).
|
||||
nous_free_tier: Optional[bool] = None
|
||||
|
||||
for row in rows:
|
||||
slug = str(row.get("slug", "")).lower()
|
||||
models = row.get("models") or []
|
||||
if not models:
|
||||
continue
|
||||
try:
|
||||
raw_pricing = get_pricing_for_provider(slug) or {}
|
||||
except Exception:
|
||||
raw_pricing = {}
|
||||
if not raw_pricing:
|
||||
continue
|
||||
|
||||
formatted: dict[str, dict] = {}
|
||||
for mid in models:
|
||||
p = raw_pricing.get(mid)
|
||||
if not p:
|
||||
continue
|
||||
inp_raw = p.get("prompt", "")
|
||||
out_raw = p.get("completion", "")
|
||||
cache_raw = p.get("input_cache_read", "")
|
||||
inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else ""
|
||||
out = _format_price_per_mtok(out_raw) if out_raw != "" else ""
|
||||
cache = _format_price_per_mtok(cache_raw) if cache_raw else None
|
||||
# A model is "free" when both input and output cost nothing.
|
||||
is_free = inp == "free" and (out == "free" or out == "")
|
||||
formatted[mid] = {
|
||||
"input": inp,
|
||||
"output": out,
|
||||
"cache": cache,
|
||||
"free": is_free,
|
||||
}
|
||||
|
||||
if formatted:
|
||||
row["pricing"] = formatted
|
||||
|
||||
if slug == "nous":
|
||||
try:
|
||||
if nous_free_tier is None:
|
||||
nous_free_tier = check_nous_free_tier(
|
||||
force_fresh=force_fresh_nous_tier
|
||||
)
|
||||
row["free_tier"] = bool(nous_free_tier)
|
||||
if nous_free_tier:
|
||||
_selectable, unavailable = partition_nous_models_by_tier(
|
||||
list(models), raw_pricing, free_tier=True
|
||||
)
|
||||
row["unavailable_models"] = unavailable
|
||||
else:
|
||||
row["unavailable_models"] = []
|
||||
except Exception:
|
||||
# Tier detection failed — fail open (no gating) so the user
|
||||
# is never blocked from picking a model.
|
||||
row["free_tier"] = False
|
||||
row["unavailable_models"] = []
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Exposes the full Kanban command surface documented in the design spec
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
|
|
@ -341,6 +342,19 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
"two retries. Omit to use the dispatcher's "
|
||||
"kanban.failure_limit config "
|
||||
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
|
||||
p_create.add_argument("--goal", action="store_true", dest="goal_mode",
|
||||
help="Run the worker in a goal loop: after each "
|
||||
"turn a judge checks the response against the "
|
||||
"card title/body and, if not done, the worker "
|
||||
"keeps going in the same session until the "
|
||||
"judge agrees it's complete (or the turn "
|
||||
"budget runs out, which blocks the card for "
|
||||
"review). Best for open-ended cards one shot "
|
||||
"rarely finishes.")
|
||||
p_create.add_argument("--goal-max-turns", type=int, default=None,
|
||||
metavar="N", dest="goal_max_turns",
|
||||
help="Turn budget for --goal workers (default 20). "
|
||||
"Ignored without --goal.")
|
||||
p_create.add_argument("--initial-status",
|
||||
choices=sorted(kb.VALID_INITIAL_STATUSES),
|
||||
default="running",
|
||||
|
|
@ -548,8 +562,46 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
help="Additional task ids to schedule with the same reason (bulk mode)")
|
||||
|
||||
p_unblock = sub.add_parser("unblock", help="Return one or more blocked/scheduled tasks to ready")
|
||||
p_unblock.add_argument(
|
||||
"--reason",
|
||||
default=None,
|
||||
help="Optional reason/note — recorded as a comment before unblocking. Quote multi-word reasons.",
|
||||
)
|
||||
p_unblock.add_argument("task_ids", nargs="+")
|
||||
|
||||
p_promote = sub.add_parser(
|
||||
"promote",
|
||||
help="Manually move one or more todo/blocked tasks to ready (recovery path)",
|
||||
)
|
||||
p_promote.add_argument("task_id")
|
||||
p_promote.add_argument(
|
||||
"reason",
|
||||
nargs="*",
|
||||
help="Audit-trail reason (recorded on the task_events row)",
|
||||
)
|
||||
p_promote.add_argument(
|
||||
"--ids",
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Additional task ids to promote with the same reason (bulk mode)",
|
||||
)
|
||||
p_promote.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Promote even if parent dependencies are not yet done/archived",
|
||||
)
|
||||
p_promote.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate the promotion without mutating state",
|
||||
)
|
||||
p_promote.add_argument(
|
||||
"--json",
|
||||
dest="json",
|
||||
action="store_true",
|
||||
help="Emit machine-readable JSON result",
|
||||
)
|
||||
|
||||
p_archive = sub.add_parser("archive", help="Archive one or more tasks")
|
||||
p_archive.add_argument("task_ids", nargs="*",
|
||||
help="Task ids to archive (default mode)")
|
||||
|
|
@ -833,16 +885,7 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
# keeps the patch small and inherits the exact same resolution the
|
||||
# dispatcher uses for workers — consistency is a feature here.
|
||||
board_override = getattr(args, "board", None)
|
||||
prev_board_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
restore_board_env = False
|
||||
|
||||
def _restore_board_env() -> None:
|
||||
if not restore_board_env:
|
||||
return
|
||||
if prev_board_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_board_env
|
||||
board_scope = contextlib.nullcontext()
|
||||
if board_override:
|
||||
try:
|
||||
normed = kb._normalize_board_slug(board_override)
|
||||
|
|
@ -861,8 +904,7 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
os.environ["HERMES_KANBAN_BOARD"] = normed
|
||||
restore_board_env = True
|
||||
board_scope = kb.scoped_current_board(normed)
|
||||
|
||||
# Auto-initialize the DB before dispatching any subcommand. init_db
|
||||
# is idempotent, so running it every invocation is cheap (one
|
||||
|
|
@ -871,65 +913,62 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
# HERMES_HOME. Previously only `init` and `daemon` triggered
|
||||
# schema creation; `create` / `list` / every other command would
|
||||
# error out on a fresh install.
|
||||
try:
|
||||
kb.init_db()
|
||||
except Exception as exc:
|
||||
print(f"kanban: could not initialize database: {exc}", file=sys.stderr)
|
||||
_restore_board_env()
|
||||
return 1
|
||||
with board_scope:
|
||||
try:
|
||||
kb.init_db()
|
||||
except Exception as exc:
|
||||
print(f"kanban: could not initialize database: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
handlers = {
|
||||
"init": _cmd_init,
|
||||
"create": _cmd_create,
|
||||
"swarm": _cmd_swarm,
|
||||
"list": _cmd_list,
|
||||
"ls": _cmd_list,
|
||||
"show": _cmd_show,
|
||||
"assign": _cmd_assign,
|
||||
"reclaim": _cmd_reclaim,
|
||||
"reassign": _cmd_reassign,
|
||||
"diagnostics": _cmd_diagnostics,
|
||||
"diag": _cmd_diagnostics,
|
||||
"link": _cmd_link,
|
||||
"unlink": _cmd_unlink,
|
||||
"claim": _cmd_claim,
|
||||
"comment": _cmd_comment,
|
||||
"complete": _cmd_complete,
|
||||
"edit": _cmd_edit,
|
||||
"block": _cmd_block,
|
||||
"schedule": _cmd_schedule,
|
||||
"unblock": _cmd_unblock,
|
||||
"archive": _cmd_archive,
|
||||
"tail": _cmd_tail,
|
||||
"dispatch": _cmd_dispatch,
|
||||
"daemon": _cmd_daemon,
|
||||
"watch": _cmd_watch,
|
||||
"stats": _cmd_stats,
|
||||
"log": _cmd_log,
|
||||
"runs": _cmd_runs,
|
||||
"heartbeat": _cmd_heartbeat,
|
||||
"assignees": _cmd_assignees,
|
||||
"notify-subscribe": _cmd_notify_subscribe,
|
||||
"notify-list": _cmd_notify_list,
|
||||
"notify-unsubscribe": _cmd_notify_unsubscribe,
|
||||
"context": _cmd_context,
|
||||
"specify": _cmd_specify,
|
||||
"decompose": _cmd_decompose,
|
||||
"gc": _cmd_gc,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if not handler:
|
||||
print(f"kanban: unknown action {action!r}", file=sys.stderr)
|
||||
_restore_board_env()
|
||||
return 2
|
||||
try:
|
||||
return int(handler(args) or 0)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
print(f"kanban: {exc}", file=sys.stderr)
|
||||
_restore_board_env()
|
||||
return 1
|
||||
finally:
|
||||
_restore_board_env()
|
||||
handlers = {
|
||||
"init": _cmd_init,
|
||||
"create": _cmd_create,
|
||||
"swarm": _cmd_swarm,
|
||||
"list": _cmd_list,
|
||||
"ls": _cmd_list,
|
||||
"show": _cmd_show,
|
||||
"assign": _cmd_assign,
|
||||
"reclaim": _cmd_reclaim,
|
||||
"reassign": _cmd_reassign,
|
||||
"diagnostics": _cmd_diagnostics,
|
||||
"diag": _cmd_diagnostics,
|
||||
"link": _cmd_link,
|
||||
"unlink": _cmd_unlink,
|
||||
"claim": _cmd_claim,
|
||||
"comment": _cmd_comment,
|
||||
"complete": _cmd_complete,
|
||||
"edit": _cmd_edit,
|
||||
"block": _cmd_block,
|
||||
"schedule": _cmd_schedule,
|
||||
"unblock": _cmd_unblock,
|
||||
"promote": _cmd_promote,
|
||||
"archive": _cmd_archive,
|
||||
"tail": _cmd_tail,
|
||||
"dispatch": _cmd_dispatch,
|
||||
"daemon": _cmd_daemon,
|
||||
"watch": _cmd_watch,
|
||||
"stats": _cmd_stats,
|
||||
"log": _cmd_log,
|
||||
"runs": _cmd_runs,
|
||||
"heartbeat": _cmd_heartbeat,
|
||||
"assignees": _cmd_assignees,
|
||||
"notify-subscribe": _cmd_notify_subscribe,
|
||||
"notify-list": _cmd_notify_list,
|
||||
"notify-unsubscribe": _cmd_notify_unsubscribe,
|
||||
"context": _cmd_context,
|
||||
"specify": _cmd_specify,
|
||||
"decompose": _cmd_decompose,
|
||||
"gc": _cmd_gc,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if not handler:
|
||||
print(f"kanban: unknown action {action!r}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
return int(handler(args) or 0)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
print(f"kanban: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -987,7 +1026,7 @@ def _board_task_counts(slug: str) -> dict[str, int]:
|
|||
path = kb.kanban_db_path(board=slug)
|
||||
if not path.exists():
|
||||
return {}
|
||||
with kb.connect(board=slug) as conn:
|
||||
with kb.connect_closing(board=slug) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
|
|
@ -1230,7 +1269,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.heartbeat_worker(
|
||||
conn,
|
||||
args.task_id,
|
||||
|
|
@ -1245,7 +1284,7 @@ def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_assignees(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
data = kb.known_assignees(conn)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
|
@ -1286,7 +1325,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task_id = kb.create_task(
|
||||
conn,
|
||||
title=args.title,
|
||||
|
|
@ -1304,6 +1343,8 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
|||
max_runtime_seconds=max_runtime,
|
||||
skills=getattr(args, "skills", None) or None,
|
||||
max_retries=max_retries,
|
||||
goal_mode=bool(getattr(args, "goal_mode", False)),
|
||||
goal_max_turns=getattr(args, "goal_max_turns", None),
|
||||
initial_status=getattr(args, "initial_status", "running"),
|
||||
)
|
||||
task = kb.get_task(conn, task_id)
|
||||
|
|
@ -1335,7 +1376,7 @@ def _cmd_swarm(args: argparse.Namespace) -> int:
|
|||
if not workers:
|
||||
print("kanban swarm: at least one --worker is required", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
created = ks.create_swarm(
|
||||
conn,
|
||||
goal=args.goal,
|
||||
|
|
@ -1361,7 +1402,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
|
|||
assignee = args.assignee
|
||||
if args.mine and not assignee:
|
||||
assignee = _profile_author()
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
# Cheap "mini-dispatch": recompute ready so list output reflects
|
||||
# dependencies that may have cleared since the last dispatcher tick.
|
||||
kb.recompute_ready(conn)
|
||||
|
|
@ -1410,7 +1451,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, args.task_id)
|
||||
if not task:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
|
|
@ -1576,7 +1617,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
|||
|
||||
def _cmd_assign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.assign_task(conn, args.task_id, profile)
|
||||
if not ok:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
|
|
@ -1586,7 +1627,7 @@ def _cmd_assign(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.reclaim_task(
|
||||
conn, args.task_id,
|
||||
reason=getattr(args, "reason", None),
|
||||
|
|
@ -1603,7 +1644,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int:
|
|||
|
||||
def _cmd_reassign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.reassign_task(
|
||||
conn, args.task_id, profile,
|
||||
reclaim_first=bool(getattr(args, "reclaim", False)),
|
||||
|
|
@ -1633,7 +1674,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
|||
|
||||
diag_config = kd.config_from_runtime_config(load_config())
|
||||
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
# Either one-task mode or fleet mode.
|
||||
if getattr(args, "task", None):
|
||||
task = kb.get_task(conn, args.task)
|
||||
|
|
@ -1756,14 +1797,14 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_link(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
kb.link_tasks(conn, args.parent_id, args.child_id)
|
||||
print(f"Linked {args.parent_id} -> {args.child_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_unlink(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.unlink_tasks(conn, args.parent_id, args.child_id)
|
||||
if not ok:
|
||||
print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr)
|
||||
|
|
@ -1773,7 +1814,7 @@ def _cmd_unlink(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_claim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl)
|
||||
if task is None:
|
||||
# Report why
|
||||
|
|
@ -1804,7 +1845,7 @@ def _cmd_comment(args: argparse.Namespace) -> int:
|
|||
suffix = f"\n\n[trimmed to {args.max_len} chars by --max-len]"
|
||||
body = body[: max(0, args.max_len - len(suffix))].rstrip() + suffix
|
||||
author = args.author or _profile_author()
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
kb.add_comment(conn, args.task_id, author, body)
|
||||
print(f"Comment added to {args.task_id}")
|
||||
return 0
|
||||
|
|
@ -1851,7 +1892,7 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
|||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if not kb.complete_task(
|
||||
conn, tid,
|
||||
|
|
@ -1878,7 +1919,7 @@ def _cmd_edit(args: argparse.Namespace) -> int:
|
|||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if not kb.edit_completed_task_result(
|
||||
conn,
|
||||
args.task_id,
|
||||
|
|
@ -1900,7 +1941,7 @@ def _cmd_block(args: argparse.Namespace) -> int:
|
|||
author = _profile_author()
|
||||
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
|
||||
|
|
@ -1922,7 +1963,7 @@ def _cmd_schedule(args: argparse.Namespace) -> int:
|
|||
author = _profile_author()
|
||||
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"SCHEDULED: {reason}")
|
||||
|
|
@ -1944,14 +1985,71 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
|
|||
if not ids:
|
||||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
reason = getattr(args, "reason", None)
|
||||
if reason is not None:
|
||||
reason = reason.strip() or None
|
||||
author = _profile_author() if reason else None
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"UNBLOCK: {reason}")
|
||||
if not kb.unblock_task(conn, tid):
|
||||
failed.append(tid)
|
||||
print(f"cannot unblock {tid} (not blocked/scheduled?)", file=sys.stderr)
|
||||
else:
|
||||
print(f"Unblocked {tid}")
|
||||
print(f"Unblocked {tid}" + (f": {reason}" if reason else ""))
|
||||
return 0 if not failed else 1
|
||||
|
||||
|
||||
def _cmd_promote(args: argparse.Namespace) -> int:
|
||||
reason = " ".join(args.reason).strip() if args.reason else None
|
||||
author = _profile_author()
|
||||
as_json = getattr(args, "json", False)
|
||||
extra_ids = list(getattr(args, "ids", None) or [])
|
||||
# Dedupe while preserving order; positional task_id always first.
|
||||
ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for tid in [args.task_id, *extra_ids]:
|
||||
if tid not in seen:
|
||||
ids.append(tid)
|
||||
seen.add(tid)
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
ok, err = kb.promote_task(
|
||||
conn,
|
||||
tid,
|
||||
actor=author,
|
||||
reason=reason,
|
||||
force=bool(args.force),
|
||||
dry_run=bool(args.dry_run),
|
||||
)
|
||||
results.append({
|
||||
"task_id": tid,
|
||||
"promoted": ok,
|
||||
"dry_run": bool(args.dry_run),
|
||||
"forced": bool(args.force),
|
||||
"reason": reason,
|
||||
"error": err,
|
||||
})
|
||||
|
||||
failed = [r for r in results if not r["promoted"]]
|
||||
if as_json:
|
||||
# Single-id stays a flat object for back-compat; bulk emits a list.
|
||||
payload: object = results[0] if len(results) == 1 else results
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
return 0 if not failed else 1
|
||||
|
||||
tag = " (dry)" if args.dry_run else ""
|
||||
label = "Would promote" if args.dry_run else "Promoted"
|
||||
for r in results:
|
||||
if r["promoted"]:
|
||||
suffix = f": {reason}" if reason else ""
|
||||
print(f"{label} {r['task_id']} -> ready{tag}{suffix}")
|
||||
else:
|
||||
print(f"cannot promote {r['task_id']}: {r['error']}", file=sys.stderr)
|
||||
return 0 if not failed else 1
|
||||
|
||||
|
||||
|
|
@ -1965,7 +2063,7 @@ def _cmd_archive(args: argparse.Namespace) -> int:
|
|||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if purge_ids:
|
||||
for tid in purge_ids:
|
||||
if not kb.delete_archived_task(conn, tid):
|
||||
|
|
@ -1988,7 +2086,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
|||
print(f"Tailing events for {args.task_id}. Ctrl-C to stop.")
|
||||
try:
|
||||
while True:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
events = kb.list_events(conn, args.task_id)
|
||||
for e in events:
|
||||
if e.id > last_id:
|
||||
|
|
@ -2002,12 +2100,52 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
# Honour kanban.default_assignee as the fallback for unassigned ready
|
||||
# tasks (#27145), kanban.max_in_progress as the global concurrency cap
|
||||
# (#33488), kanban.max_in_progress_per_profile as the per-profile
|
||||
# cap (#21582), and kanban.max_spawn as the per-tick spawn limit
|
||||
# (#28805). Same semantics as the gateway dispatch path so behavior
|
||||
# matches whether the user runs the CLI directly or relies on the
|
||||
# gateway-embedded dispatcher.
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_kanban_cfg = _cfg.get("kanban", {}) if isinstance(_cfg, dict) else {}
|
||||
default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None
|
||||
|
||||
def _coerce_positive_int(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
ival = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return ival if ival >= 1 else None
|
||||
|
||||
max_in_progress_per_profile = _coerce_positive_int(
|
||||
_kanban_cfg.get("max_in_progress_per_profile")
|
||||
)
|
||||
max_in_progress = _coerce_positive_int(_kanban_cfg.get("max_in_progress"))
|
||||
# CLI --max overrides config kanban.max_spawn when both are present;
|
||||
# CLI is the more explicit signal so it wins.
|
||||
cli_max = getattr(args, "max", None)
|
||||
max_spawn = cli_max if cli_max is not None else _coerce_positive_int(
|
||||
_kanban_cfg.get("max_spawn")
|
||||
)
|
||||
except Exception:
|
||||
default_assignee = None
|
||||
max_in_progress_per_profile = None
|
||||
max_in_progress = None
|
||||
max_spawn = getattr(args, "max", None)
|
||||
with kb.connect_closing() as conn:
|
||||
res = kb.dispatch_once(
|
||||
conn,
|
||||
dry_run=args.dry_run,
|
||||
max_spawn=args.max,
|
||||
max_spawn=max_spawn,
|
||||
max_in_progress=max_in_progress,
|
||||
failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT),
|
||||
default_assignee=default_assignee,
|
||||
max_in_progress_per_profile=max_in_progress_per_profile,
|
||||
)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps({
|
||||
|
|
@ -2023,6 +2161,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
|||
],
|
||||
"skipped_unassigned": res.skipped_unassigned,
|
||||
"skipped_nonspawnable": res.skipped_nonspawnable,
|
||||
"skipped_per_profile_capped": [
|
||||
{"task_id": tid, "assignee": who, "current": current}
|
||||
for (tid, who, current) in res.skipped_per_profile_capped
|
||||
],
|
||||
"auto_assigned_default": res.auto_assigned_default,
|
||||
}, indent=2))
|
||||
return 0
|
||||
print(f"Reclaimed: {res.reclaimed}")
|
||||
|
|
@ -2043,8 +2186,18 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
|||
for tid, who, ws in res.spawned:
|
||||
tag = " (dry)" if args.dry_run else ""
|
||||
print(f" - {tid} -> {who} @ {ws or '-'}{tag}")
|
||||
if res.auto_assigned_default:
|
||||
print(
|
||||
f"Auto-assigned to kanban.default_assignee={default_assignee!r}: "
|
||||
f"{', '.join(res.auto_assigned_default)}"
|
||||
)
|
||||
if res.skipped_unassigned:
|
||||
print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}")
|
||||
if res.skipped_per_profile_capped:
|
||||
for tid, who, current in res.skipped_per_profile_capped:
|
||||
print(
|
||||
f"Deferred ({who} at per-profile cap, {current} running): {tid}"
|
||||
)
|
||||
if res.skipped_nonspawnable:
|
||||
print(
|
||||
f"Skipped (non-spawnable assignee — terminal lane, OK): "
|
||||
|
|
@ -2172,7 +2325,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
|||
from the dispatcher's perspective, not stuck.
|
||||
"""
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
return kb.has_spawnable_ready(conn)
|
||||
except Exception:
|
||||
return False
|
||||
|
|
@ -2203,7 +2356,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
|||
cursor = 0
|
||||
print("Watching kanban events. Ctrl-C to stop.", flush=True)
|
||||
# Seed cursor at the latest id so we don't replay history.
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
|
||||
).fetchone()
|
||||
|
|
@ -2211,7 +2364,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
|||
|
||||
try:
|
||||
while True:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, "
|
||||
" t.assignee, t.tenant "
|
||||
|
|
@ -2244,7 +2397,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_stats(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
stats = kb.board_stats(conn)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
||||
|
|
@ -2264,7 +2417,7 @@ def _cmd_stats(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if kb.get_task(conn, args.task_id) is None:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
return 1
|
||||
|
|
@ -2281,7 +2434,7 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_notify_list(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
subs = kb.list_notify_subs(conn, args.task_id)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(subs, indent=2, ensure_ascii=False))
|
||||
|
|
@ -2298,7 +2451,7 @@ def _cmd_notify_list(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_notify_unsubscribe(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.remove_notify_sub(
|
||||
conn, task_id=args.task_id,
|
||||
platform=args.platform, chat_id=args.chat_id,
|
||||
|
|
@ -2332,7 +2485,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
runs = kb.list_runs(conn, args.task_id, **rsk)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps([
|
||||
|
|
@ -2371,7 +2524,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_context(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
text = kb.build_worker_context(conn, args.task_id)
|
||||
print(text)
|
||||
return 0
|
||||
|
|
@ -2537,7 +2690,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
|
|||
import shutil
|
||||
scratch_root = kb.workspaces_root()
|
||||
removed_ws = 0
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'"
|
||||
).fetchall()
|
||||
|
|
@ -2560,7 +2713,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
|
|||
|
||||
event_days = getattr(args, "event_retention_days", 30)
|
||||
log_days = getattr(args, "log_retention_days", 30)
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
removed_events = kb.gc_events(
|
||||
conn, older_than_seconds=event_days * 24 * 3600,
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,7 @@ Design notes
|
|||
|
||||
* The system prompt sees the *configured* profile roster — names plus
|
||||
descriptions plus the default fallback. Profiles without a
|
||||
description are still listed (with a note) so the orchestrator can
|
||||
description are still listed (with a note) so the decomposer can
|
||||
match on name as a fallback, but the user has an obvious incentive
|
||||
to describe them.
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ def _load_config() -> dict:
|
|||
|
||||
|
||||
def _resolve_orchestrator_profile(cfg: dict) -> str:
|
||||
"""Resolve which profile owns decomposition.
|
||||
"""Resolve which profile owns the root/orchestration task after fan-out.
|
||||
|
||||
Falls back to the active default profile when ``kanban.orchestrator_profile``
|
||||
is unset, so a task is never stranded for lack of an orchestrator.
|
||||
|
|
@ -281,7 +281,7 @@ def decompose_task(
|
|||
configured, API error, malformed response, decomposer returned
|
||||
fanout=true with empty task list) — those surface via ``ok=False``.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return DecomposeOutcome(task_id, False, "unknown task id")
|
||||
|
|
@ -370,7 +370,7 @@ def decompose_task(
|
|||
return DecomposeOutcome(
|
||||
task_id, False, "decomposer returned fanout=false with no title/body",
|
||||
)
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
|
|
@ -439,7 +439,7 @@ def decompose_task(
|
|||
})
|
||||
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
|
|
@ -467,7 +467,7 @@ def decompose_task(
|
|||
|
||||
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
"""Return task ids currently in the triage column."""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
|
|
|
|||
|
|
@ -191,23 +191,6 @@ def _active_hallucination_events(
|
|||
elif k == kind:
|
||||
active.append(ev)
|
||||
return active
|
||||
|
||||
|
||||
def _latest_clean_event_ts(events: Iterable[Any]) -> int:
|
||||
"""Timestamp of the most recent clean completion / edit event.
|
||||
|
||||
Kept for general "has this task ever been successfully completed"
|
||||
lookups; hallucination rules use ``_active_hallucination_events``
|
||||
instead because they need strict ordering.
|
||||
"""
|
||||
latest = 0
|
||||
for ev in events:
|
||||
if _event_kind(ev) in {"completed", "edited"}:
|
||||
t = _event_ts(ev)
|
||||
latest = max(latest, t)
|
||||
return latest
|
||||
|
||||
|
||||
# Standard always-available actions. Every diagnostic can offer these as
|
||||
# fallbacks regardless of kind — they're the two baseline recovery
|
||||
# primitives the kernel supports.
|
||||
|
|
@ -791,6 +774,83 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]:
|
|||
)]
|
||||
|
||||
|
||||
def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
"""Task has cycled through blocked → unblocked many times — the
|
||||
``unblock`` is not fixing the underlying problem and the worker
|
||||
keeps re-blocking for substantially the same reason.
|
||||
|
||||
``_rule_stuck_in_blocked`` resets its timer on any ``commented`` /
|
||||
``unblocked`` event, so a task that cycles every few minutes is
|
||||
invisible to it regardless of how many times it cycles (#29747
|
||||
gap 1). This rule complements that one by counting block→unblock
|
||||
cycles in a sliding window.
|
||||
|
||||
Threshold: cfg["block_cycle_threshold"] (default 3) cycles within
|
||||
cfg["block_cycle_window_seconds"] (default 24h).
|
||||
"""
|
||||
threshold = _positive_int(cfg.get("block_cycle_threshold"), 3)
|
||||
window_seconds = float(cfg.get("block_cycle_window_seconds", 24 * 3600))
|
||||
cycle_cutoff = now - window_seconds
|
||||
|
||||
# Walk events chronologically (arrival order — callers pre-sort by
|
||||
# id, which is the canonical chronological order; ``created_at``
|
||||
# alone is insufficient because multiple events can share the same
|
||||
# second). Count "blocked after unblocked" transitions: every time
|
||||
# a blocked event follows at least one unblocked event since the
|
||||
# last cycle was counted, that's a new cycle.
|
||||
cycles = 0
|
||||
seen_unblock_since_last_cycle = False
|
||||
initial_blocked_ts = 0
|
||||
last_cycle_blocked_ts = 0
|
||||
for ev in events:
|
||||
ts = _event_ts(ev)
|
||||
if ts < cycle_cutoff:
|
||||
continue
|
||||
kind = _event_kind(ev)
|
||||
if kind == "blocked":
|
||||
if initial_blocked_ts == 0:
|
||||
initial_blocked_ts = ts
|
||||
if seen_unblock_since_last_cycle:
|
||||
cycles += 1
|
||||
last_cycle_blocked_ts = ts
|
||||
seen_unblock_since_last_cycle = False
|
||||
elif kind == "unblocked":
|
||||
seen_unblock_since_last_cycle = True
|
||||
|
||||
if cycles < threshold:
|
||||
return []
|
||||
|
||||
task_id = _task_field(task, "id")
|
||||
actions: list[DiagnosticAction] = []
|
||||
if task_id:
|
||||
actions.append(DiagnosticAction(
|
||||
kind="cli_hint",
|
||||
label=f"Check block reasons: hermes kanban events {task_id}",
|
||||
payload={"command": f"hermes kanban events {task_id}"},
|
||||
suggested=True,
|
||||
))
|
||||
return [Diagnostic(
|
||||
kind="block_unblock_cycling",
|
||||
severity="warning",
|
||||
title=f"Task block→unblock cycled {cycles}x in {int(window_seconds/3600)}h",
|
||||
detail=(
|
||||
f"This task has been blocked {cycles} times after being "
|
||||
"unblocked, suggesting the unblock is not addressing the "
|
||||
"root cause and the worker keeps hitting the same wall. "
|
||||
"Review the block reasons in the event history; a different "
|
||||
"intervention (reassign, change scope, archive) may be needed."
|
||||
),
|
||||
actions=actions,
|
||||
first_seen_at=int(initial_blocked_ts) if initial_blocked_ts else int(now),
|
||||
last_seen_at=int(last_cycle_blocked_ts) if last_cycle_blocked_ts else int(now),
|
||||
count=cycles,
|
||||
data={
|
||||
"cycles": cycles,
|
||||
"window_seconds": int(window_seconds),
|
||||
},
|
||||
)]
|
||||
|
||||
|
||||
def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
"""Task has been in ``ready`` status for too long without any worker
|
||||
claiming it.
|
||||
|
|
@ -923,6 +983,7 @@ _RULES: list[RuleFn] = [
|
|||
_rule_repeated_failures,
|
||||
_rule_repeated_crashes,
|
||||
_rule_stuck_in_blocked,
|
||||
_rule_block_unblock_cycling,
|
||||
_rule_stranded_in_ready,
|
||||
]
|
||||
|
||||
|
|
@ -936,6 +997,7 @@ DIAGNOSTIC_KINDS = (
|
|||
"repeated_failures",
|
||||
"repeated_crashes",
|
||||
"stuck_in_blocked",
|
||||
"block_unblock_cycling",
|
||||
"stranded_in_ready",
|
||||
)
|
||||
|
||||
|
|
@ -1043,16 +1105,3 @@ def compute_task_diagnostics(
|
|||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def severity_of_highest(diagnostics: Iterable[Diagnostic]) -> Optional[str]:
|
||||
"""Highest severity present in the list, or None if empty. Useful
|
||||
for card badges that need a single color."""
|
||||
highest_idx = -1
|
||||
highest = None
|
||||
for d in diagnostics:
|
||||
idx = SEVERITY_ORDER.index(d.severity) if d.severity in SEVERITY_ORDER else -1
|
||||
if idx > highest_idx:
|
||||
highest_idx = idx
|
||||
highest = d.severity
|
||||
return highest
|
||||
|
|
|
|||
|
|
@ -40,9 +40,11 @@ from typing import Optional
|
|||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
from utils import env_int
|
||||
|
||||
HERMES_KANBAN_SPECIFY_MAX_TOKENS = max(
|
||||
1500,
|
||||
int(os.getenv("HERMES_KANBAN_SPECIFY_MAX_TOKENS", "6000")),
|
||||
env_int("HERMES_KANBAN_SPECIFY_MAX_TOKENS", 6000),
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -150,7 +152,7 @@ def specify_task(
|
|||
error, malformed response) — those surface via ``ok=False`` so the
|
||||
``--all`` sweep can continue past individual failures.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return SpecifyOutcome(task_id, False, "unknown task id")
|
||||
|
|
@ -239,7 +241,7 @@ def specify_task(
|
|||
task_id, False, "LLM response missing title and body"
|
||||
)
|
||||
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
|
|
@ -261,7 +263,7 @@ def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
|||
|
||||
``tenant`` narrows the sweep; ``None`` returns every triage task.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
tasks = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ def create_swarm(
|
|||
priority=priority,
|
||||
workspace_kind=workspace_kind,
|
||||
workspace_path=workspace_path,
|
||||
skills=["avoid-ai-writing"],
|
||||
skills=["humanizer"],
|
||||
)
|
||||
|
||||
created = SwarmCreated(root, worker_ids, verifier, synthesizer)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ Usage examples::
|
|||
hermes logs -f # follow agent.log in real time
|
||||
hermes logs errors # last 50 lines of errors.log
|
||||
hermes logs gateway -n 100 # last 100 lines of gateway.log
|
||||
hermes logs gui -f # follow gui.log (dashboard/pty/ws)
|
||||
hermes logs desktop -f # follow desktop.log (Electron app boot/backend)
|
||||
hermes logs --level WARNING # only WARNING+ lines
|
||||
hermes logs --session abc123 # filter by session ID substring
|
||||
hermes logs --component tools # only tool-related lines
|
||||
|
|
@ -31,6 +33,8 @@ LOG_FILES = {
|
|||
"agent": "agent.log",
|
||||
"errors": "errors.log",
|
||||
"gateway": "gateway.log",
|
||||
"gui": "gui.log",
|
||||
"desktop": "desktop.log",
|
||||
}
|
||||
|
||||
# Log line timestamp regex — matches "2026-04-05 22:35:00,123" or
|
||||
|
|
@ -150,7 +154,7 @@ def tail_log(
|
|||
Parameters
|
||||
----------
|
||||
log_name
|
||||
Which log to read: ``"agent"``, ``"errors"``, ``"gateway"``.
|
||||
Which log to read: ``"agent"``, ``"errors"``, ``"gateway"``, ``"gui"``.
|
||||
num_lines
|
||||
Number of recent lines to show (before follow starts).
|
||||
follow
|
||||
|
|
|
|||
8160
hermes_cli/main.py
8160
hermes_cli/main.py
File diff suppressed because it is too large
Load diff
254
hermes_cli/managed_uv.py
Normal file
254
hermes_cli/managed_uv.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""Managed uv — one path, no guessing.
|
||||
|
||||
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
|
||||
Windows). Every code path that needs uv resolves it from that single location.
|
||||
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
|
||||
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
|
||||
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
|
||||
probing, no conda guards, no multi-location resolution chains.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def managed_uv_path() -> Path:
|
||||
"""Return the path where Hermes keeps *its* uv binary.
|
||||
|
||||
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
|
||||
Windows. The directory may not exist yet — callers should use
|
||||
``ensure_uv()`` to bootstrap it.
|
||||
"""
|
||||
home = get_hermes_home()
|
||||
if platform.system() == "Windows":
|
||||
return home / "bin" / "uv.exe"
|
||||
return home / "bin" / "uv"
|
||||
|
||||
|
||||
def resolve_uv() -> Optional[str]:
|
||||
"""Return the managed uv path if it exists, else ``None``.
|
||||
|
||||
No side effects — pure lookup.
|
||||
"""
|
||||
p = managed_uv_path()
|
||||
if p.is_file() and os.access(p, os.X_OK):
|
||||
return str(p)
|
||||
return None
|
||||
|
||||
|
||||
class _UvResult(str):
|
||||
"""``ensure_uv()`` return value that survives an update boundary.
|
||||
|
||||
``ensure_uv()``'s arity has flipped between a single path string and a
|
||||
``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs
|
||||
the call site from the *old*, already-imported ``hermes_cli.main`` against
|
||||
this *freshly pulled* module, so the two can disagree on how many values
|
||||
``ensure_uv()`` returns. An install parked on a 2-tuple release runs
|
||||
``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module
|
||||
and crashes the first update: the returned path is a plain ``str``, which is
|
||||
itself iterable, so the 2-target unpack walks its characters and raises
|
||||
``ValueError: too many values to unpack (expected 2)`` (and on the failure
|
||||
path the ``None`` return raises ``TypeError: cannot unpack non-iterable
|
||||
NoneType``). This wrapper answers to both conventions:
|
||||
|
||||
uv_bin = ensure_uv() # behaves as the path str ("" when absent)
|
||||
uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap)
|
||||
|
||||
Missing uv is the empty string (falsy) instead of ``None`` so legacy
|
||||
2-target call sites can still unpack a failure without raising, while
|
||||
``if not uv_bin`` keeps working for single-value callers.
|
||||
|
||||
POSIX only. This wrapper is **never** returned on Windows — see
|
||||
``ensure_uv()`` for why the ``__iter__`` override is unsafe there.
|
||||
"""
|
||||
|
||||
fresh_bootstrap: bool
|
||||
|
||||
def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult":
|
||||
self = super().__new__(cls, path or "")
|
||||
self.fresh_bootstrap = fresh
|
||||
return self
|
||||
|
||||
def __iter__(self):
|
||||
# Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.
|
||||
# First element mirrors the historical contract: the path string, or
|
||||
# ``None`` when uv is unavailable.
|
||||
return iter(((str(self) or None), self.fresh_bootstrap))
|
||||
|
||||
|
||||
def _ensure_uv_path() -> Optional[str]:
|
||||
"""Resolve the managed uv path, installing it if necessary (plain ``str``/``None``)."""
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
target = managed_uv_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" → Installing managed uv into {target.parent} ...")
|
||||
|
||||
try:
|
||||
_install_uv(target)
|
||||
except Exception as exc:
|
||||
logger.warning("Managed uv install failed: %s", exc)
|
||||
print(f" ✗ Failed to install managed uv: {exc}")
|
||||
return None
|
||||
|
||||
# Verify
|
||||
result = resolve_uv()
|
||||
if result:
|
||||
version = subprocess.run(
|
||||
[result, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ Managed uv installed ({version})")
|
||||
else:
|
||||
print(" ✗ Managed uv install appeared to succeed but binary not found")
|
||||
return result
|
||||
|
||||
|
||||
def ensure_uv():
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
|
||||
On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is
|
||||
both usable directly as the path *and* unpackable as
|
||||
``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple
|
||||
release — see :class:`_UvResult` for the update-boundary rationale.
|
||||
|
||||
On **Windows** we deliberately return a plain ``str``/``None`` instead.
|
||||
``subprocess`` there serializes the argv via ``subprocess.list2cmdline``,
|
||||
which iterates every entry *as a string* (``for c in arg``). The dependency
|
||||
installer passes uv straight into the command list (``[uv_bin, "pip", ...]``),
|
||||
so a ``_UvResult`` — whose ``__iter__`` yields ``(path, fresh_bootstrap)``
|
||||
rather than characters — would inject the bool into the command line and
|
||||
crash the install with ``TypeError: sequence item 1: expected str instance,
|
||||
bool found``. A plain ``str`` matches the historical Windows contract and is
|
||||
subprocess-safe. (A single value cannot satisfy both 2-target unpacking and
|
||||
Windows char-iteration: both use the iterator protocol, with contradictory
|
||||
results.)
|
||||
|
||||
On failure the result is falsy — never raises — so callers can fall back to
|
||||
pip gracefully.
|
||||
"""
|
||||
result = _ensure_uv_path()
|
||||
if platform.system() == "Windows":
|
||||
# See docstring: a str subclass with an overridden __iter__ is unsafe as
|
||||
# a Windows subprocess argument. Hand back the plain path (or None).
|
||||
return result
|
||||
return _UvResult(result)
|
||||
|
||||
|
||||
def update_managed_uv() -> Optional[str]:
|
||||
"""Run ``uv self update`` on the managed uv binary.
|
||||
|
||||
Call this during ``hermes update`` so the managed copy stays current.
|
||||
Returns the managed path on success, ``None`` if uv isn't available or
|
||||
the self-update fails (non-fatal — the old version still works).
|
||||
"""
|
||||
existing = resolve_uv()
|
||||
if not existing:
|
||||
# Not installed yet — ensure_uv() will handle that elsewhere.
|
||||
return None
|
||||
|
||||
result = subprocess.run(
|
||||
[existing, "self", "update"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = subprocess.run(
|
||||
[existing, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ Managed uv updated ({version})")
|
||||
else:
|
||||
# Non-fatal — old uv still works fine.
|
||||
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
|
||||
return existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installer internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_uv(target: Path) -> None:
|
||||
"""Bootstrap uv into *target* using the official standalone installer.
|
||||
|
||||
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
|
||||
so the astral installer writes the binary directly into
|
||||
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
|
||||
"""
|
||||
system = platform.system()
|
||||
env = {
|
||||
**os.environ,
|
||||
# Tell the astral installer to drop the binary in our dir, not
|
||||
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
|
||||
# uses UV_INSTALL_DIR.
|
||||
"UV_UNMANAGED_INSTALL": str(target.parent),
|
||||
"UV_INSTALL_DIR": str(target.parent),
|
||||
}
|
||||
|
||||
if system == "Windows":
|
||||
_install_uv_windows(env)
|
||||
else:
|
||||
_install_uv_posix(env)
|
||||
|
||||
|
||||
def _install_uv_posix(env: dict[str, str]) -> None:
|
||||
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
|
||||
installer_path = f.name
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["sh", installer_path],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(installer_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _install_uv_windows(env: dict[str, str]) -> None:
|
||||
"""Invoke the PowerShell installer."""
|
||||
cmd = (
|
||||
'irm https://astral.sh/uv/install.ps1 | iex'
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
||||
True # dont remove me. ask ethernet
|
||||
775
hermes_cli/mcp_catalog.py
Normal file
775
hermes_cli/mcp_catalog.py
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
"""MCP catalog — curated, Nous-approved MCP servers shipped with the repo.
|
||||
|
||||
Mirrors the optional-skills/ pattern: each catalog entry lives under
|
||||
``optional-mcps/<name>/manifest.yaml`` and ships disabled. Users discover
|
||||
entries via ``hermes mcp catalog`` or the interactive ``hermes mcp picker``,
|
||||
and install them with ``hermes mcp install <name>`` (or by toggling in the
|
||||
picker, which flows them through any required env/OAuth setup).
|
||||
|
||||
Catalog policy:
|
||||
- Entries are added only by merging a PR into hermes-agent. Presence in the
|
||||
``optional-mcps/`` directory = Nous approval. No community tier, no trust
|
||||
signals beyond "it's in the catalog".
|
||||
- Manifests pin transport details (commands, args, refs). MCPs are never
|
||||
auto-updated; users explicitly re-run ``hermes mcp install <name>`` to
|
||||
pull a new manifest version after a repo update.
|
||||
- Secrets prompted at install time go to ``~/.hermes/.env`` (the
|
||||
.env-is-for-secrets rule). Non-secret env vars also go to .env to keep
|
||||
one credential store.
|
||||
|
||||
See website/docs/user-guide/mcp-catalog.md for user docs.
|
||||
See references/mcp-catalog.md (this repo's skill) for the manifest schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_constants import get_hermes_home, get_optional_mcps_dir
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import (
|
||||
load_config,
|
||||
save_config,
|
||||
get_env_value,
|
||||
save_env_value,
|
||||
)
|
||||
from hermes_cli.cli_output import prompt as _prompt_input
|
||||
|
||||
_MANIFEST_VERSION = 1
|
||||
|
||||
# Substituted at install time inside `transport.command` / `transport.args`.
|
||||
_INSTALL_DIR_VAR = "${INSTALL_DIR}"
|
||||
|
||||
|
||||
# ─── Data classes ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvVarSpec:
|
||||
name: str
|
||||
prompt: str
|
||||
required: bool = True
|
||||
secret: bool = True
|
||||
default: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthSpec:
|
||||
type: str # "api_key" | "oauth" | "none"
|
||||
env: List[EnvVarSpec] = field(default_factory=list)
|
||||
# OAuth-specific (case 2: third-party provider like Google)
|
||||
provider: Optional[str] = None
|
||||
scopes: List[str] = field(default_factory=list)
|
||||
env_var: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransportSpec:
|
||||
type: str # "stdio" | "http"
|
||||
command: Optional[str] = None
|
||||
args: List[str] = field(default_factory=list)
|
||||
url: Optional[str] = None
|
||||
version: Optional[str] = None # informational, pinned
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallSpec:
|
||||
"""Optional bootstrap step (git clone + dep install).
|
||||
|
||||
Omit for one-shot launchable servers (npx, uvx).
|
||||
"""
|
||||
type: str # "git"
|
||||
url: str
|
||||
ref: str # commit/tag/branch — pinned, never floats
|
||||
bootstrap: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsSpec:
|
||||
"""Manifest-side tool-selection hints.
|
||||
|
||||
Drives the pre-checked state of the install-time tool checklist, and acts
|
||||
as the fallback selection when probe fails. See install_entry() flow.
|
||||
"""
|
||||
|
||||
# If declared, these tool names are pre-checked in the checklist (or
|
||||
# applied directly when probe fails). If None, all probed tools are
|
||||
# pre-checked (or no filter is written when probe fails).
|
||||
default_enabled: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry:
|
||||
name: str
|
||||
description: str
|
||||
source: str
|
||||
transport: TransportSpec
|
||||
auth: AuthSpec
|
||||
tools: ToolsSpec = field(default_factory=ToolsSpec)
|
||||
install: Optional[InstallSpec] = None
|
||||
post_install: str = ""
|
||||
manifest_path: Path = field(default_factory=Path)
|
||||
|
||||
|
||||
# ─── Manifest loader ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CatalogError(Exception):
|
||||
"""Manifest parse/validation failure or install error."""
|
||||
|
||||
|
||||
def _catalog_root() -> Path:
|
||||
"""Return the optional-mcps/ directory shipped with this Hermes install."""
|
||||
# Prefer the env-var override / packaged location; fall back to the repo's
|
||||
# optional-mcps/ next to the package (source checkout).
|
||||
return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps")
|
||||
|
||||
|
||||
def _parse_env_spec(raw: Any) -> EnvVarSpec:
|
||||
if not isinstance(raw, dict):
|
||||
raise CatalogError(f"env entry must be a mapping, got {type(raw).__name__}")
|
||||
name = raw.get("name") or ""
|
||||
if not name or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
||||
raise CatalogError(f"invalid env var name: {name!r}")
|
||||
return EnvVarSpec(
|
||||
name=name,
|
||||
prompt=raw.get("prompt") or name,
|
||||
required=bool(raw.get("required", True)),
|
||||
secret=bool(raw.get("secret", True)),
|
||||
default=str(raw.get("default") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _parse_manifest(path: Path) -> CatalogEntry:
|
||||
"""Read and validate a manifest.yaml. Raise CatalogError on any problem."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
raise CatalogError(f"failed to read {path}: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise CatalogError(f"{path}: manifest must be a mapping")
|
||||
|
||||
mv = data.get("manifest_version")
|
||||
if mv != _MANIFEST_VERSION:
|
||||
raise CatalogError(
|
||||
f"{path}: manifest_version {mv!r} unsupported "
|
||||
f"(this Hermes understands version {_MANIFEST_VERSION})"
|
||||
)
|
||||
|
||||
name = data.get("name") or ""
|
||||
if not name or not re.match(r"^[A-Za-z0-9_-]+$", name):
|
||||
raise CatalogError(f"{path}: invalid or missing 'name'")
|
||||
|
||||
description = str(data.get("description") or "").strip()
|
||||
if not description:
|
||||
raise CatalogError(f"{path}: 'description' required")
|
||||
|
||||
source = str(data.get("source") or "").strip()
|
||||
|
||||
transport_raw = data.get("transport") or {}
|
||||
if not isinstance(transport_raw, dict):
|
||||
raise CatalogError(f"{path}: 'transport' must be a mapping")
|
||||
t_type = transport_raw.get("type")
|
||||
if t_type not in ("stdio", "http"):
|
||||
raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'")
|
||||
args = transport_raw.get("args") or []
|
||||
if not isinstance(args, list):
|
||||
raise CatalogError(f"{path}: transport.args must be a list")
|
||||
transport = TransportSpec(
|
||||
type=t_type,
|
||||
command=transport_raw.get("command"),
|
||||
args=[str(a) for a in args],
|
||||
url=transport_raw.get("url"),
|
||||
version=transport_raw.get("version"),
|
||||
)
|
||||
if t_type == "stdio" and not transport.command:
|
||||
raise CatalogError(f"{path}: stdio transport requires 'command'")
|
||||
if t_type == "http" and not transport.url:
|
||||
raise CatalogError(f"{path}: http transport requires 'url'")
|
||||
|
||||
auth_raw = data.get("auth") or {"type": "none"}
|
||||
if not isinstance(auth_raw, dict):
|
||||
raise CatalogError(f"{path}: 'auth' must be a mapping")
|
||||
a_type = auth_raw.get("type") or "none"
|
||||
if a_type not in ("api_key", "oauth", "none"):
|
||||
raise CatalogError(f"{path}: auth.type must be 'api_key'|'oauth'|'none'")
|
||||
env_list_raw = auth_raw.get("env") or []
|
||||
if not isinstance(env_list_raw, list):
|
||||
raise CatalogError(f"{path}: auth.env must be a list")
|
||||
env_list = [_parse_env_spec(e) for e in env_list_raw]
|
||||
auth = AuthSpec(
|
||||
type=a_type,
|
||||
env=env_list,
|
||||
provider=auth_raw.get("provider"),
|
||||
scopes=list(auth_raw.get("scopes") or []),
|
||||
env_var=auth_raw.get("env_var"),
|
||||
)
|
||||
|
||||
tools_raw = data.get("tools") or {}
|
||||
if not isinstance(tools_raw, dict):
|
||||
raise CatalogError(f"{path}: 'tools' must be a mapping")
|
||||
default_enabled = tools_raw.get("default_enabled")
|
||||
if default_enabled is not None:
|
||||
if not isinstance(default_enabled, list) or not all(
|
||||
isinstance(t, str) for t in default_enabled
|
||||
):
|
||||
raise CatalogError(
|
||||
f"{path}: tools.default_enabled must be a list of strings"
|
||||
)
|
||||
tools_spec = ToolsSpec(default_enabled=default_enabled)
|
||||
|
||||
install: Optional[InstallSpec] = None
|
||||
install_raw = data.get("install")
|
||||
if install_raw is not None:
|
||||
if not isinstance(install_raw, dict):
|
||||
raise CatalogError(f"{path}: 'install' must be a mapping")
|
||||
i_type = install_raw.get("type")
|
||||
if i_type != "git":
|
||||
raise CatalogError(f"{path}: install.type must be 'git' (got {i_type!r})")
|
||||
url = install_raw.get("url") or ""
|
||||
ref = install_raw.get("ref") or ""
|
||||
if not url or not ref:
|
||||
raise CatalogError(f"{path}: install.url and install.ref are required")
|
||||
bootstrap = install_raw.get("bootstrap") or []
|
||||
if not isinstance(bootstrap, list):
|
||||
raise CatalogError(f"{path}: install.bootstrap must be a list")
|
||||
install = InstallSpec(
|
||||
type=i_type,
|
||||
url=url,
|
||||
ref=ref,
|
||||
bootstrap=[str(c) for c in bootstrap],
|
||||
)
|
||||
|
||||
return CatalogEntry(
|
||||
name=name,
|
||||
description=description,
|
||||
source=source,
|
||||
transport=transport,
|
||||
auth=auth,
|
||||
tools=tools_spec,
|
||||
install=install,
|
||||
post_install=str(data.get("post_install") or ""),
|
||||
manifest_path=path,
|
||||
)
|
||||
|
||||
|
||||
def list_catalog() -> List[CatalogEntry]:
|
||||
"""Return all valid catalog entries, sorted by name.
|
||||
|
||||
Invalid manifests are skipped silently (CI tests catch them at PR time).
|
||||
Manifests with a future ``manifest_version`` are also skipped, but the
|
||||
skip is surfaced via :func:`catalog_diagnostics` so the picker / catalog
|
||||
UIs can tell the user their Hermes is out of date.
|
||||
"""
|
||||
root = _catalog_root()
|
||||
if not root.exists():
|
||||
return []
|
||||
entries: List[CatalogEntry] = []
|
||||
_CATALOG_DIAGNOSTICS.clear()
|
||||
for child in sorted(root.iterdir()):
|
||||
manifest = child / "manifest.yaml"
|
||||
if not manifest.is_file():
|
||||
continue
|
||||
try:
|
||||
entries.append(_parse_manifest(manifest))
|
||||
except CatalogError as exc:
|
||||
msg = str(exc)
|
||||
# Recognize the future-manifest error specifically so the UI can
|
||||
# surface a more actionable nudge than "broken manifest".
|
||||
if "manifest_version" in msg and "unsupported" in msg:
|
||||
_CATALOG_DIAGNOSTICS.append((child.name, "future_manifest", msg))
|
||||
else:
|
||||
_CATALOG_DIAGNOSTICS.append((child.name, "invalid", msg))
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
# Populated by list_catalog(). Inspected by the picker / catalog UIs so the
|
||||
# user gets actionable feedback instead of a silently-shorter list.
|
||||
_CATALOG_DIAGNOSTICS: List[tuple] = []
|
||||
|
||||
|
||||
def catalog_diagnostics() -> List[tuple]:
|
||||
"""Diagnostics from the most recent :func:`list_catalog` call.
|
||||
|
||||
Returns a list of ``(entry_name, kind, message)`` tuples where ``kind``
|
||||
is one of:
|
||||
- ``future_manifest`` — manifest_version is newer than this Hermes
|
||||
understands. Update Hermes to install this entry.
|
||||
- ``invalid`` — manifest is malformed in some other way (caught by
|
||||
CI for shipped manifests; user-modified manifests can hit this).
|
||||
"""
|
||||
return list(_CATALOG_DIAGNOSTICS)
|
||||
|
||||
|
||||
def get_entry(name: str) -> Optional[CatalogEntry]:
|
||||
"""Look up a single entry by name. ``official/<name>`` prefix accepted."""
|
||||
if name.startswith("official/"):
|
||||
name = name[len("official/"):]
|
||||
for entry in list_catalog():
|
||||
if entry.name == name:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
# ─── Status helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def installed_servers() -> Dict[str, dict]:
|
||||
"""Return current ``mcp_servers`` block from config.yaml."""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers") or {}
|
||||
return servers if isinstance(servers, dict) else {}
|
||||
|
||||
|
||||
def is_installed(name: str) -> bool:
|
||||
return name in installed_servers()
|
||||
|
||||
|
||||
def is_enabled(name: str) -> bool:
|
||||
servers = installed_servers()
|
||||
cfg = servers.get(name)
|
||||
if not cfg:
|
||||
return False
|
||||
enabled = cfg.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
return enabled.lower() in {"true", "1", "yes"}
|
||||
return bool(enabled)
|
||||
|
||||
|
||||
# ─── Install ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _install_root() -> Path:
|
||||
"""Where git-bootstrapped MCPs are cloned. Per-user, profile-aware."""
|
||||
root = get_hermes_home() / "mcp-installs"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _run_bootstrap(cwd: Path, commands: List[str]) -> None:
|
||||
"""Execute bootstrap commands in *cwd*. Raise CatalogError on first failure.
|
||||
|
||||
Each command runs through the shell (so `&&` etc. work). The output is
|
||||
streamed to the user's terminal for visibility.
|
||||
"""
|
||||
for cmd in commands:
|
||||
print(color(f" $ {cmd}", Colors.DIM))
|
||||
proc = subprocess.run(cmd, cwd=str(cwd), shell=True)
|
||||
if proc.returncode != 0:
|
||||
raise CatalogError(
|
||||
f"bootstrap step failed (exit {proc.returncode}): {cmd}"
|
||||
)
|
||||
|
||||
|
||||
def _do_git_install(entry: CatalogEntry) -> Path:
|
||||
"""Clone the entry's repo into ``~/.hermes/mcp-installs/<name>`` and run
|
||||
bootstrap commands. Returns the install directory."""
|
||||
assert entry.install is not None and entry.install.type == "git"
|
||||
install = entry.install
|
||||
dest = _install_root() / entry.name
|
||||
|
||||
git = shutil.which("git")
|
||||
if not git:
|
||||
raise CatalogError("git is required to install this MCP but was not found on PATH")
|
||||
|
||||
if dest.exists():
|
||||
# Fresh checkout each install — manifest version is the source of truth,
|
||||
# so wipe + re-clone for determinism.
|
||||
print(color(f" Removing existing install at {dest}", Colors.DIM))
|
||||
shutil.rmtree(dest)
|
||||
|
||||
print(color(f" Cloning {install.url} ({install.ref}) → {dest}", Colors.CYAN))
|
||||
|
||||
# `git clone --branch` only accepts branches and tags, NOT commit SHAs.
|
||||
# Detecting SHA-shaped refs upfront avoids a guaranteed stderr leak on
|
||||
# the fast path (the --branch attempt would always fail noisily for a
|
||||
# SHA ref before we fall back to full-clone-then-checkout).
|
||||
is_sha_ref = bool(re.fullmatch(r"[0-9a-f]{7,40}", install.ref))
|
||||
|
||||
if not is_sha_ref:
|
||||
proc = subprocess.run(
|
||||
[git, "clone", "--depth", "1", "--branch", install.ref, install.url, str(dest)],
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
pass
|
||||
else:
|
||||
# Branch/tag form failed (unlikely for valid manifests; possible if
|
||||
# the ref was deleted upstream). Fall through to the full-clone path.
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
is_sha_ref = True # treat the same as a SHA ref from here
|
||||
|
||||
if is_sha_ref:
|
||||
proc = subprocess.run([git, "clone", install.url, str(dest)])
|
||||
if proc.returncode != 0:
|
||||
raise CatalogError(f"git clone failed for {install.url}")
|
||||
proc = subprocess.run([git, "-C", str(dest), "checkout", install.ref])
|
||||
if proc.returncode != 0:
|
||||
raise CatalogError(f"git checkout {install.ref} failed")
|
||||
|
||||
if install.bootstrap:
|
||||
_run_bootstrap(dest, install.bootstrap)
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def _expand_install_dir(value: str, install_dir: Optional[Path]) -> str:
|
||||
if _INSTALL_DIR_VAR not in value:
|
||||
return value
|
||||
if install_dir is None:
|
||||
raise CatalogError(
|
||||
f"manifest references {_INSTALL_DIR_VAR} but no install block exists"
|
||||
)
|
||||
return value.replace(_INSTALL_DIR_VAR, str(install_dir))
|
||||
|
||||
|
||||
def _prompt_env_vars(specs: List[EnvVarSpec]) -> Dict[str, str]:
|
||||
"""Walk the env spec list, prompting the user for each. Writes secrets and
|
||||
non-secrets alike to ~/.hermes/.env via save_env_value()."""
|
||||
collected: Dict[str, str] = {}
|
||||
for spec in specs:
|
||||
existing = get_env_value(spec.name)
|
||||
if existing:
|
||||
print(color(f" ✓ {spec.name} already set in .env", Colors.GREEN))
|
||||
collected[spec.name] = existing
|
||||
continue
|
||||
value = _prompt_input(
|
||||
spec.prompt,
|
||||
default=spec.default or None,
|
||||
password=spec.secret,
|
||||
)
|
||||
if not value:
|
||||
if spec.required:
|
||||
raise CatalogError(f"{spec.name} is required but no value was provided")
|
||||
continue
|
||||
save_env_value(spec.name, value)
|
||||
collected[spec.name] = value
|
||||
return collected
|
||||
|
||||
|
||||
def _build_server_config(
|
||||
entry: CatalogEntry, install_dir: Optional[Path]
|
||||
) -> dict:
|
||||
"""Translate a manifest into the ``mcp_servers.<name>`` block format used
|
||||
by hermes_cli/mcp_config.py."""
|
||||
cfg: dict = {}
|
||||
t = entry.transport
|
||||
if t.type == "stdio":
|
||||
cfg["command"] = _expand_install_dir(t.command or "", install_dir)
|
||||
if t.args:
|
||||
cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args]
|
||||
elif t.type == "http":
|
||||
cfg["url"] = t.url
|
||||
if entry.auth.type == "oauth":
|
||||
cfg["auth"] = "oauth"
|
||||
return cfg
|
||||
|
||||
|
||||
def _read_prior_tool_selection(name: str) -> Optional[List[str]]:
|
||||
"""Return the user's prior `tools.include` for *name*, if any.
|
||||
|
||||
Used during reinstalls so the install-time checklist starts pre-checked
|
||||
with whatever the user already had. Tools no longer on the server are
|
||||
silently dropped at checklist-display time.
|
||||
"""
|
||||
servers = installed_servers()
|
||||
cfg = servers.get(name) or {}
|
||||
tools_cfg = cfg.get("tools") or {}
|
||||
if not isinstance(tools_cfg, dict):
|
||||
return None
|
||||
include = tools_cfg.get("include")
|
||||
if isinstance(include, list) and all(isinstance(t, str) for t in include):
|
||||
return list(include)
|
||||
return None
|
||||
|
||||
|
||||
def _probe_tools(name: str) -> Optional[List[tuple]]:
|
||||
"""Connect to a freshly-configured MCP and list its tools.
|
||||
|
||||
Returns a list of ``(tool_name, description)`` tuples on success, or
|
||||
``None`` on any failure (server unreachable, OAuth not yet completed,
|
||||
backing service offline, etc.). Failures are intentionally swallowed
|
||||
here — the fallback path in :func:`_apply_tool_selection` handles them.
|
||||
"""
|
||||
servers = installed_servers()
|
||||
server_cfg = servers.get(name)
|
||||
if not server_cfg:
|
||||
return None
|
||||
try:
|
||||
# Import lazily so the catalog module stays cheap to load.
|
||||
from hermes_cli.mcp_config import _probe_single_server
|
||||
|
||||
tools = _probe_single_server(name, server_cfg)
|
||||
return list(tools) if tools is not None else []
|
||||
except Exception as exc:
|
||||
# Display the cause but never raise from the install path.
|
||||
print(color(f" Probe failed: {exc}", Colors.YELLOW))
|
||||
return None
|
||||
|
||||
|
||||
def _write_tools_include(name: str, include: Optional[List[str]]) -> None:
|
||||
"""Persist or clear ``mcp_servers.<name>.tools.include``."""
|
||||
cfg = load_config()
|
||||
servers = cfg.setdefault("mcp_servers", {})
|
||||
server_entry = servers.get(name) or {}
|
||||
if include is None:
|
||||
# No filter — drop any existing tools block.
|
||||
server_entry.pop("tools", None)
|
||||
else:
|
||||
tools_block = server_entry.get("tools") or {}
|
||||
if not isinstance(tools_block, dict):
|
||||
tools_block = {}
|
||||
tools_block["include"] = list(include)
|
||||
tools_block.pop("exclude", None)
|
||||
server_entry["tools"] = tools_block
|
||||
servers[name] = server_entry
|
||||
cfg["mcp_servers"] = servers
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _apply_tool_selection(
|
||||
entry: CatalogEntry, *, prior_selection: Optional[List[str]]
|
||||
) -> None:
|
||||
"""Probe the server and let the user pick which tools to enable.
|
||||
|
||||
Probe-success path:
|
||||
- Curses checklist of all probed tools.
|
||||
- Pre-check uses (in priority order):
|
||||
1. *prior_selection* (reinstall: preserve what the user had)
|
||||
2. manifest's ``tools.default_enabled``
|
||||
3. all tools (default)
|
||||
- All-on selection clears any filter (no ``tools.include`` written).
|
||||
- Sub-selection writes ``tools.include``.
|
||||
|
||||
Probe-fail path:
|
||||
- If manifest declares ``tools.default_enabled`` → apply directly.
|
||||
- Otherwise → leave config with no filter (all on when reachable).
|
||||
- Either way, point the user at ``hermes mcp configure <name>``.
|
||||
"""
|
||||
print()
|
||||
print(color(f" Probing '{entry.name}' for available tools...", Colors.CYAN))
|
||||
probed = _probe_tools(entry.name)
|
||||
|
||||
# Probe failure path
|
||||
if probed is None:
|
||||
manifest_default = entry.tools.default_enabled
|
||||
if manifest_default:
|
||||
_write_tools_include(entry.name, manifest_default)
|
||||
print(color(
|
||||
f" Couldn\'t probe server. Applied manifest default "
|
||||
f"({len(manifest_default)} tools). "
|
||||
f"Run `hermes mcp configure {entry.name}` after the server "
|
||||
"is reachable to refine.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
else:
|
||||
_write_tools_include(entry.name, None)
|
||||
print(color(
|
||||
f" Couldn\'t probe server; installed with no tool filter "
|
||||
"(all tools enabled when reachable). "
|
||||
f"Run `hermes mcp configure {entry.name}` after first "
|
||||
"connect to prune.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
return
|
||||
|
||||
if not probed:
|
||||
# Probe succeeded but server reported zero tools. Nothing to filter.
|
||||
_write_tools_include(entry.name, None)
|
||||
print(color(" Server reported no tools.", Colors.YELLOW))
|
||||
return
|
||||
|
||||
tool_names = [t[0] for t in probed]
|
||||
|
||||
# Build the pre-checked set in priority order
|
||||
if prior_selection:
|
||||
pre_set = {n for n in prior_selection if n in tool_names}
|
||||
elif entry.tools.default_enabled:
|
||||
pre_set = {n for n in entry.tools.default_enabled if n in tool_names}
|
||||
else:
|
||||
pre_set = set(tool_names)
|
||||
|
||||
pre_indices = {i for i, n in enumerate(tool_names) if n in pre_set}
|
||||
|
||||
# Non-TTY: skip the checklist. Priority matches the interactive
|
||||
# pre-check priority: prior user selection > manifest default > all-on.
|
||||
import sys as _sys
|
||||
if not _sys.stdin.isatty():
|
||||
if prior_selection is not None:
|
||||
include = [n for n in prior_selection if n in tool_names]
|
||||
_write_tools_include(entry.name, include)
|
||||
elif entry.tools.default_enabled:
|
||||
include = [n for n in entry.tools.default_enabled if n in tool_names]
|
||||
_write_tools_include(entry.name, include)
|
||||
else:
|
||||
_write_tools_include(entry.name, None)
|
||||
return
|
||||
|
||||
print(color(
|
||||
f" Found {len(probed)} tool(s). "
|
||||
f"Pre-checked: {len(pre_indices)}.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
labels = [
|
||||
f"{n} — {(d[:60] + '...') if len(d) > 60 else d}"
|
||||
for n, d in probed
|
||||
]
|
||||
chosen_indices = curses_checklist(
|
||||
f"Select tools for '{entry.name}' (SPACE toggle, ENTER confirm)",
|
||||
labels,
|
||||
pre_indices,
|
||||
)
|
||||
|
||||
if not chosen_indices:
|
||||
# User unchecked everything; treat as "no tools" — write empty include
|
||||
# so the server is installed but contributes nothing until reconfigured.
|
||||
_write_tools_include(entry.name, [])
|
||||
print(color(
|
||||
f" No tools selected. Run `hermes mcp configure {entry.name}` "
|
||||
"to change.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
return
|
||||
|
||||
if len(chosen_indices) == len(probed):
|
||||
# Everything selected — clear filter for the cleanest config shape.
|
||||
# NOTE: this means any tools the server adds later (e.g. a future MCP
|
||||
# version) will also be auto-enabled. To pin to the current set,
|
||||
# the user can re-run `hermes mcp configure <name>` and unselect a
|
||||
# tool to switch back to include-mode.
|
||||
_write_tools_include(entry.name, None)
|
||||
print(color(
|
||||
f" ✓ All {len(probed)} tools enabled (no filter — new tools "
|
||||
"the server adds later will be auto-enabled).",
|
||||
Colors.GREEN,
|
||||
))
|
||||
return
|
||||
|
||||
chosen_names = [tool_names[i] for i in sorted(chosen_indices)]
|
||||
_write_tools_include(entry.name, chosen_names)
|
||||
print(color(
|
||||
f" ✓ {len(chosen_names)}/{len(probed)} tools enabled.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
|
||||
|
||||
def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
||||
"""Install a catalog entry end-to-end.
|
||||
|
||||
Steps:
|
||||
1. If ``install.type == git``, clone + run bootstrap commands.
|
||||
2. If ``auth.type == api_key``, prompt for env vars, save to .env.
|
||||
3. If ``auth.type == oauth`` (remote MCP / case 1), write the
|
||||
``auth: oauth`` marker (MCP client handles browser on first connect
|
||||
in the non-pre-authenticated case).
|
||||
4. Translate the manifest into an ``mcp_servers.<name>`` block and
|
||||
save into config.yaml.
|
||||
5. Probe the server, present a curses checklist for tool selection,
|
||||
write ``tools.include`` (or no filter, depending on choice).
|
||||
If probe fails, fall back to the manifest's
|
||||
``tools.default_enabled`` or all-on.
|
||||
6. Print post_install notes.
|
||||
"""
|
||||
print()
|
||||
print(color(f" Installing MCP '{entry.name}'", Colors.CYAN + Colors.BOLD))
|
||||
if entry.description:
|
||||
print(color(f" {entry.description}", Colors.DIM))
|
||||
if entry.source:
|
||||
print(color(f" Source: {entry.source}", Colors.DIM))
|
||||
print()
|
||||
|
||||
install_dir: Optional[Path] = None
|
||||
if entry.install is not None:
|
||||
install_dir = _do_git_install(entry)
|
||||
|
||||
# Auth
|
||||
if entry.auth.type == "api_key":
|
||||
print()
|
||||
print(color(" Configure credentials:", Colors.CYAN))
|
||||
_prompt_env_vars(entry.auth.env)
|
||||
elif entry.auth.type == "oauth":
|
||||
if entry.auth.provider:
|
||||
# Case 2: provider-mediated (Google, GitHub, etc.). We rely on
|
||||
# the existing `hermes auth <provider>` flow. Surface guidance
|
||||
# here rather than auto-running it — keeps the catalog install
|
||||
# decoupled from provider-auth lifecycle.
|
||||
print(color(
|
||||
f" This MCP uses {entry.auth.provider} OAuth. Run "
|
||||
f"`hermes auth {entry.auth.provider}` if you have not "
|
||||
"already authenticated.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
else:
|
||||
print(color(
|
||||
" This MCP uses native OAuth 2.1; tokens will be acquired "
|
||||
"on first connection (browser flow).",
|
||||
Colors.DIM,
|
||||
))
|
||||
# auth.type == "none": nothing to do.
|
||||
|
||||
# ── Preserve any prior user tool selection across reinstalls ────────
|
||||
# Reading BEFORE we overwrite the entry below so a reinstall pre-checks
|
||||
# whatever the user picked last time.
|
||||
prior_selection = _read_prior_tool_selection(entry.name)
|
||||
|
||||
# Build and write the mcp_servers entry (without tools filter yet;
|
||||
# _apply_tool_selection() finalizes it below).
|
||||
server_cfg = _build_server_config(entry, install_dir)
|
||||
server_cfg["enabled"] = enable
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("mcp_servers", {})[entry.name] = server_cfg
|
||||
save_config(cfg)
|
||||
|
||||
# ── Probe + tool selection ──────────────────────────────────────────
|
||||
_apply_tool_selection(entry, prior_selection=prior_selection)
|
||||
|
||||
print()
|
||||
print(color(
|
||||
f" ✓ Installed '{entry.name}' "
|
||||
f"({'enabled' if enable else 'disabled'}). "
|
||||
f"Start a new Hermes session to load its tools.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
if entry.post_install:
|
||||
print()
|
||||
for line in entry.post_install.strip().splitlines():
|
||||
print(color(f" {line}", Colors.DIM))
|
||||
print()
|
||||
|
||||
|
||||
def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool:
|
||||
"""Remove a catalog-installed MCP from config and (optionally) wipe its
|
||||
clone directory. Returns True if anything was removed."""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers") or {}
|
||||
removed = False
|
||||
if name in servers:
|
||||
del servers[name]
|
||||
if not servers:
|
||||
cfg.pop("mcp_servers", None)
|
||||
else:
|
||||
cfg["mcp_servers"] = servers
|
||||
save_config(cfg)
|
||||
removed = True
|
||||
|
||||
if purge_install_dir:
|
||||
clone = _install_root() / name
|
||||
if clone.exists():
|
||||
shutil.rmtree(clone)
|
||||
removed = True
|
||||
|
||||
return removed
|
||||
|
|
@ -109,6 +109,21 @@ def _env_key_for_server(name: str) -> str:
|
|||
return f"MCP_{name.upper().replace('-', '_')}_API_KEY"
|
||||
|
||||
|
||||
def _strip_bearer_prefix(token: str) -> str:
|
||||
"""Strip a leading ``Bearer `` from a pasted token.
|
||||
|
||||
The header template stores ``Authorization: Bearer ${MCP_X_API_KEY}``, so
|
||||
if a user pastes a token that already includes the ``Bearer `` prefix the
|
||||
server receives ``Bearer Bearer <jwt>`` → 401. Normalize on save. (#37792)
|
||||
"""
|
||||
if not isinstance(token, str):
|
||||
return token
|
||||
stripped = token.strip()
|
||||
if stripped[:7].lower() == "bearer ":
|
||||
return stripped[7:].strip()
|
||||
return stripped
|
||||
|
||||
|
||||
def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]:
|
||||
"""Parse ``KEY=VALUE`` strings from CLI args into an env dict."""
|
||||
parsed: Dict[str, str] = {}
|
||||
|
|
@ -164,6 +179,27 @@ def _apply_mcp_preset(
|
|||
|
||||
# ─── Discovery (temporary connect) ───────────────────────────────────────────
|
||||
|
||||
def _resolve_mcp_server_config(config: dict) -> dict:
|
||||
"""Resolve ``${ENV}`` placeholders in a server config before connecting.
|
||||
|
||||
Mirrors ``_load_mcp_config()`` in ``tools/mcp_tool.py``: load
|
||||
``~/.hermes/.env`` into ``os.environ`` and recursively interpolate any
|
||||
``${VAR}`` placeholders. The CLI builds header templates like
|
||||
``Authorization: Bearer ${MCP_X_API_KEY}`` but the probe path never
|
||||
resolved them, so the discovery probe sent the literal placeholder and
|
||||
auth-requiring servers (e.g. n8n) returned 401 — while runtime tool
|
||||
loading worked because it interpolates. (#37792)
|
||||
"""
|
||||
from tools.mcp_tool import _interpolate_env_vars
|
||||
|
||||
try:
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
load_hermes_dotenv()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
return _interpolate_env_vars(config)
|
||||
|
||||
|
||||
def _probe_single_server(
|
||||
name: str, config: dict, connect_timeout: float = 30
|
||||
) -> List[Tuple[str, str]]:
|
||||
|
|
@ -179,6 +215,8 @@ def _probe_single_server(
|
|||
_stop_mcp_loop,
|
||||
)
|
||||
|
||||
config = _resolve_mcp_server_config(config)
|
||||
|
||||
_ensure_mcp_loop()
|
||||
|
||||
tools_found: List[Tuple[str, str]] = []
|
||||
|
|
@ -187,13 +225,15 @@ def _probe_single_server(
|
|||
server = await asyncio.wait_for(
|
||||
_connect_server(name, config), timeout=connect_timeout
|
||||
)
|
||||
for t in server._tools:
|
||||
desc = getattr(t, "description", "") or ""
|
||||
# Truncate long descriptions for display
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
tools_found.append((t.name, desc))
|
||||
await server.shutdown()
|
||||
try:
|
||||
for t in server._tools:
|
||||
desc = getattr(t, "description", "") or ""
|
||||
# Truncate long descriptions for display
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
tools_found.append((t.name, desc))
|
||||
finally:
|
||||
await server.shutdown()
|
||||
|
||||
try:
|
||||
_run_on_mcp_loop(_probe(), timeout=connect_timeout + 10)
|
||||
|
|
@ -205,6 +245,22 @@ def _probe_single_server(
|
|||
return tools_found
|
||||
|
||||
|
||||
def _oauth_tokens_present(name: str) -> bool:
|
||||
"""Return True if an OAuth token file exists on disk for ``name``.
|
||||
|
||||
Used after ``hermes mcp login`` to distinguish a genuine authentication
|
||||
from a probe that succeeded only because the server allowed
|
||||
initialize/tools-list without auth (so no token was ever acquired).
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
return HermesTokenStorage(name).has_cached_tokens()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("Could not check OAuth tokens for '%s': %s", name, exc)
|
||||
# Be permissive on unexpected errors: don't block a real success.
|
||||
return True
|
||||
|
||||
|
||||
def _unwrap_exception_group(exc: BaseException) -> Exception:
|
||||
"""Extract the root-cause exception from anyio TaskGroup wrappers.
|
||||
|
||||
|
|
@ -324,6 +380,7 @@ def cmd_mcp_add(args):
|
|||
else:
|
||||
api_key = _prompt("API key / Bearer token", password=True)
|
||||
if api_key:
|
||||
api_key = _strip_bearer_prefix(api_key)
|
||||
save_env_value(env_key, api_key)
|
||||
_success(f"Saved to {display_hermes_home()}/.env as {env_key}")
|
||||
|
||||
|
|
@ -631,6 +688,36 @@ def cmd_mcp_login(args):
|
|||
# Probe triggers the OAuth flow (browser redirect + callback capture).
|
||||
try:
|
||||
tools = _probe_single_server(name, server_config)
|
||||
# A clean probe is NOT proof of authentication. Some MCP servers
|
||||
# (notably Google's official Drive server) serve initialize +
|
||||
# tools/list WITHOUT auth, so the probe lists tools even when the
|
||||
# OAuth flow never completed — e.g. dynamic client registration
|
||||
# 400'd because the provider doesn't support RFC 7591. Reporting
|
||||
# "Authenticated — N tools" in that case is a false success: every
|
||||
# real tool call later hangs until timeout because there's no token.
|
||||
# Verify a token actually landed on disk before claiming success.
|
||||
if not _oauth_tokens_present(name):
|
||||
_warning(
|
||||
"Server responded, but no OAuth token was obtained — "
|
||||
"authentication did not complete."
|
||||
)
|
||||
print()
|
||||
_info(
|
||||
"Some providers (e.g. Google Drive, Atlassian) do not support "
|
||||
"automatic client registration. For those you must create an "
|
||||
"OAuth client yourself and add its credentials to config.yaml:"
|
||||
)
|
||||
print()
|
||||
print(color(f" mcp_servers:", Colors.DIM))
|
||||
print(color(f" {name}:", Colors.DIM))
|
||||
print(color(f" url: {url}", Colors.DIM))
|
||||
print(color(f" auth: oauth", Colors.DIM))
|
||||
print(color(f" oauth:", Colors.DIM))
|
||||
print(color(f" client_id: \"<your-oauth-client-id>\"", Colors.DIM))
|
||||
print(color(f" client_secret: \"<your-oauth-client-secret>\"", Colors.DIM))
|
||||
print()
|
||||
_info("Then re-run `hermes mcp login " + name + "`.")
|
||||
return
|
||||
if tools:
|
||||
_success(f"Authenticated — {len(tools)} tool(s) available")
|
||||
else:
|
||||
|
|
@ -749,6 +836,24 @@ def mcp_command(args):
|
|||
run_mcp_server(verbose=getattr(args, "verbose", False))
|
||||
return
|
||||
|
||||
# Catalog subcommands live in mcp_picker / mcp_catalog. Import lazily so
|
||||
# the original `mcp_config` module stays import-cheap.
|
||||
if action == "picker":
|
||||
from hermes_cli.mcp_picker import run_picker
|
||||
run_picker()
|
||||
return
|
||||
if action == "catalog":
|
||||
from hermes_cli.mcp_picker import show_catalog
|
||||
show_catalog()
|
||||
return
|
||||
if action == "install":
|
||||
from hermes_cli.mcp_picker import install_by_name
|
||||
import sys as _sys
|
||||
rc = install_by_name(getattr(args, "identifier", "") or "")
|
||||
if rc:
|
||||
_sys.exit(rc)
|
||||
return
|
||||
|
||||
handlers = {
|
||||
"add": cmd_mcp_add,
|
||||
"remove": cmd_mcp_remove,
|
||||
|
|
@ -765,15 +870,20 @@ def mcp_command(args):
|
|||
if handler:
|
||||
handler(args)
|
||||
else:
|
||||
# No subcommand — show list
|
||||
cmd_mcp_list()
|
||||
# No subcommand — drop the user into the catalog picker. This is the
|
||||
# "try enabling and it flows you into setup" UX matching `hermes plugin`.
|
||||
from hermes_cli.mcp_picker import run_picker
|
||||
run_picker()
|
||||
print(color(" Commands:", Colors.CYAN))
|
||||
_info("hermes mcp Open the catalog picker (default)")
|
||||
_info("hermes mcp catalog List Nous-approved MCPs")
|
||||
_info("hermes mcp install <name> Install a catalog MCP")
|
||||
_info("hermes mcp serve Run as MCP server")
|
||||
_info("hermes mcp add <name> --url <endpoint> Add an MCP server")
|
||||
_info("hermes mcp add <name> --url <endpoint> Add a custom MCP server")
|
||||
_info("hermes mcp add <name> --command <cmd> Add a stdio server")
|
||||
_info("hermes mcp add <name> --preset <preset> Add from a known preset")
|
||||
_info("hermes mcp remove <name> Remove a server")
|
||||
_info("hermes mcp list List servers")
|
||||
_info("hermes mcp list List configured servers")
|
||||
_info("hermes mcp test <name> Test connection")
|
||||
_info("hermes mcp configure <name> Toggle tools")
|
||||
_info("hermes mcp login <name> Re-authenticate OAuth")
|
||||
|
|
|
|||
322
hermes_cli/mcp_picker.py
Normal file
322
hermes_cli/mcp_picker.py
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`).
|
||||
|
||||
Lists every catalog entry plus any custom MCP servers the user has added via
|
||||
``hermes mcp add``, lets them pick one, and routes to install / enable /
|
||||
disable / uninstall / configure-tools flows.
|
||||
|
||||
Mirrors the `hermes plugin` picker UX: arrow keys to navigate, ENTER on a row
|
||||
to act on it. The action depends on current status:
|
||||
|
||||
not installed (catalog) → install (clone/bootstrap if needed, prompt for creds)
|
||||
installed / disabled → enable
|
||||
installed / enabled → submenu: configure tools / disable / uninstall / reinstall
|
||||
custom (non-catalog) → submenu: configure tools / enable / disable / remove
|
||||
|
||||
The picker loops until the user hits ESC/q so they can manage multiple
|
||||
entries in one session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.cli_output import prompt_yes_no
|
||||
from hermes_cli.curses_ui import curses_single_select
|
||||
from hermes_cli.mcp_catalog import (
|
||||
CatalogEntry,
|
||||
CatalogError,
|
||||
catalog_diagnostics,
|
||||
install_entry,
|
||||
is_enabled,
|
||||
is_installed,
|
||||
list_catalog,
|
||||
installed_servers,
|
||||
uninstall_entry,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
|
||||
# ─── Status badges ────────────────────────────────────────────────────────────
|
||||
|
||||
_STATUS_NOT_INSTALLED = "available"
|
||||
_STATUS_DISABLED = "installed (disabled)"
|
||||
_STATUS_ENABLED = "enabled"
|
||||
_STATUS_CUSTOM_ENABLED = "custom — enabled"
|
||||
_STATUS_CUSTOM_DISABLED = "custom — disabled"
|
||||
|
||||
|
||||
# ─── Row model — unifies catalog and custom entries ──────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
"""A row in the picker. ``entry`` is set for catalog rows; for custom
|
||||
user-added MCPs only ``name`` + ``description`` + status are populated."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
status: str
|
||||
entry: Optional[CatalogEntry] = None # None for non-catalog (custom) rows
|
||||
|
||||
@property
|
||||
def is_custom(self) -> bool:
|
||||
return self.entry is None
|
||||
|
||||
|
||||
def _build_rows() -> List[_Row]:
|
||||
"""Return catalog rows + any custom (non-catalog) MCPs found in config."""
|
||||
catalog_entries = list_catalog()
|
||||
catalog_names = {e.name for e in catalog_entries}
|
||||
|
||||
rows: List[_Row] = []
|
||||
for entry in catalog_entries:
|
||||
if not is_installed(entry.name):
|
||||
status = _STATUS_NOT_INSTALLED
|
||||
elif is_enabled(entry.name):
|
||||
status = _STATUS_ENABLED
|
||||
else:
|
||||
status = _STATUS_DISABLED
|
||||
rows.append(
|
||||
_Row(
|
||||
name=entry.name,
|
||||
description=entry.description,
|
||||
status=status,
|
||||
entry=entry,
|
||||
)
|
||||
)
|
||||
|
||||
# Custom MCPs the user added directly (not in the catalog)
|
||||
for name, cfg in sorted(installed_servers().items()):
|
||||
if name in catalog_names:
|
||||
continue
|
||||
enabled = cfg.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
enabled = enabled.lower() in {"true", "1", "yes"}
|
||||
status = _STATUS_CUSTOM_ENABLED if enabled else _STATUS_CUSTOM_DISABLED
|
||||
# Use the transport URL/command as the "description" for custom rows
|
||||
desc = cfg.get("url") or cfg.get("command") or "(no transport)"
|
||||
rows.append(_Row(name=name, description=str(desc), status=status))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _format_row(row: _Row) -> str:
|
||||
return f"{row.name:<18} {row.status:<24} {row.description}"
|
||||
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _enable_disable(name: str, *, enable: bool) -> None:
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers") or {}
|
||||
server = servers.get(name)
|
||||
if not server:
|
||||
print(color(f" '{name}' is not installed.", Colors.RED))
|
||||
return
|
||||
server["enabled"] = enable
|
||||
cfg["mcp_servers"] = servers
|
||||
save_config(cfg)
|
||||
print(color(
|
||||
f" ✓ '{name}' {'enabled' if enable else 'disabled'}. "
|
||||
"Start a new Hermes session for changes to take effect.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
|
||||
|
||||
def _configure_tools(name: str) -> None:
|
||||
"""Open the tool selection checklist for an already-installed MCP.
|
||||
|
||||
Delegates to the existing ``cmd_mcp_configure`` flow which probes the
|
||||
server, displays a checklist, and writes ``tools.include``.
|
||||
"""
|
||||
import argparse
|
||||
from hermes_cli.mcp_config import cmd_mcp_configure
|
||||
|
||||
cmd_mcp_configure(argparse.Namespace(name=name))
|
||||
|
||||
|
||||
def _remove_custom(name: str) -> None:
|
||||
"""Remove a non-catalog MCP entry from config.yaml."""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers") or {}
|
||||
if name not in servers:
|
||||
print(color(f" '{name}' is not configured.", Colors.RED))
|
||||
return
|
||||
if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False):
|
||||
return
|
||||
del servers[name]
|
||||
if not servers:
|
||||
cfg.pop("mcp_servers", None)
|
||||
else:
|
||||
cfg["mcp_servers"] = servers
|
||||
save_config(cfg)
|
||||
print(color(f" ✓ Removed '{name}'", Colors.GREEN))
|
||||
|
||||
|
||||
def _handle_row(row: _Row) -> None:
|
||||
"""Act on the picked row based on its current status."""
|
||||
# === Catalog row, not yet installed ===
|
||||
if row.entry and not is_installed(row.name):
|
||||
try:
|
||||
install_entry(row.entry, enable=True)
|
||||
except CatalogError as exc:
|
||||
print(color(f" ✗ install failed: {exc}", Colors.RED))
|
||||
return
|
||||
|
||||
# === Catalog row, installed but disabled ===
|
||||
if row.entry and not is_enabled(row.name):
|
||||
_enable_disable(row.name, enable=True)
|
||||
return
|
||||
|
||||
# === Catalog row, installed + enabled OR custom row ===
|
||||
if row.is_custom:
|
||||
# Custom (non-catalog) row submenu
|
||||
actions = [
|
||||
"Configure tools (probe server + re-pick)",
|
||||
"Enable" if not is_enabled(row.name) else "Disable",
|
||||
"Remove from config",
|
||||
]
|
||||
choice = curses_single_select(f"Action for '{row.name}' (custom)", actions)
|
||||
if choice is None:
|
||||
return
|
||||
if choice == 0:
|
||||
_configure_tools(row.name)
|
||||
elif choice == 1:
|
||||
_enable_disable(row.name, enable=not is_enabled(row.name))
|
||||
elif choice == 2:
|
||||
_remove_custom(row.name)
|
||||
return
|
||||
|
||||
# Catalog row, installed + enabled
|
||||
print()
|
||||
print(color(f" '{row.name}' is already enabled.", Colors.DIM))
|
||||
actions = [
|
||||
"Configure tools (probe server + re-pick)",
|
||||
"Disable (keep config, stop loading on next session)",
|
||||
"Uninstall (remove config and any cloned files)",
|
||||
"Reinstall (re-clone, re-prompt for credentials)",
|
||||
]
|
||||
choice = curses_single_select(f"Action for '{row.name}'", actions)
|
||||
if choice is None:
|
||||
return
|
||||
if choice == 0:
|
||||
_configure_tools(row.name)
|
||||
elif choice == 1:
|
||||
_enable_disable(row.name, enable=False)
|
||||
elif choice == 2:
|
||||
if prompt_yes_no(f"Uninstall '{row.name}'?", default=False):
|
||||
if uninstall_entry(row.name):
|
||||
print(color(
|
||||
f" ✓ Uninstalled '{row.name}'. "
|
||||
"Credentials in .env preserved — delete manually if no longer needed.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
else:
|
||||
print(color(f" '{row.name}' was not installed", Colors.DIM))
|
||||
elif choice == 3:
|
||||
try:
|
||||
assert row.entry is not None
|
||||
install_entry(row.entry, enable=True)
|
||||
except CatalogError as exc:
|
||||
print(color(f" ✗ reinstall failed: {exc}", Colors.RED))
|
||||
|
||||
|
||||
# ─── Output / entry points ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _print_rows_text(rows: List[_Row]) -> None:
|
||||
"""Plain-text catalog dump used as a fallback when curses can't run, and
|
||||
as the default output of `hermes mcp catalog`."""
|
||||
if not rows:
|
||||
print()
|
||||
print(color(" No MCPs in the catalog or configured.", Colors.DIM))
|
||||
print()
|
||||
return
|
||||
|
||||
print()
|
||||
print(color(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD))
|
||||
print()
|
||||
print(f" {'Name':<18} {'Status':<24} Description")
|
||||
print(f" {'-' * 18} {'-' * 24} {'-' * 11}")
|
||||
for row in rows:
|
||||
print(f" {_format_row(row)}")
|
||||
print()
|
||||
print(color(
|
||||
" Install: hermes mcp install <name> Picker: hermes mcp",
|
||||
Colors.DIM,
|
||||
))
|
||||
|
||||
# Surface manifest-version warnings so users know when their Hermes is
|
||||
# too old to install everything in the catalog.
|
||||
diags = catalog_diagnostics()
|
||||
future = [d for d in diags if d[1] == "future_manifest"]
|
||||
if future:
|
||||
print()
|
||||
for name, _, msg in future:
|
||||
print(color(
|
||||
f" ⚠ '{name}' requires a newer Hermes — run `hermes update` "
|
||||
"to install this entry.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
print()
|
||||
print()
|
||||
|
||||
|
||||
def show_catalog() -> None:
|
||||
"""`hermes mcp catalog` — print the curated list + custom servers, no interaction."""
|
||||
_print_rows_text(_build_rows())
|
||||
|
||||
|
||||
def run_picker() -> None:
|
||||
"""`hermes mcp picker` (and default `hermes mcp`) — interactive selector.
|
||||
|
||||
Loops until the user hits ESC/q. After each action the picker re-renders
|
||||
so the user can manage several entries in one session.
|
||||
"""
|
||||
if not sys.stdin.isatty():
|
||||
# Non-interactive shell: degrade to the text dump rather than failing.
|
||||
_print_rows_text(_build_rows())
|
||||
return
|
||||
|
||||
while True:
|
||||
rows = _build_rows()
|
||||
if not rows:
|
||||
_print_rows_text(rows)
|
||||
return
|
||||
|
||||
labels = [_format_row(r) for r in rows]
|
||||
idx = curses_single_select(
|
||||
"MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit",
|
||||
labels,
|
||||
)
|
||||
if idx is None:
|
||||
return
|
||||
_handle_row(rows[idx])
|
||||
|
||||
|
||||
def install_by_name(identifier: str) -> int:
|
||||
"""`hermes mcp install <name>` — non-interactive entry-point.
|
||||
|
||||
Returns 0 on success, non-zero on failure (so the CLI can propagate
|
||||
exit codes).
|
||||
"""
|
||||
from hermes_cli.mcp_catalog import get_entry
|
||||
|
||||
entry = get_entry(identifier)
|
||||
if entry is None:
|
||||
print(color(
|
||||
f" ✗ '{identifier}' is not in the catalog. "
|
||||
"Run `hermes mcp catalog` to see available entries.",
|
||||
Colors.RED,
|
||||
))
|
||||
return 1
|
||||
try:
|
||||
install_entry(entry, enable=True)
|
||||
except CatalogError as exc:
|
||||
print(color(f" ✗ install failed: {exc}", Colors.RED))
|
||||
return 1
|
||||
return 0
|
||||
59
hermes_cli/mcp_startup.py
Normal file
59
hermes_cli/mcp_startup.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Shared CLI/TUI-safe helpers for background MCP discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
_mcp_discovery_lock = threading.Lock()
|
||||
_mcp_discovery_started = False
|
||||
_mcp_discovery_thread: Optional[threading.Thread] = None
|
||||
|
||||
|
||||
def _has_configured_mcp_servers() -> bool:
|
||||
"""Cheap config probe so non-MCP users avoid importing the MCP stack."""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
|
||||
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
|
||||
except Exception:
|
||||
# Be conservative: if config probing fails, try discovery in the
|
||||
# background so startup still can't block.
|
||||
return True
|
||||
|
||||
|
||||
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
|
||||
"""Spawn one shared background MCP discovery thread for this process."""
|
||||
global _mcp_discovery_started, _mcp_discovery_thread
|
||||
|
||||
with _mcp_discovery_lock:
|
||||
if _mcp_discovery_started:
|
||||
return
|
||||
_mcp_discovery_started = True
|
||||
if not _has_configured_mcp_servers():
|
||||
return
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug("Background MCP tool discovery failed", exc_info=True)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_discover,
|
||||
name=thread_name,
|
||||
daemon=True,
|
||||
)
|
||||
_mcp_discovery_thread = thread
|
||||
thread.start()
|
||||
|
||||
|
||||
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
|
||||
"""Briefly wait for background MCP discovery before the first tool snapshot."""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
return
|
||||
thread.join(timeout=timeout)
|
||||
|
|
@ -7,13 +7,13 @@ the provider's config schema. Writes config to config.yaml + .env.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -39,12 +39,7 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str
|
|||
"""Prompt for a value with optional default and secret masking."""
|
||||
suffix = f" [{default}]" if default else ""
|
||||
if secret:
|
||||
sys.stdout.write(f" {label}{suffix}: ")
|
||||
sys.stdout.flush()
|
||||
if sys.stdin.isatty():
|
||||
val = getpass.getpass(prompt="")
|
||||
else:
|
||||
val = sys.stdin.readline().strip()
|
||||
val = masked_secret_prompt(f" {label}{suffix}: ")
|
||||
else:
|
||||
sys.stdout.write(f" {label}{suffix}: ")
|
||||
sys.stdout.flush()
|
||||
|
|
@ -102,16 +97,25 @@ def _install_dependencies(provider_name: str) -> None:
|
|||
print(f"\n Installing dependencies: {', '.join(missing)}")
|
||||
|
||||
import shutil
|
||||
|
||||
uv_path = shutil.which("uv")
|
||||
if not uv_path:
|
||||
print(f" ⚠ uv not found — cannot install dependencies")
|
||||
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then re-run: hermes memory setup")
|
||||
return
|
||||
if uv_path:
|
||||
install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing
|
||||
manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}"
|
||||
else:
|
||||
pip_cmd = shutil.which("pip3") or shutil.which("pip")
|
||||
if not pip_cmd:
|
||||
print(f" ⚠ uv not found — cannot install dependencies")
|
||||
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then re-run: hermes memory setup")
|
||||
return
|
||||
print(f" ⚠ uv not found. Falling back to standard pip...")
|
||||
install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing
|
||||
manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing,
|
||||
install_cmd,
|
||||
check=True, timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
|
|
@ -121,10 +125,10 @@ def _install_dependencies(provider_name: str) -> None:
|
|||
stderr = (e.stderr or b"").decode()[:200]
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Install failed: {e}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
|
||||
# Also show external dependencies (non-pip) if any
|
||||
ext_deps = meta.get("external_dependencies", [])
|
||||
|
|
@ -457,7 +461,11 @@ def memory_command(args) -> None:
|
|||
"""Route memory subcommands."""
|
||||
sub = getattr(args, "memory_command", None)
|
||||
if sub == "setup":
|
||||
cmd_setup(args)
|
||||
provider = getattr(args, "provider", None)
|
||||
if provider:
|
||||
cmd_setup_provider(provider)
|
||||
else:
|
||||
cmd_setup(args)
|
||||
elif sub == "status":
|
||||
cmd_status(args)
|
||||
else:
|
||||
|
|
|
|||
313
hermes_cli/middleware.py
Normal file
313
hermes_cli/middleware.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Hermes middleware contract helpers.
|
||||
|
||||
Observer hooks report what happened. Middleware can change what happens by
|
||||
rewriting a request or wrapping the actual execution callback. Keep the small
|
||||
contract helpers here so agent-loop call sites and plugins share one vocabulary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OBSERVER_SCHEMA_VERSION = "hermes.observer.v1"
|
||||
MIDDLEWARE_SCHEMA_VERSION = "hermes.middleware.v1"
|
||||
|
||||
TOOL_REQUEST_MIDDLEWARE = "tool_request"
|
||||
TOOL_EXECUTION_MIDDLEWARE = "tool_execution"
|
||||
LLM_REQUEST_MIDDLEWARE = "llm_request"
|
||||
LLM_EXECUTION_MIDDLEWARE = "llm_execution"
|
||||
|
||||
# Back-compat aliases for older PoC branches that used API terminology.
|
||||
API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE
|
||||
API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE
|
||||
|
||||
VALID_MIDDLEWARE: set[str] = {
|
||||
TOOL_REQUEST_MIDDLEWARE,
|
||||
TOOL_EXECUTION_MIDDLEWARE,
|
||||
LLM_REQUEST_MIDDLEWARE,
|
||||
LLM_EXECUTION_MIDDLEWARE,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestMiddlewareResult:
|
||||
"""Result of applying request middleware to a mutable payload."""
|
||||
|
||||
payload: Any
|
||||
original_payload: Any
|
||||
changed: bool = False
|
||||
trace: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def observer_payload(**kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
|
||||
return kwargs
|
||||
|
||||
|
||||
def middleware_payload(**kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
|
||||
kwargs.setdefault("middleware_schema_version", MIDDLEWARE_SCHEMA_VERSION)
|
||||
return kwargs
|
||||
|
||||
|
||||
def _safe_copy(payload: Any) -> Any:
|
||||
"""Deep-copy a request payload, tolerating non-deepcopyable members.
|
||||
|
||||
Request payloads are normally plain JSON-shaped dicts, but an LLM request
|
||||
can occasionally carry non-deepcopyable objects (clients, callbacks, file
|
||||
handles). A hard ``deepcopy`` failure there would otherwise abort the whole
|
||||
request-middleware pass. Fall back to a shallow ``dict`` copy so middleware
|
||||
still runs and the original nested objects are shared by reference rather
|
||||
than corrupting the live payload.
|
||||
"""
|
||||
try:
|
||||
return deepcopy(payload)
|
||||
except Exception as exc: # pragma: no cover - exercised via fallback test
|
||||
logger.debug("deepcopy failed for request payload (%s); using shallow copy", exc)
|
||||
if isinstance(payload, dict):
|
||||
return dict(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def apply_llm_request_middleware(
|
||||
request: Dict[str, Any],
|
||||
**context: Any,
|
||||
) -> RequestMiddlewareResult:
|
||||
"""Apply registered LLM request middleware.
|
||||
|
||||
Middleware may return ``{"request": {...}}`` to replace the effective
|
||||
provider kwargs before Hermes sends them.
|
||||
"""
|
||||
if not _has_middleware(LLM_REQUEST_MIDDLEWARE):
|
||||
return RequestMiddlewareResult(
|
||||
payload=request,
|
||||
original_payload=request,
|
||||
changed=False,
|
||||
trace=[],
|
||||
)
|
||||
|
||||
original_request = _safe_copy(request)
|
||||
current_request = _safe_copy(original_request)
|
||||
trace: List[Dict[str, Any]] = []
|
||||
|
||||
for result in _invoke_middleware(
|
||||
LLM_REQUEST_MIDDLEWARE,
|
||||
request=current_request,
|
||||
original_request=original_request,
|
||||
**context,
|
||||
):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
next_request = result.get("request")
|
||||
if not isinstance(next_request, dict):
|
||||
continue
|
||||
current_request = _safe_copy(next_request)
|
||||
trace.append(_trace_entry(result))
|
||||
|
||||
return RequestMiddlewareResult(
|
||||
payload=current_request,
|
||||
original_payload=original_request,
|
||||
changed=bool(trace),
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
|
||||
def apply_tool_request_middleware(
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
**context: Any,
|
||||
) -> RequestMiddlewareResult:
|
||||
"""Apply registered tool request middleware.
|
||||
|
||||
Middleware may return ``{"args": {...}}`` to replace the effective tool
|
||||
arguments before hooks, guardrails, approvals, and execution see them.
|
||||
"""
|
||||
if not _has_middleware(TOOL_REQUEST_MIDDLEWARE):
|
||||
return RequestMiddlewareResult(
|
||||
payload=args,
|
||||
original_payload=args,
|
||||
changed=False,
|
||||
trace=[],
|
||||
)
|
||||
|
||||
original_args = _safe_copy(args)
|
||||
current_args = _safe_copy(original_args)
|
||||
trace: List[Dict[str, Any]] = []
|
||||
|
||||
for result in _invoke_middleware(
|
||||
TOOL_REQUEST_MIDDLEWARE,
|
||||
tool_name=tool_name,
|
||||
args=current_args,
|
||||
original_args=original_args,
|
||||
**context,
|
||||
):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
next_args = result.get("args")
|
||||
if not isinstance(next_args, dict):
|
||||
continue
|
||||
current_args = _safe_copy(next_args)
|
||||
trace.append(_trace_entry(result))
|
||||
|
||||
return RequestMiddlewareResult(
|
||||
payload=current_args,
|
||||
original_payload=original_args,
|
||||
changed=bool(trace),
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
|
||||
def apply_api_request_middleware(
|
||||
request: Dict[str, Any],
|
||||
**context: Any,
|
||||
) -> RequestMiddlewareResult:
|
||||
"""Compatibility wrapper for older ``api_request`` naming."""
|
||||
return apply_llm_request_middleware(request, **context)
|
||||
|
||||
|
||||
def run_llm_execution_middleware(
|
||||
request: Dict[str, Any],
|
||||
next_call: Callable[[Dict[str, Any]], Any],
|
||||
**context: Any,
|
||||
) -> Any:
|
||||
"""Run provider execution through registered LLM execution middleware."""
|
||||
callbacks = _get_middleware_callbacks(LLM_EXECUTION_MIDDLEWARE)
|
||||
if not callbacks:
|
||||
return next_call(request)
|
||||
return _run_execution_chain(
|
||||
LLM_EXECUTION_MIDDLEWARE,
|
||||
callbacks,
|
||||
next_call,
|
||||
request=request,
|
||||
original_request=context.pop("original_request", request),
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
def run_tool_execution_middleware(
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
next_call: Callable[[Dict[str, Any]], Any],
|
||||
**context: Any,
|
||||
) -> Any:
|
||||
"""Run tool execution through registered tool execution middleware."""
|
||||
callbacks = _get_middleware_callbacks(TOOL_EXECUTION_MIDDLEWARE)
|
||||
if not callbacks:
|
||||
return next_call(args)
|
||||
return _run_execution_chain(
|
||||
TOOL_EXECUTION_MIDDLEWARE,
|
||||
callbacks,
|
||||
next_call,
|
||||
tool_name=tool_name,
|
||||
args=args,
|
||||
original_args=context.pop("original_args", args),
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
def run_api_execution_middleware(
|
||||
request: Dict[str, Any],
|
||||
next_call: Callable[[Dict[str, Any]], Any],
|
||||
**context: Any,
|
||||
) -> Any:
|
||||
"""Compatibility wrapper for older ``api_execution`` naming."""
|
||||
return run_llm_execution_middleware(request, next_call, **context)
|
||||
|
||||
|
||||
def _invoke_middleware(kind: str, **kwargs: Any) -> List[Any]:
|
||||
from hermes_cli.plugins import invoke_middleware
|
||||
|
||||
return invoke_middleware(kind, **middleware_payload(**kwargs))
|
||||
|
||||
|
||||
def _has_middleware(kind: str) -> bool:
|
||||
from hermes_cli.plugins import has_middleware
|
||||
|
||||
return has_middleware(kind)
|
||||
|
||||
|
||||
def _get_middleware_callbacks(kind: str) -> List[Callable]:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
return list(get_plugin_manager()._middleware.get(kind, []))
|
||||
|
||||
|
||||
def _run_execution_chain(
|
||||
kind: str,
|
||||
callbacks: List[Callable],
|
||||
terminal_call: Callable[[Any], Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
payload_key = "request" if "request" in kwargs else "args"
|
||||
|
||||
class _DownstreamExecutionError(Exception):
|
||||
def __init__(self, original: BaseException) -> None:
|
||||
super().__init__(str(original))
|
||||
self.original = original
|
||||
|
||||
def call_at(index: int, payload: Any) -> Any:
|
||||
if index >= len(callbacks):
|
||||
return terminal_call(payload)
|
||||
|
||||
callback = callbacks[index]
|
||||
next_called = False
|
||||
next_succeeded = False
|
||||
next_result: Any = None
|
||||
|
||||
def next_call(next_payload: Any = None) -> Any:
|
||||
nonlocal next_called, next_succeeded, next_result
|
||||
# ``next_call`` is single-use per middleware frame. Calling it more
|
||||
# than once would re-run the downstream provider/tool, so a second
|
||||
# invocation is a contract violation rather than a retry. Surface it
|
||||
# instead of silently executing the terminal call twice.
|
||||
if next_called:
|
||||
raise RuntimeError(
|
||||
f"Middleware '{kind}' callback "
|
||||
f"{getattr(callback, '__name__', repr(callback))} called "
|
||||
"next_call() more than once; downstream execution is single-use"
|
||||
)
|
||||
next_called = True
|
||||
try:
|
||||
next_result = call_at(index + 1, payload if next_payload is None else next_payload)
|
||||
next_succeeded = True
|
||||
return next_result
|
||||
except Exception as exc:
|
||||
raise _DownstreamExecutionError(exc) from exc
|
||||
|
||||
call_kwargs = middleware_payload(**kwargs)
|
||||
call_kwargs[payload_key] = payload
|
||||
call_kwargs["next_call"] = next_call
|
||||
try:
|
||||
return callback(**call_kwargs)
|
||||
except _DownstreamExecutionError as exc:
|
||||
raise exc.original
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Middleware '%s' callback %s raised: %s",
|
||||
kind,
|
||||
getattr(callback, "__name__", repr(callback)),
|
||||
exc,
|
||||
)
|
||||
if next_succeeded:
|
||||
return next_result
|
||||
if next_called:
|
||||
raise
|
||||
return call_at(index + 1, payload)
|
||||
|
||||
return call_at(0, kwargs[payload_key])
|
||||
|
||||
|
||||
def _trace_entry(result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
entry: Dict[str, Any] = {}
|
||||
for key in ("source", "reason", "name"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
entry[key] = value
|
||||
if not entry:
|
||||
entry["source"] = "plugin"
|
||||
return entry
|
||||
|
|
@ -64,7 +64,16 @@ logger = logging.getLogger(__name__)
|
|||
DEFAULT_CATALOG_URL = (
|
||||
"https://hermes-agent.nousresearch.com/docs/api/model-catalog.json"
|
||||
)
|
||||
DEFAULT_TTL_HOURS = 24
|
||||
# Fallback fetch chain. The Docusaurus site is served through Vercel, which
|
||||
# occasionally returns HTTP 403 + x-vercel-mitigated: challenge for non-
|
||||
# browser clients (urllib, curl). When that happens the disk cache goes
|
||||
# stale and new model releases never reach the picker. The raw GitHub URL
|
||||
# is the same manifest published from the same repo and is not bot-gated,
|
||||
# so we fall through to it whenever the primary URL fails.
|
||||
DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = (
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json",
|
||||
)
|
||||
DEFAULT_TTL_HOURS = 1
|
||||
DEFAULT_FETCH_TIMEOUT = 8.0
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
|
|
@ -139,6 +148,31 @@ def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None:
|
|||
return data
|
||||
|
||||
|
||||
def _fetch_manifest_with_fallback(
|
||||
primary_url: str,
|
||||
timeout: float,
|
||||
fallback_urls: tuple[str, ...] = DEFAULT_CATALOG_FALLBACK_URLS,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Try ``primary_url`` first, then walk ``fallback_urls``.
|
||||
|
||||
Returns the first manifest that fetches and validates, or None when
|
||||
every URL fails. Skips fallback URLs identical to the primary so an
|
||||
operator who configured the catalog URL to point at the raw GitHub
|
||||
copy doesn't double-fetch.
|
||||
"""
|
||||
data = _fetch_manifest(primary_url, timeout)
|
||||
if data is not None:
|
||||
return data
|
||||
for url in fallback_urls:
|
||||
if not url or url == primary_url:
|
||||
continue
|
||||
data = _fetch_manifest(url, timeout)
|
||||
if data is not None:
|
||||
logger.info("model catalog primary URL failed; using fallback %s", url)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _validate_manifest(data: Any) -> bool:
|
||||
"""Return True when ``data`` matches the minimum manifest shape."""
|
||||
if not isinstance(data, dict):
|
||||
|
|
@ -235,7 +269,7 @@ def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]:
|
|||
return disk_data
|
||||
|
||||
# Need to (re)fetch. If it fails, fall back to any stale disk copy.
|
||||
fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT)
|
||||
fetched = _fetch_manifest_with_fallback(cfg["url"], DEFAULT_FETCH_TIMEOUT)
|
||||
if fetched is not None:
|
||||
_write_disk_cache(fetched)
|
||||
new_disk_data, new_mtime = _read_disk_cache()
|
||||
|
|
@ -322,6 +356,37 @@ def get_curated_nous_models() -> list[str] | None:
|
|||
return out or None
|
||||
|
||||
|
||||
def seed_cache_from_checkout(project_root: "Path | str") -> bool:
|
||||
"""Overwrite the disk cache with the catalog shipped in a local checkout.
|
||||
|
||||
``hermes update`` pulls the latest repo, so the freshly-pulled
|
||||
``website/static/api/model-catalog.json`` IS the newest catalog — no
|
||||
network round-trip needed. Copying it straight over the disk cache keeps
|
||||
the model picker current even when the remote manifest fetch is bot-gated
|
||||
or the Portal hiccups.
|
||||
|
||||
Reads the shipped manifest, validates it against the schema, and writes it
|
||||
to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
|
||||
network path uses. Returns ``True`` on success, ``False`` if the file is
|
||||
missing, malformed, or fails validation (caller should treat a ``False``
|
||||
as non-fatal — the network fetch path still applies on the next picker
|
||||
open).
|
||||
"""
|
||||
src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json"
|
||||
try:
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.debug("model catalog seed from checkout skipped (%s): %s", src, exc)
|
||||
return False
|
||||
if not _validate_manifest(data):
|
||||
logger.debug("model catalog seed from checkout skipped: invalid manifest at %s", src)
|
||||
return False
|
||||
_write_disk_cache(data)
|
||||
reset_cache() # drop the in-process copy so the next read picks up the seed
|
||||
return True
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
|
||||
global _catalog_cache, _catalog_cache_source_mtime
|
||||
|
|
|
|||
134
hermes_cli/model_cost_guard.py
Normal file
134
hermes_cli/model_cost_guard.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Expensive-model confirmation helpers for model selection surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Optional
|
||||
|
||||
from agent.models_dev import ModelInfo
|
||||
|
||||
|
||||
INPUT_COST_WARNING_THRESHOLD = Decimal("20")
|
||||
OUTPUT_COST_WARNING_THRESHOLD = Decimal("100")
|
||||
GPT55_PRO_OPENROUTER_ID = "openai/gpt-5.5-pro"
|
||||
GPT55_SUGGESTION = "did you mean to select openai/gpt-5.5?"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpensiveModelWarning:
|
||||
"""Confirmation payload for models above Hermes' cost guardrail."""
|
||||
|
||||
model: str
|
||||
provider: str
|
||||
input_cost_per_million: Optional[Decimal]
|
||||
output_cost_per_million: Optional[Decimal]
|
||||
source: str
|
||||
message: str
|
||||
|
||||
|
||||
def _to_decimal(value: object) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_money(value: Optional[Decimal]) -> str:
|
||||
if value is None:
|
||||
return "unknown"
|
||||
return f"${value:.2f}/M"
|
||||
|
||||
|
||||
def _pricing_from_model_info(
|
||||
model_info: Optional[ModelInfo],
|
||||
) -> tuple[Optional[Decimal], Optional[Decimal], str]:
|
||||
if model_info is None or not model_info.has_cost_data():
|
||||
return None, None, ""
|
||||
return (
|
||||
_to_decimal(model_info.cost_input),
|
||||
_to_decimal(model_info.cost_output),
|
||||
"models.dev",
|
||||
)
|
||||
|
||||
|
||||
def expensive_model_warning(
|
||||
model_name: str,
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
model_info: Optional[ModelInfo] = None,
|
||||
) -> Optional[ExpensiveModelWarning]:
|
||||
"""Return a warning payload when known pricing exceeds safety thresholds.
|
||||
|
||||
The guard only triggers when pricing is known. Callers should use this after
|
||||
model resolution so aliases and provider-specific model IDs have settled.
|
||||
"""
|
||||
model = (model_name or "").strip()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
input_cost, output_cost, source = _pricing_from_model_info(model_info)
|
||||
if input_cost is None and output_cost is None and provider:
|
||||
try:
|
||||
from agent.models_dev import get_model_info
|
||||
|
||||
input_cost, output_cost, source = _pricing_from_model_info(
|
||||
get_model_info(provider, model)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if input_cost is None and output_cost is None:
|
||||
try:
|
||||
from agent.usage_pricing import get_pricing_entry
|
||||
|
||||
entry = get_pricing_entry(
|
||||
model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
except Exception:
|
||||
entry = None
|
||||
if entry is not None:
|
||||
input_cost = entry.input_cost_per_million
|
||||
output_cost = entry.output_cost_per_million
|
||||
source = entry.source
|
||||
|
||||
over_input = (
|
||||
input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD
|
||||
)
|
||||
over_output = (
|
||||
output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD
|
||||
)
|
||||
if not over_input and not over_output:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
"!!! EXPENSIVE MODEL WARNING !!!",
|
||||
"",
|
||||
f"{model} has known pricing above Hermes' safety threshold.",
|
||||
f"Input tokens: {_format_money(input_cost)}",
|
||||
f"Output tokens: {_format_money(output_cost)}",
|
||||
(
|
||||
"Threshold: more than $20/M input tokens or more than "
|
||||
"$100/M output tokens."
|
||||
),
|
||||
]
|
||||
if source:
|
||||
lines.append(f"Pricing source: {source}.")
|
||||
if model.lower() == GPT55_PRO_OPENROUTER_ID:
|
||||
lines.append(GPT55_SUGGESTION)
|
||||
lines.append("Confirm only if you intend to use this model.")
|
||||
|
||||
return ExpensiveModelWarning(
|
||||
model=model,
|
||||
provider=(provider or "").strip(),
|
||||
input_cost_per_million=input_cost,
|
||||
output_cost_per_million=output_cost,
|
||||
source=source or "unknown",
|
||||
message="\n".join(lines),
|
||||
)
|
||||
|
|
@ -67,7 +67,6 @@ _VENDOR_PREFIXES: dict[str, str] = {
|
|||
_AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({
|
||||
"openrouter",
|
||||
"nous",
|
||||
"ai-gateway",
|
||||
"kilocode",
|
||||
})
|
||||
|
||||
|
|
|
|||
2736
hermes_cli/model_setup_flows.py
Normal file
2736
hermes_cli/model_setup_flows.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -277,49 +277,43 @@ class ModelSwitchResult:
|
|||
capabilities: Optional[ModelCapabilities] = None
|
||||
model_info: Optional[ModelInfo] = None
|
||||
is_global: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomAutoResult:
|
||||
"""Result of switching to bare 'custom' provider with auto-detect."""
|
||||
|
||||
success: bool
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
|
||||
"""Parse --provider and --global flags from /model command args.
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
|
||||
"""Parse --provider, --global, and --refresh flags from /model command args.
|
||||
|
||||
Returns (model_input, explicit_provider, is_global).
|
||||
Returns (model_input, explicit_provider, is_global, force_refresh).
|
||||
|
||||
Examples::
|
||||
|
||||
"sonnet" -> ("sonnet", "", False)
|
||||
"sonnet --global" -> ("sonnet", "", True)
|
||||
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False)
|
||||
"--provider my-ollama" -> ("", "my-ollama", False)
|
||||
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True)
|
||||
"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)
|
||||
"""
|
||||
is_global = False
|
||||
explicit_provider = ""
|
||||
force_refresh = 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)', r'--\1', raw_args)
|
||||
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|refresh)', r'--\1', raw_args)
|
||||
|
||||
# Extract --global
|
||||
if "--global" in raw_args:
|
||||
is_global = True
|
||||
raw_args = raw_args.replace("--global", "").strip()
|
||||
|
||||
# Extract --refresh (bust the model picker disk cache before listing)
|
||||
if "--refresh" in raw_args:
|
||||
force_refresh = True
|
||||
raw_args = raw_args.replace("--refresh", "").strip()
|
||||
|
||||
# Extract --provider <name>
|
||||
parts = raw_args.split()
|
||||
i = 0
|
||||
|
|
@ -333,7 +327,7 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
|
|||
i += 1
|
||||
|
||||
model_input = " ".join(filtered).strip()
|
||||
return (model_input, explicit_provider, is_global)
|
||||
return (model_input, explicit_provider, is_global, force_refresh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -706,6 +700,48 @@ def switch_model(
|
|||
|
||||
target_provider = pdef.id
|
||||
|
||||
# Guard against silent aggregator hops. A vendor name like bare
|
||||
# "openai" is an alias that resolves to an aggregator ("openrouter").
|
||||
# If the user explicitly asked for that vendor but the aggregator it
|
||||
# routes to has no credentials, do NOT silently switch them onto an
|
||||
# unauthed endpoint (the classic HTTP 401 "Missing Authentication
|
||||
# header"). Point them at the real direct provider instead.
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
_explicit_norm = explicit_provider.strip().lower()
|
||||
_alias_target = _PROVIDER_ALIAS_TABLE.get(_explicit_norm)
|
||||
if (
|
||||
_alias_target
|
||||
and _alias_target == target_provider
|
||||
and target_provider != _explicit_norm
|
||||
and target_provider in _AGG_PROVIDERS
|
||||
):
|
||||
_authed = get_authenticated_provider_slugs(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
)
|
||||
if target_provider not in _authed:
|
||||
_suggestions = [
|
||||
s for s in _authed
|
||||
if s.startswith(_explicit_norm) and s != _explicit_norm
|
||||
]
|
||||
_hint = (
|
||||
f" Did you mean: {', '.join(_suggestions)}?"
|
||||
if _suggestions else ""
|
||||
)
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=pdef.name,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Provider '{_explicit_norm}' is an alias that routes "
|
||||
f"through {get_label(target_provider)}, which "
|
||||
f"has no credentials configured.{_hint}"
|
||||
),
|
||||
)
|
||||
|
||||
# If no model specified, try auto-detect from endpoint
|
||||
if not new_model:
|
||||
if pdef.base_url:
|
||||
|
|
@ -860,25 +896,62 @@ def switch_model(
|
|||
api_mode = ""
|
||||
|
||||
if provider_changed or explicit_provider:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
import os
|
||||
# User-config providers (providers.<name> in config.yaml) carry their
|
||||
# own base_url + transport + key reference. resolve_runtime_provider()
|
||||
# resolves by provider NAME and doesn't know user-config slugs (e.g. a
|
||||
# block named "openai"), so it would re-resolve from scratch and fail
|
||||
# or hop to an aggregator. Use the pdef's endpoint directly instead.
|
||||
_user_pdef = None
|
||||
if explicit_provider and user_providers:
|
||||
from hermes_cli.providers import resolve_user_provider as _ruser
|
||||
_user_pdef = _ruser(explicit_provider.strip().lower(), user_providers)
|
||||
if _user_pdef is None:
|
||||
_user_pdef = _ruser(target_provider, user_providers)
|
||||
if _user_pdef is not None and _user_pdef.base_url:
|
||||
_ucfg = (user_providers or {}).get(explicit_provider.strip().lower()) \
|
||||
or (user_providers or {}).get(target_provider) or {}
|
||||
_ukey = str(_ucfg.get("api_key", "") or "").strip()
|
||||
if _ukey.startswith("${") and _ukey.endswith("}"):
|
||||
_ukey = os.environ.get(_ukey[2:-1], "").strip()
|
||||
if not _ukey:
|
||||
_kenv = str(_ucfg.get("key_env", "") or "").strip()
|
||||
if _kenv:
|
||||
_ukey = os.environ.get(_kenv, "").strip()
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
explicit_api_key=_ukey or None,
|
||||
explicit_base_url=_user_pdef.base_url,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "") or _ukey
|
||||
base_url = runtime.get("base_url", "") or _user_pdef.base_url
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception:
|
||||
api_key = _ukey
|
||||
base_url = _user_pdef.base_url
|
||||
api_mode = ""
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
|
|
@ -1044,11 +1117,69 @@ def switch_model(
|
|||
# Authenticated providers listing (for /model no-args display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Process-level guard so the picker prewarm thread is spawned at most once per
|
||||
# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a
|
||||
# long-lived process (or repeated triggers) would leak one OS thread per call.
|
||||
import threading as _threading # noqa: E402
|
||||
|
||||
_picker_prewarm_done = _threading.Event()
|
||||
|
||||
|
||||
def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
|
||||
"""Warm the provider-models disk cache in a background daemon thread.
|
||||
|
||||
The no-args ``/model`` picker calls ``list_authenticated_providers()``,
|
||||
which fetches each authenticated provider's live ``/v1/models`` list on a
|
||||
cold/stale cache. Those fetches are independent HTTP round-trips but run
|
||||
serially, so the first ``/model`` open in a session (or any open after the
|
||||
1h cache TTL expires) blocks ~1-2s on the user's critical path.
|
||||
|
||||
This pre-warms that exact path off-thread during idle session time: it
|
||||
runs ``list_authenticated_providers()`` once, which populates
|
||||
``provider_models_cache.json`` for every authed provider. By the time the
|
||||
user types ``/model``, the picker hits the warm disk cache and renders in
|
||||
~100ms.
|
||||
|
||||
Fire-and-forget. Process-level Event guard ensures it runs at most once.
|
||||
Fully exception-isolated — a slow or offline provider can never affect the
|
||||
session. Returns the spawned thread (for tests) or None if already warmed.
|
||||
"""
|
||||
if _picker_prewarm_done.is_set():
|
||||
return None
|
||||
_picker_prewarm_done.set()
|
||||
|
||||
def _warm() -> None:
|
||||
try:
|
||||
from hermes_cli.inventory import load_picker_context
|
||||
|
||||
ctx = load_picker_context()
|
||||
# Calling this is what populates cached_provider_model_ids() ->
|
||||
# provider_models_cache.json for each authed provider. We discard
|
||||
# the result; the side effect (warm disk cache) is the point.
|
||||
list_authenticated_providers(
|
||||
current_provider=ctx.current_provider,
|
||||
current_base_url=ctx.current_base_url,
|
||||
current_model=ctx.current_model,
|
||||
user_providers=ctx.user_providers,
|
||||
custom_providers=ctx.custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort warmup — never surface errors into the session.
|
||||
logger.debug("picker cache prewarm failed", exc_info=True)
|
||||
|
||||
t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
*,
|
||||
force_fresh_nous_tier: bool = False,
|
||||
max_models: int = 8,
|
||||
current_model: str = "",
|
||||
) -> List[dict]:
|
||||
|
|
@ -1068,6 +1199,9 @@ def list_authenticated_providers(
|
|||
- source: str — "built-in", "models.dev", "user-config"
|
||||
|
||||
Only includes providers that have API keys set or are user-defined endpoints.
|
||||
``force_fresh_nous_tier`` bypasses the short Nous tier cache for explicit
|
||||
account-sensitive flows. UI picker opens should leave it false so they do
|
||||
not block on fresh Portal/account checks every time.
|
||||
"""
|
||||
import os
|
||||
from agent.models_dev import (
|
||||
|
|
@ -1078,7 +1212,7 @@ def list_authenticated_providers(
|
|||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.models import (
|
||||
OPENROUTER_MODELS, _PROVIDER_MODELS,
|
||||
_MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,
|
||||
_MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids,
|
||||
get_curated_nous_model_ids,
|
||||
)
|
||||
|
||||
|
|
@ -1201,7 +1335,24 @@ def list_authenticated_providers(
|
|||
curated["lmstudio"] = live
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
# Skip vendor names that are merely aliases routing through an
|
||||
# aggregator (e.g. bare "openai" → "openrouter"). These are NOT
|
||||
# directly-routable providers: emitting them as their own picker
|
||||
# row produces a phantom entry that, when selected, resolves via
|
||||
# resolve_provider_full() to the aggregator (OpenRouter) — silently
|
||||
# switching a user off their real provider onto an endpoint they
|
||||
# may have no key for (HTTP 401). The user's real provider (e.g.
|
||||
# openai-api, or a providers.openai config row) covers this vendor.
|
||||
_alias_target = _PROVIDER_ALIAS_TABLE.get(hermes_id)
|
||||
if (
|
||||
_alias_target
|
||||
and _alias_target != hermes_id
|
||||
and _alias_target in _AGG_PROVIDERS
|
||||
):
|
||||
continue
|
||||
# Skip aliases that map to the same models.dev provider (e.g.
|
||||
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
|
||||
# The first one with valid credentials wins (#10526).
|
||||
|
|
@ -1239,13 +1390,15 @@ def list_authenticated_providers(
|
|||
if not has_creds:
|
||||
continue
|
||||
|
||||
# Use curated list, falling back to models.dev if no curated list.
|
||||
# For preferred providers, merge models.dev entries into the curated
|
||||
# catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go)
|
||||
# show up in the picker without requiring a Hermes release.
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
# Unified pathway: route through cached_provider_model_ids() so the
|
||||
# /model picker sees the SAME list `hermes model` would build, with
|
||||
# disk caching to keep the picker open snappy. Falls back to the
|
||||
# curated static list when the live fetcher returns nothing.
|
||||
model_ids = cached_provider_model_ids(hermes_id)
|
||||
if not model_ids:
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
|
|
@ -1351,25 +1504,64 @@ def list_authenticated_providers(
|
|||
# matches what the user's authenticated Codex/Copilot backend
|
||||
# actually serves — including ChatGPT-Pro-only Codex slugs
|
||||
# (e.g. gpt-5.3-codex-spark) that aren't in the static curated
|
||||
# catalog. ``provider_model_ids()`` falls back to the curated
|
||||
# list when the live endpoint is unreachable, so this is safe
|
||||
# for unauthenticated and offline cases too.
|
||||
model_ids = provider_model_ids(hermes_slug)
|
||||
# catalog. ``cached_provider_model_ids()`` falls back to the
|
||||
# curated list when the live endpoint is unreachable, so this
|
||||
# is safe for unauthenticated and offline cases too.
|
||||
model_ids = cached_provider_model_ids(hermes_slug)
|
||||
# For aws_sdk providers (bedrock), use live discovery so the list
|
||||
# reflects the active region (eu.*, ap.*) not the static us.* list.
|
||||
elif overlay.auth_type == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
_ids = bedrock_model_ids_or_none()
|
||||
model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, []))
|
||||
_ids = cached_provider_model_ids(hermes_slug)
|
||||
model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, []))
|
||||
except Exception:
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
elif hermes_slug == "nous":
|
||||
# Nous serves a large live /v1/models catalog (vendor-prefixed
|
||||
# models from many providers, returned alphabetically). The
|
||||
# `hermes model` picker deliberately shows ONLY the curated agentic
|
||||
# list — augmented with the Portal's free/paid recommendations so
|
||||
# newly-launched models surface without a CLI release — in curated
|
||||
# order. Mirror that exactly (see _model_flow_nous in main.py) so
|
||||
# the GUI picker matches the CLI. Was: falling through to
|
||||
# cached_provider_model_ids, which dumped the full alphabetical
|
||||
# catalog; then: curated-only, which dropped the 4 Portal
|
||||
# recommendations (e.g. stepfun/step-3.7-flash:free).
|
||||
model_ids = curated.get("nous", [])
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
get_pricing_for_provider as _nous_pricing,
|
||||
check_nous_free_tier as _nous_free,
|
||||
union_with_portal_free_recommendations as _union_free,
|
||||
union_with_portal_paid_recommendations as _union_paid,
|
||||
)
|
||||
from hermes_cli.auth import get_provider_auth_state as _nous_state
|
||||
|
||||
_pricing = _nous_pricing("nous") or {}
|
||||
_portal = ""
|
||||
try:
|
||||
_st = _nous_state("nous") or {}
|
||||
_portal = _st.get("portal_base_url", "") or ""
|
||||
except Exception:
|
||||
_portal = ""
|
||||
if _nous_free(force_fresh=force_fresh_nous_tier):
|
||||
model_ids, _ = _union_free(model_ids, _pricing, _portal)
|
||||
else:
|
||||
model_ids, _ = _union_paid(model_ids, _pricing, _portal)
|
||||
except Exception:
|
||||
# Portal recommendation fetch failed — fall back to the
|
||||
# curated list alone (still correct, just may lag newly
|
||||
# launched models, exactly like an offline CLI run).
|
||||
pass
|
||||
else:
|
||||
# Use curated list — look up by Hermes slug, fall back to overlay key
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
# Merge with models.dev for preferred providers (same rationale as above).
|
||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||
# Unified pathway — see Section 1 rationale. Fall back to the
|
||||
# curated dict (with models.dev merge for preferred providers)
|
||||
# when the live fetcher comes up empty.
|
||||
model_ids = cached_provider_model_ids(hermes_slug)
|
||||
if not model_ids:
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
|
|
@ -1436,13 +1628,15 @@ def list_authenticated_providers(
|
|||
# region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list.
|
||||
if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
_ids = bedrock_model_ids_or_none()
|
||||
_cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, [])
|
||||
_ids = cached_provider_model_ids(_cp.slug)
|
||||
_cp_model_ids = _ids if _ids else curated.get(_cp.slug, [])
|
||||
except Exception:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
else:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
# Unified pathway — same as sections 1 and 2.
|
||||
_cp_model_ids = cached_provider_model_ids(_cp.slug)
|
||||
if not _cp_model_ids:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
_cp_total = len(_cp_model_ids)
|
||||
_cp_top = _cp_model_ids[:max_models]
|
||||
|
||||
|
|
@ -1556,24 +1750,21 @@ def list_authenticated_providers(
|
|||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
|
||||
# are grouped into a single picker row, so e.g. four Ollama entries
|
||||
# pointing at ``http://localhost:11434/v1`` with per-model display names
|
||||
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# provider. Entries sharing the same endpoint, credential identity, and
|
||||
# wire protocol are grouped into a single picker row, so e.g. four Ollama
|
||||
# entries pointing at ``http://localhost:11434/v1`` with per-model display
|
||||
# names ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# "Ollama" row with four models inside instead of four near-duplicates
|
||||
# that differ only by suffix. Entries with distinct endpoints still
|
||||
# produce separate rows.
|
||||
#
|
||||
# When the grouped endpoint matches ``current_base_url`` the group's
|
||||
# slug becomes ``current_provider`` so that selecting a model from the
|
||||
# picker flows back through the runtime provider that already holds
|
||||
# valid credentials — no re-resolution needed.
|
||||
# that differ only by suffix. Same-host entries with different ``key_env``
|
||||
# or ``api_mode`` remain distinct providers.
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
# Key by (base_url, api_key) instead of slug: names frequently
|
||||
# differ per model ("Ollama — X") while the endpoint stays the
|
||||
# same. Slug-based grouping left them as separate rows.
|
||||
# Key by endpoint + credential identity + wire protocol instead of
|
||||
# slug: names frequently differ per model ("Ollama — X") while the
|
||||
# endpoint stays the same. Keep same-host providers with distinct
|
||||
# env-backed credentials or API protocols separate so picker selection
|
||||
# cannot route through the wrong credential/mode pair.
|
||||
groups: "OrderedDict[tuple, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
|
|
@ -1588,9 +1779,30 @@ def list_authenticated_providers(
|
|||
).strip().rstrip("/")
|
||||
if not raw_name or not api_url:
|
||||
continue
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
inline_api_key = (entry.get("api_key") or "").strip()
|
||||
key_env = (entry.get("key_env") or "").strip()
|
||||
api_key = inline_api_key or (
|
||||
os.environ.get(key_env, "").strip() if key_env else ""
|
||||
)
|
||||
api_mode = str(
|
||||
entry.get("api_mode")
|
||||
or entry.get("transport")
|
||||
or ""
|
||||
).strip().lower()
|
||||
credential_identity = (
|
||||
inline_api_key
|
||||
if inline_api_key
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
|
||||
group_key = (api_url, api_key)
|
||||
# Read discover_models from the entry (same semantics as
|
||||
# section 3: true by default, set false to keep the explicit
|
||||
# ``models:`` list instead of replacing it with live /models).
|
||||
discover = entry.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
|
||||
group_key = (api_url, credential_identity, api_mode)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
# "Ollama" for the grouped row. Em dash is the convention
|
||||
|
|
@ -1603,29 +1815,22 @@ def list_authenticated_providers(
|
|||
break
|
||||
if not display_name:
|
||||
display_name = raw_name
|
||||
# If this endpoint matches the currently active one, use
|
||||
# ``current_provider`` as the slug so picker-driven switches
|
||||
# route through the live credential pipeline.
|
||||
if (
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
# Guard against bare "custom" slug left by a prior
|
||||
# failed switch — always resolve to the canonical
|
||||
# custom:<name> form. (GH #17478)
|
||||
slug = (
|
||||
current_provider
|
||||
if current_provider and current_provider != "custom"
|
||||
else custom_provider_slug(display_name)
|
||||
)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"api_key": api_key,
|
||||
"models": [],
|
||||
"discover_models": discover,
|
||||
}
|
||||
else:
|
||||
if api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
# If any entry in this group opts out of discovery,
|
||||
# honour that for the whole grouped row.
|
||||
if not discover:
|
||||
groups[group_key]["discover_models"] = False
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
|
|
@ -1647,8 +1852,16 @@ def list_authenticated_providers(
|
|||
groups[group_key]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp_key, grp in groups.items():
|
||||
api_url, api_key = grp_key
|
||||
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
|
||||
_current_base_url_group_count = sum(
|
||||
1
|
||||
for _grp in groups.values()
|
||||
if _current_base_url_norm
|
||||
and str(_grp["api_url"]).strip().rstrip("/").lower() == _current_base_url_norm
|
||||
)
|
||||
for grp in groups.values():
|
||||
api_url = grp["api_url"]
|
||||
api_key = grp.get("api_key", "")
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
|
|
@ -1706,7 +1919,16 @@ def list_authenticated_providers(
|
|||
# - Without an api_key AND no explicit models, fall through to
|
||||
# live discovery so bare-endpoint custom providers (local
|
||||
# llama.cpp / Ollama servers) still appear populated.
|
||||
should_probe = bool(api_url) and (bool(api_key) or not grp["models"])
|
||||
# - When discover_models: false is set, skip live discovery and
|
||||
# keep the explicit ``models:`` list regardless of whether an
|
||||
# api_key is present. This supports endpoints that expose a
|
||||
# full aggregator catalog via /models but only serve a subset
|
||||
# (parity with section 3's user ``providers:`` behaviour).
|
||||
should_probe = (
|
||||
bool(api_url)
|
||||
and (bool(api_key) or not grp["models"])
|
||||
and grp.get("discover_models", True)
|
||||
)
|
||||
if should_probe:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
|
@ -1721,8 +1943,10 @@ def list_authenticated_providers(
|
|||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
"is_current": slug == current_provider or (
|
||||
bool(current_base_url)
|
||||
and _grp_url_norm == current_base_url.strip().rstrip("/").lower()
|
||||
current_provider == "custom"
|
||||
and bool(_current_base_url_norm)
|
||||
and _grp_url_norm == _current_base_url_norm
|
||||
and _current_base_url_group_count == 1
|
||||
),
|
||||
"is_user_defined": True,
|
||||
"models": grp["models"],
|
||||
|
|
|
|||
1058
hermes_cli/models.py
1058
hermes_cli/models.py
File diff suppressed because it is too large
Load diff
764
hermes_cli/nous_account.py
Normal file
764
hermes_cli/nous_account.py
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
"""Normalized Nous Portal account entitlement helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
|
||||
|
||||
# Free tool-pool coverage categories. Kept byte-for-byte aligned with the
|
||||
# Portal's TOOL_COVERAGE_CATEGORIES (nous-account-service
|
||||
# src/server/tool-pool-eligibility.ts). The Portal mints these into the
|
||||
# `tool_access.coverage` map on the JWT and /api/oauth/account; FAL video gen
|
||||
# (`fal-video`) is intentionally excluded from the pool.
|
||||
TOOL_COVERAGE_CATEGORIES = (
|
||||
"firecrawl",
|
||||
"fal",
|
||||
"fal-video",
|
||||
"openai-audio",
|
||||
"browser-use",
|
||||
"modal",
|
||||
)
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalSubscriptionInfo:
|
||||
plan: Optional[str] = None
|
||||
tier: Optional[int] = None
|
||||
monthly_charge: Optional[float] = None
|
||||
monthly_credits: Optional[float] = None
|
||||
current_period_end: Optional[str] = None
|
||||
credits_remaining: Optional[float] = None
|
||||
rollover_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPaidServiceAccessInfo:
|
||||
allowed: Optional[bool] = None
|
||||
paid_access: Optional[bool] = None
|
||||
reason: Optional[str] = None
|
||||
organisation_id: Optional[str] = None
|
||||
effective_at_ms: Optional[int] = None
|
||||
has_active_subscription: Optional[bool] = None
|
||||
active_subscription_is_paid: Optional[bool] = None
|
||||
subscription_tier: Optional[int] = None
|
||||
subscription_monthly_charge: Optional[float] = None
|
||||
subscription_credits_remaining: Optional[float] = None
|
||||
purchased_credits_remaining: Optional[float] = None
|
||||
total_usable_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousToolAccessInfo:
|
||||
"""Free tool-pool entitlement, decoupled from paid/billing access.
|
||||
|
||||
Mirrors the Portal's ``tool_access`` claim/field: ``enabled`` is true when a
|
||||
positive tool-pool balance is live and not gated off; ``coverage`` maps each
|
||||
tool category to whether the pool funds it (FAL video is excluded).
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
coverage: dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalAccountInfo:
|
||||
logged_in: bool
|
||||
source: NousAccountInfoSource
|
||||
fresh: bool
|
||||
user_id: Optional[str] = None
|
||||
org_id: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
product_id: Optional[str] = None
|
||||
nous_client: Optional[str] = None
|
||||
portal_base_url: Optional[str] = None
|
||||
inference_base_url: Optional[str] = None
|
||||
inference_credential_present: bool = False
|
||||
credential_source: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
email: Optional[str] = None
|
||||
privy_did: Optional[str] = None
|
||||
subscription: Optional[NousPortalSubscriptionInfo] = None
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
|
||||
tool_access: Optional[NousToolAccessInfo] = None
|
||||
raw_claims: Optional[dict[str, Any]] = None
|
||||
raw_account: Optional[dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.paid_service_access is True
|
||||
|
||||
@property
|
||||
def is_free_tier(self) -> bool:
|
||||
return self.paid_service_access is False
|
||||
|
||||
@property
|
||||
def tool_gateway_entitled(self) -> bool:
|
||||
"""Coarse "entitled to any managed tool" gate: paid access OR a live
|
||||
free tool pool. Use :meth:`tool_gateway_entitled_for` to gate a specific
|
||||
tool category (the pool does not cover every category)."""
|
||||
if self.paid_service_access is True:
|
||||
return True
|
||||
return self.tool_access is not None and self.tool_access.enabled
|
||||
|
||||
def tool_gateway_entitled_for(self, category: str) -> bool:
|
||||
"""Whether a specific tool category is entitled. Paid users are entitled
|
||||
everywhere; free tool-pool users only where ``coverage[category]`` is
|
||||
true (e.g. image but not video)."""
|
||||
if self.paid_service_access is True:
|
||||
return True
|
||||
ta = self.tool_access
|
||||
return bool(ta and ta.enabled and ta.coverage.get(category) is True)
|
||||
|
||||
|
||||
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
|
||||
"""Return the billing URL for a normalized Nous account snapshot."""
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
|
||||
except Exception:
|
||||
DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
|
||||
base = None
|
||||
if account_info is not None:
|
||||
base = account_info.portal_base_url
|
||||
if not isinstance(base, str) or not base.strip():
|
||||
base = DEFAULT_NOUS_PORTAL_URL
|
||||
return f"{base.rstrip('/')}/billing"
|
||||
|
||||
|
||||
def format_nous_portal_entitlement_message(
|
||||
account_info: Optional[NousPortalAccountInfo],
|
||||
*,
|
||||
capability: str = "this feature",
|
||||
include_refresh_hint: bool = True,
|
||||
coverage_category: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return user-facing guidance for a missing Nous tool-gateway entitlement.
|
||||
|
||||
``None`` means the account is entitled to use the capability — via paid
|
||||
service access OR a live free tool pool that covers it. The message works
|
||||
from normalized entitlement fields rather than subscription price alone:
|
||||
purchased credits without a subscription still count as paid access, while a
|
||||
paid subscription with exhausted usable credits does not.
|
||||
|
||||
``coverage_category`` scopes the check to a single tool category (e.g.
|
||||
``"fal-video"``). When given, a user who is entitled overall but whose
|
||||
access does not fund that category gets a neutral billing nudge instead of a
|
||||
message implying their credits are exhausted. The pool-vs-paid distinction is
|
||||
never surfaced to the user.
|
||||
"""
|
||||
billing_url = nous_portal_billing_url(account_info)
|
||||
|
||||
if account_info is not None:
|
||||
if coverage_category is not None:
|
||||
if account_info.tool_gateway_entitled_for(coverage_category):
|
||||
return None
|
||||
if account_info.tool_gateway_entitled:
|
||||
# Entitled overall (e.g. via the managed tool pool), but this
|
||||
# specific capability isn't covered. Surface a neutral billing
|
||||
# nudge without exposing pool-vs-paid internals to the user.
|
||||
return (
|
||||
f"{capability} isn't included with your current Nous Portal "
|
||||
f"access. Add credits or a subscription to enable it at {billing_url}."
|
||||
)
|
||||
elif account_info.tool_gateway_entitled:
|
||||
return None
|
||||
|
||||
if account_info is None:
|
||||
return (
|
||||
f"Hermes could not verify your Nous Portal entitlement, so {capability} "
|
||||
f"is unavailable. Run `hermes model` to refresh your login, or check "
|
||||
f"billing at {billing_url}."
|
||||
)
|
||||
|
||||
if not account_info.logged_in:
|
||||
if account_info.inference_credential_present:
|
||||
return (
|
||||
f"Nous inference credentials are configured, but Hermes cannot verify "
|
||||
f"your Nous Portal paid access for {capability}. Log in with "
|
||||
f"`hermes model` to enable Portal-managed features. Billing and "
|
||||
f"credits are managed at {billing_url}."
|
||||
)
|
||||
return (
|
||||
f"Log in to Nous Portal to use {capability}: run `hermes model`. "
|
||||
f"Billing and credits are managed at {billing_url}."
|
||||
)
|
||||
|
||||
if account_info.paid_service_access is None:
|
||||
detail = (
|
||||
f"Hermes could not verify your Nous Portal paid access, so {capability} "
|
||||
f"is unavailable."
|
||||
)
|
||||
if account_info.error:
|
||||
detail += f" Account lookup failed: {account_info.error}."
|
||||
if include_refresh_hint:
|
||||
detail += " Run `hermes model` to refresh your session."
|
||||
detail += f" Check billing at {billing_url}."
|
||||
return detail
|
||||
|
||||
access = account_info.paid_service_access_info
|
||||
reason = access.reason if access else None
|
||||
if reason == "account_missing":
|
||||
return (
|
||||
f"Hermes could not find a Nous Portal account or organisation for this "
|
||||
f"login, so {capability} is unavailable. Run `hermes model` to "
|
||||
f"authenticate again; if the problem persists, contact Nous support."
|
||||
)
|
||||
|
||||
if reason == "no_usable_credits" or account_info.paid_service_access is False:
|
||||
message = _no_paid_access_message(account_info, capability, billing_url)
|
||||
if include_refresh_hint and not account_info.fresh:
|
||||
message += " If you recently bought credits, run `hermes model` to refresh Hermes."
|
||||
return message
|
||||
|
||||
return (
|
||||
f"Your Nous Portal account does not currently have paid service access, "
|
||||
f"so {capability} is unavailable. Add credits or update billing at {billing_url}."
|
||||
)
|
||||
|
||||
|
||||
def _no_paid_access_message(
|
||||
account_info: NousPortalAccountInfo,
|
||||
capability: str,
|
||||
billing_url: str,
|
||||
) -> str:
|
||||
access = account_info.paid_service_access_info
|
||||
has_active_subscription = access.has_active_subscription if access else None
|
||||
active_subscription_is_paid = access.active_subscription_is_paid if access else None
|
||||
total_usable = access.total_usable_credits if access else None
|
||||
subscription_credits = access.subscription_credits_remaining if access else None
|
||||
purchased_credits = access.purchased_credits_remaining if access else None
|
||||
|
||||
if has_active_subscription and active_subscription_is_paid:
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal credits are exhausted{credit_detail}, so {capability} "
|
||||
f"is unavailable. Top up or renew credits at {billing_url}."
|
||||
)
|
||||
|
||||
if has_active_subscription and active_subscription_is_paid is False:
|
||||
return (
|
||||
f"Your current Nous Portal plan does not include paid service access, "
|
||||
f"so {capability} is unavailable. Upgrade or add credits at {billing_url}."
|
||||
)
|
||||
|
||||
if has_active_subscription is False:
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal account has no active subscription or usable credits"
|
||||
f"{credit_detail}, so {capability} is unavailable. Subscribe or add credits "
|
||||
f"at {billing_url}."
|
||||
)
|
||||
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal account has no usable paid credits{credit_detail}, so "
|
||||
f"{capability} is unavailable. Add credits or update billing at {billing_url}."
|
||||
)
|
||||
|
||||
|
||||
def _credit_detail(
|
||||
total_usable: Optional[float],
|
||||
subscription_credits: Optional[float],
|
||||
purchased_credits: Optional[float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if total_usable is not None:
|
||||
parts.append(f"usable ${total_usable:.2f}")
|
||||
if subscription_credits is not None:
|
||||
parts.append(f"subscription ${subscription_credits:.2f}")
|
||||
if purchased_credits is not None:
|
||||
parts.append(f"purchased ${purchased_credits:.2f}")
|
||||
if not parts:
|
||||
return ""
|
||||
return f" ({', '.join(parts)})"
|
||||
|
||||
|
||||
def reset_nous_portal_account_info_cache() -> None:
|
||||
"""Clear the short-lived account-info cache used by tests."""
|
||||
global _account_info_cache
|
||||
_account_info_cache = None
|
||||
|
||||
|
||||
def get_nous_portal_account_info(
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
min_jwt_ttl_seconds: int = 60,
|
||||
) -> NousPortalAccountInfo:
|
||||
"""Return normalized Nous Portal account entitlement information.
|
||||
|
||||
By default, a valid unexpired OAuth access JWT is used as a low-latency
|
||||
local account snapshot. ``force_fresh=True`` always calls
|
||||
``/api/oauth/account`` and bypasses the short-lived cache. JWT claims are
|
||||
decoded locally for UX gating only; server APIs remain authoritative.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
except Exception as exc:
|
||||
return _error_info(error=exc, logged_in=False)
|
||||
|
||||
access_token = state.get("access_token")
|
||||
portal_base_url = _portal_base_url(state)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
pool_oauth_info = _info_from_oauth_pool(
|
||||
force_fresh=force_fresh,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
if pool_oauth_info is not None:
|
||||
return pool_oauth_info
|
||||
pool_info = _info_from_inference_key_pool(portal_base_url)
|
||||
if pool_info is not None:
|
||||
return pool_info
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=False,
|
||||
source="none",
|
||||
fresh=False,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
if not force_fresh:
|
||||
jwt_info = _info_from_valid_jwt(
|
||||
access_token,
|
||||
state=state,
|
||||
portal_base_url=portal_base_url,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
)
|
||||
if jwt_info is not None:
|
||||
return jwt_info
|
||||
|
||||
return _fresh_account_info(
|
||||
state=state,
|
||||
force_fresh=force_fresh,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _fresh_account_info(
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
force_fresh: bool,
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
global _account_info_cache
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_access_token
|
||||
|
||||
access_token = resolve_nous_access_token()
|
||||
refreshed_state = get_provider_auth_state("nous") or state
|
||||
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
|
||||
cache_key = _cache_key(access_token, portal_base_url)
|
||||
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
|
||||
payload = _fetch_nous_account_info(access_token, portal_base_url)
|
||||
if not payload:
|
||||
return _error_info(
|
||||
error="empty_account_response",
|
||||
logged_in=True,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
if isinstance(payload.get("error"), str):
|
||||
return _error_info(
|
||||
error=payload.get("error") or "account_response_error",
|
||||
logged_in=True,
|
||||
portal_base_url=portal_base_url,
|
||||
raw_account=payload,
|
||||
)
|
||||
|
||||
info = _info_from_account_payload(
|
||||
payload,
|
||||
state=refreshed_state,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
error=exc,
|
||||
logged_in=bool(state.get("access_token")),
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _info_from_inference_key_pool(
|
||||
portal_base_url: Optional[str],
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
"""Return an explicit unknown-entitlement snapshot for opaque Nous keys."""
|
||||
try:
|
||||
entry = _select_nous_pool_entry()
|
||||
if entry is None:
|
||||
return None
|
||||
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
if not isinstance(runtime_key, str) or not runtime_key.strip():
|
||||
return None
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=False,
|
||||
source="inference_key",
|
||||
fresh=False,
|
||||
portal_base_url=(
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or portal_base_url
|
||||
),
|
||||
inference_base_url=(
|
||||
getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
),
|
||||
inference_credential_present=True,
|
||||
credential_source=f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
error="portal_oauth_missing",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _info_from_oauth_pool(
|
||||
*,
|
||||
force_fresh: bool,
|
||||
min_jwt_ttl_seconds: int,
|
||||
portal_base_url: Optional[str],
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
try:
|
||||
entry = _select_nous_pool_entry()
|
||||
except Exception:
|
||||
return None
|
||||
if entry is None or not _pool_entry_is_portal_oauth(entry):
|
||||
return None
|
||||
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return None
|
||||
|
||||
entry_portal_url = (
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or portal_base_url
|
||||
)
|
||||
state = {
|
||||
"access_token": access_token,
|
||||
"client_id": getattr(entry, "client_id", None),
|
||||
"inference_base_url": (
|
||||
getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
),
|
||||
"agent_key": getattr(entry, "agent_key", None),
|
||||
"credential_source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
}
|
||||
|
||||
if not force_fresh:
|
||||
jwt_info = _info_from_valid_jwt(
|
||||
access_token,
|
||||
state=state,
|
||||
portal_base_url=entry_portal_url,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
)
|
||||
if jwt_info is not None:
|
||||
return jwt_info
|
||||
|
||||
try:
|
||||
payload = _fetch_nous_account_info(access_token, entry_portal_url)
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
error=exc,
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
if not payload:
|
||||
return _error_info(
|
||||
error="empty_account_response",
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
if isinstance(payload.get("error"), str):
|
||||
return _error_info(
|
||||
error=payload.get("error") or "account_response_error",
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
raw_account=payload,
|
||||
)
|
||||
return _info_from_account_payload(
|
||||
payload,
|
||||
state=state,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
|
||||
|
||||
def _select_nous_pool_entry() -> Optional[Any]:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("nous")
|
||||
if not pool or not pool.has_credentials():
|
||||
return None
|
||||
entries = list(pool.entries())
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
|
||||
agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
|
||||
access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
|
||||
priority = int(getattr(entry, "priority", 0) or 0)
|
||||
return (agent_exp, access_exp, -priority)
|
||||
|
||||
return max(entries, key=_entry_sort_key)
|
||||
|
||||
|
||||
def _pool_entry_is_portal_oauth(entry: Any) -> bool:
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return False
|
||||
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
|
||||
refresh_token = getattr(entry, "refresh_token", None)
|
||||
return auth_type.startswith("oauth") or bool(refresh_token)
|
||||
|
||||
|
||||
def _fetch_nous_account_info(
|
||||
access_token: str,
|
||||
portal_base_url: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
url = f"{base}/api/oauth/account"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _info_from_valid_jwt(
|
||||
token: str,
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
min_jwt_ttl_seconds: int,
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
try:
|
||||
from hermes_cli.auth import _decode_jwt_claims
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
claims = _decode_jwt_claims(token)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
exp = _coerce_float(claims.get("exp"))
|
||||
if exp is None or exp <= time.time() + max(0, int(min_jwt_ttl_seconds)):
|
||||
return None
|
||||
|
||||
paid_access = _coerce_bool(claims.get("paid_access"))
|
||||
subscription_tier = _coerce_int(claims.get("subscription_tier"))
|
||||
access_info = NousPaidServiceAccessInfo(
|
||||
allowed=paid_access,
|
||||
paid_access=paid_access,
|
||||
organisation_id=_coerce_str(claims.get("org_id")),
|
||||
subscription_tier=subscription_tier,
|
||||
)
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="jwt",
|
||||
fresh=False,
|
||||
user_id=_coerce_str(claims.get("sub")),
|
||||
org_id=_coerce_str(claims.get("org_id")),
|
||||
client_id=_coerce_str(claims.get("client_id") or state.get("client_id")),
|
||||
product_id=_coerce_str(claims.get("product_id")),
|
||||
nous_client=_coerce_str(claims.get("nous_client")),
|
||||
portal_base_url=portal_base_url,
|
||||
inference_base_url=_coerce_str(state.get("inference_base_url")),
|
||||
inference_credential_present=True,
|
||||
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
|
||||
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access_info,
|
||||
tool_access=_tool_access_from_value(claims.get("tool_access")),
|
||||
raw_claims=dict(claims),
|
||||
)
|
||||
|
||||
|
||||
def _info_from_account_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||
organisation = (
|
||||
payload.get("organisation")
|
||||
if isinstance(payload.get("organisation"), dict)
|
||||
else {}
|
||||
)
|
||||
subscription = _subscription_from_payload(payload.get("subscription"))
|
||||
access = _paid_service_access_from_payload(payload.get("paid_service_access"))
|
||||
paid_access = access.allowed if access else None
|
||||
if paid_access is None and access is not None:
|
||||
paid_access = access.paid_access
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="account_api",
|
||||
fresh=True,
|
||||
org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None),
|
||||
client_id=_coerce_str(state.get("client_id")),
|
||||
portal_base_url=portal_base_url,
|
||||
inference_base_url=_coerce_str(state.get("inference_base_url")),
|
||||
inference_credential_present=bool(state.get("access_token") or state.get("agent_key")),
|
||||
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
|
||||
email=_coerce_str(user.get("email")),
|
||||
privy_did=_coerce_str(user.get("privy_did")),
|
||||
subscription=subscription,
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access,
|
||||
tool_access=_tool_access_from_value(payload.get("tool_access")),
|
||||
raw_account=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]:
|
||||
"""Parse a Portal ``tool_access`` object (from the JWT claim or the account
|
||||
API) into :class:`NousToolAccessInfo`. Fails closed: a non-object value
|
||||
yields ``None``, and only literal ``true`` counts for ``enabled`` and each
|
||||
coverage entry."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
enabled = _coerce_bool(value.get("enabled")) is True
|
||||
raw_coverage = value.get("coverage")
|
||||
coverage: dict[str, bool] = {}
|
||||
if isinstance(raw_coverage, dict):
|
||||
for key, val in raw_coverage.items():
|
||||
if isinstance(key, str):
|
||||
coverage[key] = val is True
|
||||
return NousToolAccessInfo(enabled=enabled, coverage=coverage)
|
||||
|
||||
|
||||
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return NousPortalSubscriptionInfo(
|
||||
plan=_coerce_str(value.get("plan")),
|
||||
tier=_coerce_int(value.get("tier")),
|
||||
monthly_charge=_coerce_float(value.get("monthly_charge")),
|
||||
monthly_credits=_coerce_float(value.get("monthly_credits")),
|
||||
current_period_end=_coerce_str(value.get("current_period_end")),
|
||||
credits_remaining=_coerce_float(value.get("credits_remaining")),
|
||||
rollover_credits=_coerce_float(value.get("rollover_credits")),
|
||||
)
|
||||
|
||||
|
||||
def _paid_service_access_from_payload(value: Any) -> Optional[NousPaidServiceAccessInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
allowed = _coerce_bool(value.get("allowed"))
|
||||
paid_access = _coerce_bool(value.get("paid_access"))
|
||||
return NousPaidServiceAccessInfo(
|
||||
allowed=allowed,
|
||||
paid_access=paid_access,
|
||||
reason=_coerce_str(value.get("reason")),
|
||||
organisation_id=_coerce_str(value.get("organisation_id")),
|
||||
effective_at_ms=_coerce_int(value.get("effective_at_ms")),
|
||||
has_active_subscription=_coerce_bool(value.get("has_active_subscription")),
|
||||
active_subscription_is_paid=_coerce_bool(value.get("active_subscription_is_paid")),
|
||||
subscription_tier=_coerce_int(value.get("subscription_tier")),
|
||||
subscription_monthly_charge=_coerce_float(value.get("subscription_monthly_charge")),
|
||||
subscription_credits_remaining=_coerce_float(value.get("subscription_credits_remaining")),
|
||||
purchased_credits_remaining=_coerce_float(value.get("purchased_credits_remaining")),
|
||||
total_usable_credits=_coerce_float(value.get("total_usable_credits")),
|
||||
)
|
||||
|
||||
|
||||
def _error_info(
|
||||
*,
|
||||
error: object,
|
||||
logged_in: bool,
|
||||
portal_base_url: Optional[str] = None,
|
||||
raw_account: Optional[dict[str, Any]] = None,
|
||||
) -> NousPortalAccountInfo:
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=logged_in,
|
||||
source="error",
|
||||
fresh=False,
|
||||
portal_base_url=portal_base_url,
|
||||
raw_account=raw_account,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
|
||||
def _portal_base_url(state: dict[str, Any]) -> Optional[str]:
|
||||
value = state.get("portal_base_url")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _cache_key(access_token: str, portal_base_url: Optional[str]) -> str:
|
||||
digest = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
|
||||
return f"{portal_base_url or ''}:{digest}"
|
||||
|
||||
|
||||
def _parse_iso_timestamp(value: Any) -> Optional[float]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_str(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> Optional[bool]:
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
|
@ -6,8 +6,12 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Optional, Set
|
||||
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.config import get_env_value, load_config
|
||||
from hermes_cli.nous_account import (
|
||||
NousPortalAccountInfo,
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
from utils import is_truthy_value
|
||||
from tools.tool_backend_helpers import (
|
||||
|
|
@ -25,6 +29,23 @@ _DEFAULT_PLATFORM_TOOLSETS = {
|
|||
"cli": "hermes-cli",
|
||||
}
|
||||
|
||||
# Maps a tools_config provider's ``managed_nous_feature`` to the tool-pool
|
||||
# coverage category (hermes_cli.nous_account.TOOL_COVERAGE_CATEGORIES). Lets the
|
||||
# `hermes tools` picker scope its entitlement gate to the selected backend, so a
|
||||
# free-tool-pool user is allowed image gen but denied video gen at select time —
|
||||
# consistent with the per-category feature gates in get_nous_subscription_features.
|
||||
MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = {
|
||||
"web": "firecrawl",
|
||||
"image_gen": "fal",
|
||||
"video_gen": "fal-video",
|
||||
"tts": "openai-audio",
|
||||
# STT shares the TTS coverage category: both ride the managed
|
||||
# "openai-audio" gateway endpoint (speech + transcriptions).
|
||||
"stt": "openai-audio",
|
||||
"browser": "browser-use",
|
||||
"modal": "modal",
|
||||
}
|
||||
|
||||
|
||||
def _uses_gateway(section: object) -> bool:
|
||||
"""Return True when a config section explicitly opts into the gateway."""
|
||||
|
|
@ -53,6 +74,7 @@ class NousSubscriptionFeatures:
|
|||
nous_auth_present: bool
|
||||
provider_is_nous: bool
|
||||
features: Dict[str, NousFeatureState]
|
||||
account_info: Optional[NousPortalAccountInfo] = None
|
||||
|
||||
@property
|
||||
def web(self) -> NousFeatureState:
|
||||
|
|
@ -74,12 +96,16 @@ class NousSubscriptionFeatures:
|
|||
def browser(self) -> NousFeatureState:
|
||||
return self.features["browser"]
|
||||
|
||||
@property
|
||||
def video_gen(self) -> NousFeatureState:
|
||||
return self.features["video_gen"]
|
||||
|
||||
@property
|
||||
def modal(self) -> NousFeatureState:
|
||||
return self.features["modal"]
|
||||
|
||||
def items(self) -> Iterable[NousFeatureState]:
|
||||
ordered = ("web", "image_gen", "tts", "stt", "browser", "modal")
|
||||
ordered = ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
|
||||
for key in ordered:
|
||||
yield self.features[key]
|
||||
|
||||
|
|
@ -140,6 +166,33 @@ def _has_agent_browser() -> bool:
|
|||
return bool(agent_browser_bin or local_bin.exists())
|
||||
|
||||
|
||||
def _local_browser_runnable() -> bool:
|
||||
"""Return True when the *local* browser backend would actually start.
|
||||
|
||||
The ``agent-browser`` CLI being present is necessary but not sufficient for
|
||||
local mode: agent-browser also needs a Chromium build on disk (without one
|
||||
it hangs on first use until the command timeout fires), unless the
|
||||
Lightpanda engine is selected — text-only navigation needs no Chromium.
|
||||
|
||||
This mirrors the local-mode tail of
|
||||
:func:`tools.browser_tool.check_browser_requirements`, so the setup/status
|
||||
surfaces advertise local browser readiness only when the runtime would
|
||||
actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host
|
||||
their own Chromium and therefore gate on :func:`_has_agent_browser` alone.
|
||||
"""
|
||||
if not _has_agent_browser():
|
||||
return False
|
||||
try:
|
||||
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
|
||||
except Exception:
|
||||
# If the runtime probe can't be imported, fall back to binary presence
|
||||
# (prior behaviour) rather than crashing the setup/status surface.
|
||||
return True
|
||||
if _using_lightpanda_engine():
|
||||
return True
|
||||
return _chromium_installed()
|
||||
|
||||
|
||||
def _browser_label(current_provider: str) -> str:
|
||||
mapping = {
|
||||
"browserbase": "Browserbase",
|
||||
|
|
@ -179,13 +232,23 @@ def _resolve_browser_feature_state(
|
|||
browser_provider: str,
|
||||
browser_provider_explicit: bool,
|
||||
browser_local_available: bool,
|
||||
browser_local_runnable: bool,
|
||||
direct_camofox: bool,
|
||||
direct_browserbase: bool,
|
||||
direct_browser_use: bool,
|
||||
direct_firecrawl: bool,
|
||||
managed_browser_available: bool,
|
||||
) -> tuple[str, bool, bool, bool]:
|
||||
"""Resolve browser availability using the same precedence as runtime."""
|
||||
"""Resolve browser availability using the same precedence as runtime.
|
||||
|
||||
``browser_local_available`` means "the agent-browser CLI is present" — the
|
||||
only local requirement for cloud providers, which host their own Chromium.
|
||||
``browser_local_runnable`` additionally requires a usable local Chromium
|
||||
build (or the Lightpanda engine), mirroring the local-mode tail of
|
||||
:func:`tools.browser_tool.check_browser_requirements`. Local mode must gate
|
||||
on the latter, or setup/status advertise a browser that fails on first use
|
||||
when Chromium is missing.
|
||||
"""
|
||||
if direct_camofox:
|
||||
return "camofox", True, bool(browser_tool_enabled), False
|
||||
|
||||
|
|
@ -214,7 +277,7 @@ def _resolve_browser_feature_state(
|
|||
return current_provider, False, False, False
|
||||
|
||||
current_provider = "local"
|
||||
available = bool(browser_local_available)
|
||||
available = bool(browser_local_runnable)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
|
||||
|
|
@ -234,13 +297,15 @@ def _resolve_browser_feature_state(
|
|||
active = bool(browser_tool_enabled and available)
|
||||
return "browserbase", available, active, False
|
||||
|
||||
available = bool(browser_local_available)
|
||||
available = bool(browser_local_runnable)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "local", available, active, False
|
||||
|
||||
|
||||
def get_nous_subscription_features(
|
||||
config: Optional[Dict[str, object]] = None,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> NousSubscriptionFeatures:
|
||||
if config is None:
|
||||
config = load_config() or {}
|
||||
|
|
@ -249,16 +314,30 @@ def get_nous_subscription_features(
|
|||
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
|
||||
|
||||
try:
|
||||
nous_status = get_nous_auth_status()
|
||||
if force_fresh:
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
else:
|
||||
account_info = get_nous_portal_account_info()
|
||||
except Exception:
|
||||
nous_status = {}
|
||||
account_info = None
|
||||
|
||||
managed_tools_flag = managed_nous_tools_enabled()
|
||||
nous_auth_present = bool(nous_status.get("logged_in"))
|
||||
# Coarse "entitled to any managed tool" gate: paid access OR a live free
|
||||
# tool pool. Per-backend availability is then narrowed by coverage below
|
||||
# (the pool funds image but not video, etc.).
|
||||
managed_tools_flag = bool(
|
||||
account_info
|
||||
and account_info.logged_in
|
||||
and account_info.tool_gateway_entitled
|
||||
)
|
||||
nous_auth_present = bool(account_info and account_info.logged_in)
|
||||
|
||||
def _entitled_for(category: str) -> bool:
|
||||
return bool(account_info and account_info.tool_gateway_entitled_for(category))
|
||||
subscribed = provider_is_nous or nous_auth_present
|
||||
|
||||
web_tool_enabled = _toolset_enabled(config, "web")
|
||||
image_tool_enabled = _toolset_enabled(config, "image_gen")
|
||||
video_tool_enabled = _toolset_enabled(config, "video_gen")
|
||||
tts_tool_enabled = _toolset_enabled(config, "tts")
|
||||
browser_tool_enabled = _toolset_enabled(config, "browser")
|
||||
modal_tool_enabled = _toolset_enabled(config, "terminal")
|
||||
|
|
@ -300,6 +379,8 @@ def get_nous_subscription_features(
|
|||
browser_use_gateway = _uses_gateway(browser_cfg)
|
||||
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
|
||||
image_use_gateway = _uses_gateway(image_gen_cfg)
|
||||
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
|
||||
video_use_gateway = _uses_gateway(video_gen_cfg)
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
|
|
@ -307,6 +388,7 @@ def get_nous_subscription_features(
|
|||
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
|
||||
direct_searxng = bool(get_env_value("SEARXNG_URL"))
|
||||
direct_fal = fal_key_is_configured()
|
||||
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
|
||||
direct_openai_tts = bool(resolve_openai_audio_api_key())
|
||||
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
|
||||
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
|
||||
|
|
@ -338,6 +420,8 @@ def get_nous_subscription_features(
|
|||
direct_tavily = False
|
||||
if image_use_gateway:
|
||||
direct_fal = False
|
||||
if video_use_gateway:
|
||||
direct_fal_video = False
|
||||
if tts_use_gateway:
|
||||
direct_openai_tts = False
|
||||
direct_elevenlabs = False
|
||||
|
|
@ -350,19 +434,54 @@ def get_nous_subscription_features(
|
|||
direct_browser_use = False
|
||||
direct_browserbase = False
|
||||
|
||||
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||
managed_web_available = (
|
||||
managed_tools_flag
|
||||
and nous_auth_present
|
||||
and is_managed_tool_gateway_ready("firecrawl")
|
||||
and _entitled_for("firecrawl")
|
||||
)
|
||||
managed_image_available = (
|
||||
managed_tools_flag
|
||||
and nous_auth_present
|
||||
and is_managed_tool_gateway_ready("fal-queue")
|
||||
and _entitled_for("fal")
|
||||
)
|
||||
# Video gen rides the same fal-queue gateway as image gen, but the free tool
|
||||
# pool funds image and NOT video — so gate it on its own coverage category
|
||||
# rather than aliasing it to image. (Paid users are entitled to both.)
|
||||
managed_video_available = (
|
||||
managed_tools_flag
|
||||
and nous_auth_present
|
||||
and is_managed_tool_gateway_ready("fal-queue")
|
||||
and _entitled_for("fal-video")
|
||||
)
|
||||
managed_tts_available = (
|
||||
managed_tools_flag
|
||||
and nous_auth_present
|
||||
and is_managed_tool_gateway_ready("openai-audio")
|
||||
and _entitled_for("openai-audio")
|
||||
)
|
||||
# STT and TTS share the same managed gateway endpoint ("openai-audio")
|
||||
# because the OpenAI audio API covers both /audio/speech (TTS) and
|
||||
# /audio/transcriptions (STT). One probe, used by both.
|
||||
# /audio/transcriptions (STT). One probe (and one entitlement), used by both.
|
||||
managed_stt_available = managed_tts_available
|
||||
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
|
||||
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||
managed_browser_available = (
|
||||
managed_tools_flag
|
||||
and nous_auth_present
|
||||
and is_managed_tool_gateway_ready("browser-use")
|
||||
and _entitled_for("browser-use")
|
||||
)
|
||||
managed_modal_available = (
|
||||
managed_tools_flag
|
||||
and nous_auth_present
|
||||
and is_managed_tool_gateway_ready("modal")
|
||||
and _entitled_for("modal")
|
||||
)
|
||||
modal_state = resolve_modal_backend_state(
|
||||
modal_mode,
|
||||
has_direct=direct_modal,
|
||||
managed_ready=managed_modal_available,
|
||||
managed_enabled=managed_tools_flag,
|
||||
)
|
||||
|
||||
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
|
||||
|
|
@ -392,6 +511,10 @@ def get_nous_subscription_features(
|
|||
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
|
||||
image_available = bool(managed_image_available or direct_fal)
|
||||
|
||||
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
|
||||
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
|
||||
video_available = bool(managed_video_available or direct_fal_video)
|
||||
|
||||
tts_current_provider = tts_provider or "edge"
|
||||
tts_managed = (
|
||||
tts_tool_enabled
|
||||
|
|
@ -426,6 +549,7 @@ def get_nous_subscription_features(
|
|||
stt_active = stt_available
|
||||
|
||||
browser_local_available = _has_agent_browser()
|
||||
browser_local_runnable = _local_browser_runnable()
|
||||
(
|
||||
browser_current_provider,
|
||||
browser_available,
|
||||
|
|
@ -436,6 +560,7 @@ def get_nous_subscription_features(
|
|||
browser_provider=browser_provider,
|
||||
browser_provider_explicit=browser_provider_explicit,
|
||||
browser_local_available=browser_local_available,
|
||||
browser_local_runnable=browser_local_runnable,
|
||||
direct_camofox=direct_camofox,
|
||||
direct_browserbase=direct_browserbase,
|
||||
direct_browser_use=direct_browser_use,
|
||||
|
|
@ -511,6 +636,18 @@ def get_nous_subscription_features(
|
|||
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
|
||||
explicit_configured=direct_fal,
|
||||
),
|
||||
"video_gen": NousFeatureState(
|
||||
key="video_gen",
|
||||
label="Video generation",
|
||||
included_by_default=False,
|
||||
available=video_available,
|
||||
active=video_active,
|
||||
managed_by_nous=video_managed,
|
||||
direct_override=video_active and not video_managed,
|
||||
toolset_enabled=video_tool_enabled,
|
||||
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
|
||||
explicit_configured=direct_fal_video,
|
||||
),
|
||||
"tts": NousFeatureState(
|
||||
key="tts",
|
||||
label="OpenAI TTS",
|
||||
|
|
@ -569,6 +706,7 @@ def get_nous_subscription_features(
|
|||
nous_auth_present=nous_auth_present,
|
||||
provider_is_nous=provider_is_nous,
|
||||
features=features,
|
||||
account_info=account_info,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -579,11 +717,15 @@ def apply_nous_managed_defaults(
|
|||
config: Dict[str, object],
|
||||
*,
|
||||
enabled_toolsets: Optional[Iterable[str]] = None,
|
||||
force_fresh: bool = False,
|
||||
) -> set[str]:
|
||||
if not managed_nous_tools_enabled():
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
if not (
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.tool_gateway_entitled
|
||||
):
|
||||
return set()
|
||||
|
||||
features = get_nous_subscription_features(config)
|
||||
if not features.provider_is_nous:
|
||||
return set()
|
||||
|
||||
|
|
@ -646,8 +788,28 @@ def apply_nous_managed_defaults(
|
|||
changed.add("browser")
|
||||
|
||||
if "image_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
image_cfg = config.get("image_gen")
|
||||
if not isinstance(image_cfg, dict):
|
||||
image_cfg = {}
|
||||
config["image_gen"] = image_cfg
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
# Video gen is not funded by the free tool pool, so only wire managed video
|
||||
# defaults for users entitled to it (paid). Pool-only users keep video off.
|
||||
if (
|
||||
"video_gen" in selected_toolsets
|
||||
and not fal_key_is_configured()
|
||||
and features.account_info.tool_gateway_entitled_for("fal-video")
|
||||
):
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
|
|
@ -658,6 +820,7 @@ def apply_nous_managed_defaults(
|
|||
_GATEWAY_TOOL_LABELS = {
|
||||
"web": "Web search & extract (Firecrawl)",
|
||||
"image_gen": "Image generation (FAL)",
|
||||
"video_gen": "Video generation (FAL)",
|
||||
"tts": "Text-to-speech (OpenAI TTS)",
|
||||
"stt": "Speech-to-text (OpenAI Whisper)",
|
||||
"browser": "Browser automation (Browser Use)",
|
||||
|
|
@ -666,6 +829,7 @@ _GATEWAY_TOOL_LABELS = {
|
|||
|
||||
def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
"""Return a dict of tool_key -> has_direct_credentials."""
|
||||
fal_direct = fal_key_is_configured()
|
||||
return {
|
||||
"web": bool(
|
||||
get_env_value("FIRECRAWL_API_KEY")
|
||||
|
|
@ -674,7 +838,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
|||
or get_env_value("TAVILY_API_KEY")
|
||||
or get_env_value("EXA_API_KEY")
|
||||
),
|
||||
"image_gen": fal_key_is_configured(),
|
||||
"image_gen": fal_direct,
|
||||
"video_gen": fal_direct,
|
||||
"tts": bool(
|
||||
resolve_openai_audio_api_key()
|
||||
or get_env_value("ELEVENLABS_API_KEY")
|
||||
|
|
@ -698,16 +863,19 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
|||
_GATEWAY_DIRECT_LABELS = {
|
||||
"web": "Firecrawl/Exa/Parallel/Tavily key",
|
||||
"image_gen": "FAL key",
|
||||
"video_gen": "FAL key",
|
||||
"tts": "OpenAI/ElevenLabs key",
|
||||
"stt": "OpenAI/Groq/Mistral key",
|
||||
"browser": "Browser Use/Browserbase key",
|
||||
}
|
||||
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "stt", "browser")
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser")
|
||||
|
||||
|
||||
def get_gateway_eligible_tools(
|
||||
config: Optional[Dict[str, object]] = None,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (unconfigured, has_direct, already_managed) tool key lists.
|
||||
|
||||
|
|
@ -718,7 +886,14 @@ def get_gateway_eligible_tools(
|
|||
All lists are empty when the user is not a paid Nous subscriber or
|
||||
is not using Nous as their provider.
|
||||
"""
|
||||
if not managed_nous_tools_enabled():
|
||||
# Fetch entitlement once: it gates the offer (paid access OR a live free tool
|
||||
# pool) AND tells us which categories are covered (the pool funds image but
|
||||
# not video, etc.). Fails closed on any error.
|
||||
try:
|
||||
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
|
||||
except Exception:
|
||||
return [], [], []
|
||||
if not (account_info and account_info.logged_in and account_info.tool_gateway_entitled):
|
||||
return [], [], []
|
||||
|
||||
if config is None:
|
||||
|
|
@ -738,6 +913,7 @@ def get_gateway_eligible_tools(
|
|||
opted_in = {
|
||||
"web": _uses_gateway(config.get("web")),
|
||||
"image_gen": _uses_gateway(config.get("image_gen")),
|
||||
"video_gen": _uses_gateway(config.get("video_gen")),
|
||||
"tts": _uses_gateway(config.get("tts")),
|
||||
"stt": _uses_gateway(config.get("stt")),
|
||||
"browser": _uses_gateway(config.get("browser")),
|
||||
|
|
@ -747,6 +923,13 @@ def get_gateway_eligible_tools(
|
|||
has_direct: list[str] = []
|
||||
already_managed: list[str] = []
|
||||
for key in _ALL_GATEWAY_KEYS:
|
||||
# Only offer tools the user's entitlement actually covers. For a free
|
||||
# tool pool that means image but not video; paid users are covered for
|
||||
# everything.
|
||||
if not account_info.tool_gateway_entitled_for(
|
||||
MANAGED_FEATURE_COVERAGE_CATEGORY[key]
|
||||
):
|
||||
continue
|
||||
if opted_in.get(key):
|
||||
already_managed.append(key)
|
||||
elif direct.get(key):
|
||||
|
|
@ -817,109 +1000,250 @@ def apply_gateway_defaults(
|
|||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in tool_keys:
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
|
||||
"""If eligible tools exist, prompt the user to enable the Tool Gateway.
|
||||
def prompt_enable_tool_gateway(
|
||||
config: Dict[str, object],
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
) -> set[str]:
|
||||
"""If eligible tools exist, prompt the user (per tool) to enable the Tool
|
||||
Gateway.
|
||||
|
||||
Uses prompt_choice() with a description parameter so the curses TUI
|
||||
shows the tool context alongside the choices.
|
||||
"Pool enabled" is the trigger: a user with a live free tool pool (or paid
|
||||
access) is shown a per-tool checklist of the covered managed backends and
|
||||
picks which to route through the gateway. The free pool funds web/image/
|
||||
tts/browser but not video, so the checklist only lists covered tools (the
|
||||
coverage filter lives in get_gateway_eligible_tools).
|
||||
|
||||
Returns the set of tools that were enabled, or empty set if the user
|
||||
declined or no tools were eligible.
|
||||
"""
|
||||
unconfigured, has_direct, already_managed = get_gateway_eligible_tools(config)
|
||||
unconfigured, has_direct, already_managed = get_gateway_eligible_tools(
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
if not unconfigured and not has_direct:
|
||||
return set()
|
||||
|
||||
try:
|
||||
from hermes_cli.setup import prompt_choice
|
||||
from hermes_cli.setup import prompt_checklist
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
# Build description lines showing full status of all gateway tools
|
||||
desc_parts: list[str] = [
|
||||
"",
|
||||
" The Tool Gateway gives you access to web search, image generation,",
|
||||
" text-to-speech, speech-to-text, and browser automation through your",
|
||||
" Nous subscription. No need to sign up for separate API keys — just",
|
||||
" pick the tools you want.",
|
||||
"",
|
||||
# Frame the offer by entitlement: a $0 free-tool-pool user is not on a paid
|
||||
# plan, so don't call it "your subscription".
|
||||
try:
|
||||
account_info = get_nous_portal_account_info(force_fresh=False)
|
||||
except Exception:
|
||||
account_info = None
|
||||
pool_only = bool(
|
||||
account_info
|
||||
and account_info.paid_service_access is not True
|
||||
and account_info.tool_access is not None
|
||||
and account_info.tool_access.enabled
|
||||
)
|
||||
source_label = "free tool pool" if pool_only else "Nous subscription"
|
||||
|
||||
# Per-tool checklist: unconfigured tools first (pre-checked for new users),
|
||||
# then tools where the user already has their own key (left unchecked so we
|
||||
# don't override their own setup unless they ask).
|
||||
offer_keys: list[str] = list(unconfigured) + list(has_direct)
|
||||
labels: list[str] = [_GATEWAY_TOOL_LABELS[k] for k in unconfigured]
|
||||
labels += [
|
||||
f"{_GATEWAY_TOOL_LABELS[k]} — keep using your {_GATEWAY_DIRECT_LABELS[k]}"
|
||||
for k in has_direct
|
||||
]
|
||||
if already_managed:
|
||||
for k in already_managed:
|
||||
desc_parts.append(f" ✓ {_GATEWAY_TOOL_LABELS[k]} — using Tool Gateway")
|
||||
if unconfigured:
|
||||
for k in unconfigured:
|
||||
desc_parts.append(f" ○ {_GATEWAY_TOOL_LABELS[k]} — not configured")
|
||||
if has_direct:
|
||||
for k in has_direct:
|
||||
desc_parts.append(f" ○ {_GATEWAY_TOOL_LABELS[k]} — using {_GATEWAY_DIRECT_LABELS[k]}")
|
||||
|
||||
# Build short choice labels — detail is in the description above
|
||||
choices: list[str] = []
|
||||
choice_keys: list[str] = [] # maps choice index -> action
|
||||
|
||||
if unconfigured and has_direct:
|
||||
choices.append("Enable for all tools (existing keys kept, not used)")
|
||||
choice_keys.append("all")
|
||||
|
||||
choices.append("Enable only for tools without existing keys")
|
||||
choice_keys.append("unconfigured")
|
||||
|
||||
choices.append("Skip")
|
||||
choice_keys.append("skip")
|
||||
|
||||
elif unconfigured:
|
||||
choices.append("Enable Tool Gateway")
|
||||
choice_keys.append("unconfigured")
|
||||
|
||||
choices.append("Skip")
|
||||
choice_keys.append("skip")
|
||||
pre_selected = list(range(len(unconfigured)))
|
||||
|
||||
if pool_only:
|
||||
title = "Your free Nous tool pool — pick the tools to enable:"
|
||||
else:
|
||||
choices.append("Enable Tool Gateway (existing keys kept, not used)")
|
||||
choice_keys.append("all")
|
||||
|
||||
choices.append("Skip")
|
||||
choice_keys.append("skip")
|
||||
|
||||
description = "\n".join(desc_parts) if desc_parts else None
|
||||
# Default to "Enable" when user has no direct keys (new user),
|
||||
# default to "Skip" when they have existing keys to preserve.
|
||||
default_idx = 0 if not has_direct else len(choices) - 1
|
||||
title = (
|
||||
"Your Nous subscription includes the Tool Gateway — "
|
||||
"pick the tools to enable:"
|
||||
)
|
||||
|
||||
try:
|
||||
idx = prompt_choice(
|
||||
"Your Nous subscription includes the Tool Gateway.",
|
||||
choices,
|
||||
default_idx,
|
||||
description=description,
|
||||
)
|
||||
chosen_idx = prompt_checklist(title, labels, pre_selected)
|
||||
except (KeyboardInterrupt, EOFError, OSError, SystemExit):
|
||||
return set()
|
||||
|
||||
action = choice_keys[idx]
|
||||
if action == "skip":
|
||||
chosen_keys = [offer_keys[i] for i in chosen_idx if 0 <= i < len(offer_keys)]
|
||||
if not chosen_keys:
|
||||
return set()
|
||||
|
||||
if action == "all":
|
||||
# Apply to switchable tools + ensure already-managed tools also
|
||||
# have use_gateway persisted in config for consistency.
|
||||
to_apply = list(_ALL_GATEWAY_KEYS)
|
||||
else:
|
||||
to_apply = unconfigured
|
||||
|
||||
changed = apply_gateway_defaults(config, to_apply)
|
||||
changed = apply_gateway_defaults(config, chosen_keys)
|
||||
if changed:
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
save_config(config)
|
||||
# Only report the tools that actually switched (not already-managed ones)
|
||||
newly_switched = changed - set(already_managed)
|
||||
for key in sorted(newly_switched):
|
||||
for key in sorted(changed):
|
||||
label = _GATEWAY_TOOL_LABELS.get(key, key)
|
||||
print(f" ✓ {label}: enabled via Nous subscription")
|
||||
if already_managed and not newly_switched:
|
||||
print(" (all tools already using Tool Gateway)")
|
||||
print(f" ✓ {label}: enabled via {source_label}")
|
||||
return changed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline Nous Portal login for the Tool Gateway picker (`hermes tools`)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_nous_portal_access(
|
||||
*,
|
||||
capability: str = "the Nous Tool Gateway",
|
||||
coverage_category: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Make sure the user is entitled to the Nous Tool Gateway, logging in if
|
||||
needed.
|
||||
|
||||
Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway
|
||||
backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous
|
||||
login, this:
|
||||
|
||||
- does NOT change the inference provider (``model.provider`` is untouched),
|
||||
- does NOT run model selection, and
|
||||
- does NOT offer the bulk "enable for all tools" Tool Gateway prompt.
|
||||
|
||||
It only performs the Nous Portal device-code OAuth (when the user isn't
|
||||
already logged in) and refreshes entitlement, so the caller can enable the
|
||||
single tool the user picked.
|
||||
|
||||
Entitlement is satisfied by paid service access OR a live free tool pool.
|
||||
When ``coverage_category`` is given (e.g. ``"fal"`` for image gen), the pool
|
||||
must cover that category specifically — so a pool user selecting video
|
||||
(``"fal-video"``, not pool-funded) is correctly denied.
|
||||
|
||||
Returns ``True`` when the account is entitled after the flow, ``False``
|
||||
otherwise (declined login, login failed, or no entitlement).
|
||||
"""
|
||||
|
||||
def _entitled(account) -> bool:
|
||||
if account is None:
|
||||
return False
|
||||
if coverage_category is not None:
|
||||
return account.tool_gateway_entitled_for(coverage_category)
|
||||
return account.tool_gateway_entitled
|
||||
|
||||
# Fast path: already entitled.
|
||||
try:
|
||||
info = get_nous_portal_account_info(force_fresh=True)
|
||||
except Exception:
|
||||
info = None
|
||||
if _entitled(info):
|
||||
return True
|
||||
|
||||
# If not logged in at all, run the device-code login (auth only).
|
||||
if info is None or not info.logged_in:
|
||||
if not _run_nous_portal_login_only(capability=capability):
|
||||
return False
|
||||
try:
|
||||
info = get_nous_portal_account_info(force_fresh=True)
|
||||
except Exception:
|
||||
info = None
|
||||
|
||||
if _entitled(info):
|
||||
return True
|
||||
|
||||
# Logged in but not entitled for this capability — surface neutral billing
|
||||
# guidance, do not enable. coverage_category keeps a pool user who lacks this
|
||||
# one category from being told their credits are exhausted.
|
||||
message = format_nous_portal_entitlement_message(
|
||||
info, capability=capability, coverage_category=coverage_category
|
||||
)
|
||||
if message:
|
||||
for line in message.splitlines():
|
||||
print(f" {line}")
|
||||
return False
|
||||
|
||||
|
||||
def _run_nous_portal_login_only(*, capability: str) -> bool:
|
||||
"""Run the Nous Portal device-code OAuth and persist credentials only.
|
||||
|
||||
No model selection, no provider switch, no Tool Gateway bulk prompt.
|
||||
Returns ``True`` on a successful login, ``False`` if the user declined or
|
||||
the flow failed.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
_auth_store_lock,
|
||||
_load_auth_store,
|
||||
_nous_device_code_login,
|
||||
_read_shared_nous_state,
|
||||
_save_auth_store,
|
||||
_save_provider_state,
|
||||
_sync_nous_pool_from_auth_store,
|
||||
_try_import_shared_nous_state,
|
||||
_write_shared_nous_state,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f" Could not start Nous Portal login: {exc}")
|
||||
return False
|
||||
|
||||
print()
|
||||
print(f" {capability} requires a Nous Portal login.")
|
||||
try:
|
||||
proceed = input(" Log in to Nous Portal now? [Y/n]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return False
|
||||
if proceed not in {"", "y", "yes"}:
|
||||
print(" Skipped Nous Portal login.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Snapshot the active_provider so a tool-config login never silently
|
||||
# switches the user's inference provider to Nous.
|
||||
with _auth_store_lock():
|
||||
prior_active_provider = _load_auth_store().get("active_provider")
|
||||
|
||||
auth_state = None
|
||||
shared = _read_shared_nous_state()
|
||||
if shared:
|
||||
try:
|
||||
do_import = input(
|
||||
" Found existing Nous OAuth credentials. Import them? [Y/n]: "
|
||||
).strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
do_import = "y"
|
||||
if do_import in {"", "y", "yes"}:
|
||||
auth_state = _try_import_shared_nous_state(timeout_seconds=15.0)
|
||||
|
||||
if auth_state is None:
|
||||
auth_state = _nous_device_code_login()
|
||||
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "nous", auth_state)
|
||||
# Preserve the user's existing inference provider — this login is
|
||||
# for tool entitlement only, not a provider switch.
|
||||
if prior_active_provider:
|
||||
auth_store["active_provider"] = prior_active_provider
|
||||
else:
|
||||
auth_store.pop("active_provider", None)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
_write_shared_nous_state(auth_state)
|
||||
_sync_nous_pool_from_auth_store()
|
||||
print(" Nous Portal login successful.")
|
||||
return True
|
||||
except KeyboardInterrupt:
|
||||
print("\n Login cancelled.")
|
||||
return False
|
||||
except SystemExit:
|
||||
# _nous_device_code_login raises SystemExit on subscription_required;
|
||||
# it already printed billing guidance.
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f" Nous Portal login failed: {exc}")
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ Model / provider selection mirrors `hermes chat`:
|
|||
|
||||
Env var fallbacks (used when the corresponding arg is not passed):
|
||||
- HERMES_INFERENCE_MODEL
|
||||
- HERMES_INFERENCE_PROVIDER (already read by resolve_runtime_provider)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -28,6 +27,8 @@ import sys
|
|||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
|
||||
def _normalize_toolsets(toolsets: object = None) -> list[str] | None:
|
||||
if not toolsets:
|
||||
|
|
@ -133,9 +134,8 @@ def run_oneshot(
|
|||
prompt: The user message to send.
|
||||
model: Optional model override. Falls back to HERMES_INFERENCE_MODEL
|
||||
env var, then config.yaml's model.default / model.model.
|
||||
provider: Optional provider override. Falls back to
|
||||
HERMES_INFERENCE_PROVIDER env var, then config.yaml's model.provider,
|
||||
then "auto".
|
||||
provider: Optional provider override. Falls back to config.yaml's
|
||||
model.provider, then "auto".
|
||||
toolsets: Optional comma-separated string or iterable of toolsets.
|
||||
|
||||
Returns the exit code. Caller should sys.exit() with the return.
|
||||
|
|
@ -174,28 +174,55 @@ def run_oneshot(
|
|||
# Redirect stderr AND stdout to devnull for the entire call tree.
|
||||
# We'll print the final response to the real stdout at the end.
|
||||
real_stdout = sys.stdout
|
||||
real_stderr = sys.stderr
|
||||
devnull = open(os.devnull, "w", encoding="utf-8")
|
||||
|
||||
response: Optional[str] = None
|
||||
failure: BaseException | None = None
|
||||
try:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
try:
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
except BaseException as exc: # noqa: BLE001
|
||||
# Capture anything that escapes the agent (including OSError
|
||||
# from prompt_toolkit/Vt100 when stdout is a non-TTY pipe,
|
||||
# KeyboardInterrupt, SystemExit, etc.) so we can surface it on
|
||||
# the real stderr instead of crashing past the redirect with a
|
||||
# traceback that the caller never sees. A silent exit in a
|
||||
# cron / SSH / subprocess context is the worst failure mode.
|
||||
# See #30623.
|
||||
failure = exc
|
||||
finally:
|
||||
try:
|
||||
devnull.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if response:
|
||||
real_stdout.write(response)
|
||||
if not response.endswith("\n"):
|
||||
real_stdout.write("\n")
|
||||
real_stdout.flush()
|
||||
if failure is not None:
|
||||
# Re-raise control-flow exceptions so the parent handles them as usual
|
||||
# (Ctrl-C / explicit sys.exit() inside the agent).
|
||||
if isinstance(failure, (KeyboardInterrupt, SystemExit)):
|
||||
raise failure
|
||||
real_stderr.write(f"hermes -z: agent failed: {failure}\n")
|
||||
real_stderr.flush()
|
||||
return 1
|
||||
|
||||
if not (response or "").strip():
|
||||
real_stderr.write("hermes -z: no final response was produced; treating the run as failed.\n")
|
||||
real_stderr.flush()
|
||||
return 1
|
||||
|
||||
assert response is not None # narrowed by the empty-response guard above
|
||||
real_stdout.write(response)
|
||||
if not response.endswith("\n"):
|
||||
real_stdout.write("\n")
|
||||
real_stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -301,14 +328,9 @@ def _run_agent(
|
|||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
|
||||
session_db = _create_session_db_for_oneshot()
|
||||
# Read fallback chain from profile config — supports both the new list
|
||||
# format (fallback_providers) and the legacy single-dict (fallback_model).
|
||||
# Mirrors the same normalization in cli.py so oneshot workers (e.g. kanban
|
||||
# workers spawned via `hermes -p <profile> chat -q ...`) honour the
|
||||
# profile's fallback chain just like interactive sessions do.
|
||||
_fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or []
|
||||
if isinstance(_fb, dict):
|
||||
_fb = [_fb] if _fb.get("provider") and _fb.get("model") else []
|
||||
# Read the effective fallback chain from profile config so oneshot workers
|
||||
# honour the same merge semantics as interactive CLI and gateway sessions.
|
||||
_fb = get_fallback_chain(cfg)
|
||||
|
||||
agent = AIAgent(
|
||||
api_key=runtime.get("api_key"),
|
||||
|
|
|
|||
235
hermes_cli/partial_compress.py
Normal file
235
hermes_cli/partial_compress.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Boundary-aware partial compression — "summarize up to here".
|
||||
|
||||
Inspired by Claude Code's Rewind menu "Summarize up to here" action
|
||||
(v2.1.139–v2.1.142, Week 20, May 2026):
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20
|
||||
|
||||
Hermes already has ``/compress`` (full-history compaction) and an
|
||||
automatic token-budget tail-protection heuristic inside
|
||||
``ContextCompressor``. What was missing is *user-chosen* boundary
|
||||
control: "fold everything before this point into a summary, but keep
|
||||
my most recent N exchanges exactly as they are." That is the value of
|
||||
the Claude Code feature — the user decides the compression boundary
|
||||
instead of leaving it to the token-budget heuristic.
|
||||
|
||||
This module owns the pure, side-effect-free split logic so both the
|
||||
CLI (``cli.py::_manual_compress``) and the gateway
|
||||
(``gateway/run.py::_handle_compress_command``) share one
|
||||
implementation. The slash-command surfaces handle compression of the
|
||||
*head* via the existing ``_compress_context`` pipeline (preserving all
|
||||
the session-rotation / lock / memory-notify machinery) and then
|
||||
re-append the verbatim *tail* returned here.
|
||||
|
||||
Design notes / invariants honored:
|
||||
|
||||
* **Role alternation.** The compressed head ends with summary/handoff
|
||||
content (assistant- or user-role, possibly a trailing todo snapshot).
|
||||
The verbatim tail must begin with a ``user`` message so the rejoined
|
||||
history keeps the user↔assistant alternation that providers validate.
|
||||
:func:`split_history_for_partial_compress` snaps the tail boundary
|
||||
backwards to the nearest ``user`` turn so the rejoin is always legal.
|
||||
|
||||
* **No silent context mutation.** This is a manual, user-invoked
|
||||
action. It rotates the session exactly like ``/compress`` does (via
|
||||
the caller), so the prompt-cache reset is explicit and expected, not
|
||||
silent.
|
||||
|
||||
* **Conservative defaults.** ``keep_last`` counts *exchanges* (a user
|
||||
turn plus its following assistant/tool turns), defaulting to 2. The
|
||||
split never compresses if doing so would leave nothing in the head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
#: Default number of recent exchanges to preserve verbatim when the user
|
||||
#: runs ``/compress here`` without an explicit count.
|
||||
DEFAULT_KEEP_LAST = 2
|
||||
|
||||
#: Hard ceiling so a fat-fingered ``/compress here 9999`` doesn't turn
|
||||
#: into a no-op surprise — clamp instead.
|
||||
MAX_KEEP_LAST = 100
|
||||
|
||||
|
||||
def parse_partial_compress_args(
|
||||
raw_args: str,
|
||||
) -> Tuple[bool, int, Optional[str]]:
|
||||
"""Parse the argument string after ``/compress``.
|
||||
|
||||
Recognizes the boundary-aware forms:
|
||||
|
||||
* ``here`` → partial compress, keep ``DEFAULT_KEEP_LAST``
|
||||
* ``here 4`` → partial compress, keep 4 exchanges
|
||||
* ``--keep 4`` → partial compress, keep 4 exchanges
|
||||
* ``up to here`` → alias for ``here`` (matches Claude Code's
|
||||
menu label "Summarize up to here")
|
||||
|
||||
Anything else is treated as a focus topic for the existing full
|
||||
``/compress <focus>`` behavior.
|
||||
|
||||
Returns ``(partial, keep_last, focus_topic)``:
|
||||
|
||||
* ``partial`` — True when a boundary-aware form was requested.
|
||||
* ``keep_last`` — exchanges to preserve verbatim (only meaningful
|
||||
when ``partial`` is True).
|
||||
* ``focus_topic`` — focus string for full compression, or None.
|
||||
Always None when ``partial`` is True (the two modes are exclusive;
|
||||
a focused partial compress is not a documented Claude Code
|
||||
behavior and would muddy the UX).
|
||||
"""
|
||||
text = (raw_args or "").strip()
|
||||
if not text:
|
||||
return False, DEFAULT_KEEP_LAST, None
|
||||
|
||||
lowered = text.lower()
|
||||
|
||||
# Normalize the "up to here" alias to "here".
|
||||
if lowered.startswith("up to here"):
|
||||
lowered = lowered[len("up to ") :]
|
||||
text = text[len("up to ") :]
|
||||
|
||||
tokens = lowered.split()
|
||||
|
||||
# Form: here [N]
|
||||
if tokens and tokens[0] == "here":
|
||||
keep = DEFAULT_KEEP_LAST
|
||||
if len(tokens) >= 2:
|
||||
keep = _coerce_keep(tokens[1])
|
||||
return True, keep, None
|
||||
|
||||
# Form: --keep N (or --keep=N)
|
||||
if tokens and tokens[0] in ("--keep", "-k") and len(tokens) >= 2:
|
||||
return True, _coerce_keep(tokens[1]), None
|
||||
if tokens and tokens[0].startswith("--keep="):
|
||||
return True, _coerce_keep(tokens[0].split("=", 1)[1]), None
|
||||
|
||||
# Otherwise: full compression with this as the focus topic.
|
||||
return False, DEFAULT_KEEP_LAST, text or None
|
||||
|
||||
|
||||
def _coerce_keep(value: str) -> int:
|
||||
"""Parse a keep-count token, clamping to [1, MAX_KEEP_LAST]."""
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_KEEP_LAST
|
||||
if n < 1:
|
||||
return 1
|
||||
if n > MAX_KEEP_LAST:
|
||||
return MAX_KEEP_LAST
|
||||
return n
|
||||
|
||||
|
||||
def split_history_for_partial_compress(
|
||||
history: List[Dict[str, Any]],
|
||||
keep_last: int,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Split ``history`` into ``(head, tail)`` for partial compression.
|
||||
|
||||
``head`` is the earlier portion that will be summarized; ``tail`` is
|
||||
the most recent ``keep_last`` exchanges, preserved verbatim.
|
||||
|
||||
An *exchange* is counted by ``user``-role messages: keeping N
|
||||
exchanges means keeping everything from the Nth-most-recent ``user``
|
||||
message onward. This guarantees the tail starts on a ``user`` turn,
|
||||
so when the caller rejoins ``compressed_head + tail`` the
|
||||
user↔assistant alternation stays valid (the compressed head's
|
||||
trailing content is followed by a fresh user turn).
|
||||
|
||||
Returns ``(head, tail)``. If the split would leave the head empty
|
||||
(not enough history to compress meaningfully), returns
|
||||
``(history, [])`` — signaling the caller to fall back to full
|
||||
compression or report "nothing to do".
|
||||
"""
|
||||
if keep_last < 1:
|
||||
keep_last = 1
|
||||
|
||||
n = len(history)
|
||||
if n == 0:
|
||||
return [], []
|
||||
|
||||
# Walk backwards collecting the indices of the most recent `keep_last`
|
||||
# user-message starts. The tail begins at the earliest such index.
|
||||
user_starts: List[int] = []
|
||||
for idx in range(n - 1, -1, -1):
|
||||
if history[idx].get("role") == "user":
|
||||
user_starts.append(idx)
|
||||
if len(user_starts) >= keep_last:
|
||||
break
|
||||
|
||||
if not user_starts:
|
||||
# No user turns at all (degenerate) — nothing sensible to keep
|
||||
# as a "recent exchange"; treat as full compression.
|
||||
return list(history), []
|
||||
|
||||
boundary = user_starts[-1] # earliest of the kept user starts
|
||||
|
||||
head = history[:boundary]
|
||||
tail = history[boundary:]
|
||||
|
||||
# If everything is in the tail (nothing left to compress), signal the
|
||||
# caller to fall back to full compression rather than producing a
|
||||
# no-op that rotates the session for no benefit.
|
||||
if not head:
|
||||
return list(history), []
|
||||
|
||||
return head, tail
|
||||
|
||||
|
||||
def rejoin_compressed_head_and_tail(
|
||||
compressed_head: List[Dict[str, Any]],
|
||||
tail: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Concatenate a compressed head with the verbatim tail, defending
|
||||
the seam against an illegal user→user / assistant→assistant adjacency.
|
||||
|
||||
In normal operation the compressed head ends with the head's own
|
||||
protected verbatim tail (the ``ContextCompressor`` always preserves a
|
||||
recent window), which terminates on an ``assistant``/``tool`` turn —
|
||||
so ``assistant → user`` at the seam is already valid. But the head
|
||||
compressor's exact output shape is not contractually guaranteed (a
|
||||
plugin context engine could return something that ends on a ``user``
|
||||
turn, or a degenerate single-summary message). Rather than trust the
|
||||
seam, this helper inspects the boundary and, if the last head message
|
||||
and the first tail message share a ``user``/``assistant`` role, folds
|
||||
the tail's first message content onto the head's last message so the
|
||||
rejoined list never violates provider role-alternation rules.
|
||||
|
||||
``tool`` messages are left alone — consecutive ``tool`` entries are
|
||||
the one legal repetition (parallel tool results).
|
||||
"""
|
||||
if not tail:
|
||||
return list(compressed_head)
|
||||
if not compressed_head:
|
||||
return list(tail)
|
||||
|
||||
head = list(compressed_head)
|
||||
rest = list(tail)
|
||||
|
||||
last = head[-1]
|
||||
first = rest[0]
|
||||
last_role = last.get("role")
|
||||
first_role = first.get("role")
|
||||
|
||||
if last_role == first_role and last_role in ("user", "assistant"):
|
||||
# Illegal adjacency. Merge the tail's first message text into the
|
||||
# head's last message so alternation is preserved. Only string
|
||||
# contents are merged inline; structured/multimodal contents fall
|
||||
# back to dropping the redundant standalone (the content is
|
||||
# preserved by concatenation when both are strings).
|
||||
last_content = last.get("content")
|
||||
first_content = first.get("content")
|
||||
if isinstance(last_content, str) and isinstance(first_content, str):
|
||||
merged = dict(last)
|
||||
merged["content"] = f"{last_content}\n\n{first_content}"
|
||||
head[-1] = merged
|
||||
rest = rest[1:]
|
||||
else:
|
||||
# Can't safely string-merge multimodal content. Insert a
|
||||
# minimal bridging turn so the seam alternates rather than
|
||||
# losing data.
|
||||
bridge_role = "assistant" if first_role == "user" else "user"
|
||||
head.append({"role": bridge_role, "content": ""})
|
||||
|
||||
return head + rest
|
||||
|
|
@ -34,7 +34,6 @@ so plugin-defined tools appear alongside the built-in tools.
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
|
|
@ -50,6 +49,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Union
|
|||
from hermes_constants import get_hermes_home
|
||||
from utils import env_var_enabled
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION, VALID_MIDDLEWARE
|
||||
|
||||
|
||||
def get_bundled_plugins_dir() -> Path:
|
||||
|
|
@ -138,10 +138,12 @@ VALID_HOOKS: Set[str] = {
|
|||
"post_llm_call",
|
||||
"pre_api_request",
|
||||
"post_api_request",
|
||||
"api_request_error",
|
||||
"on_session_start",
|
||||
"on_session_end",
|
||||
"on_session_finalize",
|
||||
"on_session_reset",
|
||||
"subagent_start",
|
||||
"subagent_stop",
|
||||
# Gateway pre-dispatch hook. Fired once per incoming MessageEvent
|
||||
# after the internal-event guard but BEFORE auth/pairing and agent
|
||||
|
|
@ -275,6 +277,7 @@ class LoadedPlugin:
|
|||
module: Optional[types.ModuleType] = None
|
||||
tools_registered: List[str] = field(default_factory=list)
|
||||
hooks_registered: List[str] = field(default_factory=list)
|
||||
middleware_registered: List[str] = field(default_factory=list)
|
||||
commands_registered: List[str] = field(default_factory=list)
|
||||
enabled: bool = False
|
||||
error: Optional[str] = None
|
||||
|
|
@ -553,6 +556,46 @@ class PluginContext:
|
|||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- dashboard auth provider registration --------------------------------
|
||||
|
||||
def register_dashboard_auth_provider(self, provider) -> None:
|
||||
"""Register a dashboard authentication provider.
|
||||
|
||||
``provider`` must be an instance of
|
||||
:class:`hermes_cli.dashboard_auth.DashboardAuthProvider`. Used by
|
||||
the dashboard OAuth auth gate, which engages when the dashboard
|
||||
binds to a non-loopback host without ``--insecure``.
|
||||
|
||||
Misbehaving providers (wrong type, duplicate name) are logged at
|
||||
WARNING and silently ignored — never raised — so a broken plugin
|
||||
cannot crash the host. Same convention as
|
||||
``register_image_gen_provider``.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider, register_provider,
|
||||
)
|
||||
|
||||
if not isinstance(provider, DashboardAuthProvider):
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a dashboard-auth provider "
|
||||
"that does not inherit from DashboardAuthProvider. Ignoring.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
try:
|
||||
register_provider(provider)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Plugin '%s' failed to register dashboard-auth provider "
|
||||
"%r: %s",
|
||||
self.manifest.name, getattr(provider, "name", "?"), e,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"Plugin '%s' registered dashboard-auth provider: %s (%s)",
|
||||
self.manifest.name, provider.name, provider.display_name,
|
||||
)
|
||||
|
||||
# -- video gen provider registration -------------------------------------
|
||||
|
||||
def register_video_gen_provider(self, provider) -> None:
|
||||
|
|
@ -640,6 +683,88 @@ class PluginContext:
|
|||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- TTS provider registration -------------------------------------------
|
||||
|
||||
def register_tts_provider(self, provider) -> None:
|
||||
"""Register a text-to-speech backend.
|
||||
|
||||
``provider`` must be an instance of
|
||||
:class:`agent.tts_provider.TTSProvider`. The ``provider.name``
|
||||
attribute is what ``tts.provider`` in ``config.yaml`` matches
|
||||
against when routing ``text_to_speech`` tool calls — **but
|
||||
only when**:
|
||||
|
||||
1. ``provider.name`` is NOT a built-in TTS provider name
|
||||
(``edge``, ``openai``, ``elevenlabs``, …). Built-ins always
|
||||
win — the registry rejects shadowing names with a warning.
|
||||
2. There is NO ``tts.providers.<name>: type: command`` entry
|
||||
with the same name. Command-providers (PR #17843) win on
|
||||
name collision because config is more local than plugin
|
||||
install.
|
||||
|
||||
Coexists with the command-provider registry rather than
|
||||
replacing it — see issue #30398 for the full design rationale.
|
||||
"""
|
||||
from agent.tts_provider import TTSProvider
|
||||
from agent.tts_registry import register_provider as _register_tts_provider
|
||||
|
||||
if not isinstance(provider, TTSProvider):
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a TTS provider that does "
|
||||
"not inherit from TTSProvider. Ignoring.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
_register_tts_provider(provider)
|
||||
logger.info(
|
||||
"Plugin '%s' registered TTS provider: %s",
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- transcription (STT) provider registration ---------------------------
|
||||
|
||||
def register_transcription_provider(self, provider) -> None:
|
||||
"""Register a speech-to-text backend.
|
||||
|
||||
``provider`` must be an instance of
|
||||
:class:`agent.transcription_provider.TranscriptionProvider`.
|
||||
The ``provider.name`` attribute is what ``stt.provider`` in
|
||||
``config.yaml`` matches against when routing
|
||||
:func:`tools.transcription_tools.transcribe_audio` calls —
|
||||
**but only when**:
|
||||
|
||||
1. ``provider.name`` is NOT a built-in STT provider name
|
||||
(``local``, ``local_command``, ``groq``, ``openai``,
|
||||
``mistral``, ``xai``). Built-ins always win — the registry
|
||||
rejects shadowing names with a warning.
|
||||
2. There is NO ``stt.providers.<name>: type: command`` entry
|
||||
with the same name. Command-providers win on name
|
||||
collision because config is more local than plugin install
|
||||
— same precedence rule as TTS.
|
||||
|
||||
Coexists with the in-tree dispatcher and the STT
|
||||
command-provider registry rather than replacing them. The 6
|
||||
built-in STT backends keep their native implementations in
|
||||
``tools/transcription_tools.py``; this hook is for *new* Python
|
||||
engines (OpenRouter, SenseAudio, Gemini-STT, custom proprietary
|
||||
backends).
|
||||
"""
|
||||
from agent.transcription_provider import TranscriptionProvider
|
||||
from agent.transcription_registry import register_provider as _register_stt_provider
|
||||
|
||||
if not isinstance(provider, TranscriptionProvider):
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a transcription provider that "
|
||||
"does not inherit from TranscriptionProvider. Ignoring.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
_register_stt_provider(provider)
|
||||
logger.info(
|
||||
"Plugin '%s' registered transcription provider: %s",
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- platform adapter registration ---------------------------------------
|
||||
|
||||
def register_platform(
|
||||
|
|
@ -698,6 +823,119 @@ class PluginContext:
|
|||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
# -- auxiliary task registration ---------------------------------------
|
||||
|
||||
def register_auxiliary_task(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
display_name: str,
|
||||
description: str,
|
||||
defaults: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Register a plugin-defined auxiliary LLM task.
|
||||
|
||||
Auxiliary tasks are LLM-backed side jobs (vision analysis, web extraction,
|
||||
compression, smart-approval, etc.) that route through ``auxiliary_client.py``.
|
||||
Each task has its own ``auxiliary.<key>`` config block where users can
|
||||
pin a provider/model independent of the main chat model.
|
||||
|
||||
Plugins use this to declare their own auxiliary tasks without touching
|
||||
core files. After registration, the task:
|
||||
|
||||
- Appears in the ``hermes model → Configure auxiliary models`` picker
|
||||
- Has its provider/model/base_url/api_key bridged from config.yaml to
|
||||
``AUXILIARY_<KEY_UPPER>_*`` env vars at gateway startup
|
||||
- Gets default routing fields (provider="auto", model="", etc.) merged
|
||||
into loaded configs so ``cfg.get("auxiliary", {}).get(key)`` works
|
||||
|
||||
Args:
|
||||
key: stable task key (snake_case). Used in config ``auxiliary.<key>``
|
||||
and env vars ``AUXILIARY_<KEY_UPPER>_*``. Must not shadow a
|
||||
built-in task key (vision, compression, web_extract, approval,
|
||||
mcp, title_generation, skills_hub, curator).
|
||||
display_name: human-readable name shown in the picker.
|
||||
description: short one-line description shown next to the name.
|
||||
defaults: optional dict of default routing fields. Recognized keys:
|
||||
``provider`` (default "auto"), ``model`` (default ""),
|
||||
``base_url`` (default ""), ``api_key`` (default ""),
|
||||
``timeout`` (default 60), ``extra_body`` (default {}),
|
||||
plus any task-specific extras (e.g. ``download_timeout``).
|
||||
Unknown keys are preserved verbatim — the plugin owns the
|
||||
schema for its own task.
|
||||
|
||||
Raises:
|
||||
ValueError: if *key* is empty, contains invalid characters, or
|
||||
shadows a built-in auxiliary task key.
|
||||
|
||||
Example:
|
||||
ctx.register_auxiliary_task(
|
||||
key="memory_retain_filter",
|
||||
display_name="Memory retain filter",
|
||||
description="hindsight pre-retain dedup/extract",
|
||||
defaults={"provider": "auto", "timeout": 30},
|
||||
)
|
||||
"""
|
||||
# Validate key shape
|
||||
if not key or not isinstance(key, str):
|
||||
raise ValueError(
|
||||
f"Plugin '{self.manifest.name}' tried to register auxiliary task "
|
||||
f"with invalid key {key!r}"
|
||||
)
|
||||
if not all(c.isalnum() or c == "_" for c in key):
|
||||
raise ValueError(
|
||||
f"Plugin '{self.manifest.name}' auxiliary task key {key!r} "
|
||||
f"must contain only alphanumeric characters and underscores"
|
||||
)
|
||||
|
||||
# Lazy import to avoid circular: hermes_cli.main imports plugins indirectly
|
||||
from hermes_cli.main import _AUX_TASKS as _BUILTIN_AUX_TASKS
|
||||
|
||||
builtin_keys = {k for k, _name, _desc in _BUILTIN_AUX_TASKS}
|
||||
if key in builtin_keys:
|
||||
raise ValueError(
|
||||
f"Plugin '{self.manifest.name}' cannot register auxiliary task "
|
||||
f"{key!r} — that key is reserved for a built-in task. "
|
||||
f"Pick a plugin-namespaced key (e.g. '{self.manifest.name}_{key}')."
|
||||
)
|
||||
|
||||
# Reject duplicate registrations across plugins
|
||||
existing = self._manager._aux_tasks.get(key)
|
||||
if existing is not None and existing.get("plugin") != self.manifest.name:
|
||||
raise ValueError(
|
||||
f"Plugin '{self.manifest.name}' cannot register auxiliary task "
|
||||
f"{key!r} — already registered by plugin "
|
||||
f"'{existing.get('plugin')}'"
|
||||
)
|
||||
|
||||
# Normalize defaults — plugin owns the schema, but we ensure routing
|
||||
# fields exist with sensible types so consumers don't crash.
|
||||
merged_defaults: Dict[str, Any] = {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 60,
|
||||
"extra_body": {},
|
||||
}
|
||||
if defaults:
|
||||
for k, v in defaults.items():
|
||||
merged_defaults[k] = v
|
||||
|
||||
self._manager._aux_tasks[key] = {
|
||||
"key": key,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
"defaults": merged_defaults,
|
||||
"plugin": self.manifest.name,
|
||||
}
|
||||
logger.debug(
|
||||
"Plugin %s registered auxiliary task: %s (%s)",
|
||||
self.manifest.name,
|
||||
key,
|
||||
display_name,
|
||||
)
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
"""Register a lifecycle hook callback.
|
||||
|
||||
|
|
@ -715,6 +953,27 @@ class PluginContext:
|
|||
self._manager._hooks.setdefault(hook_name, []).append(callback)
|
||||
logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name)
|
||||
|
||||
# -- middleware registration -------------------------------------------
|
||||
|
||||
def register_middleware(self, kind: str, callback: Callable) -> None:
|
||||
"""Register a behavior-changing middleware callback.
|
||||
|
||||
Middleware is separate from observer hooks: request middleware may
|
||||
rewrite the effective payload, and execution middleware may wrap the
|
||||
real callback. Unknown kinds are stored for forward compatibility but
|
||||
warned so plugin authors can catch typos.
|
||||
"""
|
||||
if kind not in VALID_MIDDLEWARE:
|
||||
logger.warning(
|
||||
"Plugin '%s' registered unknown middleware '%s' "
|
||||
"(valid: %s)",
|
||||
self.manifest.name,
|
||||
kind,
|
||||
", ".join(sorted(VALID_MIDDLEWARE)),
|
||||
)
|
||||
self._manager._middleware.setdefault(kind, []).append(callback)
|
||||
logger.debug("Plugin %s registered middleware: %s", self.manifest.name, kind)
|
||||
|
||||
# -- skill registration -------------------------------------------------
|
||||
|
||||
def register_skill(
|
||||
|
|
@ -773,6 +1032,7 @@ class PluginManager:
|
|||
def __init__(self) -> None:
|
||||
self._plugins: Dict[str, LoadedPlugin] = {}
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._middleware: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._plugin_platform_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
|
|
@ -782,6 +1042,9 @@ class PluginManager:
|
|||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
# Plugin skill registry: qualified name → metadata dict.
|
||||
self._plugin_skills: Dict[str, Dict[str, Any]] = {}
|
||||
# Plugin-registered auxiliary tasks: key → {key, display_name,
|
||||
# description, defaults, plugin}. See PluginContext.register_auxiliary_task.
|
||||
self._aux_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public
|
||||
|
|
@ -799,10 +1062,12 @@ class PluginManager:
|
|||
if force:
|
||||
self._plugins.clear()
|
||||
self._hooks.clear()
|
||||
self._middleware.clear()
|
||||
self._plugin_tool_names.clear()
|
||||
self._cli_commands.clear()
|
||||
self._plugin_commands.clear()
|
||||
self._plugin_skills.clear()
|
||||
self._aux_tasks.clear()
|
||||
self._context_engine = None
|
||||
self._discovered = True
|
||||
|
||||
|
|
@ -1208,15 +1473,28 @@ class PluginManager:
|
|||
for h in p.hooks_registered
|
||||
}
|
||||
)
|
||||
loaded.middleware_registered = list(
|
||||
{
|
||||
kind
|
||||
for kind, cbs in self._middleware.items()
|
||||
if cbs
|
||||
}
|
||||
- {
|
||||
kind
|
||||
for name, p in self._plugins.items()
|
||||
for kind in p.middleware_registered
|
||||
}
|
||||
)
|
||||
loaded.commands_registered = [
|
||||
c for c in self._plugin_commands
|
||||
if self._plugin_commands[c].get("plugin") == manifest.name
|
||||
]
|
||||
loaded.enabled = True
|
||||
logger.debug(
|
||||
" registered: %d tool(s), %d hook(s), %d slash command(s), %d CLI command(s)",
|
||||
" registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)",
|
||||
len(loaded.tools_registered),
|
||||
len(loaded.hooks_registered),
|
||||
len(loaded.middleware_registered),
|
||||
len(loaded.commands_registered),
|
||||
sum(
|
||||
1 for c in self._cli_commands
|
||||
|
|
@ -1313,6 +1591,7 @@ class PluginManager:
|
|||
are reused. All injected context is ephemeral — never
|
||||
persisted to session DB.
|
||||
"""
|
||||
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
|
||||
callbacks = self._hooks.get(hook_name, [])
|
||||
results: List[Any] = []
|
||||
for cb in callbacks:
|
||||
|
|
@ -1329,6 +1608,37 @@ class PluginManager:
|
|||
)
|
||||
return results
|
||||
|
||||
def has_hook(self, hook_name: str) -> bool:
|
||||
"""Return True when at least one callback is registered for a hook."""
|
||||
return bool(self._hooks.get(hook_name))
|
||||
|
||||
def has_middleware(self, kind: str) -> bool:
|
||||
"""Return True when at least one callback is registered for middleware."""
|
||||
return bool(self._middleware.get(kind))
|
||||
|
||||
def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]:
|
||||
"""Call registered middleware callbacks for *kind*.
|
||||
|
||||
Each callback is isolated so one plugin cannot break the base runtime
|
||||
path. Middleware that wants to change behavior must return the shape
|
||||
documented by the caller-specific contract.
|
||||
"""
|
||||
callbacks = self._middleware.get(kind, [])
|
||||
results: List[Any] = []
|
||||
for cb in callbacks:
|
||||
try:
|
||||
ret = cb(**kwargs)
|
||||
if ret is not None:
|
||||
results.append(ret)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Middleware '%s' callback %s raised: %s",
|
||||
kind,
|
||||
getattr(cb, "__name__", repr(cb)),
|
||||
exc,
|
||||
)
|
||||
return results
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Introspection
|
||||
# -----------------------------------------------------------------------
|
||||
|
|
@ -1348,6 +1658,7 @@ class PluginManager:
|
|||
"enabled": loaded.enabled,
|
||||
"tools": len(loaded.tools_registered),
|
||||
"hooks": len(loaded.hooks_registered),
|
||||
"middleware": len(loaded.middleware_registered),
|
||||
"commands": len(loaded.commands_registered),
|
||||
"error": loaded.error,
|
||||
}
|
||||
|
|
@ -1409,6 +1720,27 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
|||
return get_plugin_manager().invoke_hook(hook_name, **kwargs)
|
||||
|
||||
|
||||
def invoke_middleware(kind: str, **kwargs: Any) -> List[Any]:
|
||||
"""Invoke registered middleware callbacks.
|
||||
|
||||
Returns a list of non-``None`` return values from middleware callbacks.
|
||||
"""
|
||||
return get_plugin_manager().invoke_middleware(kind, **kwargs)
|
||||
|
||||
|
||||
def has_middleware(kind: str) -> bool:
|
||||
"""Return True when middleware callbacks are registered for ``kind``."""
|
||||
manager = get_plugin_manager()
|
||||
method = getattr(manager, "has_middleware", None)
|
||||
if callable(method):
|
||||
return bool(method(kind))
|
||||
return bool(getattr(manager, "_middleware", {}).get(kind))
|
||||
|
||||
|
||||
def has_hook(hook_name: str) -> bool:
|
||||
"""Return True when a hook has registered callbacks."""
|
||||
return get_plugin_manager().has_hook(hook_name)
|
||||
|
||||
|
||||
_thread_tool_whitelist = threading.local()
|
||||
|
||||
|
|
@ -1431,6 +1763,9 @@ def get_pre_tool_call_block_message(
|
|||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
tool_call_id: str = "",
|
||||
turn_id: str = "",
|
||||
api_request_id: str = "",
|
||||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Check ``pre_tool_call`` hooks for a blocking directive.
|
||||
|
||||
|
|
@ -1455,6 +1790,9 @@ def get_pre_tool_call_block_message(
|
|||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
middleware_trace=list(middleware_trace or []),
|
||||
)
|
||||
|
||||
for result in hook_results:
|
||||
|
|
@ -1548,6 +1886,21 @@ def get_plugin_commands() -> Dict[str, dict]:
|
|||
return _ensure_plugins_discovered()._plugin_commands
|
||||
|
||||
|
||||
def get_plugin_auxiliary_tasks() -> List[Dict[str, Any]]:
|
||||
"""Return all plugin-registered auxiliary tasks as a stable-ordered list.
|
||||
|
||||
Each entry is the registration dict from
|
||||
:meth:`PluginContext.register_auxiliary_task`:
|
||||
``{key, display_name, description, defaults, plugin}``.
|
||||
|
||||
Triggers idempotent plugin discovery so callers can read the registry
|
||||
before any explicit ``discover_plugins()`` call. Sorted by ``key`` for
|
||||
deterministic ordering in pickers and tests.
|
||||
"""
|
||||
manager = _ensure_plugins_discovered()
|
||||
return [manager._aux_tasks[k] for k in sorted(manager._aux_tasks)]
|
||||
|
||||
|
||||
def get_plugin_toolsets() -> List[tuple]:
|
||||
"""Return plugin toolsets as ``(key, label, description)`` tuples.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ rendered with Rich Markdown. Otherwise a default confirmation is shown.
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
|
@ -20,6 +21,7 @@ from typing import Any, Optional
|
|||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -76,22 +78,42 @@ def _plugins_dir() -> Path:
|
|||
return plugins
|
||||
|
||||
|
||||
def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
|
||||
def _sanitize_plugin_name(
|
||||
name: str,
|
||||
plugins_dir: Path,
|
||||
*,
|
||||
allow_subdir: bool = False,
|
||||
) -> Path:
|
||||
"""Validate a plugin name and return the safe target path inside *plugins_dir*.
|
||||
|
||||
Raises ``ValueError`` if the name contains path-traversal sequences or would
|
||||
resolve outside the plugins directory.
|
||||
|
||||
``allow_subdir=True`` permits a single forward slash inside *name* so
|
||||
category-namespaced plugin keys like ``observability/langfuse`` or
|
||||
``image_gen/openai`` (the registry keys emitted by ``_discover_all_plugins``)
|
||||
can be looked up. ``..`` and backslash are still rejected, leading and
|
||||
trailing slashes are stripped, and the resolved target must still live
|
||||
inside *plugins_dir*. Install paths leave this at the default ``False``
|
||||
because a freshly-cloned plugin always lands top-level under
|
||||
``~/.hermes/plugins/<name>/``.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("Plugin name must not be empty.")
|
||||
|
||||
if allow_subdir:
|
||||
name = name.strip("/")
|
||||
if not name:
|
||||
raise ValueError("Plugin name must not be empty.")
|
||||
|
||||
if name in {".", ".."}:
|
||||
raise ValueError(
|
||||
f"Invalid plugin name '{name}': must not reference the plugins directory itself."
|
||||
)
|
||||
|
||||
# Reject obvious traversal characters
|
||||
for bad in ("/", "\\", ".."):
|
||||
bad_chars = ("\\", "..") if allow_subdir else ("/", "\\", "..")
|
||||
for bad in bad_chars:
|
||||
if bad in name:
|
||||
raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.")
|
||||
|
||||
|
|
@ -113,34 +135,89 @@ def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
|
|||
return target
|
||||
|
||||
|
||||
def _resolve_git_url(identifier: str) -> str:
|
||||
"""Turn an identifier into a cloneable Git URL.
|
||||
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
|
||||
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
|
||||
|
||||
Returns ``(git_url, subdir)`` where ``subdir`` is the path within the
|
||||
cloned repository that contains the plugin (``None`` when the plugin lives
|
||||
at the repo root).
|
||||
|
||||
Accepted formats:
|
||||
- Full URL: https://github.com/owner/repo.git
|
||||
- Full URL: git@github.com:owner/repo.git
|
||||
- Full URL: ssh://git@github.com/owner/repo.git
|
||||
- Shorthand: owner/repo → https://github.com/owner/repo.git
|
||||
- Shorthand w/ subdir: owner/repo/path/to/plugin
|
||||
→ (https://github.com/owner/repo.git, "path/to/plugin")
|
||||
- Full URL w/ subdir (``.git`` boundary):
|
||||
https://github.com/owner/repo.git/path/to/plugin
|
||||
→ (https://github.com/owner/repo.git, "path/to/plugin")
|
||||
- Any URL w/ explicit subdir fragment (works for every scheme, incl.
|
||||
``file://`` and ssh): <url>#path/to/plugin
|
||||
→ (<url>, "path/to/plugin")
|
||||
|
||||
NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a
|
||||
security warning at install time.
|
||||
"""
|
||||
# Already a URL
|
||||
# Already a URL.
|
||||
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
|
||||
return identifier
|
||||
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
|
||||
if "#" in identifier:
|
||||
git_url, _, frag = identifier.partition("#")
|
||||
return git_url, (frag.strip("/") or None)
|
||||
# Natural ``.git/`` boundary (GitHub-style URLs).
|
||||
marker = ".git/"
|
||||
idx = identifier.find(marker)
|
||||
if idx != -1:
|
||||
git_url = identifier[: idx + len(".git")]
|
||||
subdir = identifier[idx + len(marker) :].strip("/")
|
||||
return git_url, (subdir or None)
|
||||
return identifier, None
|
||||
|
||||
# owner/repo shorthand
|
||||
parts = identifier.strip("/").split("/")
|
||||
if len(parts) == 2:
|
||||
owner, repo = parts
|
||||
return f"https://github.com/{owner}/{repo}.git"
|
||||
# owner/repo[/subdir...] shorthand
|
||||
parts = [p for p in identifier.strip("/").split("/") if p]
|
||||
if len(parts) >= 2:
|
||||
owner, repo = parts[0], parts[1]
|
||||
subdir = "/".join(parts[2:]).strip("/")
|
||||
git_url = f"https://github.com/{owner}/{repo}.git"
|
||||
return git_url, (subdir or None)
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid plugin identifier: '{identifier}'. "
|
||||
"Use a Git URL or owner/repo shorthand."
|
||||
"Use a Git URL or 'owner/repo' shorthand (optionally with a subdirectory: "
|
||||
"'owner/repo/path/to/plugin')."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path:
|
||||
"""Resolve ``subdir`` inside ``clone_root``, rejecting path traversal.
|
||||
|
||||
Guards against ``..`` segments, absolute paths, and symlinks that would
|
||||
escape the cloned repository. Returns the resolved directory path.
|
||||
Raises ``PluginOperationError`` if the path escapes the clone, doesn't
|
||||
exist, or is not a directory.
|
||||
"""
|
||||
clone_root = clone_root.resolve()
|
||||
candidate = (clone_root / subdir).resolve()
|
||||
|
||||
# The resolved candidate must stay within the clone root.
|
||||
if candidate != clone_root and clone_root not in candidate.parents:
|
||||
raise PluginOperationError(
|
||||
f"Plugin subdirectory '{subdir}' escapes the repository.",
|
||||
)
|
||||
|
||||
if not candidate.exists():
|
||||
raise PluginOperationError(
|
||||
f"Plugin subdirectory '{subdir}' does not exist in the repository.",
|
||||
)
|
||||
if not candidate.is_dir():
|
||||
raise PluginOperationError(
|
||||
f"Plugin subdirectory '{subdir}' is not a directory.",
|
||||
)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def _repo_name_from_url(url: str) -> str:
|
||||
"""Extract the repo name from a Git URL for the plugin directory name."""
|
||||
# Strip trailing .git and slashes
|
||||
|
|
@ -267,8 +344,7 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None:
|
|||
|
||||
try:
|
||||
if secret:
|
||||
import getpass
|
||||
value = getpass.getpass(f" {name}: ").strip()
|
||||
value = masked_secret_prompt(f" {name}: ").strip()
|
||||
else:
|
||||
value = input(f" {name}: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
|
|
@ -326,7 +402,7 @@ def _display_removed(name: str, plugins_dir: Path) -> None:
|
|||
|
||||
def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
|
||||
"""Return the plugin path if it exists, or exit with an error listing installed plugins."""
|
||||
target = _sanitize_plugin_name(name, plugins_dir)
|
||||
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
|
||||
if not target.exists():
|
||||
installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)"
|
||||
console.print(
|
||||
|
|
@ -351,14 +427,14 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
|
|||
import tempfile
|
||||
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
git_url, subdir = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
raise PluginOperationError(str(e)) from e
|
||||
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_target = Path(tmp) / "plugin"
|
||||
tmp_clone = Path(tmp) / "plugin"
|
||||
|
||||
git_exe = _resolve_git_executable()
|
||||
if not git_exe:
|
||||
|
|
@ -366,7 +442,7 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
|
|||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[git_exe, "clone", "--depth", "1", git_url, str(tmp_target)],
|
||||
[git_exe, "clone", "--depth", "1", git_url, str(tmp_clone)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
|
|
@ -384,8 +460,16 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
|
|||
err = (result.stderr or result.stdout or "").strip()
|
||||
raise PluginOperationError(f"Git clone failed:\n{err}")
|
||||
|
||||
# Resolve the directory within the clone that holds the plugin.
|
||||
if subdir:
|
||||
tmp_target = _resolve_subdir_within(tmp_clone, subdir)
|
||||
else:
|
||||
tmp_target = tmp_clone
|
||||
|
||||
manifest = _read_manifest(tmp_target)
|
||||
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
|
||||
plugin_name = manifest.get("name") or (
|
||||
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)
|
||||
)
|
||||
|
||||
try:
|
||||
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
||||
|
|
@ -450,7 +534,7 @@ def cmd_install(
|
|||
console = Console()
|
||||
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
git_url, _subdir = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
|
@ -461,7 +545,10 @@ def cmd_install(
|
|||
"Consider using https:// or git@ for production installs.",
|
||||
)
|
||||
|
||||
console.print(f"[dim]Cloning {git_url}...[/dim]")
|
||||
if _subdir:
|
||||
console.print(f"[dim]Cloning {git_url} (subdir: {_subdir})...[/dim]")
|
||||
else:
|
||||
console.print(f"[dim]Cloning {git_url}...[/dim]")
|
||||
|
||||
try:
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
|
|
@ -628,29 +715,62 @@ def _save_enabled_set(enabled: set) -> None:
|
|||
save_config(config)
|
||||
|
||||
|
||||
def _resolve_plugin_key(name: str) -> Optional[str]:
|
||||
"""Resolve a user-supplied plugin identifier to its canonical registry key.
|
||||
|
||||
Accepts either the bare manifest name (``nemo_relay``), the directory
|
||||
name, or the full path-derived key (``observability/nemo_relay``) and
|
||||
returns the canonical key the loader gates on (``manifest.key`` or, for a
|
||||
flat plugin, the bare name). Returns ``None`` when no plugin matches.
|
||||
|
||||
This is the single normalization point so ``hermes plugins enable`` /
|
||||
``disable`` write the same key that ``PluginManager`` matches against —
|
||||
nested category plugins (e.g. ``observability/nemo_relay``) included.
|
||||
"""
|
||||
entries = _discover_all_plugins()
|
||||
# 1. Exact match on canonical key or manifest name — always unambiguous.
|
||||
for entry in entries:
|
||||
# entry = (name, version, description, source, dir_path, key)
|
||||
if name == entry[5] or name == entry[0]:
|
||||
return entry[5]
|
||||
# 2. Fall back to a bare leaf-name match (e.g. "nemo_relay" ->
|
||||
# "observability/nemo_relay"), but only when it resolves to exactly one
|
||||
# plugin so we never silently pick the wrong same-named nested plugin.
|
||||
leaf_matches = [entry[5] for entry in entries if name == entry[5].split("/")[-1]]
|
||||
if len(leaf_matches) == 1:
|
||||
return leaf_matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def cmd_enable(name: str) -> None:
|
||||
"""Add a plugin to the enabled allow-list (and remove it from disabled)."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
# Discover the plugin — check installed (user) AND bundled.
|
||||
if not _plugin_exists(name):
|
||||
# Discover the plugin — check installed (user) AND bundled, including
|
||||
# nested category plugins — and normalize to its canonical registry key.
|
||||
key = _resolve_plugin_key(name)
|
||||
if key is None:
|
||||
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
if name in enabled and name not in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
|
||||
if key in enabled and key not in disabled:
|
||||
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
|
||||
return
|
||||
|
||||
enabled.add(name)
|
||||
disabled.discard(name)
|
||||
enabled.add(key)
|
||||
disabled.discard(key)
|
||||
# Drop any legacy bare-name entry so the two don't drift out of sync.
|
||||
bare = key.split("/")[-1]
|
||||
if bare != key:
|
||||
disabled.discard(bare)
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. "
|
||||
f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. "
|
||||
"Takes effect on next session."
|
||||
)
|
||||
|
||||
|
|
@ -660,137 +780,147 @@ def cmd_disable(name: str) -> None:
|
|||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
if not _plugin_exists(name):
|
||||
key = _resolve_plugin_key(name)
|
||||
if key is None:
|
||||
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
if name not in enabled and name in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
|
||||
if key not in enabled and key in disabled:
|
||||
console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
|
||||
return
|
||||
|
||||
enabled.discard(name)
|
||||
disabled.add(name)
|
||||
enabled.discard(key)
|
||||
# Drop any legacy bare-name entry from the allow-list too, so a stale
|
||||
# bare name can't keep a nested plugin loading after an explicit disable.
|
||||
bare = key.split("/")[-1]
|
||||
if bare != key:
|
||||
enabled.discard(bare)
|
||||
disabled.add(key)
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(
|
||||
f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. "
|
||||
f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. "
|
||||
"Takes effect on next session."
|
||||
)
|
||||
|
||||
|
||||
def _plugin_exists(name: str) -> bool:
|
||||
"""Return True if a plugin with *name* is installed (user) or bundled."""
|
||||
# Installed: directory name or manifest name match in user plugins dir
|
||||
user_dir = _plugins_dir()
|
||||
if user_dir.is_dir():
|
||||
if (user_dir / name).is_dir():
|
||||
return True
|
||||
for child in user_dir.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
manifest = _read_manifest(child)
|
||||
if manifest.get("name") == name:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
(candidate / "plugin.yaml").exists()
|
||||
or (candidate / "plugin.yml").exists()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
"""Return True if a plugin with *name* (bare name or key) exists."""
|
||||
return _resolve_plugin_key(name) is not None
|
||||
|
||||
|
||||
def _discover_all_plugins() -> list:
|
||||
"""Return a list of (key, version, description, source, dir_path) for
|
||||
every plugin the loader can see — user + bundled.
|
||||
def _read_manifest_info(d: Path, prefix: str):
|
||||
"""Read a plugin.yaml manifest and return (name, version, description, key).
|
||||
|
||||
Mirrors :meth:`PluginManager._scan_directory_level` so category-namespaced
|
||||
plugins (``observability/langfuse``, ``image_gen/openai``) surface here
|
||||
just like flat ones (``disk-cleanup``). A subdirectory with no
|
||||
``plugin.yaml`` of its own is treated as a category and recursed into
|
||||
one level deeper (depth capped at 2, same as the loader).
|
||||
|
||||
The returned ``key`` is the path-derived registry key — the value the
|
||||
user types into ``hermes plugins enable <key>``. For category-namespaced
|
||||
plugins that's ``<category>/<dirname>``; for flat plugins it's the
|
||||
manifest's ``name`` (or the directory name if the manifest omits it).
|
||||
|
||||
User entries override bundled on key collision, matching
|
||||
``PluginManager.discover_and_load``.
|
||||
Returns None if no manifest file exists.
|
||||
"""
|
||||
manifest_file = d / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
manifest_file = d / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
name = d.name
|
||||
version = ""
|
||||
description = ""
|
||||
if yaml:
|
||||
try:
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
name = manifest.get("name", d.name)
|
||||
version = manifest.get("version", "")
|
||||
description = manifest.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
key = f"{prefix}/{d.name}" if prefix else name
|
||||
return name, version, description, key
|
||||
|
||||
seen: dict = {} # key -> (key, version, description, source, path)
|
||||
|
||||
def _scan(base: Path, source: str, prefix: str, depth: int) -> None:
|
||||
if not base.is_dir():
|
||||
return
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
def _scan_level(
|
||||
base: Path,
|
||||
source: str,
|
||||
skip_names: set,
|
||||
prefix: str,
|
||||
depth: int,
|
||||
seen: dict,
|
||||
) -> None:
|
||||
"""Recursive directory scan matching PluginManager._scan_directory_level.
|
||||
|
||||
Populates *seen* with key -> (name, version, description, source, dir, key).
|
||||
"""
|
||||
if not base.is_dir():
|
||||
return
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
if depth == 0 and skip_names and d.name in skip_names:
|
||||
continue
|
||||
info = _read_manifest_info(d, prefix)
|
||||
if info is not None:
|
||||
name, version, description, key = info
|
||||
if key in seen and source == "bundled":
|
||||
continue
|
||||
if (
|
||||
depth == 0
|
||||
and source == "bundled"
|
||||
and d.name in {"memory", "context_engine"}
|
||||
):
|
||||
continue
|
||||
manifest_file = d / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
manifest_file = d / "plugin.yml"
|
||||
src_label = source
|
||||
if source == "user" and (d / ".git").exists():
|
||||
src_label = "git"
|
||||
seen[key] = (name, version, description, src_label, d, key)
|
||||
continue
|
||||
if depth >= 1:
|
||||
continue
|
||||
sub_prefix = f"{prefix}/{d.name}" if prefix else d.name
|
||||
_scan_level(d, source, set(), sub_prefix, depth + 1, seen)
|
||||
|
||||
if manifest_file.exists():
|
||||
manifest_name = d.name
|
||||
version = ""
|
||||
description = ""
|
||||
if yaml:
|
||||
try:
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
manifest_name = manifest.get("name", d.name)
|
||||
version = manifest.get("version", "")
|
||||
description = manifest.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
# Path-derived key, intentionally ignoring the manifest
|
||||
# ``name:`` field for category-namespaced plugins — mirrors
|
||||
# ``PluginManager._parse_manifest`` in plugins.py:1027-1028
|
||||
# so renaming a directory (without touching plugin.yaml) shifts
|
||||
# the registry key in both places consistently.
|
||||
key = f"{prefix}/{d.name}" if prefix else manifest_name
|
||||
src_label = source
|
||||
if source == "user" and (d / ".git").exists():
|
||||
src_label = "git"
|
||||
# Bundled is scanned before user, so the user pass overwrites
|
||||
# bundled entries with the same key — matches
|
||||
# PluginManager.discover_and_load's "user wins" semantics.
|
||||
seen[key] = (key, version, description, src_label, d)
|
||||
continue
|
||||
|
||||
# No manifest at this level — treat as a category namespace and
|
||||
# recurse one level deeper. Cap at depth 2 (same as the loader).
|
||||
if depth >= 1:
|
||||
continue
|
||||
sub_prefix = f"{prefix}/{d.name}" if prefix else d.name
|
||||
_scan(d, source, sub_prefix, depth + 1)
|
||||
def _discover_all_plugins() -> list:
|
||||
"""Return a list of (name, version, description, source, dir_path, key) for
|
||||
every plugin the loader can see — user + bundled + project.
|
||||
|
||||
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
|
||||
bundled first, then user, then project; user overrides bundled on
|
||||
key collision.
|
||||
"""
|
||||
seen: dict = {} # key -> (name, version, description, source, path, key)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
_scan(get_bundled_plugins_dir(), "bundled", "", 0)
|
||||
_scan(_plugins_dir(), "user", "", 0)
|
||||
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source, skip in (
|
||||
(repo_plugins, "bundled", {"memory", "context_engine"}),
|
||||
(_plugins_dir(), "user", set()),
|
||||
):
|
||||
_scan_level(base, source, skip, "", 0, seen)
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def cmd_list() -> None:
|
||||
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str:
|
||||
"""Return the user-facing activation state for a plugin name or key."""
|
||||
if name in disabled or key in disabled:
|
||||
return "disabled"
|
||||
if name in enabled or key in enabled:
|
||||
return "enabled"
|
||||
return "not enabled"
|
||||
|
||||
|
||||
def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set) -> list:
|
||||
"""Apply ``hermes plugins list`` CLI filters."""
|
||||
filtered = entries
|
||||
if getattr(args, "no_bundled", False) or getattr(args, "user", False):
|
||||
filtered = [entry for entry in filtered if entry[3] != "bundled"]
|
||||
if getattr(args, "enabled", False):
|
||||
filtered = [
|
||||
entry for entry in filtered
|
||||
if _plugin_status(entry[0], enabled, disabled, key=entry[5]) == "enabled"
|
||||
]
|
||||
return filtered
|
||||
|
||||
|
||||
def cmd_list(args: Any | None = None) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
|
@ -804,6 +934,31 @@ def cmd_list() -> None:
|
|||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
entries = _filter_plugin_entries(entries, args, enabled, disabled)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
payload = [
|
||||
{
|
||||
"name": name,
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
"version": str(version),
|
||||
"description": description,
|
||||
"source": source,
|
||||
}
|
||||
for name, version, description, source, _dir, key in entries
|
||||
]
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
if getattr(args, "plain", False):
|
||||
for name, version, _description, source, _dir, key in entries:
|
||||
status = _plugin_status(name, enabled, disabled, key=key)
|
||||
print(f"{status:12} {source:8} {str(version):8} {name}")
|
||||
return
|
||||
|
||||
if not entries:
|
||||
console.print("[dim]No plugins matched the selected filters.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title="Plugins", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
|
|
@ -812,10 +967,11 @@ def cmd_list() -> None:
|
|||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
|
||||
for name, version, description, source, _dir in entries:
|
||||
if name in disabled:
|
||||
for name, version, description, source, _dir, key in entries:
|
||||
status_name = _plugin_status(name, enabled, disabled, key=key)
|
||||
if status_name == "disabled":
|
||||
status = "[red]disabled[/red]"
|
||||
elif name in enabled:
|
||||
elif status_name == "enabled":
|
||||
status = "[green]enabled[/green]"
|
||||
else:
|
||||
status = "[yellow]not enabled[/yellow]"
|
||||
|
|
@ -824,6 +980,7 @@ def cmd_list() -> None:
|
|||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
|
||||
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
||||
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
|
||||
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
|
||||
|
|
@ -844,12 +1001,35 @@ def _discover_memory_providers() -> list[tuple[str, str]]:
|
|||
|
||||
|
||||
def _discover_context_engines() -> list[tuple[str, str]]:
|
||||
"""Return [(name, description), ...] for available context engines."""
|
||||
"""Return [(name, description), ...] for available context engines.
|
||||
|
||||
Includes repo-shipped engines from ``plugins/context_engine/`` AND
|
||||
plugin-registered engines (third-party engines installed as Hermes
|
||||
plugins via ``ctx.register_context_engine``). Repo-shipped descriptions
|
||||
win when a plugin-registered engine collides on name.
|
||||
"""
|
||||
engines: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
try:
|
||||
from plugins.context_engine import discover_context_engines
|
||||
return [(name, desc) for name, desc, _avail in discover_context_engines()]
|
||||
for name, desc, _avail in discover_context_engines():
|
||||
if name not in seen:
|
||||
engines.append((name, desc))
|
||||
seen.add(name)
|
||||
except Exception:
|
||||
return []
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_context_engine
|
||||
discover_plugins()
|
||||
plugin_engine = get_plugin_context_engine()
|
||||
if plugin_engine and getattr(plugin_engine, "name", None) and plugin_engine.name not in seen:
|
||||
engines.append((plugin_engine.name, "installed plugin"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return engines
|
||||
|
||||
|
||||
def _get_current_memory_provider() -> str:
|
||||
|
|
@ -988,14 +1168,14 @@ def cmd_toggle() -> None:
|
|||
plugin_labels = []
|
||||
plugin_selected = set()
|
||||
|
||||
for i, (name, _version, description, source, _d) in enumerate(entries):
|
||||
for i, (name, _version, description, source, _d, key) in enumerate(entries):
|
||||
label = f"{name} \u2014 {description}" if description else name
|
||||
if source == "bundled":
|
||||
label = f"{label} [bundled]"
|
||||
plugin_names.append(name)
|
||||
plugin_labels.append(label)
|
||||
# Selected (enabled) when in enabled-set AND not in disabled-set
|
||||
if name in enabled_set and name not in disabled_set:
|
||||
if (name in enabled_set or key in enabled_set) and name not in disabled_set and key not in disabled_set:
|
||||
plugin_selected.add(i)
|
||||
|
||||
# -- Provider categories --
|
||||
|
|
@ -1051,7 +1231,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
|||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(4, 8, -1) # dim gray
|
||||
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
|
||||
cursor = 0
|
||||
scroll_offset = 0
|
||||
|
||||
|
|
@ -1067,7 +1247,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
|||
stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" \u2191\u2193 navigate SPACE toggle ENTER configure/confirm ESC done",
|
||||
" ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
|
|
@ -1107,7 +1287,9 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
|||
pass
|
||||
y += 1
|
||||
|
||||
for i in range(n_plugins):
|
||||
plugin_start = scroll_offset
|
||||
plugin_stop = min(n_plugins, scroll_offset + max(visible_rows, 0))
|
||||
for i in range(plugin_start, plugin_stop):
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
check = "\u2713" if i in chosen else " "
|
||||
|
|
@ -1165,6 +1347,16 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
|||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
if total_items > 0:
|
||||
cursor = (cursor + 1) % total_items
|
||||
elif key in {curses.KEY_NPAGE, ord("f")}:
|
||||
if total_items > 0:
|
||||
cursor = min(total_items - 1, cursor + max(1, max_y - 5))
|
||||
elif key in {curses.KEY_PPAGE, ord("b")}:
|
||||
if total_items > 0:
|
||||
cursor = max(0, cursor - max(1, max_y - 5))
|
||||
elif key == curses.KEY_HOME:
|
||||
cursor = 0
|
||||
elif key == curses.KEY_END:
|
||||
cursor = max(0, total_items - 1)
|
||||
elif key == ord(" "):
|
||||
if cursor < n_plugins:
|
||||
# Toggle general plugin
|
||||
|
|
@ -1196,7 +1388,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
|||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(4, 8, -1)
|
||||
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
|
||||
curses.curs_set(0)
|
||||
elif key in {curses.KEY_ENTER, 10, 13}:
|
||||
if cursor < n_plugins:
|
||||
|
|
@ -1228,7 +1420,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
|||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(4, 8, -1)
|
||||
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
|
||||
curses.curs_set(0)
|
||||
elif key in {27, ord("q")}:
|
||||
# Save plugin changes on exit
|
||||
|
|
@ -1347,7 +1539,7 @@ def dashboard_install_plugin(
|
|||
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
git_url, _subdir = _resolve_git_url(identifier)
|
||||
if git_url.startswith(("http://", "file://")):
|
||||
warnings.append(
|
||||
"Insecure URL scheme; prefer https:// or git@ for production installs.",
|
||||
|
|
@ -1508,7 +1700,7 @@ def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
|||
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
|
||||
plugins_dir = _plugins_dir()
|
||||
try:
|
||||
target = _sanitize_plugin_name(name, plugins_dir)
|
||||
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return target if target.is_dir() else None
|
||||
|
|
@ -1566,7 +1758,7 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
|
|||
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
|
||||
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
|
||||
plugins_dir = _plugins_dir()
|
||||
for n, _ver, _d, src, _path in _discover_all_plugins():
|
||||
for n, _ver, _d, src, _path, _key in _discover_all_plugins():
|
||||
if n == name and src == "bundled":
|
||||
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
|
||||
|
||||
|
|
@ -1606,7 +1798,7 @@ def plugins_command(args) -> None:
|
|||
elif action == "disable":
|
||||
cmd_disable(args.name)
|
||||
elif action in {"list", "ls"}:
|
||||
cmd_list()
|
||||
cmd_list(args)
|
||||
elif action is None:
|
||||
cmd_toggle()
|
||||
else:
|
||||
|
|
|
|||
245
hermes_cli/portal_cli.py
Normal file
245
hermes_cli/portal_cli.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""``hermes portal`` — the human-readable entry point for Nous Portal.
|
||||
|
||||
Running ``hermes portal`` with no subcommand performs the one-shot Portal
|
||||
onboarding: OAuth login, pick a Nous model, switch the inference provider to
|
||||
Nous, and offer to enable the Tool Gateway. It is the friendly alias for
|
||||
``hermes auth add nous --type oauth`` (which still works), is identical to
|
||||
``hermes setup --portal``, and runs the same Nous flow as the first-time quick
|
||||
setup.
|
||||
|
||||
Subcommands:
|
||||
(none) Log in to Nous Portal + set it up (one-shot onboarding).
|
||||
login Explicit alias for the default one-shot onboarding.
|
||||
info Show Portal auth state + which Tool Gateway tools are routed.
|
||||
open Open the Portal subscription page in the user's default browser.
|
||||
tools List Tool Gateway tools and which are active in the current config.
|
||||
|
||||
This command is intentionally minimal — it does not duplicate functionality
|
||||
already in ``hermes auth`` or ``hermes tools``. It's the onboarding + discovery
|
||||
surface for the Portal subscription itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription"
|
||||
DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway"
|
||||
|
||||
|
||||
def _cmd_status(args) -> int:
|
||||
"""Show Portal auth + Tool Gateway routing summary."""
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
config = load_config() or {}
|
||||
|
||||
try:
|
||||
auth = get_nous_auth_status() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
logged_in = bool(auth.get("logged_in"))
|
||||
|
||||
print()
|
||||
print(color(" Nous Portal", Colors.MAGENTA))
|
||||
print(color(" ───────────", Colors.MAGENTA))
|
||||
if logged_in:
|
||||
portal = auth.get("portal_base_url") or DEFAULT_PORTAL_URL
|
||||
print(f" Auth: {color('✓ logged in', Colors.GREEN)}")
|
||||
print(f" Portal: {portal}")
|
||||
inference = auth.get("inference_base_url")
|
||||
if inference:
|
||||
print(f" API: {inference}")
|
||||
else:
|
||||
print(f" Auth: {color('not logged in', Colors.YELLOW)}")
|
||||
print(f" Sign up: {SUBSCRIPTION_URL}")
|
||||
print(f" Login: hermes portal")
|
||||
|
||||
# Provider selection (independent of auth)
|
||||
model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {}
|
||||
provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if provider == "nous":
|
||||
print(f" Model: {color('✓ using Nous as inference provider', Colors.GREEN)}")
|
||||
elif provider:
|
||||
print(f" Model: currently {provider} (switch with `hermes model`)")
|
||||
|
||||
# Tool Gateway routing
|
||||
print()
|
||||
print(color(" Tool Gateway", Colors.MAGENTA))
|
||||
print(color(" ────────────", Colors.MAGENTA))
|
||||
try:
|
||||
features = get_nous_subscription_features(config)
|
||||
except Exception:
|
||||
features = None
|
||||
|
||||
if features is None:
|
||||
print(" (could not resolve subscription state)")
|
||||
return 0
|
||||
|
||||
rows = []
|
||||
for feat in features.items():
|
||||
if feat.managed_by_nous:
|
||||
state = color("via Nous Portal", Colors.GREEN)
|
||||
elif feat.active and feat.current_provider:
|
||||
state = feat.current_provider
|
||||
elif feat.active:
|
||||
state = "active"
|
||||
else:
|
||||
state = color("not configured", Colors.DIM)
|
||||
rows.append((feat.label, state))
|
||||
|
||||
width = max((len(r[0]) for r in rows), default=0)
|
||||
for label, state in rows:
|
||||
print(f" {label:<{width}} {state}")
|
||||
|
||||
if not logged_in:
|
||||
print()
|
||||
print(color(f" Docs: {DOCS_URL}", Colors.DIM))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_open(args) -> int:
|
||||
"""Open the Portal subscription page in the default browser."""
|
||||
target = SUBSCRIPTION_URL
|
||||
print(f"Opening {target}")
|
||||
try:
|
||||
opened = webbrowser.open(target)
|
||||
except Exception:
|
||||
opened = False
|
||||
if not opened:
|
||||
print()
|
||||
print("Could not launch a browser. Visit the URL above manually.")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_tools(args) -> int:
|
||||
"""List the Tool Gateway catalog + current routing."""
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
config = load_config() or {}
|
||||
try:
|
||||
features = get_nous_subscription_features(config)
|
||||
except Exception:
|
||||
print("Could not resolve Tool Gateway state.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Static catalog — the partners Tool Gateway routes to today.
|
||||
catalog = [
|
||||
("web", "Web search & extract", "Firecrawl"),
|
||||
("image_gen", "Image generation", "FAL"),
|
||||
("tts", "Text-to-speech", "OpenAI TTS"),
|
||||
("browser", "Browser automation", "Browser Use"),
|
||||
("modal", "Cloud terminal", "Modal"),
|
||||
]
|
||||
|
||||
print()
|
||||
print(color(" Tool Gateway catalog", Colors.MAGENTA))
|
||||
print(color(" ────────────────────", Colors.MAGENTA))
|
||||
|
||||
if not features.nous_auth_present:
|
||||
print(color(" Not logged into Nous Portal — sign in with `hermes portal`.", Colors.YELLOW))
|
||||
print()
|
||||
|
||||
label_width = max(len(label) for _, label, _ in catalog)
|
||||
for key, label, partner in catalog:
|
||||
feat = features.features.get(key)
|
||||
if feat is None:
|
||||
state = color("unknown", Colors.DIM)
|
||||
elif feat.managed_by_nous:
|
||||
state = color("✓ via Nous Portal", Colors.GREEN)
|
||||
elif feat.active and feat.current_provider:
|
||||
state = feat.current_provider
|
||||
elif feat.active:
|
||||
state = "active"
|
||||
else:
|
||||
state = color("not configured", Colors.DIM)
|
||||
print(f" {label:<{label_width}} partner: {partner:<14} {state}")
|
||||
|
||||
print()
|
||||
print(color(f" Manage your subscription: {SUBSCRIPTION_URL}", Colors.DIM))
|
||||
print(color(f" Docs: {DOCS_URL}", Colors.DIM))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_login(args) -> int:
|
||||
"""Run the one-shot Nous Portal onboarding (login + model + provider + tools).
|
||||
|
||||
This is the human-readable front door for `hermes auth add nous --type
|
||||
oauth`. It reuses the exact wiring behind `hermes setup --portal` (which in
|
||||
turn runs the same Nous flow as the first-time quick setup), so the
|
||||
commands stay in lockstep: device-code login, pick a Nous model, switch the
|
||||
inference provider to Nous, then offer the Tool Gateway opt-in.
|
||||
"""
|
||||
from hermes_cli.setup import _run_portal_one_shot
|
||||
|
||||
config = load_config() or {}
|
||||
try:
|
||||
_run_portal_one_shot(config)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print("Portal setup cancelled.")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def portal_command(args) -> int:
|
||||
"""Top-level dispatch for `hermes portal <subcommand>`."""
|
||||
sub = getattr(args, "portal_command", None)
|
||||
if sub in {None, "", "login"}:
|
||||
# Default to the one-shot onboarding — `hermes portal` is the
|
||||
# human-readable alias for `hermes auth add nous --type oauth` /
|
||||
# `hermes setup --portal`.
|
||||
return _cmd_login(args)
|
||||
if sub in {"info", "status"}:
|
||||
# `status` kept as a back-compat alias for the prior default.
|
||||
return _cmd_status(args)
|
||||
if sub == "open":
|
||||
return _cmd_open(args)
|
||||
if sub == "tools":
|
||||
return _cmd_tools(args)
|
||||
print(f"Unknown portal subcommand: {sub}", file=sys.stderr)
|
||||
print("Run `hermes portal -h` for usage.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def add_parser(subparsers) -> None:
|
||||
"""Register `hermes portal` on the given argparse subparsers object."""
|
||||
portal_parser = subparsers.add_parser(
|
||||
"portal",
|
||||
help="Set up Nous Portal (login, model pick, Tool Gateway); see also `portal info`",
|
||||
description=(
|
||||
"Run `hermes portal` with no subcommand to log in to Nous Portal "
|
||||
"and set it up — pick a model, set Nous as your provider, and offer "
|
||||
"the Tool Gateway (the human-readable alias for `hermes auth add "
|
||||
"nous --type oauth`, identical to `hermes setup --portal`). "
|
||||
"Subcommands: login (default), info, open, tools."
|
||||
),
|
||||
)
|
||||
portal_sub = portal_parser.add_subparsers(dest="portal_command")
|
||||
|
||||
portal_sub.add_parser(
|
||||
"login",
|
||||
help="Log in to Nous Portal + set it up (default; one-shot onboarding)",
|
||||
)
|
||||
portal_sub.add_parser(
|
||||
"info",
|
||||
help="Show Portal auth + Tool Gateway routing summary",
|
||||
)
|
||||
# `status` retained as a hidden back-compat alias for `info`.
|
||||
portal_sub.add_parser("status")
|
||||
portal_sub.add_parser(
|
||||
"open",
|
||||
help="Open the Portal subscription page in your default browser",
|
||||
)
|
||||
portal_sub.add_parser(
|
||||
"tools",
|
||||
help="List Tool Gateway tools and which are routed via Nous",
|
||||
)
|
||||
|
||||
portal_parser.set_defaults(func=portal_command)
|
||||
|
|
@ -28,7 +28,6 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
|
|||
|
|
@ -432,6 +432,20 @@ def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]:
|
|||
)
|
||||
|
||||
|
||||
def _reject_distribution_symlinks(staged: Path) -> None:
|
||||
"""Reject symlinks before reading or copying distribution files."""
|
||||
for entry in staged.rglob("*"):
|
||||
if not entry.is_symlink():
|
||||
continue
|
||||
try:
|
||||
rel = entry.relative_to(staged)
|
||||
except ValueError:
|
||||
rel = entry
|
||||
raise DistributionError(
|
||||
f"Profile distributions cannot contain symlinks: {rel}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -484,6 +498,7 @@ def plan_install(
|
|||
from hermes_cli import __version__ as hermes_version
|
||||
|
||||
staged, provenance = _stage_source(source, workdir)
|
||||
_reject_distribution_symlinks(staged)
|
||||
manifest = read_manifest(staged)
|
||||
if manifest is None:
|
||||
raise DistributionError(
|
||||
|
|
@ -558,10 +573,15 @@ def _copy_dist_payload(
|
|||
if entry.is_dir():
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
staged_resolved = staged.resolve()
|
||||
shutil.copytree(
|
||||
entry,
|
||||
dest,
|
||||
ignore=lambda d, names: [n for n in names if n in USER_OWNED_EXCLUDE],
|
||||
ignore=lambda d, names: (
|
||||
[n for n in names if n in USER_OWNED_EXCLUDE]
|
||||
if Path(d).resolve() == staged_resolved
|
||||
else []
|
||||
),
|
||||
)
|
||||
else:
|
||||
shutil.copy2(entry, dest)
|
||||
|
|
|
|||
|
|
@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:
|
|||
|
||||
# Check existing commands in PATH
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
is_windows = sys.platform == "win32"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", canon], capture_output=True, text=True, timeout=5,
|
||||
["where" if is_windows else "which", canon],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
existing_path = result.stdout.strip()
|
||||
existing_path = result.stdout.strip().splitlines()[0]
|
||||
# Allow overwriting our own wrappers
|
||||
if existing_path == str(wrapper_dir / canon):
|
||||
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
|
||||
if existing_path == str(expected):
|
||||
try:
|
||||
content = (wrapper_dir / canon).read_text()
|
||||
content = expected.read_text()
|
||||
if "hermes -p" in content:
|
||||
return None # it's our wrapper, safe to overwrite
|
||||
except Exception:
|
||||
|
|
@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:
|
|||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
|
||||
The wrapper file is named after ``name`` (the alias). The profile it
|
||||
activates is ``target`` if given, otherwise ``name`` — this lets a custom
|
||||
alias name point at a differently-named profile without a post-hoc rewrite.
|
||||
|
||||
On Windows, creates a ``.bat`` file instead of a POSIX shell script.
|
||||
Returns the path to the created wrapper, or None if creation failed.
|
||||
"""
|
||||
canon = normalize_profile_name(name)
|
||||
profile = normalize_profile_name(target) if target else canon
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
try:
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -369,31 +378,94 @@ def create_wrapper_script(name: str) -> Optional[Path]:
|
|||
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||
return None
|
||||
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
is_windows = sys.platform == "win32"
|
||||
if is_windows:
|
||||
wrapper_path = wrapper_dir / f"{canon}.bat"
|
||||
try:
|
||||
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
else:
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
canon = normalize_profile_name(name)
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
||||
"""Return the alias name of the wrapper that activates *profile_name*, or None.
|
||||
|
||||
A wrapper created by :func:`create_wrapper_script` is a file named after the
|
||||
alias whose body invokes ``hermes -p <profile>``. When the alias name equals
|
||||
the profile name this is trivial, but a custom alias (``hermes profile alias
|
||||
<profile> --name <custom>``) produces a differently-named file — so the
|
||||
display side cannot assume ``wrapper == profile`` and must reverse-look-up.
|
||||
|
||||
A custom alias (name != profile) is preferred over the profile-named wrapper
|
||||
so ``profile list``/``show`` surface the command the user actually typed.
|
||||
Results are sorted for deterministic output when several aliases match.
|
||||
"""
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
if not wrapper_dir.is_dir():
|
||||
return None
|
||||
canon = normalize_profile_name(profile_name)
|
||||
is_windows = sys.platform == "win32"
|
||||
needle = f"hermes -p {canon}"
|
||||
|
||||
custom: Optional[str] = None
|
||||
profile_named: Optional[str] = None
|
||||
for entry in sorted(wrapper_dir.iterdir()):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
# Only our own wrappers are named with the alias and (on Windows) .bat.
|
||||
if is_windows and entry.suffix != ".bat":
|
||||
continue
|
||||
if not is_windows and entry.suffix:
|
||||
continue
|
||||
try:
|
||||
content = entry.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if needle not in content:
|
||||
continue
|
||||
alias = entry.stem if is_windows else entry.name
|
||||
if alias == canon:
|
||||
profile_named = alias
|
||||
elif custom is None:
|
||||
custom = alias
|
||||
return custom if custom is not None else profile_named
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProfileInfo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -410,6 +482,10 @@ class ProfileInfo:
|
|||
has_env: bool = False
|
||||
skill_count: int = 0
|
||||
alias_path: Optional[Path] = None
|
||||
# Custom alias name (the wrapper file name) when it differs from ``name``;
|
||||
# falls back to ``name`` when a profile-named wrapper exists. None if no
|
||||
# wrapper points at this profile. See ``find_alias_for_profile``.
|
||||
alias_name: Optional[str] = None
|
||||
# Distribution metadata (None if the profile wasn't installed from a distribution).
|
||||
distribution_name: Optional[str] = None
|
||||
distribution_version: Optional[str] = None
|
||||
|
|
@ -607,10 +683,17 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
if name == "default":
|
||||
continue # already added as the built-in default above
|
||||
if not _PROFILE_ID_RE.match(name):
|
||||
continue
|
||||
model, provider = _read_config_model(entry)
|
||||
alias_path = wrapper_dir / name
|
||||
alias_name = find_alias_for_profile(name)
|
||||
if alias_name:
|
||||
is_windows = sys.platform == "win32"
|
||||
alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)
|
||||
else:
|
||||
alias_path = None
|
||||
dist_name, dist_version, dist_source = _read_distribution_meta(entry)
|
||||
meta = read_profile_meta(entry)
|
||||
profiles.append(ProfileInfo(
|
||||
|
|
@ -622,7 +705,8 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
provider=provider,
|
||||
has_env=(entry / ".env").exists(),
|
||||
skill_count=_count_skills(entry),
|
||||
alias_path=alias_path if alias_path.exists() else None,
|
||||
alias_path=alias_path if (alias_path and alias_path.exists()) else None,
|
||||
alias_name=alias_name,
|
||||
distribution_name=dist_name,
|
||||
distribution_version=dist_version,
|
||||
distribution_source=dist_source,
|
||||
|
|
@ -723,7 +807,17 @@ def create_profile(
|
|||
for filename in _CLONE_CONFIG_FILES:
|
||||
src = source_dir / filename
|
||||
if src.exists():
|
||||
shutil.copy2(src, profile_dir / filename)
|
||||
dst = profile_dir / filename
|
||||
shutil.copy2(src, dst)
|
||||
# Tighten .env to owner-only after copy. shutil.copy2
|
||||
# preserves source mode bits, but if the source's .env
|
||||
# was loose (host umask 0o022 leaving 0o644), tighten
|
||||
# explicitly so the clone doesn't inherit weak perms.
|
||||
if filename == ".env":
|
||||
try:
|
||||
os.chmod(str(dst), 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Clone installed skills from the source profile. The dashboard's
|
||||
# "clone from default" flow is expected to preserve both bundled
|
||||
|
|
@ -777,6 +871,14 @@ def create_profile(
|
|||
except Exception:
|
||||
pass # non-fatal — user can describe later with `hermes profile describe`
|
||||
|
||||
# Phase 4: when running inside a container under s6, register the
|
||||
# new profile's gateway as a runtime s6 service so
|
||||
# `hermes -p <profile> gateway start` can supervise it via
|
||||
# `s6-svc -u` instead of spawning a bare process. On host (systemd
|
||||
# / launchd / windows) this is a no-op — the existing per-profile
|
||||
# unit-generation paths handle gateway lifecycle.
|
||||
_maybe_register_gateway_service(canon)
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
|
|
@ -893,6 +995,10 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
|
||||
# 1. Disable service (prevents auto-restart)
|
||||
_cleanup_gateway_service(canon, profile_dir)
|
||||
# 1b. Phase 4: unregister the s6 service slot (container path).
|
||||
# On host this is a no-op; on container it removes
|
||||
# /run/service/gateway-<profile>/ so s6-supervise drops it.
|
||||
_maybe_unregister_gateway_service(canon)
|
||||
|
||||
# 2. Stop running gateway
|
||||
if gw_running:
|
||||
|
|
@ -904,6 +1010,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
print(f"✓ Removed {wrapper_path}")
|
||||
|
||||
# 4. Remove profile directory
|
||||
remove_error: Exception | None = None
|
||||
try:
|
||||
def _make_writable(func, path, exc):
|
||||
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed.
|
||||
|
|
@ -918,7 +1025,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
``sys.exc_info()`` tuple).
|
||||
"""
|
||||
import stat as _stat
|
||||
import sys as _sys
|
||||
|
||||
# Normalise the two callback signatures:
|
||||
# onexc(func, path, exc_instance) — 3.12+
|
||||
|
|
@ -951,6 +1057,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
print(f"✓ Removed {profile_dir}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not remove {profile_dir}: {e}")
|
||||
remove_error = e
|
||||
|
||||
# 5. Clear active_profile if it pointed to this profile
|
||||
try:
|
||||
|
|
@ -961,10 +1068,94 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if remove_error is not None:
|
||||
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
|
||||
|
||||
print(f"\nProfile '{canon}' deleted.")
|
||||
return profile_dir
|
||||
|
||||
|
||||
def _maybe_register_gateway_service(profile_name: str) -> None:
|
||||
"""Register a profile's gateway with s6 inside the container.
|
||||
|
||||
No-op on host (systemd/launchd/windows) — those backends raise
|
||||
``NotImplementedError`` on ``register_profile_gateway`` and the
|
||||
existing per-profile unit-generation paths handle lifecycle.
|
||||
|
||||
Best-effort: any error (no backend detected, s6 not yet ready,
|
||||
etc.) is logged and swallowed so profile creation doesn't fail
|
||||
because the s6 supervision tree is in a weird state. The user
|
||||
can re-register manually later via the gateway start command,
|
||||
which goes through the same dispatch path.
|
||||
|
||||
Port selection is governed by the profile's ``config.yaml``
|
||||
(``[gateway] port = …``) — there is no Python-side allocator
|
||||
(PR #30136 review item I5 retired the SHA-256-derived range
|
||||
[9200, 9800) because it was dead code through the entire stack).
|
||||
|
||||
Host short-circuit: check ``detect_service_manager()`` first and
|
||||
return immediately if it isn't ``"s6"``. This keeps host
|
||||
(systemd/launchd/windows) profile creation completely silent —
|
||||
no ``get_service_manager()`` call, no exception path, no chance
|
||||
of the ``⚠ Could not register s6 gateway service`` warning ever
|
||||
rendering on a non-container machine. The earlier
|
||||
``supports_runtime_registration()`` check still catches the case
|
||||
where detection somehow returns ``"s6"`` but the backend isn't
|
||||
actually the S6 one.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
if detect_service_manager() != "s6":
|
||||
return # host path — silent, no registration needed
|
||||
from hermes_cli.service_manager import get_service_manager
|
||||
mgr = get_service_manager()
|
||||
except RuntimeError:
|
||||
return # no backend on this host — nothing to do
|
||||
except Exception:
|
||||
# Defensive: detect_service_manager failed for some other
|
||||
# reason. Stay silent on host rather than printing a confusing
|
||||
# s6 warning to users who have never touched the container.
|
||||
return
|
||||
if not mgr.supports_runtime_registration():
|
||||
return # host backend; no-op
|
||||
try:
|
||||
mgr.register_profile_gateway(profile_name)
|
||||
except ValueError:
|
||||
# Already registered (e.g. the container-boot reconciler ran
|
||||
# first and brought up a stale slot). That's fine.
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Don't fail profile create over a supervision-tree hiccup.
|
||||
print(f"⚠ Could not register s6 gateway service: {exc}")
|
||||
|
||||
|
||||
def _maybe_unregister_gateway_service(profile_name: str) -> None:
|
||||
"""Tear down a profile's s6 gateway service inside the container.
|
||||
|
||||
No-op on host. Idempotent: absent services are silently skipped
|
||||
by ``unregister_profile_gateway``.
|
||||
|
||||
Same host short-circuit as :func:`_maybe_register_gateway_service`
|
||||
— see that docstring.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
if detect_service_manager() != "s6":
|
||||
return # host path — silent
|
||||
from hermes_cli.service_manager import get_service_manager
|
||||
mgr = get_service_manager()
|
||||
except RuntimeError:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
if not mgr.supports_runtime_registration():
|
||||
return
|
||||
try:
|
||||
mgr.unregister_profile_gateway(profile_name)
|
||||
except Exception as exc:
|
||||
print(f"⚠ Could not unregister s6 gateway service: {exc}")
|
||||
|
||||
|
||||
def _cleanup_gateway_service(name: str, profile_dir: Path) -> None:
|
||||
"""Disable and remove systemd/launchd service for a profile."""
|
||||
import platform as _platform
|
||||
|
|
@ -1341,8 +1532,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
|||
|
||||
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
|
||||
"""Rename Honcho host blocks for a renamed profile without changing peers."""
|
||||
old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes.{new_name}"
|
||||
old_host = f"hermes_{old_name}"
|
||||
legacy_old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes_{new_name}"
|
||||
|
||||
candidates = [
|
||||
new_dir / "honcho.json",
|
||||
|
|
@ -1366,18 +1558,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
|||
continue
|
||||
|
||||
hosts = raw.get("hosts")
|
||||
if not isinstance(hosts, dict) or old_host not in hosts:
|
||||
if not isinstance(hosts, dict):
|
||||
continue
|
||||
source_host = old_host if old_host in hosts else legacy_old_host
|
||||
if source_host not in hosts:
|
||||
continue
|
||||
|
||||
if new_host in hosts:
|
||||
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
|
||||
continue
|
||||
|
||||
block = hosts[old_host]
|
||||
block = hosts[source_host]
|
||||
if isinstance(block, dict) and "aiPeer" not in block:
|
||||
bare = old_host.split(".", 1)[1] if "." in old_host else old_host
|
||||
if source_host.startswith("hermes_"):
|
||||
bare = source_host.split("_", 1)[1]
|
||||
else:
|
||||
bare = source_host.split(".", 1)[1] if "." in source_host else source_host
|
||||
block["aiPeer"] = bare
|
||||
hosts[new_host] = hosts.pop(old_host)
|
||||
hosts[new_host] = hosts.pop(source_host)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
|
@ -1389,7 +1587,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
|||
pass
|
||||
continue
|
||||
|
||||
print(f"✓ Honcho host updated: {old_host} → {new_host}")
|
||||
print(f"✓ Honcho host updated: {source_host} → {new_host}")
|
||||
|
||||
|
||||
def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
|
|
|
|||
153
hermes_cli/prompt_size.py
Normal file
153
hermes_cli/prompt_size.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Prompt-size diagnostic: ``hermes prompt-size``.
|
||||
|
||||
Reports a byte/char breakdown of the system prompt the agent would build for
|
||||
a fresh session — system prompt total, the ``<available_skills>`` index,
|
||||
memory + user profile, and tool-schema JSON. Lets users see where their fixed
|
||||
prompt budget goes (issue #34667) without parsing a saved session JSON by hand.
|
||||
|
||||
The diagnostic builds a real inspection agent (so the numbers match what
|
||||
actually ships on the wire) but never makes a network call: it passes dummy
|
||||
credentials so ``AIAgent.__init__`` takes the direct-construction path, then
|
||||
calls ``build_system_prompt_parts`` / inspects ``agent.tools`` offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
# The skills index is wrapped in this tag pair inside the stable tier.
|
||||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||||
|
||||
|
||||
def _bytes(s: str) -> int:
|
||||
return len(s.encode("utf-8"))
|
||||
|
||||
|
||||
def _build_inspection_agent(platform: str) -> Any:
|
||||
"""Construct an offline AIAgent for prompt inspection.
|
||||
|
||||
Dummy ``api_key`` + ``base_url`` force the direct-construction path in
|
||||
``run_agent.py`` (no provider auto-detection, no network). Toolsets and
|
||||
platform come from the caller so the breakdown matches a real session.
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
|
||||
model = model_cfg.get("default") or model_cfg.get("model") or ""
|
||||
|
||||
return AIAgent(
|
||||
model=model,
|
||||
api_key="inspect-only",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
save_trajectories=False,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
|
||||
def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]:
|
||||
"""Return a dict of prompt-size measurements for a fresh session.
|
||||
|
||||
Keys: ``system_prompt`` (chars/bytes), ``skills_index``, ``memory``,
|
||||
``user_profile``, ``tools`` (count + json bytes), and ``sections`` (a list
|
||||
of (label, chars, bytes) for the three prompt tiers).
|
||||
"""
|
||||
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
|
||||
|
||||
agent = _build_inspection_agent(platform)
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
full = build_system_prompt(agent)
|
||||
|
||||
stable = parts.get("stable", "")
|
||||
context = parts.get("context", "")
|
||||
volatile = parts.get("volatile", "")
|
||||
|
||||
# Skills index — the <available_skills> block (the largest single block
|
||||
# when many skills are installed). Measured inside the stable tier.
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_index = skills_match.group(0) if skills_match else ""
|
||||
|
||||
# Memory + user profile live in the volatile tier. We re-derive their
|
||||
# blocks directly from the memory store so the numbers are attributable
|
||||
# even though they're joined into ``volatile``.
|
||||
memory_block = ""
|
||||
user_block = ""
|
||||
store = getattr(agent, "_memory_store", None)
|
||||
if store is not None:
|
||||
try:
|
||||
if getattr(agent, "_memory_enabled", True):
|
||||
memory_block = store.format_for_system_prompt("memory") or ""
|
||||
if getattr(agent, "_user_profile_enabled", True):
|
||||
user_block = store.format_for_system_prompt("user") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool-schema JSON — the other half of the fixed per-call payload.
|
||||
tools = getattr(agent, "tools", None) or []
|
||||
tools_json = json.dumps(tools, ensure_ascii=False)
|
||||
|
||||
sections: List[Tuple[str, int, int]] = [
|
||||
("stable (identity/guidance/skills)", len(stable), _bytes(stable)),
|
||||
("context (AGENTS.md/cwd files)", len(context), _bytes(context)),
|
||||
("volatile (memory/profile/timestamp)", len(volatile), _bytes(volatile)),
|
||||
]
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
"system_prompt": {"chars": len(full), "bytes": _bytes(full)},
|
||||
"skills_index": {"chars": len(skills_index), "bytes": _bytes(skills_index)},
|
||||
"memory": {"chars": len(memory_block), "bytes": _bytes(memory_block)},
|
||||
"user_profile": {"chars": len(user_block), "bytes": _bytes(user_block)},
|
||||
"tools": {"count": len(tools), "json_bytes": _bytes(tools_json)},
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def _fmt_kb(n: int) -> str:
|
||||
return f"{n / 1024:.1f} KB"
|
||||
|
||||
|
||||
def render_breakdown(data: Dict[str, Any]) -> str:
|
||||
"""Render the breakdown as plain text suitable for a terminal."""
|
||||
lines: List[str] = []
|
||||
sp = data["system_prompt"]
|
||||
lines.append(f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})")
|
||||
lines.append("")
|
||||
lines.append(f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)")
|
||||
lines.append("")
|
||||
lines.append(" Major blocks:")
|
||||
si = data["skills_index"]
|
||||
mem = data["memory"]
|
||||
up = data["user_profile"]
|
||||
lines.append(f" skills index : {si['bytes']:>8,} B ({_fmt_kb(si['bytes'])})")
|
||||
lines.append(f" memory : {mem['bytes']:>8,} B ({_fmt_kb(mem['bytes'])})")
|
||||
lines.append(f" user profile : {up['bytes']:>8,} B ({_fmt_kb(up['bytes'])})")
|
||||
lines.append("")
|
||||
lines.append(" Prompt tiers:")
|
||||
for label, chars, byts in data["sections"]:
|
||||
lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})")
|
||||
lines.append("")
|
||||
tools = data["tools"]
|
||||
lines.append(f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_prompt_size(args: Any) -> None:
|
||||
"""Entry point for ``hermes prompt-size``."""
|
||||
platform = getattr(args, "platform", "cli") or "cli"
|
||||
as_json = getattr(args, "json", False)
|
||||
try:
|
||||
data = compute_prompt_breakdown(platform)
|
||||
except Exception as e:
|
||||
print(f"Could not compute prompt-size breakdown: {e}")
|
||||
return
|
||||
if as_json:
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(render_breakdown(data))
|
||||
|
|
@ -47,7 +47,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
|||
"openrouter": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
extra_env_vars=("OPENAI_API_KEY",),
|
||||
base_url_env_var="OPENROUTER_BASE_URL",
|
||||
),
|
||||
"nous": HermesOverlay(
|
||||
|
|
@ -60,6 +59,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
|||
auth_type="oauth_external",
|
||||
base_url_override="https://chatgpt.com/backend-api/codex",
|
||||
),
|
||||
"openai-api": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
base_url_override="https://api.openai.com/v1",
|
||||
base_url_env_var="OPENAI_BASE_URL",
|
||||
),
|
||||
"xai-oauth": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
auth_type="oauth_external",
|
||||
|
|
@ -138,10 +142,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
|||
transport="openai_chat",
|
||||
base_url_env_var="ALIBABA_CODING_PLAN_BASE_URL",
|
||||
),
|
||||
"vercel": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
),
|
||||
"opencode": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
|
|
@ -285,11 +285,6 @@ ALIASES: Dict[str, str] = {
|
|||
"github": "github-copilot",
|
||||
"github-copilot-acp": "copilot-acp",
|
||||
|
||||
# vercel (models.dev ID for AI Gateway)
|
||||
"ai-gateway": "vercel",
|
||||
"aigateway": "vercel",
|
||||
"vercel-ai-gateway": "vercel",
|
||||
|
||||
# opencode (models.dev ID for OpenCode Zen)
|
||||
"opencode-zen": "opencode",
|
||||
"zen": "opencode",
|
||||
|
|
@ -381,6 +376,7 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
|||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
"xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -496,7 +492,10 @@ def get_label(provider_id: str) -> str:
|
|||
|
||||
def is_aggregator(provider: str) -> bool:
|
||||
"""Return True when the provider is a multi-model aggregator."""
|
||||
pdef = get_provider(provider)
|
||||
provider_norm = normalize_provider(provider or "")
|
||||
if provider_norm.startswith("custom:"):
|
||||
return True
|
||||
pdef = get_provider(provider_norm)
|
||||
return pdef.is_aggregator if pdef else False
|
||||
|
||||
|
||||
|
|
@ -680,6 +679,20 @@ def resolve_provider_full(
|
|||
ProviderDef if found, else None.
|
||||
"""
|
||||
canonical = normalize_provider(name)
|
||||
raw = name.strip().lower()
|
||||
|
||||
# 0. User-defined config providers win over the built-in alias table.
|
||||
# A user who declares ``providers.<name>`` in config.yaml has stated
|
||||
# explicit intent for that name — it must not be hijacked by a legacy
|
||||
# vendor alias (e.g. bare "openai" → "openrouter"). Resolve the raw
|
||||
# name against user config FIRST so a configured ``providers.openai``
|
||||
# (pointing at api.openai.com) beats the alias that would otherwise
|
||||
# silently route to OpenRouter. Only the raw (pre-alias) name is tried
|
||||
# here; canonical/alias resolution still happens below.
|
||||
if user_providers:
|
||||
user_pdef = resolve_user_provider(raw, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
# 1. Built-in (models.dev + overlays)
|
||||
pdef = get_provider(canonical)
|
||||
|
|
@ -693,7 +706,7 @@ def resolve_provider_full(
|
|||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
# Try original name (in case alias didn't match)
|
||||
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
|
||||
user_pdef = resolve_user_provider(raw, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@ class UpstreamAdapter(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def get_credential(self) -> UpstreamCredential:
|
||||
"""Return a fresh credential, refreshing/minting if necessary.
|
||||
"""Return a fresh credential, refreshing or rotating if necessary.
|
||||
|
||||
Implementations should:
|
||||
- refresh the access token if it's near expiry
|
||||
- mint/rotate the upstream bearer key if it's near expiry
|
||||
- rotate the upstream bearer key if it's near expiry
|
||||
- persist any refreshed state back to disk
|
||||
|
||||
Raises:
|
||||
|
|
@ -90,8 +90,7 @@ class UpstreamAdapter(ABC):
|
|||
"""Return an alternate credential after an upstream auth failure.
|
||||
|
||||
The default is no retry. Providers can override this for one-shot
|
||||
fallback paths, such as switching from a preferred token type to a
|
||||
legacy bearer after the upstream rejects the first request.
|
||||
fallback paths after the upstream rejects the first request.
|
||||
"""
|
||||
_ = failed_credential, status_code
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
"""Nous Portal upstream adapter.
|
||||
|
||||
Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the
|
||||
shared runtime resolver, refreshes the access token and resolves the
|
||||
``agent_key`` compatibility credential when needed, then exposes the upstream
|
||||
base URL plus bearer for the proxy server to forward to.
|
||||
|
||||
The ``agent_key`` field may hold either a NAS invoke JWT or the legacy
|
||||
opaque session key. The refresh helper handles both — see
|
||||
:func:`hermes_cli.auth.resolve_nous_runtime_credentials`.
|
||||
shared runtime resolver, validates or refreshes the inference JWT, then exposes
|
||||
the upstream base URL plus bearer for the proxy server to forward to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,14 +14,13 @@ from typing import Any, Dict, FrozenSet, Optional
|
|||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_NOUS_INFERENCE_URL,
|
||||
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
_load_auth_store,
|
||||
_auth_store_lock,
|
||||
_is_terminal_nous_refresh_error,
|
||||
_quarantine_nous_oauth_state,
|
||||
_quarantine_nous_pool_entries,
|
||||
_save_auth_store,
|
||||
_validate_nous_inference_url_from_network,
|
||||
_write_shared_nous_state,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
|
|
@ -71,17 +65,15 @@ class NousPortalAdapter(UpstreamAdapter):
|
|||
state = self._read_state()
|
||||
if state is None:
|
||||
return False
|
||||
# We need either a usable agent_key OR (refresh_token + access_token)
|
||||
# to recover. The refresh helper will mint/refresh as needed.
|
||||
# We need either a usable inference JWT OR (refresh_token + access_token)
|
||||
# to recover. The refresh helper validates and refreshes as needed.
|
||||
return bool(
|
||||
state.get("agent_key")
|
||||
or (state.get("refresh_token") and state.get("access_token"))
|
||||
)
|
||||
|
||||
def get_credential(self) -> UpstreamCredential:
|
||||
return self._get_credential(
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||
)
|
||||
return self._get_credential()
|
||||
|
||||
def get_retry_credential(
|
||||
self,
|
||||
|
|
@ -89,26 +81,29 @@ class NousPortalAdapter(UpstreamAdapter):
|
|||
failed_credential: UpstreamCredential,
|
||||
status_code: int,
|
||||
) -> Optional[UpstreamCredential]:
|
||||
_ = failed_credential
|
||||
if status_code != 401:
|
||||
return None
|
||||
if failed_credential.bearer.count(".") != 2:
|
||||
return None
|
||||
logger.info("proxy: Nous upstream rejected bearer; retrying with legacy session key")
|
||||
logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT")
|
||||
return self._get_credential(
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
force_refresh=True,
|
||||
)
|
||||
|
||||
def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential:
|
||||
def _get_credential(
|
||||
self,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> UpstreamCredential:
|
||||
with self._lock:
|
||||
state = self._read_state()
|
||||
if state is None:
|
||||
raise RuntimeError(
|
||||
"Not logged into Nous Portal. Run `hermes login nous` first."
|
||||
"Not logged into Nous Portal. Run `hermes auth add nous` first."
|
||||
)
|
||||
|
||||
try:
|
||||
refreshed = resolve_nous_runtime_credentials(
|
||||
inference_auth_mode=inference_auth_mode,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
except AuthError as exc:
|
||||
if _is_terminal_nous_refresh_error(exc):
|
||||
|
|
@ -130,18 +125,21 @@ class NousPortalAdapter(UpstreamAdapter):
|
|||
f"Failed to refresh Nous Portal credentials: {exc}"
|
||||
) from exc
|
||||
|
||||
agent_key = refreshed.get("api_key")
|
||||
if not agent_key:
|
||||
runtime_key = refreshed.get("api_key")
|
||||
if not runtime_key:
|
||||
raise RuntimeError(
|
||||
"Nous Portal refresh did not return a usable agent_key. "
|
||||
"Try `hermes login nous` to re-authenticate."
|
||||
"Nous Portal refresh did not return a usable inference JWT. "
|
||||
"Try `hermes auth add nous` to re-authenticate."
|
||||
)
|
||||
|
||||
base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL
|
||||
base_url = (
|
||||
_validate_nous_inference_url_from_network(refreshed.get("base_url"))
|
||||
or DEFAULT_NOUS_INFERENCE_URL
|
||||
)
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
return UpstreamCredential(
|
||||
bearer=agent_key,
|
||||
bearer=runtime_key,
|
||||
base_url=base_url,
|
||||
expires_at=refreshed.get("expires_at"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class XAIGrokAdapter(UpstreamAdapter):
|
|||
failed_credential: UpstreamCredential,
|
||||
status_code: int,
|
||||
) -> Optional[UpstreamCredential]:
|
||||
if status_code != 401:
|
||||
if status_code not in {401, 429}:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
|
|
@ -87,16 +87,25 @@ class XAIGrokAdapter(UpstreamAdapter):
|
|||
if pool is None:
|
||||
return None
|
||||
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is None:
|
||||
if status_code == 429:
|
||||
# Mark the rate-limited key with its 1-hour cooldown and rotate
|
||||
# to the next available credential. Returns None when the pool
|
||||
# has no other key to offer — the 429 will flow back to the client.
|
||||
refreshed = pool.mark_exhausted_and_rotate(status_code=status_code)
|
||||
else:
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is None:
|
||||
refreshed = pool.mark_exhausted_and_rotate(status_code=status_code)
|
||||
if refreshed is None:
|
||||
return None
|
||||
|
||||
retry_cred = self._credential_from_entry(refreshed)
|
||||
if retry_cred.bearer == failed_credential.bearer:
|
||||
return None
|
||||
logger.info("proxy: xAI upstream rejected bearer; retrying with refreshed pool credential")
|
||||
logger.info(
|
||||
"proxy: xAI upstream returned %s; retrying with rotated pool credential",
|
||||
status_code,
|
||||
)
|
||||
return retry_cred
|
||||
|
||||
def _load_pool(self) -> Optional[CredentialPool]:
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ def cmd_proxy_start(args: Any) -> int:
|
|||
return 2
|
||||
|
||||
if not adapter.is_authenticated():
|
||||
auth_hint = getattr(adapter, "auth_hint", f"hermes login {adapter.name}")
|
||||
auth_hint = getattr(adapter, "auth_hint", f"hermes auth add {adapter.name}")
|
||||
print(
|
||||
f"Not logged into {adapter.display_name}. "
|
||||
f"Run `{auth_hint}` first.",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ or rewrite request/response bodies. It's a credential-attaching forwarder.
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
from typing import Optional
|
||||
|
|
@ -105,17 +104,6 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
|
|||
}
|
||||
)
|
||||
|
||||
async def handle_models_fallback(request: "web.Request") -> "web.Response":
|
||||
# Most clients hit /v1/models on startup. If the upstream doesn't
|
||||
# serve /models, synthesize a minimal response so clients don't
|
||||
# crash. The actual forwarding path handles /models when allowed.
|
||||
return web.json_response(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_proxy(request: "web.Request") -> "web.StreamResponse":
|
||||
# Extract the path *after* /v1
|
||||
rel_path = request.match_info.get("tail", "")
|
||||
|
|
@ -206,7 +194,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
|
|||
return session_or_response
|
||||
session = session_or_response
|
||||
|
||||
if upstream_resp.status == 401:
|
||||
if upstream_resp.status in {401, 429}:
|
||||
try:
|
||||
retry_cred = adapter.get_retry_credential(
|
||||
failed_credential=cred,
|
||||
|
|
|
|||
108
hermes_cli/psutil_android.py
Normal file
108
hermes_cli/psutil_android.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Helpers for the temporary psutil-on-Android compatibility installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
# Pin a version we know patches cleanly. Update when a newer psutil
|
||||
# changes the marker line shape and we need to follow upstream.
|
||||
PSUTIL_URL = (
|
||||
"https://files.pythonhosted.org/packages/aa/c6/"
|
||||
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
|
||||
"psutil-7.2.2.tar.gz"
|
||||
)
|
||||
|
||||
MARKER = 'LINUX = sys.platform.startswith("linux")'
|
||||
REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
|
||||
|
||||
|
||||
class PsutilAndroidInstallError(RuntimeError):
|
||||
"""Raised when the pinned psutil sdist is missing or unsafe."""
|
||||
|
||||
|
||||
def _normalize_member_parts(member_name: str) -> tuple[str, ...]:
|
||||
path = PurePosixPath(member_name)
|
||||
parts = tuple(part for part in path.parts if part not in ("", "."))
|
||||
if path.is_absolute() or ".." in parts or not parts:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Unsafe archive member path: {member_name!r}"
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def _safe_extract_tar_gz(archive: Path, destination: Path) -> None:
|
||||
"""Extract a tar.gz without allowing traversal or link members."""
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
parts = _normalize_member_parts(member.name)
|
||||
target = destination.joinpath(*parts)
|
||||
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
if not member.isfile():
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Unsupported archive member type: {member.name}"
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
extracted = tf.extractfile(member)
|
||||
if extracted is None:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Cannot read archive member: {member.name}"
|
||||
)
|
||||
|
||||
with extracted, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(extracted, dst)
|
||||
|
||||
try:
|
||||
target.chmod(member.mode & 0o777)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path:
|
||||
"""Safely extract the pinned psutil sdist and patch it for Android."""
|
||||
_safe_extract_tar_gz(archive, destination)
|
||||
|
||||
src_roots = sorted(
|
||||
(
|
||||
path for path in destination.iterdir()
|
||||
if path.is_dir() and path.name.startswith("psutil-")
|
||||
),
|
||||
key=lambda path: path.name,
|
||||
)
|
||||
if not src_roots:
|
||||
raise PsutilAndroidInstallError(
|
||||
"psutil sdist did not contain a psutil-* directory"
|
||||
)
|
||||
|
||||
src_root = src_roots[0]
|
||||
common_py = src_root / "psutil" / "_common.py"
|
||||
if not common_py.is_file():
|
||||
raise PsutilAndroidInstallError(
|
||||
f"psutil sdist did not contain {common_py.relative_to(src_root)!s}"
|
||||
)
|
||||
try:
|
||||
content = common_py.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Failed to read {common_py.relative_to(src_root)!s}"
|
||||
) from exc
|
||||
if MARKER not in content:
|
||||
raise PsutilAndroidInstallError(
|
||||
"psutil Android compatibility patch marker not found"
|
||||
)
|
||||
try:
|
||||
common_py.write_text(
|
||||
content.replace(MARKER, REPLACEMENT),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as exc:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Failed to write {common_py.relative_to(src_root)!s}"
|
||||
) from exc
|
||||
return src_root
|
||||
|
|
@ -81,3 +81,40 @@ def install_ctrl_enter_alias() -> int:
|
|||
ANSI_SEQUENCES[seq] = alt_enter
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def install_ignored_terminal_sequences() -> int:
|
||||
"""Map terminal-emitted noise sequences to ``Keys.Ignore`` so they
|
||||
are consumed by the VT100 parser before they reach key bindings or
|
||||
the input buffer.
|
||||
|
||||
Currently covers focus reports:
|
||||
- ``\\x1b[I`` — terminal regained focus (focus in)
|
||||
- ``\\x1b[O`` — terminal lost focus (focus out)
|
||||
|
||||
Ghostty, iTerm2, and some xterm builds can emit these sequences when
|
||||
the user switches tabs / windows or when a multiplexer toggles focus
|
||||
tracking upstream. prompt_toolkit does not map these by default, so
|
||||
its parser falls back to literal key presses (ESC, ``[``, ``I``/``O``)
|
||||
and inserts ``[I``/``[O`` into the prompt buffer after the ESC byte
|
||||
is handled.
|
||||
|
||||
Registering them as ``Keys.Ignore`` is parser-level — strictly
|
||||
cleaner than post-hoc regex stripping in the input sanitizer because
|
||||
the bytes never reach the buffer. ``setdefault`` is used so any user
|
||||
or downstream registration wins.
|
||||
|
||||
Returns the number of sequences whose mapping was changed.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
||||
from prompt_toolkit.keys import Keys
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
changed = 0
|
||||
for seq in ("\x1b[I", "\x1b[O"):
|
||||
if seq not in ANSI_SEQUENCES:
|
||||
ANSI_SEQUENCES[seq] = Keys.Ignore
|
||||
changed += 1
|
||||
return changed
|
||||
|
|
|
|||
|
|
@ -50,6 +50,33 @@ except ImportError: # pragma: no cover - dev env without ptyprocess
|
|||
__all__ = ["PtyBridge", "PtyUnavailableError"]
|
||||
|
||||
|
||||
# ``struct winsize`` packs rows/cols as unsigned short (0..65535). We clamp
|
||||
# well below that ceiling: real terminals never exceed a couple thousand
|
||||
# columns, and a value above this is a broken probe (WSL2 reports
|
||||
# columns=131072) rather than a genuine ultrawide. Lower bound is 1 — a
|
||||
# zero/negative dimension is the classic "no size yet" signal.
|
||||
_MIN_DIMENSION = 1
|
||||
_MAX_COLS = 2000
|
||||
_MAX_ROWS = 1000
|
||||
|
||||
|
||||
def _clamp_dimension(value: int, maximum: int) -> int:
|
||||
"""Clamp a reported terminal dimension into ``[_MIN_DIMENSION, maximum]``.
|
||||
|
||||
Non-integer / non-finite values fall back to ``_MIN_DIMENSION`` so a bad
|
||||
probe can never reach ``struct.pack`` and raise ``struct.error``.
|
||||
"""
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return _MIN_DIMENSION
|
||||
if n < _MIN_DIMENSION:
|
||||
return _MIN_DIMENSION
|
||||
if n > maximum:
|
||||
return maximum
|
||||
return n
|
||||
|
||||
|
||||
class PtyUnavailableError(RuntimeError):
|
||||
"""Raised when a PTY cannot be created on this platform.
|
||||
|
||||
|
|
@ -189,11 +216,23 @@ class PtyBridge:
|
|||
view = view[n:]
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
"""Forward a terminal resize to the child via ``TIOCSWINSZ``."""
|
||||
"""Forward a terminal resize to the child via ``TIOCSWINSZ``.
|
||||
|
||||
Dimensions are clamped to a sane range first. Some hosts report
|
||||
garbage window sizes — the motivating case is WSL2, where xterm.js
|
||||
in the dashboard ``/chat`` tab can pick up ``columns=131072,
|
||||
rows=1`` from a broken winsize probe. ``struct winsize`` packs each
|
||||
field as an unsigned short (max 65535), so an unclamped 131072 would
|
||||
raise ``struct.error`` (not ``OSError``) and break the resize path,
|
||||
leaving the TUI laid out for a one-row / absurdly-wide screen —
|
||||
which is what shows up as blank / disappearing text.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
cols = _clamp_dimension(cols, _MAX_COLS)
|
||||
rows = _clamp_dimension(rows, _MAX_ROWS)
|
||||
# struct winsize: rows, cols, xpixel, ypixel (all unsigned short)
|
||||
winsize = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize)
|
||||
except OSError:
|
||||
|
|
@ -211,13 +250,23 @@ class PtyBridge:
|
|||
return
|
||||
self._closed = True
|
||||
|
||||
try:
|
||||
pgid = os.getpgid(self._proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
||||
except Exception:
|
||||
pgid = None
|
||||
|
||||
# SIGHUP is the conventional "your terminal went away" signal.
|
||||
# We escalate if the child ignores it.
|
||||
# Send it to the whole foreground process group, not just the PTY
|
||||
# leader: the dashboard TUI starts helper children such as the Python
|
||||
# slash worker, and killing only the leader can strand those helpers.
|
||||
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
||||
if not self._proc.isalive():
|
||||
break
|
||||
try:
|
||||
self._proc.kill(sig)
|
||||
if pgid is not None:
|
||||
os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
||||
else:
|
||||
self._proc.kill(sig)
|
||||
except Exception:
|
||||
pass
|
||||
deadline = time.monotonic() + 0.5
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from hermes_cli.auth import (
|
|||
)
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from utils import base_url_host_matches, base_url_hostname, env_int
|
||||
|
||||
|
||||
def _normalize_custom_provider_name(value: str) -> str:
|
||||
|
|
@ -474,6 +474,21 @@ def _try_resolve_from_custom_pool(
|
|||
return None
|
||||
|
||||
|
||||
def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> None:
|
||||
"""Propagate a per-provider output cap onto the resolved runtime dict.
|
||||
|
||||
Accepts ``max_output_tokens`` or ``max_tokens`` on a ``custom_providers``
|
||||
entry so a provider block can pin its own output limit. Gateway and CLI
|
||||
map this onto ``AIAgent.max_tokens`` only when the top-level
|
||||
``model.max_tokens`` isn't set, so the documented global key still wins.
|
||||
"""
|
||||
for _k in ("max_output_tokens", "max_tokens"):
|
||||
_v = entry.get(_k)
|
||||
if isinstance(_v, int) and _v > 0:
|
||||
result["max_output_tokens"] = _v
|
||||
return
|
||||
|
||||
|
||||
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
|
||||
requested_norm = _normalize_custom_provider_name(requested_provider or "")
|
||||
if not requested_norm or requested_norm == "custom":
|
||||
|
|
@ -541,6 +556,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
|||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
_lift_max_output_tokens(entry, result)
|
||||
return result
|
||||
# Also check the 'name' field if present
|
||||
display_name = entry.get("name", "")
|
||||
|
|
@ -562,6 +578,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
|||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
_lift_max_output_tokens(entry, result)
|
||||
return result
|
||||
|
||||
# Fall back to custom_providers: list (legacy format)
|
||||
|
|
@ -611,6 +628,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
|||
model_name = str(entry.get("model", "") or "").strip()
|
||||
if model_name:
|
||||
result["model"] = model_name
|
||||
_lift_max_output_tokens(entry, result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
|
@ -699,6 +717,8 @@ def _resolve_named_custom_runtime(
|
|||
model_name = custom_provider.get("model")
|
||||
if model_name:
|
||||
pool_result["model"] = model_name
|
||||
if isinstance(custom_provider.get("max_output_tokens"), int):
|
||||
pool_result["max_output_tokens"] = custom_provider["max_output_tokens"]
|
||||
request_overrides = _custom_provider_request_overrides(custom_provider)
|
||||
if request_overrides:
|
||||
pool_result["request_overrides"] = {
|
||||
|
|
@ -736,6 +756,8 @@ def _resolve_named_custom_runtime(
|
|||
# provider name differs from the actual model string the API expects.
|
||||
if custom_provider.get("model"):
|
||||
result["model"] = custom_provider["model"]
|
||||
if isinstance(custom_provider.get("max_output_tokens"), int):
|
||||
result["max_output_tokens"] = custom_provider["max_output_tokens"]
|
||||
request_overrides = _custom_provider_request_overrides(custom_provider)
|
||||
if request_overrides:
|
||||
result["request_overrides"] = request_overrides
|
||||
|
|
@ -1115,14 +1137,20 @@ def _resolve_explicit_runtime(
|
|||
explicit_base_url
|
||||
or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/")
|
||||
)
|
||||
# Only use the agent_key compatibility field for inference. It may be
|
||||
# either a NAS invoke JWT or a legacy opaque session key; raw OAuth
|
||||
# access_token fallback is handled by resolve_nous_runtime_credentials().
|
||||
api_key = explicit_api_key or str(state.get("agent_key") or "").strip()
|
||||
# Only use the agent_key compatibility field for inference when it
|
||||
# contains a NAS invoke JWT; raw OAuth access_token fallback is handled
|
||||
# by resolve_nous_runtime_credentials().
|
||||
api_key = explicit_api_key or (
|
||||
str(state.get("agent_key") or "").strip()
|
||||
if _agent_key_is_usable(
|
||||
state,
|
||||
max(60, env_int("HERMES_NOUS_MIN_KEY_TTL_SECONDS", 1800)),
|
||||
)
|
||||
else ""
|
||||
)
|
||||
expires_at = state.get("agent_key_expires_at") or state.get("expires_at")
|
||||
if not api_key:
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
)
|
||||
api_key = creds.get("api_key", "")
|
||||
|
|
@ -1309,14 +1337,13 @@ def resolve_runtime_provider(
|
|||
or getattr(entry, "access_token", "")
|
||||
)
|
||||
# For Nous, the pool entry's runtime_api_key is the agent_key
|
||||
# compatibility field: either an invoke JWT or legacy opaque key.
|
||||
# The pool doesn't
|
||||
# compatibility field. It must be an invoke JWT. The pool doesn't
|
||||
# refresh it during selection (that would trigger network calls in
|
||||
# non-runtime contexts like `hermes auth list`). If the key is
|
||||
# expired, clear pool_api_key so we fall through to
|
||||
# resolve_nous_runtime_credentials() which handles refresh + fallback.
|
||||
# resolve_nous_runtime_credentials() which handles refresh.
|
||||
if provider == "nous" and entry is not None and pool_api_key:
|
||||
min_ttl = max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
|
||||
min_ttl = max(60, env_int("HERMES_NOUS_MIN_KEY_TTL_SECONDS", 1800))
|
||||
nous_state = {
|
||||
"agent_key": getattr(entry, "agent_key", None),
|
||||
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
|
||||
|
|
@ -1338,7 +1365,6 @@ def resolve_runtime_provider(
|
|||
if provider == "nous":
|
||||
try:
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
)
|
||||
return {
|
||||
|
|
|
|||
126
hermes_cli/secret_prompt.py
Normal file
126
hermes_cli/secret_prompt.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Secret input prompts with masked typing feedback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
_BACKSPACE_CHARS = {"\b", "\x7f"}
|
||||
_ENTER_CHARS = {"\r", "\n"}
|
||||
_EOF_CHARS = {"\x04", "\x1a"}
|
||||
|
||||
|
||||
def _collect_masked_input(
|
||||
read_char: Callable[[], str],
|
||||
write: Callable[[str], object],
|
||||
prompt: str,
|
||||
*,
|
||||
mask: str = "*",
|
||||
) -> str:
|
||||
"""Read one secret line while writing a mask character per typed char."""
|
||||
value: list[str] = []
|
||||
write(prompt)
|
||||
|
||||
while True:
|
||||
ch = read_char()
|
||||
if ch == "":
|
||||
write("\n")
|
||||
raise EOFError
|
||||
if ch in _ENTER_CHARS:
|
||||
write("\n")
|
||||
return "".join(value)
|
||||
if ch == "\x03":
|
||||
write("\n")
|
||||
raise KeyboardInterrupt
|
||||
if ch in _EOF_CHARS:
|
||||
write("\n")
|
||||
raise EOFError
|
||||
if ch in _BACKSPACE_CHARS:
|
||||
if value:
|
||||
value.pop()
|
||||
write("\b \b")
|
||||
continue
|
||||
if ch == "\x1b":
|
||||
# Ignore escape itself. Terminals commonly send escape-prefixed
|
||||
# navigation/delete sequences; they should not become secret text.
|
||||
continue
|
||||
|
||||
value.append(ch)
|
||||
if mask:
|
||||
write(mask)
|
||||
|
||||
|
||||
def masked_secret_prompt(prompt: str, *, mask: str = "*") -> str:
|
||||
"""Prompt for a secret while showing masked typing feedback.
|
||||
|
||||
Falls back to ``getpass.getpass`` when stdin/stdout are not interactive or
|
||||
when raw terminal handling is unavailable.
|
||||
"""
|
||||
stdin = sys.stdin
|
||||
stdout = sys.stdout
|
||||
|
||||
if not _stream_is_tty(stdin) or not _stream_is_tty(stdout):
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
if os.name == "nt":
|
||||
try:
|
||||
return _masked_secret_prompt_windows(prompt, mask=mask)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise
|
||||
except Exception:
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
try:
|
||||
return _masked_secret_prompt_posix(prompt, mask=mask)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise
|
||||
except Exception:
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
|
||||
def _stream_is_tty(stream) -> bool:
|
||||
try:
|
||||
return bool(stream.isatty())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _masked_secret_prompt_windows(prompt: str, *, mask: str) -> str:
|
||||
import msvcrt
|
||||
|
||||
def read_char() -> str:
|
||||
ch = msvcrt.getwch()
|
||||
if ch in {"\x00", "\xe0"}:
|
||||
msvcrt.getwch()
|
||||
return "\x1b"
|
||||
return ch
|
||||
|
||||
def write(text: str) -> None:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
return _collect_masked_input(read_char, write, prompt, mask=mask)
|
||||
|
||||
|
||||
def _masked_secret_prompt_posix(prompt: str, *, mask: str) -> str:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_attrs = termios.tcgetattr(fd)
|
||||
|
||||
def read_char() -> str:
|
||||
return sys.stdin.read(1)
|
||||
|
||||
def write(text: str) -> None:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
return _collect_masked_input(read_char, write, prompt, mask=mask)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
|
||||
|
|
@ -11,13 +11,12 @@ Subcommands:
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
|
@ -30,6 +29,7 @@ from hermes_cli.config import (
|
|||
save_config,
|
||||
save_env_value,
|
||||
)
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -57,6 +57,15 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:
|
|||
"--access-token",
|
||||
help="Provide the access token non-interactively (will be stored in .env)",
|
||||
)
|
||||
setup.add_argument(
|
||||
"--server-url",
|
||||
help=(
|
||||
"Bitwarden region / self-hosted endpoint. Examples: "
|
||||
"https://vault.bitwarden.com (US, default), "
|
||||
"https://vault.bitwarden.eu (EU), or your self-hosted URL. "
|
||||
"Skips the interactive region prompt."
|
||||
),
|
||||
)
|
||||
setup.set_defaults(func=cmd_setup)
|
||||
|
||||
status = sub.add_parser("status", help="Show config + binary + last fetch")
|
||||
|
|
@ -121,6 +130,29 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
)
|
||||
return 1
|
||||
|
||||
# -- non-interactive guard --
|
||||
if not sys.stdin.isatty():
|
||||
missing = []
|
||||
if not (args.access_token and args.access_token.strip()):
|
||||
missing.append("--access-token")
|
||||
if not (args.server_url and args.server_url.strip()):
|
||||
# Also accept BWS_SERVER_URL env var as non-interactive substitute
|
||||
if not os.environ.get("BWS_SERVER_URL", "").strip():
|
||||
missing.append("--server-url")
|
||||
if not (args.project_id and args.project_id.strip()):
|
||||
missing.append("--project-id")
|
||||
if missing:
|
||||
console.print(
|
||||
f" [red]Non-interactive mode (no TTY) requires all setup flags.[/red]\n"
|
||||
f" Missing: {', '.join(missing)}\n\n"
|
||||
" Usage:\n"
|
||||
" hermes secrets bitwarden setup \\\n"
|
||||
" --access-token '0.xxx' \\\n"
|
||||
" --server-url 'https://vault.bitwarden.com' \\\n"
|
||||
" --project-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'"
|
||||
)
|
||||
return 1
|
||||
|
||||
# ------------------------------------------------------------------- token
|
||||
console.print()
|
||||
console.print("[bold]Step 2[/bold] Provide your access token")
|
||||
|
|
@ -131,7 +163,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
|
||||
token = (args.access_token or "").strip()
|
||||
if not token:
|
||||
token = getpass.getpass(f" Paste access token ({token_env}): ").strip()
|
||||
token = masked_secret_prompt(f" Paste access token ({token_env}): ").strip()
|
||||
if not token:
|
||||
console.print(" [red]Empty token, aborting.[/red]")
|
||||
return 1
|
||||
|
|
@ -145,14 +177,28 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
os.environ[token_env] = token # so the test fetch below sees it
|
||||
console.print(f" [green]✓[/green] stored in {get_env_path()} as {token_env}")
|
||||
|
||||
# ------------------------------------------------------------------ region
|
||||
console.print()
|
||||
console.print("[bold]Step 3[/bold] Pick a Bitwarden region")
|
||||
server_url = _resolve_server_url(args, secrets_cfg, console)
|
||||
if server_url is None:
|
||||
return 1
|
||||
if server_url:
|
||||
console.print(f" [green]✓[/green] using {server_url}")
|
||||
else:
|
||||
console.print(
|
||||
" [green]✓[/green] using bws default "
|
||||
"(US Cloud, https://vault.bitwarden.com)"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------- project
|
||||
if args.project_id and args.project_id.strip():
|
||||
project_id = args.project_id.strip()
|
||||
else:
|
||||
console.print()
|
||||
console.print("[bold]Step 3[/bold] Pick a project")
|
||||
console.print("[bold]Step 4[/bold] Pick a project")
|
||||
project_id = ""
|
||||
projects = _list_projects(binary, token, console)
|
||||
projects = _list_projects(binary, token, console, server_url=server_url)
|
||||
if projects is None:
|
||||
return 1
|
||||
if not projects:
|
||||
|
|
@ -187,7 +233,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
|
||||
# ------------------------------------------------------------------- test
|
||||
console.print()
|
||||
step_num = 4 if not (args.project_id and args.project_id.strip()) else 3
|
||||
step_num = 5 if not (args.project_id and args.project_id.strip()) else 4
|
||||
console.print(f"[bold]Step {step_num}[/bold] Test fetch")
|
||||
try:
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
|
|
@ -195,6 +241,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
project_id=project_id,
|
||||
binary=binary,
|
||||
use_cache=False,
|
||||
server_url=server_url,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f" [red]✗ Fetch failed: {exc}[/red]")
|
||||
|
|
@ -221,6 +268,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
# ------------------------------------------------------------------- save
|
||||
secrets_cfg["enabled"] = True
|
||||
secrets_cfg["project_id"] = project_id
|
||||
secrets_cfg["server_url"] = server_url
|
||||
secrets_cfg.setdefault("access_token_env", token_env)
|
||||
secrets_cfg.setdefault("cache_ttl_seconds", 300)
|
||||
secrets_cfg.setdefault("override_existing", True)
|
||||
|
|
@ -248,6 +296,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||
enabled = bool(bw_cfg.get("enabled"))
|
||||
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
project_id = bw_cfg.get("project_id", "")
|
||||
server_url = str(bw_cfg.get("server_url", "") or "").strip()
|
||||
token_set = bool(os.environ.get(token_env))
|
||||
|
||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
|
|
@ -257,6 +306,10 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||
table.add_row("Token env var", token_env)
|
||||
table.add_row("Token in env", _yn(token_set))
|
||||
table.add_row("Project ID", project_id or "[dim](unset)[/dim]")
|
||||
table.add_row(
|
||||
"Server URL",
|
||||
server_url or "[dim]default (US Cloud, https://vault.bitwarden.com)[/dim]",
|
||||
)
|
||||
table.add_row("Override existing", _yn(bool(bw_cfg.get("override_existing", False))))
|
||||
table.add_row("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300)))
|
||||
table.add_row("Auto-install", _yn(bool(bw_cfg.get("auto_install", True))))
|
||||
|
|
@ -306,11 +359,14 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
|||
console.print("[red]No project_id configured.[/red]")
|
||||
return 1
|
||||
|
||||
server_url = str(bw_cfg.get("server_url", "") or "").strip()
|
||||
|
||||
try:
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token=token,
|
||||
project_id=project_id,
|
||||
use_cache=False,
|
||||
server_url=server_url,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f"[red]Fetch failed: {exc}[/red]")
|
||||
|
|
@ -407,12 +463,14 @@ def _bws_version(binary: Path) -> str:
|
|||
|
||||
|
||||
def _list_projects(
|
||||
binary: Path, token: str, console: Console
|
||||
binary: Path, token: str, console: Console, *, server_url: str = ""
|
||||
) -> Optional[List[dict]]:
|
||||
"""Call ``bws project list`` and return the parsed list, or None on failure."""
|
||||
env = os.environ.copy()
|
||||
env["BWS_ACCESS_TOKEN"] = token
|
||||
env.setdefault("NO_COLOR", "1")
|
||||
if server_url:
|
||||
env["BWS_SERVER_URL"] = server_url
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(binary), "project", "list", "--output", "json"],
|
||||
|
|
@ -428,7 +486,16 @@ def _list_projects(
|
|||
if res.returncode != 0:
|
||||
err = (res.stderr or res.stdout).strip()[:300]
|
||||
console.print(f" [red]bws project list failed: {err}[/red]")
|
||||
if "authorization" in err.lower() or "invalid" in err.lower():
|
||||
lowered = err.lower()
|
||||
if "invalid_client" in lowered or "400 bad request" in lowered:
|
||||
console.print(
|
||||
" [yellow]'invalid_client' from the US identity endpoint usually "
|
||||
"means the token is for a different Bitwarden region. Re-run "
|
||||
"[cyan]hermes secrets bitwarden setup[/cyan] and pick EU or "
|
||||
"self-hosted at the region prompt, or set [cyan]secrets.bitwarden."
|
||||
"server_url[/cyan] in config.yaml.[/yellow]"
|
||||
)
|
||||
elif "authorization" in lowered or "invalid" in lowered:
|
||||
console.print(
|
||||
" [yellow]This usually means the access token is wrong or revoked. "
|
||||
"Double-check it in the Bitwarden web app.[/yellow]"
|
||||
|
|
@ -443,3 +510,91 @@ def _list_projects(
|
|||
if not isinstance(data, list):
|
||||
return []
|
||||
return [p for p in data if isinstance(p, dict) and p.get("id")]
|
||||
|
||||
|
||||
# Canonical Bitwarden region endpoints. Keep in sync with what Bitwarden
|
||||
# publishes — these are stable but if a third region appears, add it here
|
||||
# and to the prompt below.
|
||||
_REGION_PRESETS = [
|
||||
("US Cloud (https://vault.bitwarden.com — bws default)", ""),
|
||||
("EU Cloud (https://vault.bitwarden.eu)", "https://vault.bitwarden.eu"),
|
||||
]
|
||||
|
||||
|
||||
def _resolve_server_url(
|
||||
args: argparse.Namespace,
|
||||
secrets_cfg: dict,
|
||||
console: Console,
|
||||
) -> Optional[str]:
|
||||
"""Pick a Bitwarden server URL for setup.
|
||||
|
||||
Resolution order:
|
||||
1. ``--server-url`` CLI flag (non-interactive)
|
||||
2. ``BWS_SERVER_URL`` env var (so users running with that already set
|
||||
in their shell don't have to re-enter it)
|
||||
3. Existing ``secrets.bitwarden.server_url`` value (for re-runs)
|
||||
4. Interactive menu: US / EU / self-hosted
|
||||
|
||||
Returns the chosen URL as a string (empty string = bws default,
|
||||
i.e. US Cloud). Returns None if the user aborted with an empty
|
||||
custom URL.
|
||||
"""
|
||||
if args.server_url and args.server_url.strip():
|
||||
return args.server_url.strip()
|
||||
|
||||
env_url = os.environ.get("BWS_SERVER_URL", "").strip()
|
||||
if env_url:
|
||||
console.print(
|
||||
f" Detected [cyan]BWS_SERVER_URL[/cyan]={env_url} in your shell — using it."
|
||||
)
|
||||
return env_url
|
||||
|
||||
existing = str(secrets_cfg.get("server_url", "") or "").strip()
|
||||
if existing:
|
||||
console.print(
|
||||
f" Existing config: [cyan]{existing}[/cyan]. "
|
||||
"Press Enter to keep, or pick a different option below."
|
||||
)
|
||||
|
||||
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
||||
table.add_column("#", style="cyan", width=4)
|
||||
table.add_column("Region / endpoint")
|
||||
for i, (label, _url) in enumerate(_REGION_PRESETS, 1):
|
||||
table.add_row(str(i), label)
|
||||
table.add_row(str(len(_REGION_PRESETS) + 1), "Self-hosted / custom URL")
|
||||
console.print(table)
|
||||
|
||||
custom_idx = len(_REGION_PRESETS) + 1
|
||||
while True:
|
||||
prompt = f" Select region [1-{custom_idx}]"
|
||||
if existing:
|
||||
prompt += " (Enter to keep current)"
|
||||
prompt += ": "
|
||||
choice = console.input(prompt).strip()
|
||||
if not choice:
|
||||
if existing:
|
||||
return existing
|
||||
console.print(" [red]Enter a number.[/red]")
|
||||
continue
|
||||
try:
|
||||
idx = int(choice)
|
||||
except ValueError:
|
||||
console.print(" [red]Enter a number.[/red]")
|
||||
continue
|
||||
if 1 <= idx <= len(_REGION_PRESETS):
|
||||
return _REGION_PRESETS[idx - 1][1]
|
||||
if idx == custom_idx:
|
||||
custom = console.input(
|
||||
" Enter your Bitwarden server URL "
|
||||
"(e.g. https://vault.example.com): "
|
||||
).strip()
|
||||
if not custom:
|
||||
console.print(" [red]Empty URL, aborting.[/red]")
|
||||
return None
|
||||
if not custom.startswith(("http://", "https://")):
|
||||
console.print(
|
||||
" [yellow]Warning: URL doesn't start with http:// or "
|
||||
"https:// — bws may reject it.[/yellow]"
|
||||
)
|
||||
return custom
|
||||
console.print(f" [red]Out of range — pick 1-{custom_idx}.[/red]")
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
|
@ -104,7 +104,9 @@ ADVISORIES: tuple[Advisory, ...] = (
|
|||
"them to a hardcoded webhook. If you ran any Python process that "
|
||||
"imported mistralai 2.4.6 — including hermes when configured "
|
||||
"with provider=mistral for TTS or STT — assume those credentials "
|
||||
"are exposed."
|
||||
"are exposed. PyPI has since removed 2.4.6 and the project ships "
|
||||
"clean releases again (2.4.7, 2.4.8); this advisory only fires if "
|
||||
"the compromised 2.4.6 is still installed."
|
||||
),
|
||||
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
|
||||
compromised=(
|
||||
|
|
|
|||
576
hermes_cli/security_audit.py
Normal file
576
hermes_cli/security_audit.py
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
"""On-demand supply-chain audit for Hermes Agent installs.
|
||||
|
||||
Scans three surfaces a Hermes user actually controls and we can map to
|
||||
upstream advisories without auth or extra binaries:
|
||||
|
||||
1. The Hermes venv (every PyPI dist via ``importlib.metadata``).
|
||||
2. Python deps declared by user-installed plugins under ``~/.hermes/plugins``
|
||||
(``requirements.txt`` + ``pyproject.toml`` best-effort pin extraction).
|
||||
3. MCP servers wired in ``config.yaml`` whose ``command/args`` look like
|
||||
``npx -y <pkg>@<ver>`` or ``uvx <pkg>==<ver>``.
|
||||
|
||||
Vulnerabilities are looked up against OSV.dev (``api.osv.dev/v1/querybatch``
|
||||
+ ``/v1/vulns/{id}``). Single-shot, on-demand, never daily — see the design
|
||||
notes in ``references/security-disclosure-triage.md``.
|
||||
|
||||
Out of scope on purpose: global pip/npm, editor/browser extensions,
|
||||
daily background scans, auto-blocking installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
|
||||
OSV_VULN_URL = "https://api.osv.dev/v1/vulns/{vid}"
|
||||
OSV_BATCH_MAX = 1000 # OSV documented hard cap per request
|
||||
HTTP_TIMEOUT = 20
|
||||
DETAIL_PARALLELISM = 8
|
||||
|
||||
# Severity ordering for --fail-on gating. UNKNOWN sits below LOW so it
|
||||
# never blocks unless --fail-on is passed something even lower (we don't
|
||||
# expose that).
|
||||
SEVERITY_ORDER = {
|
||||
"UNKNOWN": 0,
|
||||
"LOW": 1,
|
||||
"MODERATE": 2,
|
||||
"MEDIUM": 2,
|
||||
"HIGH": 3,
|
||||
"CRITICAL": 4,
|
||||
}
|
||||
|
||||
|
||||
# ─── Data shapes ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Component:
|
||||
"""A single (name, version, ecosystem) tuple discovered on disk."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
ecosystem: str # "PyPI" | "npm" — exactly as OSV expects
|
||||
source: str # human-readable origin, e.g. "venv", "plugin:foo", "mcp:bar"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Vulnerability:
|
||||
osv_id: str
|
||||
severity: str = "UNKNOWN"
|
||||
summary: str = ""
|
||||
fixed_versions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
component: Component
|
||||
vuln: Vulnerability
|
||||
|
||||
|
||||
# ─── Component discovery ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _discover_venv() -> list[Component]:
|
||||
"""Every dist installed in the running Python's import path."""
|
||||
from importlib.metadata import distributions
|
||||
|
||||
out: list[Component] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for dist in distributions():
|
||||
try:
|
||||
name = (dist.metadata["Name"] or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
version = (dist.version or "").strip()
|
||||
if not name or not version:
|
||||
continue
|
||||
key = (name.lower(), version)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(Component(name=name, version=version, ecosystem="PyPI", source="venv"))
|
||||
return out
|
||||
|
||||
|
||||
# requirements.txt line: drop comments, environment markers, options, extras
|
||||
_REQ_LINE = re.compile(
|
||||
r"""^\s*
|
||||
(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)
|
||||
(?:\[[^\]]+\])? # extras
|
||||
\s*==\s*
|
||||
(?P<version>[A-Za-z0-9._+!-]+)
|
||||
\s*(?:;.*)?$
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_requirements(text: str) -> list[tuple[str, str]]:
|
||||
"""Extract ``name==version`` pins. Everything else (>=, ~=, no pin) is skipped.
|
||||
|
||||
A loose pin can't be mapped to a single OSV query, and getting it wrong
|
||||
is worse than missing a finding for an audit tool — false positives
|
||||
train users to ignore output.
|
||||
"""
|
||||
pins: list[tuple[str, str]] = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith("-"):
|
||||
continue
|
||||
m = _REQ_LINE.match(line)
|
||||
if m:
|
||||
pins.append((m.group("name"), m.group("version")))
|
||||
return pins
|
||||
|
||||
|
||||
def _parse_pyproject_pins(text: str) -> list[tuple[str, str]]:
|
||||
"""Pull ``name==version`` pins from a ``pyproject.toml`` ``dependencies`` list.
|
||||
|
||||
Uses stdlib ``tomllib`` (3.11+). Same exact-pin policy as requirements.
|
||||
"""
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover - 3.10 only
|
||||
return []
|
||||
try:
|
||||
data = tomllib.loads(text)
|
||||
except Exception:
|
||||
return []
|
||||
deps: list[str] = []
|
||||
project = data.get("project") or {}
|
||||
if isinstance(project.get("dependencies"), list):
|
||||
deps.extend(str(x) for x in project["dependencies"])
|
||||
optional = project.get("optional-dependencies") or {}
|
||||
if isinstance(optional, dict):
|
||||
for group in optional.values():
|
||||
if isinstance(group, list):
|
||||
deps.extend(str(x) for x in group)
|
||||
pins: list[tuple[str, str]] = []
|
||||
for dep in deps:
|
||||
m = _REQ_LINE.match(dep)
|
||||
if m:
|
||||
pins.append((m.group("name"), m.group("version")))
|
||||
return pins
|
||||
|
||||
|
||||
def _discover_plugins(hermes_home: Path) -> list[Component]:
|
||||
"""Python deps declared by plugins under ``~/.hermes/plugins``.
|
||||
|
||||
Plugins typically don't install into the venv (they're directory-based
|
||||
with relative imports), so their stated requirements are useful audit
|
||||
surface even when the venv scan misses them.
|
||||
"""
|
||||
plugins_dir = hermes_home / "plugins"
|
||||
if not plugins_dir.is_dir():
|
||||
return []
|
||||
|
||||
out: list[Component] = []
|
||||
for plugin_dir in sorted(plugins_dir.iterdir()):
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
|
||||
continue
|
||||
source = f"plugin:{plugin_dir.name}"
|
||||
for req_file in ("requirements.txt", "requirements-dev.txt"):
|
||||
path = plugin_dir / req_file
|
||||
if path.is_file():
|
||||
try:
|
||||
pins = _parse_requirements(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except OSError:
|
||||
continue
|
||||
for name, version in pins:
|
||||
out.append(Component(name=name, version=version, ecosystem="PyPI", source=source))
|
||||
pyproject = plugin_dir / "pyproject.toml"
|
||||
if pyproject.is_file():
|
||||
try:
|
||||
pins = _parse_pyproject_pins(pyproject.read_text(encoding="utf-8", errors="replace"))
|
||||
except OSError:
|
||||
continue
|
||||
for name, version in pins:
|
||||
out.append(Component(name=name, version=version, ecosystem="PyPI", source=source))
|
||||
return out
|
||||
|
||||
|
||||
# npx forms we recognise:
|
||||
# npx -y @scope/pkg@1.2.3
|
||||
# npx --yes pkg@1.2.3
|
||||
# npx pkg@1.2.3 [...args]
|
||||
# We deliberately don't try to resolve unversioned names — that maps to
|
||||
# "latest" at runtime and isn't a stable audit subject.
|
||||
_NPX_PKG = re.compile(r"^(@[A-Za-z0-9._-]+/[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)@([A-Za-z0-9._+-]+)$")
|
||||
# uvx forms:
|
||||
# uvx pkg==1.2.3
|
||||
# uvx --with pkg==1.2.3 entrypoint
|
||||
_UVX_PKG = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9._+!-]+)$")
|
||||
|
||||
|
||||
def _extract_mcp_component(server_name: str, command: str, args: list[str]) -> Optional[Component]:
|
||||
"""Best-effort: parse `command/args` into a (name, version, ecosystem).
|
||||
|
||||
Returns None when the entry doesn't pin a version we can audit (local
|
||||
paths, Docker images, unversioned npx, etc.). Audit output stays silent
|
||||
rather than guess.
|
||||
"""
|
||||
cmd = (command or "").strip().lower()
|
||||
if not args:
|
||||
return None
|
||||
# npx (any prefix path)
|
||||
if cmd.endswith("npx") or cmd == "npx":
|
||||
# Skip flag tokens until we see the first thing that looks like a pkg ref
|
||||
for token in args:
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
m = _NPX_PKG.match(token)
|
||||
if m:
|
||||
return Component(
|
||||
name=m.group(1),
|
||||
version=m.group(2),
|
||||
ecosystem="npm",
|
||||
source=f"mcp:{server_name}",
|
||||
)
|
||||
return None # First non-flag token isn't a pinned ref
|
||||
# uvx (any prefix path)
|
||||
if cmd.endswith("uvx") or cmd == "uvx":
|
||||
for token in args:
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
m = _UVX_PKG.match(token)
|
||||
if m:
|
||||
return Component(
|
||||
name=m.group(1),
|
||||
version=m.group(2),
|
||||
ecosystem="PyPI",
|
||||
source=f"mcp:{server_name}",
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _discover_mcp() -> list[Component]:
|
||||
"""Pinned MCP server packages from ``config.yaml``."""
|
||||
try:
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
out: list[Component] = []
|
||||
servers = _get_mcp_servers()
|
||||
if not isinstance(servers, dict):
|
||||
return []
|
||||
for name, cfg in servers.items():
|
||||
if not isinstance(cfg, dict):
|
||||
continue
|
||||
command = cfg.get("command", "") or ""
|
||||
args = cfg.get("args") or []
|
||||
if not isinstance(args, list):
|
||||
continue
|
||||
comp = _extract_mcp_component(name, command, [str(a) for a in args])
|
||||
if comp is not None:
|
||||
out.append(comp)
|
||||
return out
|
||||
|
||||
|
||||
# ─── OSV client ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict) -> dict:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _http_get_json(url: str) -> dict:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _osv_query_batch(components: list[Component]) -> dict[Component, list[str]]:
|
||||
"""Return {component -> [osv_id, ...]} for components with any vulns.
|
||||
|
||||
Components without findings are omitted from the result dict.
|
||||
"""
|
||||
if not components:
|
||||
return {}
|
||||
findings: dict[Component, list[str]] = {}
|
||||
for chunk_start in range(0, len(components), OSV_BATCH_MAX):
|
||||
chunk = components[chunk_start:chunk_start + OSV_BATCH_MAX]
|
||||
payload = {
|
||||
"queries": [
|
||||
{
|
||||
"package": {"name": c.name, "ecosystem": c.ecosystem},
|
||||
"version": c.version,
|
||||
}
|
||||
for c in chunk
|
||||
]
|
||||
}
|
||||
try:
|
||||
resp = _http_post_json(OSV_BATCH_URL, payload)
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
|
||||
raise RuntimeError(f"OSV batch query failed: {exc}") from exc
|
||||
results = resp.get("results") or []
|
||||
for comp, result in zip(chunk, results):
|
||||
vulns = (result or {}).get("vulns") or []
|
||||
ids = [v.get("id") for v in vulns if v.get("id")]
|
||||
if ids:
|
||||
findings[comp] = ids
|
||||
return findings
|
||||
|
||||
|
||||
def _osv_severity_from_record(record: dict) -> str:
|
||||
"""Extract CVSS-derived severity tier from an OSV vuln record."""
|
||||
# OSV puts CVSS in `severity` (top-level or per-affected) and a
|
||||
# human-readable bucket in `database_specific.severity` for GHSAs.
|
||||
db_specific = record.get("database_specific") or {}
|
||||
raw = db_specific.get("severity")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
upper = raw.strip().upper()
|
||||
if upper in SEVERITY_ORDER:
|
||||
return upper
|
||||
# Fall back to CVSS score → tier
|
||||
score: Optional[float] = None
|
||||
for sev_entry in record.get("severity") or []:
|
||||
s = sev_entry.get("score")
|
||||
if isinstance(s, str):
|
||||
# CVSS vector strings look like "CVSS:3.1/AV:N/..." — we can't
|
||||
# parse without a lib. Look for an explicit numeric in
|
||||
# affected[].ecosystem_specific later if present.
|
||||
continue
|
||||
affected = record.get("affected") or []
|
||||
for entry in affected:
|
||||
eco_spec = entry.get("ecosystem_specific") or {}
|
||||
sev = eco_spec.get("severity")
|
||||
if isinstance(sev, str) and sev.strip().upper() in SEVERITY_ORDER:
|
||||
return sev.strip().upper()
|
||||
if score is not None:
|
||||
if score >= 9.0:
|
||||
return "CRITICAL"
|
||||
if score >= 7.0:
|
||||
return "HIGH"
|
||||
if score >= 4.0:
|
||||
return "MODERATE"
|
||||
if score > 0:
|
||||
return "LOW"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _osv_fixed_versions(record: dict) -> list[str]:
|
||||
fixes: list[str] = []
|
||||
for entry in record.get("affected") or []:
|
||||
for rng in entry.get("ranges") or []:
|
||||
for event in rng.get("events") or []:
|
||||
if "fixed" in event:
|
||||
fixes.append(str(event["fixed"]))
|
||||
# Dedupe, preserve order
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for f in fixes:
|
||||
if f not in seen:
|
||||
seen.add(f)
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
def _osv_fetch_details(vuln_ids: Iterable[str]) -> dict[str, Vulnerability]:
|
||||
"""Fetch summary/severity for each unique vuln id, in parallel."""
|
||||
unique = sorted({vid for vid in vuln_ids if vid})
|
||||
if not unique:
|
||||
return {}
|
||||
out: dict[str, Vulnerability] = {}
|
||||
|
||||
def _fetch_one(vid: str) -> Vulnerability:
|
||||
try:
|
||||
rec = _http_get_json(OSV_VULN_URL.format(vid=vid))
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
||||
return Vulnerability(osv_id=vid)
|
||||
return Vulnerability(
|
||||
osv_id=vid,
|
||||
severity=_osv_severity_from_record(rec),
|
||||
summary=(rec.get("summary") or "").strip(),
|
||||
fixed_versions=_osv_fixed_versions(rec),
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=DETAIL_PARALLELISM) as pool:
|
||||
for vuln in pool.map(_fetch_one, unique):
|
||||
out[vuln.osv_id] = vuln
|
||||
return out
|
||||
|
||||
|
||||
# ─── Orchestration ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_audit(
|
||||
*,
|
||||
skip_venv: bool = False,
|
||||
skip_plugins: bool = False,
|
||||
skip_mcp: bool = False,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> list[Finding]:
|
||||
"""Discover components, query OSV, return findings sorted by severity desc."""
|
||||
home = hermes_home or Path(get_hermes_home())
|
||||
components: list[Component] = []
|
||||
if not skip_venv:
|
||||
components.extend(_discover_venv())
|
||||
if not skip_plugins:
|
||||
components.extend(_discover_plugins(home))
|
||||
if not skip_mcp:
|
||||
components.extend(_discover_mcp())
|
||||
|
||||
if not components:
|
||||
return []
|
||||
|
||||
raw = _osv_query_batch(components)
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
all_ids: list[str] = []
|
||||
for ids in raw.values():
|
||||
all_ids.extend(ids)
|
||||
details = _osv_fetch_details(all_ids)
|
||||
|
||||
findings: list[Finding] = []
|
||||
for comp, ids in raw.items():
|
||||
for vid in ids:
|
||||
vuln = details.get(vid) or Vulnerability(osv_id=vid)
|
||||
findings.append(Finding(component=comp, vuln=vuln))
|
||||
|
||||
findings.sort(
|
||||
key=lambda f: (
|
||||
-SEVERITY_ORDER.get(f.vuln.severity, 0),
|
||||
f.component.source,
|
||||
f.component.name.lower(),
|
||||
f.vuln.osv_id,
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
# ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _render_human(findings: list[Finding], total_components: int) -> str:
|
||||
if not findings:
|
||||
return f"No known vulnerabilities found across {total_components} component(s)."
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(
|
||||
f"Found {len(findings)} known vulnerability finding(s) "
|
||||
f"across {total_components} component(s):"
|
||||
)
|
||||
lines.append("")
|
||||
last_source = None
|
||||
for f in findings:
|
||||
if f.component.source != last_source:
|
||||
lines.append(f"[{f.component.source}]")
|
||||
last_source = f.component.source
|
||||
sev = f.vuln.severity.ljust(8)
|
||||
head = f" {sev} {f.component.name}=={f.component.version} {f.vuln.osv_id}"
|
||||
lines.append(head)
|
||||
if f.vuln.summary:
|
||||
summary = f.vuln.summary
|
||||
if len(summary) > 100:
|
||||
summary = summary[:97] + "..."
|
||||
lines.append(f" {summary}")
|
||||
if f.vuln.fixed_versions:
|
||||
lines.append(f" fixed in: {', '.join(f.vuln.fixed_versions[:3])}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_json(findings: list[Finding], total_components: int) -> str:
|
||||
payload = {
|
||||
"total_components_scanned": total_components,
|
||||
"finding_count": len(findings),
|
||||
"findings": [
|
||||
{
|
||||
"package": f.component.name,
|
||||
"version": f.component.version,
|
||||
"ecosystem": f.component.ecosystem,
|
||||
"source": f.component.source,
|
||||
"vuln_id": f.vuln.osv_id,
|
||||
"severity": f.vuln.severity,
|
||||
"summary": f.vuln.summary,
|
||||
"fixed_versions": f.vuln.fixed_versions,
|
||||
}
|
||||
for f in findings
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
|
||||
def _count_components(
|
||||
*, skip_venv: bool, skip_plugins: bool, skip_mcp: bool, hermes_home: Path
|
||||
) -> int:
|
||||
total = 0
|
||||
if not skip_venv:
|
||||
total += len(_discover_venv())
|
||||
if not skip_plugins:
|
||||
total += len(_discover_plugins(hermes_home))
|
||||
if not skip_mcp:
|
||||
total += len(_discover_mcp())
|
||||
return total
|
||||
|
||||
|
||||
# ─── CLI entrypoint ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_security_audit(args: argparse.Namespace) -> int:
|
||||
"""Implementation of `hermes security audit`."""
|
||||
home = Path(get_hermes_home())
|
||||
skip_venv = bool(getattr(args, "skip_venv", False))
|
||||
skip_plugins = bool(getattr(args, "skip_plugins", False))
|
||||
skip_mcp = bool(getattr(args, "skip_mcp", False))
|
||||
output_json = bool(getattr(args, "json", False))
|
||||
fail_on = (getattr(args, "fail_on", None) or "critical").upper()
|
||||
if fail_on not in SEVERITY_ORDER:
|
||||
print(
|
||||
f"unknown --fail-on value: {fail_on.lower()} "
|
||||
f"(choose from: low, moderate, high, critical)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
total = _count_components(
|
||||
skip_venv=skip_venv, skip_plugins=skip_plugins, skip_mcp=skip_mcp, hermes_home=home
|
||||
)
|
||||
if total == 0:
|
||||
msg = "No components discovered (everything skipped, or empty environment)."
|
||||
if output_json:
|
||||
print(json.dumps({"total_components_scanned": 0, "finding_count": 0, "findings": []}))
|
||||
else:
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
try:
|
||||
findings = run_audit(
|
||||
skip_venv=skip_venv,
|
||||
skip_plugins=skip_plugins,
|
||||
skip_mcp=skip_mcp,
|
||||
hermes_home=home,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"audit failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if output_json:
|
||||
print(_render_json(findings, total))
|
||||
else:
|
||||
print(_render_human(findings, total))
|
||||
|
||||
# Exit code: 1 iff any finding meets or exceeds the --fail-on threshold.
|
||||
threshold = SEVERITY_ORDER[fail_on]
|
||||
for f in findings:
|
||||
if SEVERITY_ORDER.get(f.vuln.severity, 0) >= threshold:
|
||||
return 1
|
||||
return 0
|
||||
976
hermes_cli/service_manager.py
Normal file
976
hermes_cli/service_manager.py
Normal file
|
|
@ -0,0 +1,976 @@
|
|||
"""Abstract service manager interface.
|
||||
|
||||
Wraps the existing systemd (Linux host), launchd (macOS host), Windows
|
||||
Scheduled Task (native Windows host), and s6 (container) backends behind
|
||||
a common Protocol. Only the s6 backend supports runtime registration
|
||||
(for per-profile gateways) — host backends raise NotImplementedError
|
||||
from those methods, and callers MUST check supports_runtime_registration()
|
||||
before invoking them.
|
||||
|
||||
Host-side call sites (setup wizard, uninstall, status) continue to use
|
||||
the existing module-level functions in hermes_cli.gateway and
|
||||
hermes_cli.gateway_windows directly. This protocol is a thin facade
|
||||
used by new code that needs to be backend-agnostic — specifically the
|
||||
profile create/delete hooks (Phase 4) and the s6 dispatch path in
|
||||
``hermes gateway start/stop/restart`` when running inside a container.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
ServiceManagerKind = Literal["systemd", "launchd", "windows", "s6", "none"]
|
||||
|
||||
# Profile name → service directory mapping. Profile names must be safe
|
||||
# as filesystem directory names because the s6 backend creates a service
|
||||
# directory at ``<scandir>/gateway-<profile>/``. We reject anything that
|
||||
# could traverse paths, span filesystems, or break s6's own naming rules.
|
||||
_VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||
_MAX_PROFILE_LEN = 251 # s6-svscan default name_max
|
||||
|
||||
|
||||
def validate_profile_name(name: str) -> None:
|
||||
"""Raise ValueError if ``name`` is not usable as a profile name.
|
||||
|
||||
Profile names are used as s6 service directory names, so they must
|
||||
match a conservative subset of filesystem-safe characters. Reject
|
||||
empty strings, uppercase, paths-traversal sequences, and anything
|
||||
longer than s6's default ``name_max``.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("profile name must not be empty")
|
||||
if len(name) > _MAX_PROFILE_LEN:
|
||||
raise ValueError(
|
||||
f"profile name too long ({len(name)} > {_MAX_PROFILE_LEN})"
|
||||
)
|
||||
if not _VALID_PROFILE_RE.match(name):
|
||||
raise ValueError(
|
||||
f"profile name must match [a-z0-9][a-z0-9_-]*, got {name!r}"
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ServiceManager(Protocol):
|
||||
"""Abstract interface for init-system-specific service operations.
|
||||
|
||||
Lifecycle methods (start / stop / restart / is_running) are
|
||||
implemented by every backend. Runtime registration
|
||||
(register_profile_gateway / unregister_profile_gateway /
|
||||
list_profile_gateways) is implemented only by the s6 backend —
|
||||
callers MUST check ``supports_runtime_registration()`` before
|
||||
invoking the registration methods.
|
||||
"""
|
||||
|
||||
kind: ServiceManagerKind
|
||||
|
||||
# Lifecycle of a pre-declared service.
|
||||
def start(self, name: str) -> None: ...
|
||||
def stop(self, name: str) -> None: ...
|
||||
def restart(self, name: str) -> None: ...
|
||||
def is_running(self, name: str) -> bool: ...
|
||||
|
||||
# Runtime registration (s6 only).
|
||||
def supports_runtime_registration(self) -> bool: ...
|
||||
def register_profile_gateway(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> None: ...
|
||||
def unregister_profile_gateway(self, profile: str) -> None: ...
|
||||
def list_profile_gateways(self) -> list[str]: ...
|
||||
|
||||
|
||||
def detect_service_manager() -> ServiceManagerKind:
|
||||
"""Detect which service manager is available in this environment.
|
||||
|
||||
Returns:
|
||||
"s6" — inside a container when /init is s6-svscan (Phase 2+)
|
||||
"windows" — native Windows host
|
||||
"launchd" — macOS host
|
||||
"systemd" — Linux host with a working user/system bus
|
||||
"none" — anything else (Termux, sandbox shells, etc.)
|
||||
|
||||
This function does NOT replace ``supports_systemd_services()`` —
|
||||
host call sites continue to use that. It exists for new backend-
|
||||
agnostic code (profile create/delete hooks, the s6 dispatch path
|
||||
in ``hermes gateway start/stop/restart``).
|
||||
"""
|
||||
# Imports deferred so importing this module doesn't drag in the
|
||||
# whole gateway dependency graph for callers that only need the
|
||||
# Protocol type or validate_profile_name().
|
||||
from hermes_constants import is_container
|
||||
from hermes_cli.gateway import (
|
||||
is_macos,
|
||||
is_windows,
|
||||
supports_systemd_services,
|
||||
)
|
||||
|
||||
if is_container() and _s6_running():
|
||||
return "s6"
|
||||
if is_windows():
|
||||
return "windows"
|
||||
if is_macos():
|
||||
return "launchd"
|
||||
if supports_systemd_services():
|
||||
return "systemd"
|
||||
return "none"
|
||||
|
||||
|
||||
def _s6_running() -> bool:
|
||||
"""True when s6-svscan is running as PID 1 in this container.
|
||||
|
||||
Detection has to work for **both** root and the unprivileged hermes
|
||||
user (UID 10000). The obvious probe — ``Path('/proc/1/exe').resolve()``
|
||||
— only works as root: for any other UID, the symlink at
|
||||
``/proc/1/exe`` is unreadable and ``resolve()`` silently returns the
|
||||
path unchanged, so the resolved name is the literal ``"exe"`` and
|
||||
detection always fails. Since every Hermes runtime call inside the
|
||||
container drops to hermes via ``s6-setuidgid``, that silent failure
|
||||
made the entire service-manager runtime-registration path inert in
|
||||
production (PR #30136 review).
|
||||
|
||||
Probe instead via:
|
||||
* ``/proc/1/comm`` — world-readable, contains the process comm
|
||||
(``s6-svscan`` when s6-overlay is PID 1).
|
||||
* ``/run/s6/basedir`` — s6-overlay-specific directory created by
|
||||
stage1. World-readable. More specific than ``/run/s6`` (which
|
||||
other tools occasionally create).
|
||||
|
||||
Both signals are required; either alone could false-positive
|
||||
(e.g. a container with the s6 binaries installed but a different
|
||||
init, or an unrelated process named ``s6-svscan``).
|
||||
"""
|
||||
try:
|
||||
comm = Path("/proc/1/comm").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return False
|
||||
if comm != "s6-svscan":
|
||||
return False
|
||||
return Path("/run/s6/basedir").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend wrappers
|
||||
#
|
||||
# These adapters are thin facades over the existing module-level functions
|
||||
# in ``hermes_cli.gateway`` (systemd/launchd) and ``hermes_cli.gateway_windows``
|
||||
# (Windows Scheduled Tasks). The protocol's ``name`` parameter is currently
|
||||
# unused for host backends — they operate on whichever profile is currently
|
||||
# active (set via the ``hermes -p <profile>`` flag before the call). This
|
||||
# matches existing host-side semantics; the parameter shape is designed
|
||||
# for s6 where each profile maps to a distinct service directory.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RegistrationUnsupportedMixin:
|
||||
"""Mixin for host backends that don't support runtime registration."""
|
||||
|
||||
def supports_runtime_registration(self) -> bool:
|
||||
return False
|
||||
|
||||
def register_profile_gateway(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support runtime profile "
|
||||
"gateway registration (container-only feature)"
|
||||
)
|
||||
|
||||
def unregister_profile_gateway(self, profile: str) -> None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support runtime profile "
|
||||
"gateway unregistration (container-only feature)"
|
||||
)
|
||||
|
||||
def list_profile_gateways(self) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
class SystemdServiceManager(_RegistrationUnsupportedMixin):
|
||||
"""Thin wrapper around the ``systemd_*`` functions in hermes_cli.gateway.
|
||||
|
||||
Existing host call sites continue to use those functions directly;
|
||||
this wrapper exists for new code that needs to be backend-agnostic
|
||||
(the Phase 4 profile create/delete hooks).
|
||||
"""
|
||||
|
||||
kind: ServiceManagerKind = "systemd"
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
from hermes_cli.gateway import systemd_start
|
||||
systemd_start()
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
from hermes_cli.gateway import systemd_stop
|
||||
systemd_stop()
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
from hermes_cli.gateway import systemd_restart
|
||||
systemd_restart()
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
from hermes_cli.gateway import _probe_systemd_service_running
|
||||
_, running = _probe_systemd_service_running()
|
||||
return running
|
||||
|
||||
|
||||
class LaunchdServiceManager(_RegistrationUnsupportedMixin):
|
||||
"""Thin wrapper around the ``launchd_*`` functions in hermes_cli.gateway."""
|
||||
|
||||
kind: ServiceManagerKind = "launchd"
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
from hermes_cli.gateway import launchd_start
|
||||
launchd_start()
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
from hermes_cli.gateway import launchd_stop
|
||||
launchd_stop()
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
from hermes_cli.gateway import launchd_restart
|
||||
launchd_restart()
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
from hermes_cli.gateway import _probe_launchd_service_running
|
||||
return _probe_launchd_service_running()
|
||||
|
||||
|
||||
class WindowsServiceManager(_RegistrationUnsupportedMixin):
|
||||
"""Thin wrapper around ``hermes_cli.gateway_windows`` (Scheduled Task /
|
||||
Startup-folder fallback).
|
||||
|
||||
The native Windows backend uses a Scheduled Task rather than a true
|
||||
init-system service, but for protocol purposes the lifecycle is the
|
||||
same: start / stop / restart / is_running. ``install`` accepts a
|
||||
handful of Windows-specific kwargs (start_now, start_on_login,
|
||||
elevated_handoff) that are passed straight through — non-Windows
|
||||
callers should never invoke ``install`` on this wrapper.
|
||||
"""
|
||||
|
||||
kind: ServiceManagerKind = "windows"
|
||||
|
||||
def install(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
start_now: bool | None = None,
|
||||
start_on_login: bool | None = None,
|
||||
elevated_handoff: bool = False,
|
||||
) -> None:
|
||||
from hermes_cli import gateway_windows
|
||||
gateway_windows.install(
|
||||
force=force,
|
||||
start_now=start_now,
|
||||
start_on_login=start_on_login,
|
||||
elevated_handoff=elevated_handoff,
|
||||
)
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
from hermes_cli import gateway_windows
|
||||
gateway_windows.start()
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
from hermes_cli import gateway_windows
|
||||
gateway_windows.stop()
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
from hermes_cli import gateway_windows
|
||||
gateway_windows.restart()
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
if not gateway_windows.is_installed():
|
||||
return False
|
||||
return bool(find_gateway_pids())
|
||||
|
||||
|
||||
def get_service_manager() -> ServiceManager:
|
||||
"""Return the ServiceManager instance for the current environment.
|
||||
|
||||
Raises:
|
||||
RuntimeError: when no supported backend is available.
|
||||
"""
|
||||
kind = detect_service_manager()
|
||||
if kind == "systemd":
|
||||
return SystemdServiceManager()
|
||||
if kind == "launchd":
|
||||
return LaunchdServiceManager()
|
||||
if kind == "windows":
|
||||
return WindowsServiceManager()
|
||||
if kind == "s6":
|
||||
return S6ServiceManager()
|
||||
raise RuntimeError("no supported service manager detected")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S6ServiceManager (container-only)
|
||||
#
|
||||
# Per-profile gateways are registered dynamically when `hermes profile create`
|
||||
# runs inside the container (Phase 4). Static services (main-hermes, dashboard)
|
||||
# live in /etc/s6-overlay/s6-rc.d/ and are NOT managed by this class — they're
|
||||
# part of the image, not runtime-created.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# s6-overlay's dynamic scandir for runtime-registered services. Lives on
|
||||
# tmpfs and is the directory s6-svscan watches. Writes here trigger
|
||||
# automatic supervision on the next rescan.
|
||||
S6_DYNAMIC_SCANDIR = Path("/run/service")
|
||||
S6_SERVICE_PREFIX = "gateway-"
|
||||
|
||||
# s6-overlay installs its binaries under /command/ and only adds that
|
||||
# directory to PATH for processes started under the supervision tree
|
||||
# (services started by s6-svscan, cont-init.d scripts, etc.). Code
|
||||
# that runs via `docker exec` or any other out-of-tree entry point —
|
||||
# notably our Phase 4 profile create/delete hooks — inherits the
|
||||
# container's base PATH which does NOT include /command/.
|
||||
#
|
||||
# Rather than asking every caller to fix up its environment, the
|
||||
# S6ServiceManager calls s6-* binaries by absolute path via this
|
||||
# constant. We don't use `/usr/bin/s6-…` symlinks because the
|
||||
# s6-overlay-symlinks-noarch tarball only links a subset, and we
|
||||
# want every s6 invocation to be guaranteed-findable.
|
||||
_S6_BIN_DIR = "/command"
|
||||
|
||||
|
||||
# UID/GID of the in-image ``hermes`` user. Hardcoded to match what
|
||||
# ``stage2-hook.sh`` enforces (the runtime invariant — see also
|
||||
# tests/docker/test_uid_remap.py). The container starts s6-supervise
|
||||
# under root and immediately drops to this UID via ``s6-setuidgid``.
|
||||
_HERMES_UID = 10000
|
||||
_HERMES_GID = 10000
|
||||
|
||||
|
||||
def _seed_supervise_skeleton(svc_dir: Path) -> None:
|
||||
"""Pre-create the ``supervise/`` and top-level ``event/`` skeleton
|
||||
inside a service directory, owned by the hermes user.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
When s6-supervise spawns a service it tries to ``mkdir`` two
|
||||
directories: ``<svc>/event`` and ``<svc>/supervise``, both with mode
|
||||
``0700``. It also ``mkfifo``s ``<svc>/supervise/control`` with mode
|
||||
``0600``. Because s6-supervise runs as PID 1's effective UID (root)
|
||||
these dirs end up root-owned mode 0700, and an unprivileged client
|
||||
(the ``hermes`` user — UID 10000 — running every Hermes runtime
|
||||
operation via ``s6-setuidgid``) gets ``EACCES`` on any ``s6-svc``,
|
||||
``s6-svstat``, or ``s6-svwait`` invocation against the slot.
|
||||
|
||||
The PR #30136 review surfaced this as a real product gap: the
|
||||
entire S6ServiceManager lifecycle (``register/start/stop/unregister
|
||||
_profile_gateway``) was inert in production because every operation
|
||||
is dispatched as the hermes user.
|
||||
|
||||
Why this works
|
||||
--------------
|
||||
Reading s6's source (src/supervision/s6-supervise.c::trymkdir +
|
||||
control_init): the ``mkdir`` and ``mkfifo`` calls both treat
|
||||
``EEXIST`` as success. If the directory is already present, the
|
||||
chown/chmod fix-up that would normally make event/ ``03730
|
||||
root:root`` is **skipped** entirely — s6-supervise just opens the
|
||||
pre-existing FIFOs and proceeds. So if we lay the skeleton down
|
||||
with hermes ownership before triggering ``s6-svscanctl -a``,
|
||||
s6-supervise inherits our layout and never touches it.
|
||||
|
||||
Layout produced
|
||||
---------------
|
||||
``svc_dir/`` hermes:hermes, 0755 (parent must already exist)
|
||||
``svc_dir/event/`` hermes:hermes, 03730 (setgid + g+rwx + sticky)
|
||||
``svc_dir/supervise/`` hermes:hermes, 0755
|
||||
``svc_dir/supervise/event/`` hermes:hermes, 03730
|
||||
``svc_dir/supervise/control`` hermes:hermes, 0660 (FIFO)
|
||||
|
||||
The ``death_tally``, ``lock``, and ``status`` regular files end up
|
||||
written by s6-supervise itself (as root), but those land mode 0644 —
|
||||
world-readable — and ``s6-svstat`` only needs read access, so the
|
||||
hermes user reads them fine.
|
||||
|
||||
If ``svc_dir/log/`` is present (the canonical s6 logger pattern —
|
||||
one s6-supervise instance per service, plus a second for its
|
||||
logger), the same skeleton is seeded under ``log/`` as well:
|
||||
``log/event/``, ``log/supervise/``, ``log/supervise/event/``,
|
||||
``log/supervise/control``. Without this, unregister teardown
|
||||
would EACCES on the logger's supervise dir even after the parent
|
||||
slot's supervise/ was hermes-owned.
|
||||
|
||||
Idempotency
|
||||
-----------
|
||||
Safe to call against a directory where the skeleton already exists.
|
||||
Existing entries are left untouched (the helper doesn't try to
|
||||
re-chown / re-chmod live FIFOs that s6-supervise may have already
|
||||
opened).
|
||||
|
||||
Reference
|
||||
---------
|
||||
Discussed at length on the skarnet `skaware` mailing list in 2020
|
||||
(`<http://skarnet.org/lists/skaware/1424.html>`_); see also
|
||||
just-containers/s6-overlay#130. The pre-creation pattern was
|
||||
historically called out as forward-compatibility-fragile, but the
|
||||
EEXIST handling in s6-supervise has been stable since 2015 — it's
|
||||
the same pattern ``s6-svperms`` and ``fix-attrs.d`` rely on.
|
||||
"""
|
||||
import os
|
||||
|
||||
def _mkdir_owned(path: Path, mode: int) -> None:
|
||||
if path.exists():
|
||||
return
|
||||
path.mkdir(parents=False, exist_ok=False)
|
||||
path.chmod(mode)
|
||||
try:
|
||||
os.chown(path, _HERMES_UID, _HERMES_GID)
|
||||
except PermissionError:
|
||||
# Running as the hermes user already — directory is hermes-
|
||||
# owned by default. The chown is a no-op in that case, so
|
||||
# swallowing this keeps both root and unprivileged callers
|
||||
# on one code path.
|
||||
pass
|
||||
|
||||
# Top-level event/ dir (this is the s6-svlisten1 event-subscription
|
||||
# dir at the service root, distinct from supervise/event/).
|
||||
_mkdir_owned(svc_dir / "event", 0o3730)
|
||||
|
||||
# supervise/ dir + its inner event/ dir.
|
||||
supervise = svc_dir / "supervise"
|
||||
_mkdir_owned(supervise, 0o755)
|
||||
_mkdir_owned(supervise / "event", 0o3730)
|
||||
|
||||
# supervise/control FIFO. Same EEXIST-safe pattern: if it's already
|
||||
# there (s6-supervise has already started against this slot), leave
|
||||
# it alone. The explicit chmod after mkfifo is required because
|
||||
# mkfifo honors the process umask, which can strip group-write
|
||||
# (e.g. the default 0022 on most dev hosts → 0o660 becomes 0o640).
|
||||
# The container runs with umask 0 inside s6-overlay's stage2, but
|
||||
# being defensive here keeps the helper consistent under any
|
||||
# invocation context.
|
||||
control = supervise / "control"
|
||||
if not control.exists():
|
||||
os.mkfifo(control, 0o660)
|
||||
control.chmod(0o660)
|
||||
try:
|
||||
os.chown(control, _HERMES_UID, _HERMES_GID)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# If a log/ subdir is present (the canonical s6 logger pattern —
|
||||
# see servicedir(7)), it gets its own s6-supervise instance and
|
||||
# needs the same skeleton. Without this, unregister teardown
|
||||
# would EACCES on the logger's root-owned supervise/ dir even
|
||||
# when the parent slot's supervise/ is hermes-owned.
|
||||
log_dir = svc_dir / "log"
|
||||
if log_dir.is_dir():
|
||||
_mkdir_owned(log_dir / "event", 0o3730)
|
||||
log_supervise = log_dir / "supervise"
|
||||
_mkdir_owned(log_supervise, 0o755)
|
||||
_mkdir_owned(log_supervise / "event", 0o3730)
|
||||
log_control = log_supervise / "control"
|
||||
if not log_control.exists():
|
||||
os.mkfifo(log_control, 0o660)
|
||||
log_control.chmod(0o660)
|
||||
try:
|
||||
os.chown(log_control, _HERMES_UID, _HERMES_GID)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
class S6Error(RuntimeError):
|
||||
"""Base error for S6ServiceManager lifecycle failures.
|
||||
|
||||
Concrete subclasses carry the slot name (and, where useful, the
|
||||
underlying subprocess output) so the CLI can render an actionable
|
||||
message instead of leaking a raw ``CalledProcessError`` traceback.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, service: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.service = service
|
||||
|
||||
|
||||
class GatewayNotRegisteredError(S6Error):
|
||||
"""Raised when a lifecycle method targets a slot that doesn't exist.
|
||||
|
||||
Most commonly: ``hermes -p typo gateway start`` when no profile
|
||||
``typo`` exists. Carries the unprefixed profile name (not the
|
||||
full ``gateway-<profile>`` service-dir name) so callers can phrase
|
||||
a user-facing message like "no such gateway 'typo'".
|
||||
"""
|
||||
|
||||
def __init__(self, profile: str) -> None:
|
||||
self.profile = profile
|
||||
super().__init__(
|
||||
f"no such gateway {profile!r}: register it with "
|
||||
f"`hermes profile create {profile}` first, or pass "
|
||||
"an existing profile name via `-p <name>`",
|
||||
service=f"gateway-{profile}",
|
||||
)
|
||||
|
||||
|
||||
class S6CommandError(S6Error):
|
||||
"""Raised when an s6 command fails for a reason other than a
|
||||
missing slot — e.g. permission denied on the supervise control
|
||||
FIFO, or s6-svc returning a non-zero exit for an unexpected
|
||||
reason. Carries the stderr from the failing command so callers
|
||||
can surface it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *, service: str, action: str, returncode: int, stderr: str,
|
||||
) -> None:
|
||||
self.action = action
|
||||
self.returncode = returncode
|
||||
self.stderr = stderr
|
||||
message = (
|
||||
f"s6-svc {action} on {service!r} failed (rc={returncode})"
|
||||
)
|
||||
if stderr.strip():
|
||||
message += f": {stderr.strip()}"
|
||||
super().__init__(message, service=service)
|
||||
|
||||
|
||||
class S6ServiceManager:
|
||||
"""Per-profile gateway supervision via s6-overlay.
|
||||
|
||||
Only handles runtime-registered services under
|
||||
``S6_DYNAMIC_SCANDIR``. Static services (main-hermes, dashboard)
|
||||
are managed by s6-rc at image-build time and are out of scope.
|
||||
"""
|
||||
|
||||
kind: ServiceManagerKind = "s6"
|
||||
|
||||
def __init__(self, scandir: Path = S6_DYNAMIC_SCANDIR) -> None:
|
||||
self.scandir = scandir
|
||||
|
||||
# -- internal helpers --------------------------------------------------
|
||||
|
||||
def _service_dir(self, profile: str) -> Path:
|
||||
validate_profile_name(profile)
|
||||
return self.scandir / f"{S6_SERVICE_PREFIX}{profile}"
|
||||
|
||||
def _service_name(self, profile: str) -> str:
|
||||
return f"{S6_SERVICE_PREFIX}{profile}"
|
||||
|
||||
@staticmethod
|
||||
def _render_run_script(
|
||||
profile: str,
|
||||
extra_env: dict[str, str],
|
||||
) -> str:
|
||||
"""Generate the run script for a profile-gateway s6 service.
|
||||
|
||||
The script:
|
||||
1. Sources HERMES_HOME (and any extra env) via with-contenv —
|
||||
so e.g. ``-e HERMES_HOME=/data/hermes`` is honored at run
|
||||
time, not Python-substituted at registration time (OQ8-C).
|
||||
2. Resets ``HOME`` to ``/opt/data`` before the privilege drop
|
||||
so with-contenv's root HOME does not leak into the
|
||||
unprivileged gateway process.
|
||||
3. Activates the bundled venv.
|
||||
4. Drops to the hermes user and exec's
|
||||
``hermes -p <profile> gateway run`` (or just ``hermes
|
||||
gateway run`` for the default profile — see below).
|
||||
|
||||
Special case: ``profile == "default"`` emits ``hermes gateway
|
||||
run`` with **no** ``-p`` flag. This is the sentinel for "the
|
||||
root HERMES_HOME profile" (the implicit profile that exists at
|
||||
the top of $HERMES_HOME, not under profiles/). It must be
|
||||
spelled this way because ``_profile_suffix()`` returns the
|
||||
empty string for the root profile, and the dispatcher in
|
||||
``hermes_cli.gateway`` maps that empty string to the
|
||||
``gateway-default`` service slot. Passing ``-p default`` here
|
||||
would instead look up ``$HERMES_HOME/profiles/default/`` — a
|
||||
completely different (and almost always nonexistent) profile.
|
||||
|
||||
Port selection: the gateway picks its bind port from the
|
||||
profile's ``config.yaml`` (``[gateway] port = ...``) — that
|
||||
is the single source of truth. Previously this method took a
|
||||
``port`` parameter that was passed in but never substituted
|
||||
into the rendered script (it was carried in for "API parity"
|
||||
with a deterministic SHA-256 allocator in
|
||||
``hermes_cli.profiles._allocate_gateway_port``). PR #30136
|
||||
review item I5 retired both the allocator and the parameter
|
||||
because they were dead code through the entire stack.
|
||||
"""
|
||||
import shlex
|
||||
lines = [
|
||||
"#!/command/with-contenv sh",
|
||||
"# shellcheck shell=sh",
|
||||
"set -e",
|
||||
"export HOME=/opt/data",
|
||||
"cd /opt/data",
|
||||
". /opt/hermes/.venv/bin/activate",
|
||||
]
|
||||
for k, v in sorted(extra_env.items()):
|
||||
lines.append(f"export {k}={shlex.quote(v)}")
|
||||
# Sentinel for the supervised-child path. Prevents recursive
|
||||
# redirect when the supervised gateway re-enters
|
||||
# `_gateway_command_inner` with subcmd == "run" — without it the
|
||||
# supervisor would dispatch `gateway start` which would re-exec
|
||||
# `gateway run --replace` which would re-dispatch `gateway
|
||||
# start`, etc. See `_gateway_command_inner` for the matching
|
||||
# guard.
|
||||
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
|
||||
if profile == "default":
|
||||
gateway_cmd = "hermes gateway run"
|
||||
else:
|
||||
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run"
|
||||
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID →
|
||||
# s6 boot-loop).
|
||||
lines.append(f'[ "$(id -u)" = 0 ] || exec {gateway_cmd}')
|
||||
lines.append(f"exec s6-setuidgid hermes {gateway_cmd}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _render_log_run(profile: str) -> str:
|
||||
"""Generate the log/run script for a profile-gateway service.
|
||||
|
||||
OQ8-C: persist to ``${HERMES_HOME}/logs/gateways/<profile>/``.
|
||||
CRITICAL: the HERMES_HOME path is sourced from the runtime env
|
||||
via with-contenv — NOT Python-substituted at registration time
|
||||
— so a container started with ``-e HERMES_HOME=/data/hermes``
|
||||
gets its logs under /data/hermes/logs/..., not the build-time
|
||||
default.
|
||||
|
||||
Output routing — the script is two action directives, applied
|
||||
per line, in order:
|
||||
|
||||
1. ``1`` (forward to stdout) — propagates the line up the
|
||||
s6-supervise pipeline to /init's stdout, which is the
|
||||
container's stdout, which is ``docker logs``. Without
|
||||
this, supervised stdout would be terminated inside
|
||||
s6-log and never reach the container's log stream;
|
||||
users would have to ``docker exec`` and ``tail`` the
|
||||
file just to see startup banners. (Python's ``logging``
|
||||
module defaults to stderr, which s6-supervise leaves
|
||||
unfiltered — so warnings/errors already reach docker
|
||||
logs. This change is specifically about the rich-console
|
||||
banner output and other plain stdout writes.)
|
||||
2. ``T <log_dir>`` — also write a timestamped copy to the
|
||||
rotated log directory (``current`` + archived ``@*.s``
|
||||
files). This is what ``hermes logs`` reads and what
|
||||
persists across container restarts via the volume mount.
|
||||
|
||||
``T`` is non-sticky: it only prefixes lines for the next
|
||||
action directive. We deliberately put ``T`` between ``1``
|
||||
and the log dir (not before ``1``) so:
|
||||
|
||||
* ``docker logs`` shows raw lines — Python's logging
|
||||
formatter has its own timestamps, and ``docker logs
|
||||
--timestamps`` adds a third layer when desired. No
|
||||
double-stamping in the most common reading path.
|
||||
* The persisted file gets s6-log's own ISO 8601 timestamp
|
||||
so even output that lacked a Python-logger timestamp
|
||||
(rich banners, third-party libs' raw prints) is
|
||||
correlatable in ``current``.
|
||||
"""
|
||||
import shlex
|
||||
prof = shlex.quote(profile)
|
||||
return (
|
||||
f"#!/command/with-contenv sh\n"
|
||||
f"# shellcheck shell=sh\n"
|
||||
f': "${{HERMES_HOME:=/opt/data}}"\n'
|
||||
f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n'
|
||||
f'mkdir -p "$log_dir"\n'
|
||||
f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n'
|
||||
# Skip the drop when already non-root (CAP_SETGID).
|
||||
f'[ "$(id -u)" = 0 ] || exec s6-log 1 n10 s1000000 T "$log_dir"\n'
|
||||
f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n'
|
||||
)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def _run_svc(self, action_flag: str, action_label: str, name: str) -> None:
|
||||
"""Shared lifecycle dispatch for start / stop / restart.
|
||||
|
||||
Translates the two failure modes operators care about into
|
||||
named errors:
|
||||
|
||||
* ``GatewayNotRegisteredError`` — the service directory at
|
||||
``<scandir>/<name>/`` doesn't exist. ``s6-svc`` would
|
||||
exit non-zero with a fairly opaque message; we pre-empt
|
||||
it with a clear "no such gateway 'X'" tied to the profile
|
||||
name (without the ``gateway-`` prefix).
|
||||
* ``S6CommandError`` — anything else (EACCES on the
|
||||
supervise control FIFO, timeout, etc.). Carries the
|
||||
subprocess return code and stderr so callers can render
|
||||
them inline.
|
||||
|
||||
``action_flag`` is the ``s6-svc`` flag (``-u`` / ``-d`` /
|
||||
``-t``); ``action_label`` is the human verb (``start`` /
|
||||
``stop`` / ``restart``) used in error messages.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
service_dir = self.scandir / name
|
||||
if not service_dir.is_dir():
|
||||
# Strip the gateway- prefix back off so the message
|
||||
# matches what the user typed on the CLI (``-p <profile>``).
|
||||
profile = (
|
||||
name[len(S6_SERVICE_PREFIX):]
|
||||
if name.startswith(S6_SERVICE_PREFIX)
|
||||
else name
|
||||
)
|
||||
raise GatewayNotRegisteredError(profile)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svc", action_flag, str(service_dir)],
|
||||
check=True, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise S6CommandError(
|
||||
service=name,
|
||||
action=action_label,
|
||||
returncode=exc.returncode,
|
||||
stderr=exc.stderr or "",
|
||||
) from exc
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
"""Bring up a registered service (``s6-svc -u``).
|
||||
|
||||
Raises:
|
||||
GatewayNotRegisteredError: no service directory for ``name``.
|
||||
S6CommandError: s6-svc exited non-zero for any other reason
|
||||
(permission denied on the supervise FIFO, timeout, etc.).
|
||||
"""
|
||||
self._run_svc("-u", "start", name)
|
||||
|
||||
def _supervised_pid(self, name: str) -> int | None:
|
||||
"""Return the PID of the supervised gateway process, or None.
|
||||
|
||||
Parses ``s6-svstat`` output (``up (pid NNNN) ...``). Used to
|
||||
mark an operator-initiated stop with the planned-stop marker so
|
||||
the gateway's shutdown handler classifies the incoming SIGTERM
|
||||
as intentional rather than an unexpected kill (issue #42675).
|
||||
Best-effort: any parse/exec failure returns None.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svstat", str(self.scandir / name)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
m = re.search(r"\(pid (\d+)\)", result.stdout)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
"""Bring down a registered service (``s6-svc -d``).
|
||||
|
||||
Writes a planned-stop marker naming the supervised gateway PID
|
||||
BEFORE sending the down command, so the gateway's shutdown
|
||||
handler recognises this SIGTERM as an operator-initiated stop
|
||||
and persists ``gateway_state=stopped`` (respecting the explicit
|
||||
intent). Without the marker, an intentional ``hermes gateway
|
||||
stop`` is indistinguishable from the container/s6 SIGTERM sent on
|
||||
``docker restart``; the latter must NOT persist ``stopped`` or
|
||||
container_boot refuses to auto-start on the next boot (#42675).
|
||||
The marker write is best-effort — a failure only means the stop
|
||||
is treated as signal-initiated, which is the safe fallback.
|
||||
|
||||
Raises:
|
||||
GatewayNotRegisteredError: no service directory for ``name``.
|
||||
S6CommandError: s6-svc exited non-zero for any other reason.
|
||||
"""
|
||||
pid = self._supervised_pid(name)
|
||||
if pid is not None:
|
||||
try:
|
||||
from gateway.status import write_planned_stop_marker
|
||||
|
||||
write_planned_stop_marker(pid)
|
||||
except Exception:
|
||||
pass
|
||||
self._run_svc("-d", "stop", name)
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
"""Restart a registered service (``s6-svc -t`` = SIGTERM).
|
||||
|
||||
Raises:
|
||||
GatewayNotRegisteredError: no service directory for ``name``.
|
||||
S6CommandError: s6-svc exited non-zero for any other reason.
|
||||
"""
|
||||
self._run_svc("-t", "restart", name)
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
"""True iff ``s6-svstat`` reports the service as up."""
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svstat", str(self.scandir / name)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.returncode == 0 and "up " in result.stdout
|
||||
|
||||
# -- runtime registration ---------------------------------------------
|
||||
|
||||
def supports_runtime_registration(self) -> bool:
|
||||
return True
|
||||
|
||||
def register_profile_gateway(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Create the s6 service directory for a profile gateway.
|
||||
|
||||
Triggers ``s6-svscanctl -a`` so s6-svscan picks the new directory
|
||||
up immediately. The service is created in the *up* state — to
|
||||
register without auto-starting, follow up with ``stop(profile)``
|
||||
(or pass the start flag via the future ``start_now=False`` arg,
|
||||
which the Phase 4 reconciliation path uses via a ``down``
|
||||
marker file written directly).
|
||||
|
||||
Raises:
|
||||
ValueError: if the profile name is invalid or the service
|
||||
directory already exists.
|
||||
RuntimeError: if ``s6-svscanctl`` fails.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
svc_dir = self._service_dir(profile)
|
||||
if svc_dir.exists():
|
||||
raise ValueError(
|
||||
f"profile gateway {profile!r} already registered at {svc_dir}"
|
||||
)
|
||||
|
||||
# Build the service directory atomically: write to a sibling
|
||||
# temp dir, then rename. Avoids s6-svscan observing a half-
|
||||
# populated directory on a fast rescan.
|
||||
tmp_dir = svc_dir.with_name(svc_dir.name + ".tmp")
|
||||
if tmp_dir.exists():
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
tmp_dir.mkdir(parents=True)
|
||||
|
||||
try:
|
||||
(tmp_dir / "type").write_text("longrun\n")
|
||||
|
||||
run_script = self._render_run_script(profile, extra_env or {})
|
||||
run_path = tmp_dir / "run"
|
||||
run_path.write_text(run_script)
|
||||
run_path.chmod(0o755)
|
||||
|
||||
# Persistent log rotation (OQ8-C).
|
||||
log_subdir = tmp_dir / "log"
|
||||
log_subdir.mkdir()
|
||||
log_run = log_subdir / "run"
|
||||
log_run.write_text(self._render_log_run(profile))
|
||||
log_run.chmod(0o755)
|
||||
|
||||
# Pre-create the supervise/ skeleton with hermes ownership
|
||||
# BEFORE we publish the slot. s6-supervise will EEXIST our
|
||||
# dirs/FIFOs and inherit the ownership, so the runtime
|
||||
# s6-svc / s6-svstat / s6-svwait calls (all dispatched as
|
||||
# the hermes user) won't hit EACCES on root-owned 0700
|
||||
# dirs. See ``_seed_supervise_skeleton`` for the full
|
||||
# rationale.
|
||||
_seed_supervise_skeleton(tmp_dir)
|
||||
|
||||
tmp_dir.rename(svc_dir)
|
||||
except Exception:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
# Trigger rescan so s6-svscan picks up the new service.
|
||||
result = subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svscanctl", "-a", str(self.scandir)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Clean up: rescan failed, leave the directory in place would
|
||||
# be confusing (no supervisor watching it).
|
||||
shutil.rmtree(svc_dir, ignore_errors=True)
|
||||
raise RuntimeError(
|
||||
f"s6-svscanctl failed: {result.stderr or result.stdout}"
|
||||
)
|
||||
|
||||
def unregister_profile_gateway(self, profile: str) -> None:
|
||||
"""Stop the profile gateway service and remove its directory.
|
||||
|
||||
Idempotent: absent services are a no-op. Best-effort stop +
|
||||
wait-for-down before removal so the running gateway process
|
||||
gets a chance to shut down cleanly before its service dir
|
||||
disappears.
|
||||
|
||||
Teardown ordering matters: ``s6-svscanctl -an`` is fired
|
||||
**before** ``rmtree`` so s6-svscan reaps the supervise child
|
||||
process (releasing its handle on ``supervise/lock`` and the
|
||||
regular files inside the supervise dir), giving us a clean
|
||||
directory to remove. Without the reap-first ordering, the
|
||||
rmtree races s6-supervise on a set of root-owned files inside
|
||||
the supervise dir and the dir is left half-removed.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
svc_dir = self._service_dir(profile)
|
||||
if not svc_dir.exists():
|
||||
return
|
||||
|
||||
# Stop the service (best effort — service may already be down).
|
||||
subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svc", "-d", str(svc_dir)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
check=False,
|
||||
)
|
||||
# Wait for it to actually go down (up to 10s).
|
||||
subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svwait", "-D", "-t", "10000", str(svc_dir)],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Reap the supervise child FIRST: -n tells s6-svscan to drop
|
||||
# any supervise processes whose service dir is gone (which
|
||||
# includes any service dir we're about to remove). This
|
||||
# releases the file handles s6-supervise holds against the
|
||||
# supervise/lock + supervise/status + supervise/death_tally
|
||||
# files inside the slot, so the upcoming rmtree doesn't race.
|
||||
subprocess.run(
|
||||
[f"{_S6_BIN_DIR}/s6-svscanctl", "-an", str(self.scandir)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
check=False,
|
||||
)
|
||||
# Give s6-svscan a moment to reap. There's no synchronous
|
||||
# "scan completed" handshake — the -a/-n trigger just sets a
|
||||
# flag s6-svscan reads on its next loop iteration. 200ms is
|
||||
# comfortably above the loop's resolution but well under any
|
||||
# user-perceived latency.
|
||||
time.sleep(0.2)
|
||||
|
||||
# Now the supervise dir's files are no longer held open by a
|
||||
# live s6-supervise, so rmtree can remove them. Files inside
|
||||
# supervise/ are root-owned (death_tally, lock, status, written
|
||||
# by s6-supervise itself) — but the parent supervise/ directory
|
||||
# is hermes-owned (see ``_seed_supervise_skeleton``), and on
|
||||
# POSIX you only need write+execute on the parent to remove
|
||||
# contained files regardless of file ownership.
|
||||
shutil.rmtree(svc_dir, ignore_errors=True)
|
||||
|
||||
def list_profile_gateways(self) -> list[str]:
|
||||
"""Return the profile names of all currently-registered gateway services.
|
||||
|
||||
Filters the scandir to entries that match the ``gateway-`` prefix.
|
||||
Other services (e.g. ``s6-linux-init-shutdownd``) are ignored.
|
||||
"""
|
||||
if not self.scandir.exists():
|
||||
return []
|
||||
profiles: list[str] = []
|
||||
for entry in self.scandir.iterdir():
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if not entry.name.startswith(S6_SERVICE_PREFIX):
|
||||
continue
|
||||
profiles.append(entry.name[len(S6_SERVICE_PREFIX):])
|
||||
return profiles
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -58,7 +58,9 @@ def _resolve_short_name(name: str, sources, console: Console) -> str:
|
|||
table = Table()
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Trust", style="dim")
|
||||
table.add_column("Identifier", style="bold cyan")
|
||||
# overflow="fold" keeps the full slug visible (wraps instead of ellipsis-truncating)
|
||||
# so users can copy it for `hermes skills install`.
|
||||
table.add_column("Identifier", style="bold cyan", overflow="fold", no_wrap=False)
|
||||
for r in exact:
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim")
|
||||
trust_label = "official" if r.source == "official" else r.trust_level
|
||||
|
|
@ -244,15 +246,39 @@ def _prompt_for_category(c: Console, existing: List[str]) -> str:
|
|||
|
||||
|
||||
def do_search(query: str, source: str = "all", limit: int = 10,
|
||||
console: Optional[Console] = None) -> None:
|
||||
"""Search registries and display results as a Rich table."""
|
||||
console: Optional[Console] = None, as_json: bool = False) -> None:
|
||||
"""Search registries and display results as a Rich table.
|
||||
|
||||
When ``as_json=True`` writes a JSON array of result records to stdout
|
||||
(one object per skill: ``name``, ``identifier``, ``source``,
|
||||
``trust_level``, ``description``) and skips the table render. This is
|
||||
the scripting / copy-paste handle: the full identifier is always
|
||||
intact, even for browse-sh slugs that the table would otherwise wrap.
|
||||
"""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
|
||||
|
||||
c = console or _console
|
||||
c.print(f"\n[bold]Searching for:[/] {query}")
|
||||
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
if as_json:
|
||||
# Avoid Rich status spinner contaminating stdout — JSON consumers
|
||||
# expect a clean parseable stream.
|
||||
results = unified_search(query, sources, source_filter=source, limit=limit)
|
||||
payload = [
|
||||
{
|
||||
"name": r.name,
|
||||
"identifier": r.identifier,
|
||||
"source": r.source,
|
||||
"trust_level": r.trust_level,
|
||||
"description": r.description,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
c.print(f"\n[bold]Searching for:[/] {query}")
|
||||
with c.status("[bold]Searching registries..."):
|
||||
results = unified_search(query, sources, source_filter=source, limit=limit)
|
||||
|
||||
|
|
@ -265,7 +291,11 @@ def do_search(query: str, source: str = "all", limit: int = 10,
|
|||
table.add_column("Description", max_width=60)
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Trust", style="dim")
|
||||
table.add_column("Identifier", style="dim")
|
||||
# overflow="fold" keeps the full slug visible (wraps instead of
|
||||
# ellipsis-truncating). Browse.sh slugs end in a `-XXXXXX` hash that
|
||||
# is part of the actual identifier — truncating it makes copy-paste
|
||||
# into `hermes skills install` fail.
|
||||
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
|
||||
|
||||
for r in results:
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim")
|
||||
|
|
@ -280,7 +310,8 @@ def do_search(query: str, source: str = "all", limit: int = 10,
|
|||
|
||||
c.print(table)
|
||||
c.print("[dim]Use: hermes skills inspect <identifier> to preview, "
|
||||
"hermes skills install <identifier> to install[/]\n")
|
||||
"hermes skills install <identifier> to install "
|
||||
"(--json for scripting)[/]\n")
|
||||
|
||||
|
||||
def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
|
|
@ -304,19 +335,45 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
|||
# Collect results from all (or filtered) sources in parallel.
|
||||
# Per-source limits are generous — parallelism + 30s timeout cap prevents hangs.
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
# NOTE: when the centralized index is available, parallel_search_sources
|
||||
# skips the external API sources and serves everything from "hermes-index".
|
||||
# That source MUST therefore carry a limit large enough to cover the whole
|
||||
# catalog, or browse silently caps the hub — it shipped at 50 (surfaced
|
||||
# ~136 of 88k skills), then 5000 (surfaced ~5.4k of 90k). The index is
|
||||
# disk-cached and browse paginates client-side, so a ceiling above the
|
||||
# current catalog size is the right call. The external-source limits below
|
||||
# only apply when the index is unavailable (offline / first run before the
|
||||
# cache populates).
|
||||
_PER_SOURCE_LIMIT = {
|
||||
"hermes-index": 1000000,
|
||||
"official": 200, "skills-sh": 200, "well-known": 50,
|
||||
"github": 200, "clawhub": 500, "claude-marketplace": 100,
|
||||
"lobehub": 500, "browse-sh": 500,
|
||||
}
|
||||
|
||||
with c.status("[bold]Fetching skills from registries..."):
|
||||
with c.status("[bold]Fetching skills from registries...") as status:
|
||||
# Live progress: tick off each source as it resolves so the wait is
|
||||
# visible instead of a frozen spinner. parallel_search_sources invokes
|
||||
# this callback from the collecting thread as each source completes;
|
||||
# the page itself is still rendered once, after the correctly-merged
|
||||
# and trust-sorted result set is final (browse's ordering contract is
|
||||
# computed over the whole set, so we never render a half-sorted page).
|
||||
_done: List[str] = []
|
||||
|
||||
def _on_source_done(sid: str, count: int) -> None:
|
||||
_done.append(f"{sid} ({count})")
|
||||
status.update(
|
||||
"[bold]Fetching skills from registries...[/] "
|
||||
f"[dim]done: {', '.join(_done)}[/]"
|
||||
)
|
||||
|
||||
all_results, source_counts, timed_out = parallel_search_sources(
|
||||
sources,
|
||||
query="",
|
||||
per_source_limits=_PER_SOURCE_LIMIT,
|
||||
source_filter=source,
|
||||
overall_timeout=30,
|
||||
on_source_done=_on_source_done,
|
||||
)
|
||||
|
||||
if not all_results:
|
||||
|
|
@ -365,18 +422,22 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
|||
# Build table
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("#", style="dim", width=4, justify="right")
|
||||
table.add_column("Name", style="bold cyan", max_width=25)
|
||||
table.add_column("Description", max_width=50)
|
||||
table.add_column("Name", style="bold cyan", max_width=22)
|
||||
table.add_column("Description", max_width=44)
|
||||
table.add_column("Source", style="dim", width=12)
|
||||
table.add_column("Trust", width=10)
|
||||
# The identifier is what you pass to `hermes skills install`. Browse used
|
||||
# to omit it entirely, so users couldn't act on what they saw without a
|
||||
# second `search`. overflow="fold" keeps long slugs copy-pasteable.
|
||||
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
|
||||
|
||||
for i, r in enumerate(page_items, start=start + 1):
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green",
|
||||
"community": "yellow"}.get(r.trust_level, "dim")
|
||||
trust_label = "★ official" if r.source == "official" else r.trust_level
|
||||
|
||||
desc = r.description[:50]
|
||||
if len(r.description) > 50:
|
||||
desc = r.description[:44]
|
||||
if len(r.description) > 44:
|
||||
desc += "..."
|
||||
|
||||
table.add_row(
|
||||
|
|
@ -385,6 +446,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
|||
desc,
|
||||
r.source,
|
||||
f"[{trust_style}]{trust_label}[/]",
|
||||
r.identifier,
|
||||
)
|
||||
|
||||
c.print(table)
|
||||
|
|
@ -408,7 +470,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
|||
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
|
||||
f"— run again for cached results[/]")
|
||||
|
||||
c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
|
||||
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
|
||||
"'hermes skills install <identifier>' to install, "
|
||||
"'hermes skills search <query>' to search deeper[/]\n")
|
||||
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
|
|
@ -519,11 +583,13 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
|||
if bundle.source == "url" and not category and not skip_confirm:
|
||||
category = _prompt_for_category(c, _existing_categories())
|
||||
|
||||
# Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox")
|
||||
# Auto-detect the full parent path for official skills. Optional skills
|
||||
# can be nested (e.g. "official/mlops/training/trl-fine-tuning"), so keep
|
||||
# every identifier segment between "official" and the final skill slug.
|
||||
if bundle.source == "official" and not category:
|
||||
id_parts = bundle.identifier.split("/") # ["official", "category", "skill"]
|
||||
id_parts = bundle.identifier.split("/")
|
||||
if len(id_parts) >= 3:
|
||||
category = id_parts[1]
|
||||
category = "/".join(id_parts[1:-1])
|
||||
|
||||
# Check if already installed
|
||||
lock = HubLockFile()
|
||||
|
|
@ -550,7 +616,14 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
|||
|
||||
# Scan
|
||||
c.print("[bold]Running security scan...[/]")
|
||||
scan_source = getattr(bundle, "identifier", "") or getattr(meta, "identifier", "") or identifier
|
||||
if bundle.source == "official":
|
||||
scan_source = "official"
|
||||
else:
|
||||
scan_source = (
|
||||
getattr(bundle, "identifier", "")
|
||||
or getattr(meta, "identifier", "")
|
||||
or identifier
|
||||
)
|
||||
result = scan_skill(q_path, source=scan_source)
|
||||
c.print(format_scan_report(result))
|
||||
|
||||
|
|
@ -685,24 +758,27 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
|
|||
|
||||
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
|
||||
"""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, parallel_search_sources,
|
||||
)
|
||||
|
||||
page_size = max(1, min(page_size, 100))
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
|
||||
# "hermes-index" must carry a high limit: when the index is available the
|
||||
# router skips external API sources and serves everything from it, so a
|
||||
# low cap here silently truncates the whole hub (see do_browse note).
|
||||
_PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100,
|
||||
"well-known": 25, "github": 100, "clawhub": 50,
|
||||
"claude-marketplace": 50, "lobehub": 50, "browse-sh": 500}
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
all_results: list = []
|
||||
for src in sources:
|
||||
sid = src.source_id()
|
||||
if source != "all" and sid != source and sid != "official":
|
||||
continue
|
||||
try:
|
||||
limit = _PER_SOURCE_LIMIT.get(sid, 50)
|
||||
all_results.extend(src.search("", limit=limit))
|
||||
except Exception:
|
||||
continue
|
||||
# Delegate to the shared parallel walker so this inherits the index-aware
|
||||
# source-skip logic — querying hermes-index AND the external APIs at once
|
||||
# would double-count every skill.
|
||||
all_results, _counts, _timed_out = parallel_search_sources(
|
||||
sources, query="", per_source_limits=_PER_SOURCE_LIMIT,
|
||||
source_filter=source, overall_timeout=30,
|
||||
)
|
||||
if not all_results:
|
||||
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
|
||||
seen: dict = {}
|
||||
|
|
@ -719,7 +795,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
|
|||
page_items = deduped[start : min(start + page_size, total)]
|
||||
return {
|
||||
"items": [{"name": r.name, "description": r.description, "source": r.source,
|
||||
"trust": r.trust_level} for r in page_items],
|
||||
"trust": r.trust_level, "identifier": r.identifier} for r in page_items],
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
|
|
@ -906,8 +982,14 @@ def do_update(name: Optional[str] = None, console: Optional[Console] = None) ->
|
|||
c.print(f"[bold green]Updated {len(updates)} skill(s).[/]\n")
|
||||
|
||||
|
||||
def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
||||
"""Re-run security scan on installed hub skills."""
|
||||
def do_audit(name: Optional[str] = None, console: Optional[Console] = None,
|
||||
deep: bool = False) -> None:
|
||||
"""Re-run security scan on installed hub skills.
|
||||
|
||||
When ``deep=True``, also runs an opt-in AST-level diagnostic on Python
|
||||
files (review aid only — not a security gate; skills_guard.py verdicts
|
||||
are unchanged).
|
||||
"""
|
||||
from tools.skills_hub import HubLockFile, SKILLS_DIR
|
||||
from tools.skills_guard import scan_skill, format_scan_report
|
||||
|
||||
|
|
@ -928,6 +1010,9 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> N
|
|||
|
||||
c.print(f"\n[bold]Auditing {len(targets)} skill(s)...[/]\n")
|
||||
|
||||
if deep:
|
||||
from tools.skills_ast_audit import ast_scan_path, format_ast_report
|
||||
|
||||
for entry in targets:
|
||||
skill_path = SKILLS_DIR / entry["install_path"]
|
||||
if not skill_path.exists():
|
||||
|
|
@ -936,6 +1021,10 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> N
|
|||
|
||||
result = scan_skill(skill_path, source=entry.get("identifier", entry["source"]))
|
||||
c.print(format_scan_report(result))
|
||||
|
||||
if deep:
|
||||
c.print(format_ast_report(ast_scan_path(skill_path), skill_name=entry["name"]))
|
||||
|
||||
c.print()
|
||||
|
||||
|
||||
|
|
@ -1019,6 +1108,149 @@ def do_reset(name: str, restore: bool = False,
|
|||
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
||||
|
||||
|
||||
def do_opt_out(remove: bool = False,
|
||||
console: Optional[Console] = None,
|
||||
skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Opt the active profile out of bundled-skill seeding.
|
||||
|
||||
Always writes the .no-bundled-skills marker (stop future seeding). With
|
||||
``remove``, also deletes already-present bundled skills that are pristine
|
||||
(manifest-tracked AND unmodified); user-edited and non-bundled skills are
|
||||
never touched.
|
||||
"""
|
||||
from tools.skills_sync import (
|
||||
set_bundled_skills_opt_out,
|
||||
remove_pristine_bundled_skills,
|
||||
)
|
||||
|
||||
c = console or _console
|
||||
|
||||
# Write the marker first (the always-safe part).
|
||||
res = set_bundled_skills_opt_out(True)
|
||||
if not res["ok"]:
|
||||
c.print(f"[bold red]Error:[/] {res['message']}\n")
|
||||
return
|
||||
c.print(f"[bold green]{res['message']}[/]")
|
||||
c.print(f"[dim]Marker: {res['marker']}[/]")
|
||||
|
||||
if not remove:
|
||||
c.print("[dim]Existing skills on disk were left in place. "
|
||||
"Re-run with --remove to also delete unmodified bundled skills.[/]\n")
|
||||
return
|
||||
|
||||
# Destructive step: preview, confirm, then delete.
|
||||
preview = remove_pristine_bundled_skills(dry_run=True)
|
||||
candidates = preview["removed"]
|
||||
kept = preview["skipped"]
|
||||
if not candidates:
|
||||
c.print("[dim]No pristine bundled skills to remove "
|
||||
"(nothing tracked, or all are user-modified/local).[/]\n")
|
||||
return
|
||||
|
||||
c.print(f"\n[bold]Will remove {len(candidates)} unmodified bundled skill(s):[/]")
|
||||
c.print(f"[dim]{', '.join(candidates)}[/]")
|
||||
if kept:
|
||||
c.print(f"[dim]Keeping {len(kept)} (user-modified or non-bundled).[/]")
|
||||
|
||||
if not skip_confirm:
|
||||
c.print("[dim]This deletes the on-disk copies. User-edited and "
|
||||
"hub/local skills are NOT touched.[/]")
|
||||
try:
|
||||
answer = input("Confirm [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer not in {"y", "yes"}:
|
||||
c.print("[dim]Marker kept; no skills deleted.[/]\n")
|
||||
return
|
||||
|
||||
result = remove_pristine_bundled_skills(dry_run=False)
|
||||
c.print(f"[bold green]{result['message']}[/]")
|
||||
if result["removed"]:
|
||||
c.print(f"[dim]Removed: {', '.join(result['removed'])}[/]")
|
||||
c.print()
|
||||
|
||||
if invalidate_cache:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def do_opt_in(sync: bool = False,
|
||||
console: Optional[Console] = None,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Remove the opt-out marker so bundled-skill seeding resumes.
|
||||
|
||||
With ``sync``, immediately re-seed bundled skills instead of waiting for
|
||||
the next ``hermes update``.
|
||||
"""
|
||||
from tools.skills_sync import set_bundled_skills_opt_out, sync_skills
|
||||
|
||||
c = console or _console
|
||||
|
||||
res = set_bundled_skills_opt_out(False)
|
||||
if not res["ok"]:
|
||||
c.print(f"[bold red]Error:[/] {res['message']}\n")
|
||||
return
|
||||
c.print(f"[bold green]{res['message']}[/]")
|
||||
|
||||
if sync:
|
||||
synced = sync_skills(quiet=True)
|
||||
copied = len(synced.get("copied", []))
|
||||
c.print(f"[dim]Re-seeded {copied} bundled skill(s).[/]")
|
||||
if invalidate_cache:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
c.print()
|
||||
|
||||
|
||||
def do_repair_official(name: str, restore: bool = False,
|
||||
console: Optional[Console] = None,
|
||||
skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Backfill or restore official optional skills from repo source."""
|
||||
from tools.skills_sync import restore_official_optional_skill
|
||||
|
||||
c = console or _console
|
||||
if restore and not skip_confirm:
|
||||
c.print(f"\n[bold]Restore official optional skill '{name}' from repo source?[/]")
|
||||
c.print("[dim]Existing matching active copies will be moved to a restore backup before copying the official source.[/]")
|
||||
try:
|
||||
answer = input("Confirm [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer not in {"y", "yes"}:
|
||||
c.print("[dim]Cancelled.[/]\n")
|
||||
return
|
||||
|
||||
result = restore_official_optional_skill(name, restore=restore)
|
||||
if not result.get("ok"):
|
||||
c.print(f"[bold red]Error:[/] {result.get('message', 'Repair failed')}\n")
|
||||
return
|
||||
|
||||
c.print(f"[bold green]{result['message']}[/]")
|
||||
if result.get("restored"):
|
||||
c.print(f"[dim]Restored: {', '.join(result['restored'])}[/]")
|
||||
if result.get("backfilled"):
|
||||
c.print(f"[dim]Backfilled provenance: {', '.join(result['backfilled'])}[/]")
|
||||
if result.get("backed_up"):
|
||||
c.print(f"[dim]Backed up: {', '.join(result['backed_up'])}[/]")
|
||||
c.print(f"[dim]Backup dir: {result.get('backup_dir')}[/]")
|
||||
c.print()
|
||||
|
||||
if invalidate_cache:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> None:
|
||||
"""Manage taps (custom GitHub repo sources)."""
|
||||
from tools.skills_hub import TapsManager
|
||||
|
|
@ -1326,7 +1558,8 @@ def skills_command(args) -> None:
|
|||
if action == "browse":
|
||||
do_browse(page=args.page, page_size=args.size, source=args.source)
|
||||
elif action == "search":
|
||||
do_search(args.query, source=args.source, limit=args.limit)
|
||||
do_search(args.query, source=args.source, limit=args.limit,
|
||||
as_json=getattr(args, "json", False))
|
||||
elif action == "install":
|
||||
do_install(args.identifier, category=args.category, force=args.force,
|
||||
skip_confirm=getattr(args, "yes", False),
|
||||
|
|
@ -1343,12 +1576,21 @@ def skills_command(args) -> None:
|
|||
elif action == "update":
|
||||
do_update(name=getattr(args, "name", None))
|
||||
elif action == "audit":
|
||||
do_audit(name=getattr(args, "name", None))
|
||||
do_audit(name=getattr(args, "name", None),
|
||||
deep=getattr(args, "deep", False))
|
||||
elif action == "uninstall":
|
||||
do_uninstall(args.name)
|
||||
elif action == "reset":
|
||||
do_reset(args.name, restore=getattr(args, "restore", False),
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "opt-out":
|
||||
do_opt_out(remove=getattr(args, "remove", False),
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "opt-in":
|
||||
do_opt_in(sync=getattr(args, "sync", False))
|
||||
elif action == "repair-official":
|
||||
do_repair_official(args.name, restore=getattr(args, "restore", False),
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "publish":
|
||||
do_publish(
|
||||
args.skill_path,
|
||||
|
|
@ -1371,7 +1613,7 @@ def skills_command(args) -> None:
|
|||
return
|
||||
do_tap(tap_action, repo=repo)
|
||||
else:
|
||||
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|publish|snapshot|tap]\n")
|
||||
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
|
||||
_console.print("Run 'hermes skills <command> --help' for details.\n")
|
||||
|
||||
|
||||
|
|
@ -1395,6 +1637,8 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|||
/skills update
|
||||
/skills audit
|
||||
/skills audit my-skill
|
||||
/skills audit --deep
|
||||
/skills audit my-skill --deep
|
||||
/skills uninstall my-skill
|
||||
/skills tap list
|
||||
/skills tap add owner/repo
|
||||
|
|
@ -1441,10 +1685,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|||
|
||||
elif action == "search":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N]\n")
|
||||
c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N] [--json]\n")
|
||||
return
|
||||
source = "all"
|
||||
limit = 10
|
||||
as_json = False
|
||||
query_parts = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
|
|
@ -1457,10 +1702,14 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|||
except ValueError:
|
||||
pass
|
||||
i += 2
|
||||
elif args[i] == "--json":
|
||||
as_json = True
|
||||
i += 1
|
||||
else:
|
||||
query_parts.append(args[i])
|
||||
i += 1
|
||||
do_search(" ".join(query_parts), source=source, limit=limit, console=c)
|
||||
do_search(" ".join(query_parts), source=source, limit=limit,
|
||||
console=c, as_json=as_json)
|
||||
|
||||
elif action == "install":
|
||||
if not args:
|
||||
|
|
@ -1509,8 +1758,9 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|||
do_update(name=name, console=c)
|
||||
|
||||
elif action == "audit":
|
||||
name = args[0] if args else None
|
||||
do_audit(name=name, console=c)
|
||||
name = args[0] if args and not args[0].startswith("--") else None
|
||||
deep = "--deep" in args
|
||||
do_audit(name=name, console=c, deep=deep)
|
||||
|
||||
elif action == "uninstall":
|
||||
if not args:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ Shows the status of all Hermes Agent components.
|
|||
import os
|
||||
import sys
|
||||
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
|
@ -16,9 +15,12 @@ from hermes_cli.auth import AuthError, resolve_provider
|
|||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
|
|
@ -194,26 +196,57 @@ def show_status(args):
|
|||
qwen_status = {}
|
||||
minimax_status = {}
|
||||
|
||||
nous_logged_in = bool(nous_status.get("logged_in"))
|
||||
nous_account_info = None
|
||||
if (
|
||||
nous_status.get("logged_in")
|
||||
or nous_status.get("access_token")
|
||||
or nous_status.get("portal_base_url")
|
||||
or nous_status.get("inference_credential_present")
|
||||
or nous_status.get("error_code")
|
||||
):
|
||||
try:
|
||||
nous_account_info = get_nous_portal_account_info()
|
||||
except Exception:
|
||||
nous_account_info = None
|
||||
|
||||
nous_logged_in = bool(
|
||||
nous_status.get("logged_in")
|
||||
or (nous_account_info and nous_account_info.logged_in)
|
||||
)
|
||||
nous_inference_present = bool(
|
||||
nous_status.get("inference_credential_present")
|
||||
or (nous_account_info and nous_account_info.inference_credential_present)
|
||||
)
|
||||
nous_error = nous_status.get("error")
|
||||
nous_label = "logged in" if nous_logged_in else "not logged in (run: hermes auth add nous --type oauth)"
|
||||
if nous_logged_in:
|
||||
nous_label = "logged in"
|
||||
elif nous_inference_present:
|
||||
nous_label = "not logged in (Nous inference key configured)"
|
||||
else:
|
||||
nous_label = "not logged in (run: hermes portal)"
|
||||
print(
|
||||
f" {'Nous Portal':<12} {check_mark(nous_logged_in)} "
|
||||
f"{nous_label}"
|
||||
)
|
||||
portal_url = nous_status.get("portal_base_url") or "(unknown)"
|
||||
inference_url = (
|
||||
nous_status.get("inference_base_url")
|
||||
or (nous_account_info.inference_base_url if nous_account_info else None)
|
||||
)
|
||||
access_exp = _format_iso_timestamp(nous_status.get("access_expires_at"))
|
||||
key_exp = _format_iso_timestamp(nous_status.get("agent_key_expires_at"))
|
||||
refresh_label = "yes" if nous_status.get("has_refresh_token") else "no"
|
||||
if nous_logged_in or portal_url != "(unknown)" or nous_error:
|
||||
print(f" Portal URL: {portal_url}")
|
||||
if nous_inference_present and inference_url:
|
||||
print(f" Inference: {inference_url}")
|
||||
if nous_logged_in or nous_status.get("access_expires_at"):
|
||||
print(f" Access exp: {access_exp}")
|
||||
if nous_logged_in or nous_status.get("agent_key_expires_at"):
|
||||
if nous_logged_in or nous_inference_present or nous_status.get("agent_key_expires_at"):
|
||||
print(f" Key exp: {key_exp}")
|
||||
if nous_logged_in or nous_status.get("has_refresh_token"):
|
||||
print(f" Refresh: {refresh_label}")
|
||||
if nous_error and not nous_logged_in:
|
||||
if nous_error:
|
||||
print(f" Error: {nous_error}")
|
||||
|
||||
codex_logged_in = bool(codex_status.get("logged_in"))
|
||||
|
|
@ -304,18 +337,18 @@ def show_status(args):
|
|||
else:
|
||||
state = "not configured"
|
||||
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
|
||||
elif nous_logged_in:
|
||||
# Logged into Nous but on the free tier — show upgrade nudge
|
||||
elif nous_logged_in or nous_inference_present:
|
||||
# Nous OAuth without entitlement, or an opaque inference key without
|
||||
# Portal account information, cannot enable the Tool Gateway.
|
||||
print()
|
||||
print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD))
|
||||
print(" Your free-tier Nous account does not include Tool Gateway access.")
|
||||
print(" Upgrade your subscription to unlock managed web, image, TTS, STT, and browser tools.")
|
||||
try:
|
||||
portal_url = nous_status.get("portal_base_url", "").rstrip("/")
|
||||
if portal_url:
|
||||
print(f" Upgrade: {portal_url}")
|
||||
except Exception:
|
||||
pass
|
||||
message = format_nous_portal_entitlement_message(
|
||||
nous_account_info,
|
||||
capability="managed web, image, TTS, STT, browser, and Modal tools",
|
||||
)
|
||||
if message:
|
||||
for line in message.splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
# =========================================================================
|
||||
# API-Key Providers
|
||||
|
|
@ -380,23 +413,6 @@ def show_status(args):
|
|||
elif terminal_env == "daytona":
|
||||
daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
print(f" Daytona Image: {daytona_image}")
|
||||
elif terminal_env == "vercel_sandbox":
|
||||
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24"
|
||||
persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
|
||||
if persist is None:
|
||||
persist_enabled = bool(terminal_cfg.get("container_persistent", True))
|
||||
else:
|
||||
persist_enabled = persist.lower() in {"1", "true", "yes", "on"}
|
||||
auth_status = describe_vercel_auth()
|
||||
sdk_ok = importlib.util.find_spec("vercel") is not None
|
||||
sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')"
|
||||
print(f" Runtime: {runtime}")
|
||||
print(f" SDK: {check_mark(sdk_ok)} {sdk_label}")
|
||||
print(f" Auth: {check_mark(auth_status.ok)} {auth_status.label}")
|
||||
for line in auth_status.detail_lines:
|
||||
print(f" Auth detail: {line}")
|
||||
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
||||
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
||||
|
||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||
|
|
|
|||
|
|
@ -216,7 +216,6 @@ def _augment_path_with_known_tools() -> None:
|
|||
if not is_windows():
|
||||
return
|
||||
|
||||
import shutil as _shutil
|
||||
|
||||
local_appdata = os.environ.get("LOCALAPPDATA", "")
|
||||
if not local_appdata:
|
||||
|
|
|
|||
18
hermes_cli/subcommands/__init__.py
Normal file
18
hermes_cli/subcommands/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""CLI subcommand parser builders for ``hermes <subcommand>``.
|
||||
|
||||
``hermes_cli/main.py:main()`` historically built the entire argparse tree
|
||||
inline — 179 ``add_parser`` calls across ~26 subcommand groups, all wedged
|
||||
into one 3,300-line function. This package breaks that tree apart: each
|
||||
subcommand group owns a ``build_<group>_parser(subparsers, ...)`` function in
|
||||
its own module, and ``main()`` calls those builders instead of inlining the
|
||||
argument definitions.
|
||||
|
||||
Handlers (the ``cmd_*`` functions) still live in ``main.py`` for now and are
|
||||
dependency-injected into the builders so these modules never import ``main``
|
||||
(which would create a cycle). Shared parser helpers live in
|
||||
``_shared.py``.
|
||||
|
||||
Part of the god-file decomposition plan (Phase 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
29
hermes_cli/subcommands/_shared.py
Normal file
29
hermes_cli/subcommands/_shared.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Shared parser helpers used across multiple CLI subcommand builders.
|
||||
|
||||
These were module-level helpers in ``hermes_cli/main.py``. They are pulled
|
||||
into a neutral module so both ``main.py`` and every
|
||||
``hermes_cli/subcommands/<group>.py`` builder can import them without an
|
||||
import cycle. ``main.py`` re-exports them for backwards compatibility, so
|
||||
existing references keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def add_accept_hooks_flag(parser: argparse.ArgumentParser) -> None:
|
||||
"""Attach the ``--accept-hooks`` flag.
|
||||
|
||||
Shared across every agent subparser so the flag works regardless of CLI
|
||||
position.
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve unseen shell hooks without a TTY prompt "
|
||||
"(equivalent to HERMES_ACCEPT_HOOKS=1 / hooks_auto_accept: true)."
|
||||
),
|
||||
)
|
||||
52
hermes_cli/subcommands/acp.py
Normal file
52
hermes_cli/subcommands/acp.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""``hermes acp`` subcommand parser.
|
||||
|
||||
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
|
||||
Handler injected to avoid importing ``main``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from hermes_cli.subcommands._shared import add_accept_hooks_flag
|
||||
|
||||
|
||||
def build_acp_parser(subparsers, *, cmd_acp: Callable) -> None:
|
||||
"""Attach the ``acp`` subcommand to ``subparsers``."""
|
||||
acp_parser = subparsers.add_parser(
|
||||
"acp",
|
||||
help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
|
||||
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
|
||||
)
|
||||
add_accept_hooks_flag(acp_parser)
|
||||
acp_parser.add_argument(
|
||||
"--version",
|
||||
action="store_true",
|
||||
dest="acp_version",
|
||||
help="Print Hermes ACP version and exit",
|
||||
)
|
||||
acp_parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Verify ACP dependencies and adapter imports, then exit",
|
||||
)
|
||||
acp_parser.add_argument(
|
||||
"--setup",
|
||||
action="store_true",
|
||||
help="Run interactive Hermes provider/model setup for ACP terminal auth",
|
||||
)
|
||||
acp_parser.add_argument(
|
||||
"--setup-browser",
|
||||
action="store_true",
|
||||
help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ "
|
||||
"for browser tool support (idempotent).",
|
||||
)
|
||||
acp_parser.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
dest="assume_yes",
|
||||
help="Accept all prompts (used by --setup-browser to skip the "
|
||||
"~400 MB Chromium download confirmation).",
|
||||
)
|
||||
acp_parser.set_defaults(func=cmd_acp)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue