Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
"""
Hermes CLI - Unified command-line interface for Hermes Agent.
Provides subcommands for:
- hermes chat - Interactive chat (same as ./hermes)
- hermes gateway - Run gateway in foreground
- hermes gateway start - Start gateway service
- hermes gateway stop - Stop gateway service
- hermes setup - Interactive setup wizard
- hermes status - Show status of all components
- hermes cron - Manage cron jobs
"""
import os
import sys
__version__ = "0.21.0"
__release_date__ = "2026.8.31"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
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.
"""
repaired = False
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
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()
+666
View File
@@ -0,0 +1,666 @@
"""Dependency-light venv recovery that runs BEFORE hermes_cli.main's imports.
The ``hermes`` console entry point is ``hermes_cli.main:main``. Importing
``hermes_cli.main`` pulls in third-party packages at module level (``dotenv``
via ``hermes_cli.env_loader``, ``yaml`` via ``hermes_cli.config``, ...). In
the exact failure state the update-recovery markers exist for — a failed lazy
backend refresh or interrupted core install that wiped a core package's
import files (#57828) — a normal launch crashes *while importing main.py*,
before ``_recover_from_interrupted_install()`` can run. The marker system is
unreachable precisely when it is needed most.
This module is deliberately **stdlib-only** so importing it can never fail on
a corrupted venv. ``hermes_cli.main`` imports and calls
:func:`recover_if_needed` at the very top of its module body, before any
third-party import.
Scope: this early pass only repairs enough for ``hermes_cli.main`` to become
importable again (force-reinstall of the known-fragile core packages, using
the pins from pyproject.toml). It NEVER clears the recovery markers — the
full, confirmed marker lifecycle stays with ``_recover_from_interrupted_install()``
in main.py, which runs right after import succeeds.
"""
from __future__ import annotations
import importlib
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
# Core packages a failed lazy ``uv pip install`` is known to leave with intact
# distribution metadata but wiped import files (#57828). ``module`` is what we
# probe via a real import; ``attr`` guards against an empty/stub module.
# main.py's marker-recovery path reuses these tables — keep them here (the
# dependency-light module) so both layers probe and repair the same set.
LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
("yaml", "SafeDumper"),
("dotenv", "load_dotenv"),
("click", "Command"),
("certifi", "contents"),
("rich", "print"),
("cryptography", "__version__"),
("jwt", "encode"),
)
LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = {
"yaml": "PyYAML",
"dotenv": "python-dotenv",
"click": "click",
"certifi": "certifi",
"rich": "rich",
"cryptography": "cryptography",
"jwt": "PyJWT",
}
# --- Windows entry-point shim quarantine -----------------------------------
#
# ``hermes update`` renames the live ``hermes*.exe`` shims aside
# (``hermes.exe.old.<unix-ms>``) so uv can write replacements. Putting them BACK
# is the safety-critical direction: losing that rename leaves the install with
# no ``hermes`` on PATH, and the command that would repair it IS ``hermes
# update`` (#75584).
#
# Three call sites restore a quarantined shim -- the updater, the
# early-recovery installer, and the startup sweep's orphan rescue. They used to
# be separate one-shot renames with swallowed errors; the two that had messages
# had already drifted apart. The logic lives here, in the one stdlib-only module
# all of them can import, so the ladder and the recovery wording stay in
# lockstep.
QUARANTINE_RESTORE_BACKOFF_MS: tuple[int, ...] = (0, 100, 250, 500, 1000)
def restore_quarantined_shims(
moved: list[tuple[Path, Path]],
*,
stream=None,
backoff_ms: tuple[int, ...] = QUARANTINE_RESTORE_BACKOFF_MS,
) -> list[tuple[Path, Path]]:
"""Rename quarantined shims back, retrying a lock instead of giving up.
``moved`` holds ``(original, quarantined)`` pairs. Returns the pairs that
could NOT be restored, and prints an actionable recovery command for each.
A pair is not a failure when ``original`` already exists or ``quarantined``
has gone: the installer wrote a fresh shim, or a concurrent sweep won the
race. Both are silent, so two processes sweeping the same orphan cannot
produce a spurious error.
Messages go to stderr by default -- the startup sweep runs on EVERY hermes
invocation, and ``hermes acp`` speaks JSON-RPC on stdout.
"""
if stream is None:
stream = sys.stderr
failed: list[tuple[Path, Path]] = []
for original, quarantined in moved:
last_exc: OSError | None = None
for delay_ms in backoff_ms:
try:
if os.path.exists(original) or not os.path.exists(quarantined):
last_exc = None
break
if delay_ms:
time.sleep(delay_ms / 1000.0)
os.rename(quarantined, original)
last_exc = None
break
except OSError as exc:
last_exc = exc
continue
if last_exc is None:
continue
failed.append((original, quarantined))
name = os.path.basename(str(original))
stem = name[:-4] if name.lower().endswith(".exe") else name
print(
f" ✖ FAILED to restore {name} "
f"({last_exc.__class__.__name__}) — it is still quarantined "
f"as {os.path.basename(str(quarantined))}.\n"
f" `{stem}` will NOT be on PATH until it is put back. Run this, "
f"then re-run the update:\n"
f' move "{quarantined}" "{original}"',
file=stream,
)
return failed
# Set only when this process successfully finishes a deferred core install for
# an ``update`` invocation. The normal CLI import that follows must not resolve
# external secret sources: a configured source can map cryptography._rust and
# immediately recreate the self-lock marker this fresh process just consumed.
# Process-local state is intentional so child processes do not inherit the
# bootstrap exception.
_UPDATE_RETRY_RECOVERED = False
def _should_skip_external_secret_sources() -> bool:
"""Whether this updater already completed its deferred native install."""
return _UPDATE_RETRY_RECOVERED
def _project_root() -> Path:
return Path(__file__).resolve().parent.parent
def _pid_is_running(pid: int) -> bool:
"""Best-effort stdlib-only process liveness probe.
``os.kill(pid, 0)`` is not a no-op on Windows, so use the Win32 process
handle API there. An access-denied result is conservatively live: racing
an elevated updater is worse than postponing recovery for one launch.
"""
if pid <= 0:
return False
if sys.platform == "win32":
try:
import ctypes
synchronize = 0x00100000
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_ulong,
]
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
kernel32.WaitForSingleObject.restype = ctypes.c_ulong
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
kernel32.CloseHandle.restype = ctypes.c_int
handle = kernel32.OpenProcess(synchronize, False, pid)
if not handle:
return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED
try:
return kernel32.WaitForSingleObject(handle, 0) == 258
finally:
kernel32.CloseHandle(handle)
except Exception:
return True
try:
os.kill(pid, 0) # windows-footgun: ok — Windows returns above
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _marker_owner_is_live(marker: Path) -> bool:
"""True when a legacy update marker names a process still running."""
try:
body = marker.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
for line in body.splitlines():
key, separator, value = line.partition("=")
if separator and key.strip() == "pid":
try:
return _pid_is_running(int(value.strip()))
except ValueError:
return False
return False
def _pinned_specs(packages: list[str], project_root: Path) -> list[str]:
"""Map bare package names to their pinned specs from pyproject.toml.
Stdlib-only (tomllib + naive requirement-head parsing — ``packaging`` may
itself be broken in the failure state this module exists for). Unknown
packages fall back to their bare name.
"""
pyproject = project_root / "pyproject.toml"
if not pyproject.is_file():
return packages
try:
import tomllib
with open(pyproject, "rb") as f:
raw_deps = tomllib.load(f).get("project", {}).get("dependencies", []) or []
except Exception:
return packages
name_to_spec: dict[str, str] = {}
for spec in raw_deps:
head = spec.split(";", 1)[0].strip()
bare = head
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in bare:
bare = bare.split(op, 1)[0]
break
key = bare.strip().split("[", 1)[0].strip().lower()
if key:
name_to_spec[key] = head
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
def _certifi_bundle_broken() -> bool:
"""True when certifi imports but its ``cacert.pem`` is missing/corrupt.
A brew Python upgrade or an interrupted venv rebuild can leave certifi's
distribution metadata (and even the module) intact while the bundled
``cacert.pem`` is gone or a dangling symlink — every TLS connection then
fails with an opaque ``Could not find a suitable TLS CA certificate
bundle`` from deep inside httpx/requests (#29866). An attribute probe
alone passes in that state, so validate the bundle path itself.
"""
try:
import certifi
bundle = Path(certifi.where())
# <1 KiB cannot hold a single PEM certificate — treat as corrupt.
return not bundle.is_file() or bundle.stat().st_size < 1024
except Exception:
# Import failure is caught by the regular probe table; a failure to
# even stat is treated as broken.
return True
def _probe_broken_packages() -> list[str]:
"""Import-probe the fragile core packages in THIS process.
Returns repair package names (deduped, probe order) for modules that fail
to import or lack their sentinel attribute. Failed imports leave nothing
in ``sys.modules``, so a post-repair retry in the same process works.
certifi additionally gets a bundle-file check: the module can import
cleanly while ``cacert.pem`` is missing (#29866).
"""
broken: list[str] = []
for mod_name, attr in LAZY_REFRESH_IMPORT_PROBES:
try:
mod = importlib.import_module(mod_name)
if not hasattr(mod, attr):
raise ImportError(f"{mod_name} missing {attr}")
if mod_name == "certifi" and _certifi_bundle_broken():
raise ImportError("certifi cacert.pem missing or corrupt")
except Exception:
pkg = LAZY_REFRESH_REPAIR_PACKAGES.get(mod_name)
if pkg and pkg not in broken:
broken.append(pkg)
return broken
def _find_uv_binary() -> str | None:
"""Locate a ``uv`` binary without importing third-party modules.
uv-managed base interpreters carry an ``EXTERNALLY-MANAGED`` marker, so
the stdlib ``pip`` fallback below refuses to touch them. In that state
the only sanctioned installer is uv itself, which Hermes already vendors
(``~/.hermes/bin/uv.exe``) or the user has on PATH. Stdlib-only.
"""
exe = "uv.exe" if sys.platform == "win32" else "uv"
candidates = [
Path.home() / ".hermes" / "bin" / exe,
Path.home() / ".local" / "bin" / exe,
Path.home() / ".cargo" / "bin" / exe,
]
for path in candidates:
if path.is_file():
return str(path)
return shutil.which(exe)
def _base_interpreter_is_externally_managed() -> bool:
"""True when ``sys.executable`` is a uv/standalone-builds managed install.
Those interpreters ship an ``EXTERNALLY-MANAGED`` marker next to their
stdlib (PEP 668), so ``python -m pip install`` aborts with
``externally-managed-environment``. The early repair must then go
through uv (or explicitly override pip) or the reinstall no-ops and the
venv stays broken (#83569).
"""
try:
import sysconfig
stdlib = Path(sysconfig.get_path("stdlib"))
if (stdlib / "EXTERNALLY-MANAGED").exists():
return True
# uv 0.5+ moved the marker into a ``_uv_managed`` sentinel dir…
if (stdlib.parent / "EXTERNALLY-MANAGED").exists():
return True
except Exception:
pass
return False
def _run_repair_install(specs: list[str], project_root: Path) -> bool:
"""``uv pip`` (or stdlib ``pip``) force-reinstall of the given specs.
Streams nothing to stdout (``hermes acp`` speaks JSON-RPC on stdout);
output is captured and replayed to stderr only on failure. Never raises.
Two installer paths, in priority order:
1. ``uv pip install`` with ``VIRTUAL_ENV`` pointed at the project venv —
required when the base interpreter is uv-managed (Windows git checkouts
install exactly this way: uv's Python declares PEP 668
``EXTERNALLY-MANAGED`` and plain ``python -m pip`` refuses to run).
2. ``sys.executable -m pip`` as before, for self-contained venvs whose
interpreter carries no PEP 668 marker.
"""
externally_managed = _base_interpreter_is_externally_managed()
if externally_managed:
uv = _find_uv_binary()
if uv:
env = {**os.environ, "VIRTUAL_ENV": str(project_root / "venv")}
env.pop("PYTHONHOME", None)
env.pop("PYTHONPATH", None)
try:
result = subprocess.run(
[uv, "pip", "install", "--force-reinstall", *specs],
cwd=project_root,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
env=env,
)
if result.returncode == 0:
return True
tail = (result.stderr or result.stdout or "")[-2000:]
if tail:
print(tail, file=sys.stderr)
return False
except Exception as exc:
print(f" ✗ Early venv repair could not run uv: {exc}", file=sys.stderr)
return False
# No uv available: fall through to pip with the PEP 668 override so
# the repair at least attempts to fix the venv instead of no-oping.
print(
" ⚠ Base interpreter is externally managed and no uv binary was "
"found; retrying repair via pip with PEP 668 override.",
file=sys.stderr,
)
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=project_root,
capture_output=True,
)
except Exception:
pass
pip_cmd = [sys.executable, "-m", "pip", "install", "--force-reinstall"]
if externally_managed:
pip_cmd.append("--break-system-packages")
pip_cmd.extend(specs)
try:
result = subprocess.run(
pip_cmd,
cwd=project_root,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
except Exception as exc:
print(f" ✗ Early venv repair could not run pip: {exc}", file=sys.stderr)
return False
if result.returncode != 0:
tail = (result.stderr or result.stdout or "")[-2000:]
if tail:
print(tail, file=sys.stderr)
return False
return True
def _pytest_owns_live_checkout(root: Path) -> bool:
"""True when running under pytest AND ``root`` is this module's own
checkout — the one whose venv is executing the suite right now.
Lifecycle tests spawn real subprocesses that import ``hermes_cli.main``
with recovery armed; ``PYTEST_CURRENT_TEST`` rides the inherited env into
those children. Without this guard, a genuinely-broken dev venv gets a
REAL ``ensurepip`` + ``pip install --force-reinstall`` from inside a
running test suite. Tests that sandbox ``project_root`` to a tmp_path are
unaffected (same posture as ``managed_scope._under_pytest``)."""
return (
"PYTEST_CURRENT_TEST" in os.environ
and root == Path(__file__).resolve().parent.parent
)
def recover_if_needed(
project_root: Path | None = None,
argv: list[str] | None = None,
) -> None:
"""Repair wiped core packages so ``hermes_cli.main`` can import at all.
Fast path (no marker present) is two ``lstat`` calls. Only acts when a
recovery marker from a prior ``hermes update`` exists AND an import probe
confirms a core package is actually broken. Markers are intentionally
NOT cleared here — ``_recover_from_interrupted_install()`` in main.py owns
the confirmed marker lifecycle and runs immediately after import succeeds.
Never raises: on any failure the import of main.py proceeds and surfaces
the real error.
"""
global _UPDATE_RETRY_RECOVERED
try:
args = sys.argv[1:] if argv is None else argv
root = _project_root() if project_root is None else project_root
if _pytest_owns_live_checkout(root):
return
core_marker = root / ".update-incomplete"
lazy_marker = root / ".lazy-refresh-incomplete"
if not core_marker.exists() and not lazy_marker.exists():
return
# Managed/Docker/PyPI installs have no source tree here — the marker
# is not ours to act on; main.py's recovery clears it.
if not (root / "pyproject.toml").is_file():
return
# Pending core install (.update-incomplete) — complete it NOW, before
# any native extension can be imported by this process. The lazy
# import-probe path below only proves main.py is importable; the core
# install here guarantees the WHOLE dependency set is replaced while
# nothing pins venv .pyd files yet (#83569 self-lock: deferring the
# install to main()'s post-import recovery re-locks it on Windows).
# Bounded retries: a persistently failing install must not hammer
# every launch, so attempts past the ceiling are left for main.py's
# post-import recovery path (which can safely probe-import after this
# process already holds whatever extensions it needs).
# A live marker owner means another updater is currently inside the
# marker-to-install window. Never race it. A dead owner means this is
# a prior deferral/interruption and MUST be recovered even when this
# launch is itself `hermes update`: CLI and Desktop retries preserve
# that argv, and skipping solely on argv recreates the self-lock loop.
if core_marker.exists():
if _marker_owner_is_live(core_marker):
return
completed = _complete_pending_core_install(root, core_marker)
if completed and "update" in args:
_UPDATE_RETRY_RECOVERED = True
return
# Keep the historical update-argv exclusion for the lazy-refresh
# marker. Unlike the core marker it is not a deferred native install,
# and the active update flow owns its probe/repair lifecycle.
if "update" in args:
return
broken = _probe_broken_packages()
if not broken:
# Imports are fine — main.py will load and run full recovery.
return
# Single-flight: share main.py's recovery lock so an early repair
# never races a concurrent full recovery into the same shared venv.
lock_path = root / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError:
pass # read-only fs / perms — proceed unlocked, install surfaces it
try:
specs = _pinned_specs(broken, root)
print(
"⚠ Core package(s) broken by an interrupted update — "
f"repairing before launch: {', '.join(broken)}",
file=sys.stderr,
)
if _run_repair_install(specs, root) and not _probe_broken_packages():
print(" ✓ Core packages repaired.", file=sys.stderr)
else:
print(
" ✗ Automatic repair incomplete. Recover manually with:",
file=sys.stderr,
)
print(
f" {sys.executable} -m pip install --force-reinstall "
+ " ".join(specs),
file=sys.stderr,
)
finally:
try:
lock_path.unlink()
except OSError:
pass
except Exception:
# Never block launch — the import of main.py will surface the truth.
pass
# Cap on automatic early-pass install retries. A persistently failing
# install (e.g. network down, index unreachable) must not reinstall-hammer
# every `hermes` launch: past this many attempts the early pass hands the
# marker to main.py's post-import recovery, which presents the manual
# recovery command. The counter lives inside the marker file itself (JSON
# body) and is bumped on each failed attempt.
_EARLY_CORE_INSTALL_MAX_ATTEMPTS = 3
def _claim_recovery_lock(root: Path) -> bool:
"""Single-flight claim on the shared recovery lock. Never raises."""
lock_path = root / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
return True
except FileExistsError:
try:
if time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return False
except OSError:
# Read-only fs / perms — proceed unlocked; the install itself
# surfaces the real problem. Recoverable.
return True
def _release_recovery_lock(root: Path) -> None:
"""Best-effort release of the shared recovery lock."""
try:
(root / ".update-incomplete.lock").unlink()
except OSError:
pass
def _complete_pending_core_install(root: Path, core_marker: Path) -> bool:
"""Run the pending core install BEFORE main.py can import native modules.
``recover_if_needed`` invokes this when ``.update-incomplete`` exists —
a prior ``hermes update`` (or the self-lock preflight, #83569) left the
dependency sync deliberately unfinished. Completing it here matters on
Windows: the deferral exists precisely because the process that wrote the
marker had a native venv extension mapped; this process, running before
``hermes_cli.main``'s third-party imports, maps nothing yet, so the
installer can replace ``.pyd`` files without hitting the lock.
Marker lifecycle: cleared on success; kept (attempts counter bumped) on
failure for the next launch or main.py's post-import recovery. An
attempts ceiling caps automatic retries so a persistent installer
failure does not block every launch (``hermes acp`` included).
Never raises: any failure leaves the marker for the post-import path and
returns ``False``. Returns ``True`` only after the install succeeds.
"""
try:
from hermes_cli import _install_repair as ir
# Retry backoff: read current attempts before claiming the lock so a
# persistently-failing install stops hammering early. After the
# increment the counter reflects THIS attempt.
attempts = 0
try:
raw = core_marker.read_text(encoding="utf-8", errors="replace").strip()
if raw:
import json as _json
try:
attempts = int(_json.loads(raw).get("attempts", 0))
except (ValueError, AttributeError):
attempts = 0
except OSError:
attempts = 0
if attempts >= _EARLY_CORE_INSTALL_MAX_ATTEMPTS:
print(
"⚠ Pending interrupted-update install has already failed "
f"{attempts} times in the early pass — leaving it for the "
"post-import recovery path.",
file=sys.stderr,
)
return False
if not _claim_recovery_lock(root):
return False
try:
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now (before any native "
"extensions load)...",
file=sys.stderr,
)
ir.run_core_install(root)
except Exception as exc:
new_attempts = ir.bump_marker_attempts(core_marker)
print(
f" ✗ Early interrupted-install completion failed (attempt "
f"{new_attempts}/{_EARLY_CORE_INSTALL_MAX_ATTEMPTS}): {exc}",
file=sys.stderr,
)
print(
" The next launch will retry; hermes will keep working from "
"the current venv in the meantime.",
file=sys.stderr,
)
return False
finally:
_release_recovery_lock(root)
try:
core_marker.unlink()
except OSError:
pass
print(
" ✓ Dependency installation completed in the early pass.",
file=sys.stderr,
)
return True
except Exception:
# Never block launch — the marker stays for the post-import path.
return False
+692
View File
@@ -0,0 +1,692 @@
"""Dependency install execution shared between early recovery and full recovery.
Both callers need to run the same core ``.[all]`` reinstall:
- ``hermes_cli._early_recovery.recover_if_needed`` — stdlib-only, runs BEFORE
``hermes_cli.main``'s third-party imports, so it can complete a pending
update while no native extension is mapped yet (#83569).
- ``hermes_cli.main._recover_core_update_marker_locked`` — the historical
post-import recovery path. Kept as a fallback for installs the early pass
could not complete (marker left in place on failure).
This module is deliberately **stdlib-only** so importing it can never fail in
the corrupted-venv state it exists to repair. ``hermes_cli.main`` imports
``managed_uv``, ``hermes_constants``, and friends only in its late path; the
early path must not. Where the late path uses ``managed_uv.ensure_uv`` to
bootstrap uv if missing, the early path uses the stdlib
:func:`hermes_cli._early_recovery._find_uv_binary` lookup and falls back to
plain pip when uv is absent — a degraded but working installer (the late
recovery will bootstrap uv on the next launch if it ever matters).
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
# Single source of truth for the recovery-lock lifecycle and uv lookup —
# _early_recovery already owns both, and importing it is free (stdlib-only).
from hermes_cli import _early_recovery as _er
def _is_windows() -> bool:
return sys.platform == "win32"
def _is_termux_env(env: dict | None = None) -> bool:
"""Stdlib Termux probe (hermes_cli.main's version lives behind imports)."""
env = env if env is not None else os.environ
try:
if env.get("TERMUX_VERSION"):
return True
prefix = env.get("PREFIX", "")
return "com.termux" in prefix
except Exception:
return False
@contextlib.contextmanager
def _stdout_to_stderr():
"""Route fd 1 (and sys.stdout) to stderr for the duration of an install.
``hermes acp`` speaks JSON-RPC on stdout; an inherited-fd install child
writing there would corrupt the protocol. Mirrors
``main.py::_recover_from_interrupted_install``.
"""
saved_fd = None
saved_sys_stdout = sys.stdout
try:
saved_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_fd = None
sys.stdout = sys.stderr
try:
yield
finally:
sys.stdout = saved_sys_stdout
if saved_fd is not None:
try:
os.dup2(saved_fd, 1)
except OSError:
pass
try:
os.close(saved_fd)
except OSError:
pass
def _resolve_install_target(root: Path) -> tuple[list[str], dict | None]:
"""(install_cmd_prefix, env) for the project venv — stdlib uv lookup.
Mirrors ``main.py::_default_venv_install_target`` but without
``managed_uv``. ``VIRTUAL_ENV`` steers ``uv pip`` at the project venv even
when invoked from the base interpreter (the early-recovery case).
Termux strips leaked interpreter-path env vars so uv resolves the venv
correctly.
"""
uv_bin = _er._find_uv_binary()
if uv_bin:
from hermes_constants import project_venv_dir
env = {**os.environ, "VIRTUAL_ENV": str(project_venv_dir(root) or root / "venv")}
if _is_termux_env(env):
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
return [uv_bin, "pip"], env
return [sys.executable, "-m", "pip"], None
def _venv_scripts_dir(root: Path) -> Path | None:
"""Project venv Scripts/bin dir, when present. stdlib-only."""
# hermes_constants is stdlib-only, so the canonical layout helpers are safe
# to use from this corrupted-venv repair path (#76105: never open-code
# the Scripts/bin split).
from hermes_constants import project_venv_dir, venv_bin_dir
venv_dir = project_venv_dir(root)
if venv_dir is None:
return None
scripts = venv_bin_dir(venv_dir, windows=_is_windows())
return scripts if scripts.is_dir() else None
#: Launcher command names install.ps1's Set-PathVariable exposes from the
#: managed binary dir (the default Hermes root's ``bin``, next to uv.exe)
#: on the user PATH. Keep in lockstep with the launcher list in
#: scripts/install.ps1.
_WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp")
def _venv_is_relocatable(venv_dir: Path) -> bool:
"""True when the venv's pyvenv.cfg declares ``relocatable = true``.
uv writes the flag; ``hermes_cli.managed_uv`` builds its replacement
venvs with ``--relocatable`` (they are constructed aside and swapped
into place). A relocatable venv's console-script trampolines embed a
RELATIVE interpreter reference, so a COPY of one placed outside
``venv\\Scripts`` fails at run time with ``uv trampoline failed to
canonicalize script path``. Non-relocatable venvs (fresh installs)
embed the absolute interpreter path and their trampolines survive
copying. This flag decides which launcher form a PATH dir gets.
"""
try:
cfg = (Path(venv_dir) / "pyvenv.cfg").read_text(
encoding="utf-8", errors="replace"
)
except OSError:
return False
for line in cfg.splitlines():
key, _, value = line.partition("=")
if key.strip().lower() == "relocatable" and value.strip().lower() == "true":
return True
return False
def _normalize_windows_path(value) -> str:
"""Windows path equality key: backslashes, no trailing separator, lowered.
Lowercase via ``.lower()`` (what ``ntpath.normcase`` does) rather than
``os.path.normcase`` — that is an identity function on POSIX, and this
comparison must behave Windows-correct even when tests exercise the
Windows branch from another host (same rationale as
``venv_bin_dir(windows=...)``).
"""
return str(value).replace("/", "\\").rstrip("\\").lower()
def _windows_user_path_entries() -> list[str]:
"""User PATH entries from the registry — the value install.ps1 writes.
Falls back to the process PATH when the registry is unreadable. Only
called on Windows.
"""
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
raw, _kind = winreg.QueryValueEx(key, "Path")
value = os.path.expandvars(str(raw))
except (OSError, ImportError):
value = os.environ.get("PATH", "")
return [entry for entry in value.split(";") if entry.strip()]
def ensure_windows_bin_launchers(
root,
*,
windows: bool | None = None,
user_path_entries: list[str] | None = None,
) -> list[str]:
"""Re-stage the Windows ``hermes`` launchers when they vanish.
On Windows, ``hermes`` resolves through launchers derived from the venv
console scripts — never ``venv\\Scripts`` itself on PATH, which would
shadow the user's ``python`` (#83797). The canonical launcher home is
the managed binary dir — the default Hermes root's ``bin``
(``%LOCALAPPDATA%\\hermes\\bin``, next to the managed uv) — which lives
OUTSIDE the git checkout so no git operation can ever touch it. It is
a per-machine dir shared by every profile: ``get_hermes_home()`` would
point inside ``profiles\\<name>`` under ``hermes -p``, so the anchor
here is :func:`hermes_constants.get_default_hermes_root`.
Earlier installer versions staged them at ``<checkout>\\bin`` instead —
inside the git working tree — where ``hermes update``'s pre-update
autostash (``git stash push --include-untracked``) swept them off disk;
once the desktop updater stopped re-applying stashes (``--keep-stash``)
nothing restored them and ``hermes`` stopped resolving in every new
terminal. That legacy location is re-staged too, during the transition,
for installs whose user PATH still resolves through it.
The launcher FORM depends on the venv (see :func:`_venv_is_relocatable`):
a normal venv's exe trampoline embeds an absolute interpreter path and
survives copying, so it is copied as ``<name>.exe``; a relocatable
venv's trampoline resolves relative to its own location and a copy
dies with ``uv trampoline failed to canonicalize script path``, so a
``<name>.cmd`` delegator invoking the in-venv exe by absolute path is
written instead. A name counts as present when EITHER form exists —
exe copies staged before a venv rebuild keep working (they embed the
swapped-in-place venv's absolute path) and are left alone.
Two targets, two gates, both failing toward inaction:
- canonical managed binary dir: only when *root* is the managed clone
(``root.parent == get_default_hermes_root()``), so source checkouts
elsewhere never gain launchers;
- legacy ``<root>\\bin``: only when that dir is on the user PATH
(registry value, process PATH as fallback), i.e. the install opted
into the old layout and still resolves through it.
Writes go through a staging name + ``os.replace`` so concurrent process
starts cannot tear a launcher. Never raises; returns the restored paths.
*windows* and *user_path_entries* are injectable for tests, same pattern
as ``hermes_constants.venv_bin_dir``.
"""
if windows is None:
windows = _is_windows()
if not windows:
return []
root = Path(root)
# Per-machine anchor: the DEFAULT Hermes root, not get_hermes_home() —
# under ``hermes -p <name>`` that returns ``profiles\\<name>``, which
# would fail the managed-clone gate below and silently skip the heal
# for profile users. The launcher dir serves the whole machine.
from hermes_constants import get_default_hermes_root
try:
home = Path(get_default_hermes_root())
except Exception:
return []
def _launcher_present(target: Path, name: str) -> bool:
return (target / f"{name}.exe").exists() or (target / f"{name}.cmd").exists()
targets: list[Path] = []
# Canonical target — gate on the managed-clone shape. This runs at
# every hermes_cli.main process start (right after the profile
# override), so the healthy path must stay at a couple of stat calls.
if _normalize_windows_path(root.parent) == _normalize_windows_path(home):
canonical = home / "bin"
if any(not _launcher_present(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS):
targets.append(canonical)
# Legacy transition target — the pre-migration in-checkout dir. Only
# re-staged while the user PATH still points at it (consent), compared
# as normalized literal strings: the installer wrote the long literal
# path, and realpath'ing arbitrary PATH entries could hang on dead
# network shares. An entry stored some other way (8.3 short path,
# subst drive) misses the re-stage, which fails safe: no-op.
legacy = root / "bin"
if any(not _launcher_present(legacy, name) for name in _WINDOWS_BIN_LAUNCHERS):
if user_path_entries is None:
user_path_entries = _windows_user_path_entries()
configured = {_normalize_windows_path(entry) for entry in user_path_entries}
if _normalize_windows_path(legacy) in configured:
targets.append(legacy)
if not targets:
return []
from hermes_constants import project_venv_dir, venv_bin_dir
venv_dir = project_venv_dir(root)
if venv_dir is None:
return []
scripts_dir = venv_bin_dir(venv_dir, windows=windows)
sources = [
(name, scripts_dir / f"{name}.exe")
for name in _WINDOWS_BIN_LAUNCHERS
if (scripts_dir / f"{name}.exe").is_file()
]
if not sources:
return []
relocatable = _venv_is_relocatable(venv_dir)
restored: list[str] = []
for target in targets:
try:
target.mkdir(parents=True, exist_ok=True)
except OSError:
continue
for name, source in sources:
if _launcher_present(target, name):
continue
final = target / (f"{name}.cmd" if relocatable else f"{name}.exe")
staging = target / f"{final.name}.heal.{os.getpid()}"
try:
if relocatable:
staging.write_text(
"@echo off\r\n" f'"{source}" %*\r\n', encoding="ascii"
)
else:
shutil.copy2(source, staging)
os.replace(staging, final)
restored.append(str(final))
except OSError:
with contextlib.suppress(OSError):
staging.unlink()
if restored:
# Guarded like everything else in this never-raises helper: a
# closed/broken stderr must not turn a successful heal into a crash.
with contextlib.suppress(OSError, ValueError):
print(
" ✓ Restored hermes launcher(s): " + ", ".join(restored),
file=sys.stderr,
)
return restored
def _read_user_path_raw() -> tuple[list[str], int]:
"""Raw (unexpanded) user PATH entries + registry value type.
Raw so a rewrite preserves ``%VARS%`` exactly as the user stored them
(same discipline as ``hermes_cli.uninstall``). Only called on Windows.
"""
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
try:
raw, kind = winreg.QueryValueEx(key, "Path")
except FileNotFoundError:
return [], winreg.REG_EXPAND_SZ
return [entry for entry in str(raw).split(";") if entry], int(kind)
def _write_user_path_raw(entries: list[str], kind: int) -> None:
"""Write the user PATH back, preserving the registry value type."""
import winreg
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE
) as key:
winreg.SetValueEx(key, "Path", 0, kind, ";".join(entries))
def migrate_windows_bin_path(
root,
*,
windows: bool | None = None,
read_user_path=None,
write_user_path=None,
) -> bool:
"""One-time PATH migration to the ``HERMES_HOME\\bin`` launcher layout.
Runs from the ``hermes update`` tail (and mirrors what install.ps1's
Set-PathVariable does on fresh installs/repairs, which never reach
existing installs — updates don't run install.ps1):
1. stage the launcher copies into the managed binary dir (via
:func:`ensure_windows_bin_launchers`);
2. verify both launchers are present there — otherwise STOP, leaving
the user PATH untouched (never strip a working entry before its
replacement is proven);
3. ensure the managed binary dir is on the user PATH (prepend);
4. strip the legacy entries: ``<root>\\bin`` (in-checkout launcher dir
the update autostash could sweep) and ``<root>\\venv\\Scripts``
(shadowed the user's ``python``, #83797).
The legacy ``<root>\\bin`` FILES are deliberately left in place: editor
and ACP configs that captured absolute launcher paths keep working
(the launchers run fine from there — only PATH resolution through a
dir git could sweep was the bug), and the dir is git-ignored so it
cannot dirty the tree.
Registry writes preserve the stored value type and raw ``%VARS%``.
Never raises; returns True when the canonical layout is in place.
*read_user_path*/*write_user_path* are injectable for tests.
"""
if windows is None:
windows = _is_windows()
if not windows:
return False
root = Path(root)
# Same per-machine anchor as ensure_windows_bin_launchers (see there).
from hermes_constants import get_default_hermes_root, venv_bin_dir
try:
home = Path(get_default_hermes_root())
except Exception:
return False
if _normalize_windows_path(root.parent) != _normalize_windows_path(home):
return False # not the managed clone — nothing to migrate
ensure_windows_bin_launchers(root, windows=windows, user_path_entries=[])
home_bin = home / "bin"
if any(
not ((home_bin / f"{name}.exe").is_file() or (home_bin / f"{name}.cmd").is_file())
for name in _WINDOWS_BIN_LAUNCHERS
):
return False # staging incomplete — leave the PATH alone
if read_user_path is None:
read_user_path = _read_user_path_raw
if write_user_path is None:
write_user_path = _write_user_path_raw
try:
entries, kind = read_user_path()
except (OSError, ImportError):
return False
legacy_keys = {
_normalize_windows_path(root / "bin"),
# The pre-#83797 installer put the venv's Scripts dir itself on PATH,
# always at the literal `venv` layout (never `.venv`) — this strips
# that stale entry, so it must match what the installer wrote then,
# not where the venv lives now.
_normalize_windows_path(venv_bin_dir(root / "venv", windows=True)),
}
home_bin_key = _normalize_windows_path(home_bin)
def _entry_key(entry: str) -> str:
return _normalize_windows_path(os.path.expandvars(entry))
kept = [e for e in entries if _entry_key(e) not in legacy_keys]
have_home_bin = any(_entry_key(e) == home_bin_key for e in kept)
if not have_home_bin:
kept = [str(home_bin)] + kept
if kept != entries:
try:
write_user_path(kept, kind)
except (OSError, ImportError):
return False
with contextlib.suppress(OSError, ValueError):
print(
f" ✓ hermes launchers now resolve from {home_bin} "
"(legacy PATH entries removed)",
file=sys.stderr,
)
return True
def _load_console_script_names(root: Path) -> list[str]:
"""``[project.scripts]`` names from pyproject.toml (tomllib, 3.11+)."""
try:
import tomllib
except ImportError: # pragma: no cover
return []
pyproject = root / "pyproject.toml"
if not pyproject.is_file():
return []
try:
with open(pyproject, "rb") as f:
data = tomllib.load(f)
scripts = data.get("project", {}).get("scripts", {}) or {}
return [str(name) for name in scripts if name]
except Exception:
return []
class ShimQuarantineError(RuntimeError):
"""A live shim could not be renamed aside — the venv is contended (#87331).
Raised BEFORE the install command runs. Callers (early-pass recovery,
core-marker recovery) catch it like any install failure: the
update-incomplete marker survives and a later launch retries once the
holder exits — the contended venv is never mutated.
"""
def __init__(self, failed_shims: list[str]):
self.failed_shims = list(failed_shims)
super().__init__(
"could not quarantine live shim(s): " + ", ".join(self.failed_shims)
)
def _quarantine_running_hermes_exe(
scripts_dir: Path, *, failed_out: list[str] | None = None
) -> list[tuple[Path, Path]]:
"""Rename live hermes*.exe shims aside so the installer can rewrite them.
Windows blocks REPLACE on a running .exe but allows RENAME. Best-effort:
silently skips anything that cannot be renamed. Returns (original,
quarantined) pairs. stdlib-only — the console-script set comes from
pyproject ``[project.scripts]`` (fallback: the well-known trio).
``failed_out``: when provided, names of shims that could not be renamed
are appended so the caller can refuse instead of mutating a contended
venv (#87331 fail-closed).
"""
if not _is_windows():
return []
names = set(_load_console_script_names(scripts_dir.parent.parent)) or {
"hermes",
"hermes-agent",
"hermes-acp",
}
names.add("hermes-gateway")
moved: list[tuple[Path, Path]] = []
for name in sorted(names):
shim = scripts_dir / f"{name}.exe"
if not shim.exists():
continue
quarantined = shim.with_name(f"{name}.exe.old.{int(time.time() * 1000)}")
try:
os.rename(shim, quarantined)
moved.append((shim, quarantined))
except OSError:
if failed_out is not None:
failed_out.append(shim.name)
return moved
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
"""Put quarantined shims back when the installer did not replace them.
Delegates to the shared helper in the stdlib-only ``_early_recovery``
module: one retry ladder and one recovery message for every restore site,
instead of the near-identical copies that had already drifted (#75584).
Warnings land on stderr — this module runs in the early-recovery path and
``hermes acp`` speaks JSON-RPC on stdout.
"""
_er.restore_quarantined_shims(moved)
def _run_install_cmd(cmd: list[str], *, env: dict | None, root: Path) -> None:
"""Run an install command with quarantine protection for venv shims.
Fail-closed (#87331): when any live shim cannot be renamed aside, the
venv is contended and the installer would die partway on the same locks
— raise :class:`ShimQuarantineError` WITHOUT running it. The caller's
marker-keeping failure handling turns that into "retry next launch".
Raises CalledProcessError on install failure (callers implement the
per-extra fallback ladder).
"""
scripts_dir = _venv_scripts_dir(root) if _is_windows() else None
failed: list[str] = []
moved = (
_quarantine_running_hermes_exe(scripts_dir, failed_out=failed)
if scripts_dir
else []
)
if failed:
_restore_quarantined_exes(moved)
raise ShimQuarantineError(failed)
try:
subprocess.run(cmd, cwd=root, check=True, env=env)
finally:
# Restore runs on success AND failure: a SUCCESSFUL install can still
# skip the entry-points step entirely (uv audits an already-satisfied
# editable install as a no-op and rewrites nothing), which would leave
# the quarantined shims renamed aside and `hermes` gone from PATH
# (#75584). _restore_quarantined_exes only renames back when the
# installer did NOT write a fresh shim, so this is safe in both cases.
if scripts_dir is not None:
_restore_quarantined_exes(moved)
def _load_installable_optional_extras(root: Path, group: str) -> list[str]:
"""Optional extras referenced by a dependency group (all / termux-all)."""
try:
import tomllib
with (root / "pyproject.toml").open("rb") as handle:
project = tomllib.load(handle).get("project", {})
except Exception:
return []
optional_deps = project.get("optional-dependencies", {})
if not isinstance(optional_deps, dict):
return []
refs = optional_deps.get(group, [])
referenced: list[str] = []
for ref in refs:
if "[" in ref and "]" in ref:
name = ref.split("[", 1)[1].split("]", 1)[0]
if name in optional_deps:
referenced.append(name)
return referenced
def run_core_install(root: Path) -> None:
"""Full core ``.[all]`` editable reinstall — the recovery install.
Equal in behavior to the install half of
``main.py::_recover_core_update_marker_locked``:
- bootstrap pip via ensurepip (a killed install can leave the venv with no
pip module at all)
- prefer ``uv pip`` with VIRTUAL_ENV pointed at the project venv; fall back
to ``python -m pip`` when no uv binary is available
- target ``.[all]`` (or ``.[termux-all]`` on Termux) with the per-extra
fallback ladder when the combined extras resolve fails
- quarantine live ``hermes*.exe`` shims on Windows so they can be replaced
- route ALL install output to stderr (acp/JSON-RPC safety)
- Termux strips leaked PYTHONPATH/PYTHONHOME from the uv env
Raises ``subprocess.CalledProcessError`` when even the base install fails;
callers own marker lifecycle (clear on success, keep on failure).
"""
prefix, env = _resolve_install_target(root)
group = "termux-all" if _is_termux_env(env) else "all"
with _stdout_to_stderr():
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=root,
capture_output=True,
)
except Exception:
pass
try:
_run_install_cmd(
prefix + ["install", "-e", f".[{group}]"], env=env, root=root
)
return
except subprocess.CalledProcessError:
print(
" ⚠ Optional extras failed, reinstalling base dependencies "
"and retrying extras individually..."
)
_run_install_cmd(prefix + ["install", "-e", "."], env=env, root=root)
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras(root, group):
try:
_run_install_cmd(
prefix + ["install", "-e", f".[{extra}]"], env=env, root=root
)
installed_extras.append(extra)
except subprocess.CalledProcessError:
failed_extras.append(extra)
if installed_extras:
print(
" ✓ Reinstalled optional extras individually: "
+ ", ".join(installed_extras)
)
if failed_extras:
print(
" ⚠ Skipped optional extras that still failed: "
+ ", ".join(failed_extras)
)
# ---------------------------------------------------------------------------
# Marker metadata (attempt counter for early-pass retry backoff)
# ---------------------------------------------------------------------------
def bump_marker_attempts(marker_path: Path) -> int:
"""Increment an attempts counter stored inside the marker file.
The marker's existence is the signal; opportunistic JSON body carries the
retry count so a persistently failing install can back off instead of
reinstall-hammering every launch. Corrupt/missing bodies restart at 1.
Returns the new attempt count. Never raises.
"""
attempts = 0
try:
raw = marker_path.read_text(encoding="utf-8", errors="replace").strip()
if raw:
try:
attempts = int(json.loads(raw).get("attempts", 0))
except (ValueError, AttributeError):
attempts = 0
except OSError:
attempts = 0
attempts += 1
try:
marker_path.write_text(json.dumps({"attempts": attempts}), encoding="utf-8")
except OSError:
pass
return attempts
+609
View File
@@ -0,0 +1,609 @@
"""
Top-level argparse construction for the hermes CLI.
Lives in its own module so other modules (e.g. ``relaunch.py``) can
introspect the parser to discover which flags exist without running the
``main`` fn.
Only the top-level parser and the ``chat`` subparser live here. Every other
subparser (model, gateway, sessions, …) is built inline in ``main.py``
because its dispatch is tightly coupled to module-level ``cmd_*`` functions.
"""
import argparse
from functools import lru_cache
# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before
# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``),
# so it isn't on the parser. Listed here so all "carry over on relaunch"
# metadata lives in one file.
PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [
("--profile", True),
("-p", True),
]
# Static snapshot fallback for ``top_level_value_flag_sets`` — used only if
# introspecting the live parser fails (e.g. argparse surface broken mid-edit).
# The derived path is authoritative; a parity test in
# tests/hermes_cli/test_top_level_value_flags_parity.py fails CI if the parser
# grows a value-taking flag this snapshot lacks AND derivation regresses.
_VALUE_FLAGS_FALLBACK: frozenset[str] = frozenset(
{
"-z", "--oneshot",
"-m", "--model",
"--provider", "--reasoning",
"-t", "--toolsets",
"-r", "--resume",
"-s", "--skills",
"--usage-file",
"--in",
}
)
_OPTIONAL_VALUE_FLAGS_FALLBACK: frozenset[str] = frozenset({"-c", "--continue"})
@lru_cache(maxsize=1)
def top_level_value_flag_sets() -> tuple[frozenset[str], frozenset[str]]:
"""(required-value, optional-value) top-level flags, derived from the
REAL parser.
Introspects ``build_top_level_parser()`` (every option with nargs != 0)
so the argv scanners in ``main.py`` (``_first_positional_argv``,
``_apply_profile_override``) can never drift from the argparse surface —
the exact drift that made ``hermes --reasoning high chat …`` misread
``high`` as the subcommand and forced eager plugin discovery (#93530).
Mirrors the ``update_cmd._holder_value_flags`` precedent, including the
handwritten-snapshot fallback for a broken parser import. Cached per
process.
"""
try:
parser = build_top_level_parser()[0]
required: set[str] = set()
optional: set[str] = set()
for action in parser._actions:
if not action.option_strings or action.nargs == 0:
continue
target = optional if action.nargs == "?" else required
target.update(action.option_strings)
return frozenset(required), frozenset(optional)
except Exception:
return _VALUE_FLAGS_FALLBACK, _OPTIONAL_VALUE_FLAGS_FALLBACK
def _inherited_flag(parser, *args, **kwargs):
"""Register a flag that ``hermes_cli.relaunch`` should carry over when
the CLI re-execs itself (e.g. after ``sessions browse`` picks a session,
or after the setup wizard launches chat).
Equivalent to ``parser.add_argument(...)`` plus tagging the resulting
Action with ``inherit_on_relaunch = True`` so the relaunch table builder
can find it via introspection.
"""
action = parser.add_argument(*args, **kwargs)
action.inherit_on_relaunch = True
return action
_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
hermes --resume latest Resume the most recent session (same as -c)
hermes --tui --resume latest --in ./dir Resume ./dir's latest session in the TUI
hermes setup Run setup wizard
hermes logout Clear stored authentication
hermes auth add <provider> Add a pooled credential
hermes auth list List pooled credentials
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth reset <provider> Clear exhaustion status for a provider
hermes model Select default model
hermes fallback [list] Show fallback provider chain
hermes fallback add Add a fallback provider (same picker as `hermes model`)
hermes fallback remove Remove a fallback provider from the chain
hermes config View configuration
hermes config edit Edit config in $EDITOR
hermes config set model gpt-4 Set a config value
hermes gateway Run messaging gateway
hermes -s hermes-agent-dev,github-auth
hermes -w Start in isolated git worktree
hermes gateway install Install gateway background service
hermes sessions list List past sessions
hermes sessions browse Interactive session picker
hermes sessions rename ID T Rename/title a session
hermes logs View agent.log (last 50 lines)
hermes logs -f Follow agent.log in real time
hermes logs errors View errors.log
hermes logs --since 1h Lines from the last hour
hermes debug share Upload debug report for support
hermes console Open the safe Hermes command console
hermes update Update to latest version
hermes dashboard Start web UI dashboard (port 9119)
hermes dashboard --stop Stop running dashboard processes
hermes dashboard --status List running dashboard processes
For more help on a command:
hermes <command> --help
"""
def build_top_level_parser():
"""Build the top-level parser, the subparsers action, and the ``chat`` subparser.
Returns ``(parser, subparsers, chat_parser)``. The caller wires
``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
other subparsers via ``subparsers.add_parser(...)``.
"""
parser = argparse.ArgumentParser(
prog="hermes",
description="Hermes Agent - AI assistant with tool-calling capabilities",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=_EPILOGUE,
)
parser.add_argument(
"--version", "-V", action="store_true", help="Show version and exit"
)
parser.add_argument(
"-z",
"--oneshot",
metavar="PROMPT",
default=None,
help=(
"One-shot mode: send a single prompt and print ONLY the final "
"response text to stdout. No banner, no spinner, no tool "
"previews, no session_id line. Tools, memory, rules, and "
"AGENTS.md in the CWD are loaded as normal; approvals are "
"auto-bypassed. Intended for scripts / pipes."
),
)
parser.add_argument(
"--usage-file",
metavar="PATH",
default=None,
help=(
"One-shot mode only: after the run, write a JSON usage report "
"(estimated cost, token counts, model, api_calls) to PATH. "
"The report is written even when the run fails, so pipelines "
"can always account for spend. No effect outside -z/--oneshot."
),
)
# --model / --provider are accepted at the top level so they can pair
# with -z without needing the `chat` subcommand. If neither -z nor a
# subcommand consumes them, they fall through harmlessly as None.
# Mirrors `hermes chat --model ... --provider ...` semantics.
_inherited_flag(
parser,
"-m",
"--model",
default=None,
help=(
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
),
)
_inherited_flag(
parser,
"--provider",
default=None,
help=(
"Provider override for this invocation (e.g. openrouter, anthropic). "
"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."
),
)
_inherited_flag(
parser,
"--reasoning",
default=None,
metavar="LEVEL",
help=(
"Reasoning effort for this invocation: none, minimal, low, medium, "
"high, xhigh, max, or ultra. Overrides agent.reasoning_effort in "
"config.yaml for this run only; the persistent level lives there "
"(or per-model under agent.reasoning_overrides)."
),
)
parser.add_argument(
"-t",
"--toolsets",
default=None,
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
)
parser.add_argument(
"--resume",
"-r",
metavar="SESSION",
default=None,
help=(
"Resume a previous session by ID or title, or pass 'latest' for "
"the most recent session (workspace-scoped, like -c with no name)"
),
)
parser.add_argument(
"--no-restore-cwd",
action="store_true",
default=False,
help="Don't cd into a resumed session's recorded working directory.",
)
parser.add_argument(
"--in",
dest="in_dir",
metavar="DIR",
default=None,
help=(
"Change into DIR before starting or resuming. Combined with "
"'--resume latest' or -c, the most recent session for DIR's "
"workspace is picked, and the session stays in DIR (skips the "
"recorded-cwd restore)."
),
)
parser.add_argument(
"--continue",
"-c",
dest="continue_last",
nargs="?",
const=True,
default=None,
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given",
)
parser.add_argument(
"--worktree",
"-w",
action="store_true",
default=False,
help="Run in an isolated git worktree (for parallel agents)",
)
_inherited_flag(
parser,
"--accept-hooks",
action="store_true",
default=False,
help=(
"Auto-approve any unseen shell hooks declared in config.yaml "
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
"runs that can't prompt."
),
)
_inherited_flag(
parser,
"--skills",
"-s",
action="append",
default=None,
help="Preload one or more skills for the session (repeat flag or comma-separate)",
)
_inherited_flag(
parser,
"--yolo",
action="store_true",
default=False,
help="Bypass all dangerous command approval prompts (use at your own risk)",
)
_inherited_flag(
parser,
"--pass-session-id",
action="store_true",
default=False,
help="Include the session ID in the agent's system prompt",
)
_inherited_flag(
parser,
"--ignore-user-config",
action="store_true",
default=False,
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
)
_inherited_flag(
parser,
"--ignore-rules",
action="store_true",
default=False,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
)
_inherited_flag(
parser,
"--safe-mode",
action="store_true",
default=False,
help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules)",
)
_inherited_flag(
parser,
"--tui",
action="store_true",
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",
dest="tui_dev",
action="store_true",
default=False,
help="With --tui: run TypeScript sources via tsx (skip dist build)",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# =========================================================================
# chat command
# =========================================================================
chat_parser = subparsers.add_parser(
"chat",
help="Interactive chat with the agent",
description="Start an interactive chat session with Hermes Agent",
)
_query_group = chat_parser.add_mutually_exclusive_group()
_query_group.add_argument(
"-q", "--query",
help=(
"Query to run. On a real TTY the prompt seeds an interactive "
"session (submitted literally as the first turn); combined with "
"--oneshot or -Q, or on a non-TTY, it answers and exits."
),
)
_query_group.add_argument(
"--query-file",
metavar="PATH",
help=(
"Read the single query from a file instead of the command line "
"('-' reads stdin). Safe for arbitrary text: nothing is shell-"
"interpreted, so quotes, $(...), and backticks are preserved "
"verbatim. Mutually exclusive with -q."
),
)
chat_parser.add_argument(
"--oneshot",
dest="oneshot_exit",
action="store_true",
# Distinct dest: the top-level `-z/--oneshot PROMPT` is value-taking
# and its dispatch sites do `if args.oneshot: _run_and_exit_oneshot(
# args.oneshot)` — a shared boolean dest would be passed as the
# prompt. `oneshot_exit` keeps the surfaces independent.
default=False,
help=(
"With -q/--query-file: answer the query and exit (legacy "
"single-query behavior) instead of seeding an interactive "
"session. Implied on non-TTY stdio and by -Q/--quiet."
),
)
chat_parser.add_argument(
"--image", help="Optional local image path to attach to a single query"
)
# `default=argparse.SUPPRESS` on flags that are ALSO declared on the
# top-level parser: when the user writes `hermes -m foo chat`, argparse
# first sets `args.model = "foo"` from the top-level parser, then
# dispatches to the chat subparser. Without SUPPRESS the chat subparser's
# own default (`None`) would silently clobber the top-level value because
# the subparser shares the same namespace and `dest`. SUPPRESS keeps the
# subparser action a no-op unless the user actually passes the flag after
# the subcommand. Matches the pattern already used for `-s/--skills` and
# the relaunch-inherited flags `-r/--resume`, `-c/--continue`,
# `-w/--worktree`, `--yolo`, etc. (see tests/hermes_cli/
# test_argparse_flag_propagation.py).
_inherited_flag(
chat_parser,
"-m", "--model",
default=argparse.SUPPRESS,
help="Model to use (e.g., anthropic/claude-sonnet-4)",
)
chat_parser.add_argument(
"-t", "--toolsets",
default=argparse.SUPPRESS,
help="Comma-separated toolsets to enable",
)
_inherited_flag(
chat_parser,
"--reasoning",
default=argparse.SUPPRESS,
metavar="LEVEL",
help=(
"Reasoning effort for this session: none, minimal, low, medium, "
"high, xhigh, max, or ultra. Overrides agent.reasoning_effort for "
"this run only (same levels as the /reasoning slash command)."
),
)
_inherited_flag(
chat_parser,
"-s",
"--skills",
action="append",
default=argparse.SUPPRESS,
help="Preload one or more skills for the session (repeat flag or comma-separate)",
)
_inherited_flag(
chat_parser,
"--provider",
# No `choices=` here: user-defined providers from config.yaml `providers:`
# are also valid values, and runtime resolution (resolve_runtime_provider)
# handles validation/error reporting consistently with the top-level
# `--provider` flag.
default=argparse.SUPPRESS,
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",
default=argparse.SUPPRESS,
help="Verbose output",
)
chat_parser.add_argument(
"-Q",
"--quiet",
action="store_true",
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
)
chat_parser.add_argument(
"--resume",
"-r",
metavar="SESSION_ID",
default=argparse.SUPPRESS,
help=(
"Resume a previous session by ID (shown on exit), or 'latest' "
"for the most recent session"
),
)
chat_parser.add_argument(
"--no-restore-cwd",
action="store_true",
default=argparse.SUPPRESS,
help="Don't cd into a resumed session's recorded working directory.",
)
chat_parser.add_argument(
"--in",
dest="in_dir",
metavar="DIR",
default=argparse.SUPPRESS,
help=(
"Change into DIR before starting or resuming (scopes "
"'--resume latest' / -c lookups to DIR's workspace)."
),
)
chat_parser.add_argument(
"--continue",
"-c",
dest="continue_last",
nargs="?",
const=True,
default=argparse.SUPPRESS,
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given",
)
chat_parser.add_argument(
"--create-if-missing",
action="store_true",
default=argparse.SUPPRESS,
help=(
"With -c/--continue <name>: if no session matches the name, "
"create a new session with that title and proceed (instead of "
"failing with a not-found error). Programmatic callers that "
"want 'send to this named thread, making it if needed'."
),
)
chat_parser.add_argument(
"--worktree",
"-w",
action="store_true",
default=argparse.SUPPRESS,
help="Run in an isolated git worktree (for parallel agents on the same repo)",
)
_inherited_flag(
chat_parser,
"--accept-hooks",
action="store_true",
default=argparse.SUPPRESS,
help=(
"Auto-approve any unseen shell hooks declared in config.yaml "
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
"hooks_auto_accept: in config.yaml)."
),
)
chat_parser.add_argument(
"--checkpoints",
action="store_true",
default=False,
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
)
chat_parser.add_argument(
"--max-turns",
type=int,
default=None,
metavar="N",
help="Maximum tool-calling iterations per conversation turn (default: 500, or agent.max_turns in config)",
)
chat_parser.add_argument(
"--run-budget",
type=float,
default=None,
metavar="SECONDS",
dest="run_budget",
help=(
"Optional wall-clock budget in seconds for each conversation run. "
"At 80%% elapsed the agent gets a one-time wrap-up notice, and "
"implicit provider stale timeouts are capped to the remaining "
"budget so one hung call can't consume the run. Unset = off. "
"Also configurable as agent.run_budget_seconds in config.yaml. "
"Intended for one-shot/eval invocations with a hard ceiling."
),
)
_inherited_flag(
chat_parser,
"--yolo",
action="store_true",
default=argparse.SUPPRESS,
help="Bypass all dangerous command approval prompts (use at your own risk)",
)
_inherited_flag(
chat_parser,
"--pass-session-id",
action="store_true",
default=argparse.SUPPRESS,
help="Include the session ID in the agent's system prompt",
)
_inherited_flag(
chat_parser,
"--ignore-user-config",
action="store_true",
default=argparse.SUPPRESS,
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
)
_inherited_flag(
chat_parser,
"--ignore-rules",
action="store_true",
default=argparse.SUPPRESS,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
)
_inherited_flag(
chat_parser,
"--safe-mode",
action="store_true",
default=argparse.SUPPRESS,
help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules). Use to isolate whether a problem comes from your setup or from Hermes itself.",
)
chat_parser.add_argument(
"--source",
default=None,
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
)
_inherited_flag(
chat_parser,
"--tui",
action="store_true",
default=argparse.SUPPRESS,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
chat_parser,
"--cli",
action="store_true",
default=argparse.SUPPRESS,
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
)
_inherited_flag(
chat_parser,
"--dev",
dest="tui_dev",
action="store_true",
default=argparse.SUPPRESS,
help="With --tui: run TypeScript sources via tsx (skip dist build)",
)
return parser, subparsers, chat_parser
+434
View File
@@ -0,0 +1,434 @@
"""``hermes_cli/_scan_venv_blockers.py`` — Standalone venv-process scan for JSON consumption.
Invoked by the Desktop Electron app::
venv\\Scripts\\python.exe -m hermes_cli._scan_venv_blockers
Exits 0 for valid clear or blocked results. Non-zero exit signals probe
failure (the detector itself crashed, psutil unavailable, etc.). Exactly
one JSON document on stdout; diagnostics on stderr only.
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import PureWindowsPath
from typing import NoReturn
# Long CLI flags whose argument value must be redacted from the cmdline.
_SENSITIVE_LONG_FLAGS: list[str] = [
"--token",
"--api-key",
"--password",
"--secret",
"--authorization",
"--access-key",
"--private-key",
"--session-key",
]
def _probe_fail_json(diagnostic: str = "probe failed") -> str:
"""Return the standard probe-failure JSON document.
``ok: false`` plus ``probe_failed: true`` means the detector itself could
not run — this is *not* a clear scan. Callers must treat
``ok is not True`` / non-zero exit as probe failure, never as
``blocked: false`` "clear" (#83149).
"""
return json.dumps(
{
"ok": False,
"probe_failed": True,
"blocked": False,
"processes": [],
"error": diagnostic,
}
)
def _emit_probe_fail(diagnostic: str) -> NoReturn:
"""Print one JSON to stdout, diagnostic to stderr, exit non-zero."""
print(_probe_fail_json(diagnostic))
print(diagnostic, file=sys.stderr)
sys.exit(1)
def _find_flag(text: str, flag: str) -> int:
"""Return the index of *flag* when it starts the string or follows a space.
Returns -1 when not found. This avoids matching ``--token`` inside an
embedded token or path like ``/some--token-thing``.
"""
low = text.lower()
fl = flag.lower()
pos = 0
while True:
idx = low.find(fl, pos)
if idx == -1:
return -1
if idx == 0 or text[idx - 1] == " ":
return idx
pos = idx + 1
def _redact_sensitive_cmdline(cmdline: str) -> str:
"""Apply generic secret redaction then long-flag redaction.
If the generic redactor itself fails, return ``"<redacted>"`` — the PID
and process name still provide actionable diagnostics.
"""
# Generic pass: the project's shared secret redactor.
try:
from agent.redact import redact_sensitive_text # noqa: PLC0415
cmdline = redact_sensitive_text(cmdline, force=True)
except Exception:
return "<redacted>"
# Conservative long-flag pass: preserve the flag name, replace the value
# and everything after it with ``<redacted>``. Short flags (-t, -k, -p)
# are intentionally not redacted — they are ambiguous and may be useful
# diagnostics (toolset, port, profile).
earliest = len(cmdline)
for flag in _SENSITIVE_LONG_FLAGS:
# --flag=value → preserve "--flag="
idx = _find_flag(cmdline, flag + "=")
if idx != -1 and idx + len(flag) + 1 < earliest:
earliest = idx + len(flag) + 1
# --flag value → preserve "--flag "
idx = _find_flag(cmdline, flag + " ")
if idx != -1 and idx + len(flag) + 1 < earliest:
earliest = idx + len(flag) + 1
if earliest < len(cmdline):
return cmdline[:earliest] + "<redacted>"
return cmdline
def _classify_local_preview_args(args: object) -> dict[str, object]:
"""Return safe UI metadata for an exact ``python -m http.server`` argv.
The general holder detector intentionally truncates its diagnostic command
line. Reading argv separately preserves a useful directory label without
exposing an unbounded command line to the renderer.
"""
if not isinstance(args, (list, tuple)) or not all(isinstance(arg, str) for arg in args):
return {}
# For a real interpreter module invocation, ``-m`` is the first argument
# after the executable. A later ``-m http.server`` can merely be data passed
# to an unrelated script and must never authorize termination.
if len(args) < 3 or args[1] != "-m" or args[2].lower() != "http.server":
return {}
module_index = 1
port = 8000
if module_index + 2 < len(args):
candidate = args[module_index + 2]
if candidate.isdigit() and 0 < int(candidate) <= 65535:
port = int(candidate)
label = ""
try:
directory_index = args.index("--directory")
if directory_index + 1 < len(args):
label = PureWindowsPath(args[directory_index + 1]).name
except ValueError:
pass
metadata: dict[str, object] = {
"kind": "local-preview",
"safeToStop": True,
"port": port,
}
if label:
metadata["label"] = label
return metadata
def _local_preview_metadata(pid: int, name: str) -> dict[str, object]:
if name.lower() not in {"python.exe", "pythonw.exe", "python", "pythonw"}:
return {}
try:
import psutil # noqa: PLC0415
process = psutil.Process(pid)
metadata = _classify_local_preview_args(process.cmdline())
if metadata:
metadata["createTime"] = process.create_time()
return metadata
except Exception:
return {}
def _terminate_safe_preview(
pid: int,
expected_create_time: float,
*,
psutil_module: object | None = None,
) -> tuple[bool, str | None]:
"""Terminate one verified local preview process tree.
A fresh ``psutil.Process`` identity check and exact argv classification occur
immediately before termination. psutil guards mutating Process methods
against PID reuse, avoiding taskkill's stale-PID race.
"""
try:
if psutil_module is None:
import psutil as psutil_module # type: ignore[no-redef] # noqa: PLC0415
process = psutil_module.Process(pid) # type: ignore[attr-defined]
if abs(process.create_time() - expected_create_time) > 0.001:
return False, "process identity changed"
if not _classify_local_preview_args(process.cmdline()):
return False, "process is no longer a local preview"
children = process.children(recursive=True)
targets = [*reversed(children), process]
for target in targets:
target.terminate()
_gone, alive = psutil_module.wait_procs(targets, timeout=3) # type: ignore[attr-defined]
for target in alive:
target.kill()
if alive:
psutil_module.wait_procs(alive, timeout=2) # type: ignore[attr-defined]
return True, None
except Exception as exc:
return False, f"termination failed: {type(exc).__name__}"
def _is_pausable_gateway(cmdline: str) -> bool:
"""Return True when *cmdline* is a gateway process the updater can pause.
A running gateway shows up in the venv-holder scan as one or both halves
of its launcher/worker chain (``venv\\Scripts\\python.exe -m
hermes_cli.main gateway run`` and the uv-side interpreter re-running the
same argv). Reporting those as blockers dead-ends the Desktop update:
the preflight aborts with ``venv-blocked`` *before* spawning
``hermes-setup``, so the CLI updater's own
``_pause_windows_gateways_for_update()`` — which exists precisely to
stop these processes (and is always active: ``hermes-setup`` invokes
``hermes update --yes --gateway``) — never gets the chance to run.
Only gateway invocations are exempted. Anything else running from the
venv (an operator's REPL, a stray script, a ``serve`` backend that
survived the desktop's own teardown) has no pause machinery downstream
and must keep blocking the handoff.
Delegates to ``gateway.status.looks_like_gateway_command_line`` — the
canonical ``gateway run`` matcher (profile-selector aware, shlex
tokenization, ``run``-only) — so this exemption, the pause discovery,
and the updater's guard fallback all share one parser. A hand-rolled
token scan here regressed ``--profile gateway gateway run``: the profile
*value* shadowed the subcommand token. An import failure counts as
not-pausable — the scan then reports the process as a blocker, which is
exactly the pre-exemption behavior.
"""
try:
from gateway.status import looks_like_gateway_command_line # noqa: PLC0415
except Exception:
return False
return looks_like_gateway_command_line(cmdline)
def _is_updater_owned_backend(pid: int, cmdline: str) -> bool:
"""Return True when *pid* is a Hermes backend the CLI updater can stop.
The gateway exemption above keeps ``gateway run`` holders out of the
blocker list because the updater's own pause machinery stops and resumes
them. ``hermes serve`` / ``hermes dashboard`` backends had no such
deferral, so a leaked serve child (or a Desktop-owned backend the
teardown lost track of) dead-ended the hand-off with ``venv-blocked`` —
or, worse, survived the hand-off and made the shim quarantine fail with
``os error 32`` (#98336) — even though the updater downstream owns
exactly this case with its ledger rungs (`_ledger_reapable_backend_pids`
reaps dead-spawner orphans; `_ledger_manual_serve_holders` stops manual
serves and relaunches them on their recorded host/port).
Positive identity only — never name/substring matching (#90778, and the
#99558 identity-guard contract):
- the argv's parsed SUBCOMMAND (token-based) is ``serve``/``dashboard``;
- the machine spawn ledger has a live-verified ``(pid, create_time)``
entry for the process with a matching purpose;
- ownership is provable: the recorded spawner is dead or unrecorded
(the updater's rungs stop/relaunch those), or the spawner is an
ancestor of THIS scan — i.e. the Desktop app performing the hand-off,
which exits before the updater runs, turning the backend into exactly
the dead-spawner orphan the ledger rung reaps.
A backend whose recorded spawner is alive and is NOT this hand-off's
Desktop (a second Desktop window, another supervisor) keeps blocking:
that supervisor would respawn whatever the updater kills. Anything
unprovable → not exempt (fail closed, pre-exemption behavior).
"""
return _updater_owned_backend_entry(pid, cmdline) is not None
def _updater_owned_backend_entry(pid: int, cmdline: str) -> dict | None:
"""Ledger entry for a deferred backend, or ``None`` when it must block.
Same decision logic as ``_is_updater_owned_backend`` (which delegates
here); returning the matched ledger entry lets ``main()`` emit sanitized
decision evidence — structured identity fields only, never argv, which
can carry tokens or private endpoints (#98350).
"""
try:
from hermes_cli.update_cmd import _hermes_holder_subcommand # noqa: PLC0415
purpose = _hermes_holder_subcommand(cmdline)
except Exception:
return None
if purpose not in ("serve", "dashboard"):
return None
try:
from hermes_cli.process_identity import ( # noqa: PLC0415
ledger_entries,
spawner_is_dead,
)
entries = ledger_entries()
except Exception:
return None
for entry in entries:
if entry.get("pid") != pid:
continue
if entry.get("purpose") not in ("serve", "dashboard"):
return None
dead = spawner_is_dead(entry)
if dead is not False:
# Spawner dead, unrecorded, or unprovable-but-registered: the
# updater's ledger rungs own this holder (reap or stop+relaunch).
return entry
if _spawner_is_this_handoff_desktop(entry):
return entry
return None
return None
def _deferred_backend_evidence(entries: list[dict]) -> list[dict]:
"""Sanitized decision evidence for deferred serve/dashboard backends.
Structured ledger fields only — pid, purpose, recorded port — never the
command line, which can carry tokens or private endpoints. Lets the
scan result explain *why* a holder disappeared from ``processes``
without echoing argv (#98350).
"""
evidence = []
for entry in entries:
pid = entry.get("pid")
if not isinstance(pid, int):
continue
evidence.append(
{"pid": pid, "purpose": entry.get("purpose"), "port": entry.get("port")}
)
return evidence
def _spawner_is_this_handoff_desktop(entry: dict) -> bool:
"""True when the entry's live spawner is an ancestor of this scan.
The scan subprocess is spawned by the Desktop app's update preflight, so
the Desktop performing the hand-off is in our ancestor chain. Identity is
verified by ``(pid, create_time)`` — a recycled PID cannot forge the pair.
"""
spawner_pid = entry.get("spawner_pid")
if not isinstance(spawner_pid, int) or spawner_pid <= 0:
return False
try:
import psutil # noqa: PLC0415
for ancestor in psutil.Process().parents():
if ancestor.pid != spawner_pid:
continue
expected = entry.get("spawner_create")
if expected is None:
return True
return abs(float(ancestor.create_time()) - float(expected)) < 2.0
except Exception:
return False
return False
def main() -> None:
"""Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan."""
try:
import psutil # noqa: PLC0415, F401
except Exception as exc:
_emit_probe_fail(f"psutil is not available: {exc}")
try:
from hermes_cli.main import _detect_venv_python_processes # noqa: PLC0415
matches = _detect_venv_python_processes()
except Exception as exc:
_emit_probe_fail(f"scan aborted: {exc}")
processes = []
exempted_gateways = 0
deferred_entries: list[dict] = []
for pid, name, cmdline in matches:
if _is_pausable_gateway(cmdline):
exempted_gateways += 1
continue
deferred_entry = _updater_owned_backend_entry(pid, cmdline)
if deferred_entry is not None:
# Ledger-verified serve/dashboard backend the CLI updater's own
# rungs stop (and relaunch) downstream — reporting it here would
# dead-end the hand-off before that machinery can run (#98336).
deferred_entries.append(deferred_entry)
continue
process = {
"pid": pid,
"name": name,
# Truncate for display AFTER the gateway exemption has seen the
# full cmdline (long managed-runtime interpreter paths would
# otherwise swallow the `gateway run` argv).
"cmdline": _redact_sensitive_cmdline(cmdline)[:120],
}
process.update(_local_preview_metadata(pid, name))
processes.append(process)
data = {
"ok": True,
"blocked": bool(processes),
"processes": processes,
# Diagnostic only: gateway processes present but not counted as
# blockers because the downstream updater pauses them itself.
"pausable_gateways": exempted_gateways,
# Diagnostic only: ledger-verified serve/dashboard backends deferred
# to the updater's stop/relaunch rungs (#98336).
"deferred_backends": len(deferred_entries),
# Diagnostic only: sanitized evidence (structured ledger identity,
# never argv) explaining which holders the deferral consumed (#98350).
"deferred_backend_evidence": _deferred_backend_evidence(deferred_entries),
}
print(json.dumps(data))
sys.exit(0)
def _terminate_safe_main(argv: list[str]) -> NoReturn:
if len(argv) != 2:
print(json.dumps({"ok": False, "error": "expected pid and create time"}))
raise SystemExit(2)
try:
pid = int(argv[0])
create_time = float(argv[1])
if pid <= 0 or not math.isfinite(create_time) or create_time <= 0:
raise ValueError
except ValueError:
print(json.dumps({"ok": False, "error": "invalid process identity"}))
raise SystemExit(2)
stopped, error = _terminate_safe_preview(pid, create_time)
print(json.dumps({"ok": stopped, "pid": pid, "error": error}))
raise SystemExit(0 if stopped else 1)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--terminate-safe":
_terminate_safe_main(sys.argv[2:])
main()
+275
View File
@@ -0,0 +1,275 @@
"""Pre-import startup fast paths — THE canonical lightweight helpers.
This module is imported by ``hermes_cli/main.py`` BEFORE its heavy import
wall (config, argparse tree, logging, providers). Everything here must stay
**stdlib-only and cheap** (os/sys file probes; no yaml, no hermes_cli.config,
no argparse). A guard test (``test_startup_fast_import_weight``) subprocess-
imports this module and fails if any heavy module sneaks into sys.modules.
Why this module exists (the bug class it kills): version-printing kept being
reimplemented as ``*_fast()`` copies at the top of main.py (Termux first,
then globally), each duplicating canonical logic — project-root resolution,
container detection, profile detection. The copies drifted: eb4040242
changed the canonical output and referenced ``PROJECT_ROOT`` inside the fast
function, which doesn't exist yet on the fast path → the Termux fast path
NameError'd on --version and nobody noticed. One implementation, imported
by both the fast path and the module constants, makes that drift
structurally impossible; the parity guard test would have caught eb4040242
the day it landed.
``hermes_cli/config.py``'s ``get_container_exec_info()`` reads the same
``.container-mode`` file; keep the file-format assumptions here and there in
sync (this module deliberately only PROBES existence/typos cheaply and errs
toward the slow path, which then does the authoritative parse).
"""
from __future__ import annotations
import os
import sys
__all__ = [
"project_root_str",
"ensure_project_root_on_path",
"is_termux_env",
"is_termux_fast_version_argv",
"is_global_fast_version_argv",
"is_container_startup_environment",
"active_profile_may_override_home",
"container_mode_may_be_active",
"read_openai_version",
"read_install_method",
"print_fast_version_info",
"try_fast_version",
]
def project_root_str() -> str:
"""Repo root as a str — the single source for main.py's PROJECT_ROOT."""
return os.path.realpath(os.path.join(os.path.dirname(__file__), os.pardir))
def ensure_project_root_on_path() -> None:
"""Put the project root at sys.path[0], deduping realpath-equivalents."""
project_root = project_root_str()
normalized_root = os.path.normcase(os.path.realpath(project_root))
sys.path[:] = [
entry
for entry in sys.path
if not entry
or os.path.normcase(os.path.realpath(entry)) != normalized_root
]
sys.path.insert(0, project_root)
def is_termux_env() -> bool:
"""Tiny Termux check for pre-import startup shortcuts."""
prefix = os.environ.get("PREFIX", "")
return bool(
os.environ.get("TERMUX_VERSION")
or "com.termux/files/usr" in prefix
or prefix.startswith("/data/data/com.termux/")
)
def is_termux_fast_version_argv(argv: list[str]) -> bool:
return argv in (["--version"], ["-V"])
def is_global_fast_version_argv(argv: list[str]) -> bool:
return argv in (["--version"], ["-V"])
def is_container_startup_environment() -> bool:
"""True when we're already INSIDE a container (fast path is then safe)."""
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return True
try:
with open("/proc/1/cgroup", encoding="utf-8") as handle:
cgroup = handle.read()
except OSError:
return False
return "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup
def active_profile_may_override_home(hermes_root: str) -> bool:
"""Cheap probe: does an active non-default profile redirect HERMES_HOME?"""
active_profile = os.path.join(hermes_root, "active_profile")
try:
if os.path.exists(active_profile):
with open(active_profile, encoding="utf-8") as handle:
active = handle.read().strip()
return bool(active and active != "default")
except (OSError, UnicodeDecodeError):
pass
return False
def _resolved_home() -> str:
hermes_home = os.environ.get("HERMES_HOME", "").strip()
if hermes_home:
return hermes_home
return os.path.join(os.path.expanduser("~"), ".hermes")
def container_mode_may_be_active() -> bool:
"""Conservative probe for NixOS container-mode routing.
False positives are fine (we fall through to the slow path, whose
``get_container_exec_info()`` does the authoritative check and routes
into the container). False negatives are NOT fine — they'd print the
host's version instead of the container's. Hence: any profile
ambiguity → assume container mode may be active.
"""
if os.environ.get("HERMES_DEV") == "1":
return False
if is_container_startup_environment():
return False
hermes_home = os.environ.get("HERMES_HOME", "").strip()
if hermes_home:
if os.path.exists(os.path.join(hermes_home, ".container-mode")):
return True
parent_name = os.path.basename(os.path.dirname(os.path.normpath(hermes_home)))
return (
parent_name != "profiles"
and active_profile_may_override_home(hermes_home)
)
default_home = os.path.join(os.path.expanduser("~"), ".hermes")
if active_profile_may_override_home(default_home):
return True
return os.path.exists(os.path.join(default_home, ".container-mode"))
def read_openai_version() -> str | None:
"""Read OpenAI SDK version without importing ``importlib.metadata``."""
for base in sys.path:
if not base:
base = os.getcwd()
version_file = os.path.join(base, "openai", "_version.py")
try:
with open(version_file, encoding="utf-8") as handle:
for line in handle:
stripped = line.strip()
if not stripped.startswith("__version__"):
continue
_key, _sep, value = stripped.partition("=")
value = value.split("#", 1)[0].strip().strip("\"'")
return value or None
except OSError:
continue
return None
def read_install_method() -> str | None:
"""Read the installer's ``.install_method`` stamp, if present.
Only the stamp (step 1 of ``config.detect_install_method``'s resolution
order) — the managed/git/pip fallbacks need heavier imports and stay on
the slow path. On the fast path home ambiguity is already excluded:
``container_mode_may_be_active()`` bails to the slow path whenever a
non-default profile might redirect HERMES_HOME.
"""
stamp = os.path.join(_resolved_home(), ".install_method")
try:
with open(stamp, encoding="utf-8") as handle:
method = handle.read().strip().lower()
return method or None
except OSError:
return None
def print_fast_version_info(*, check_updates: bool = True) -> None:
"""THE canonical ``hermes --version`` output (also used by /version).
The static lines print instantly from stdlib-only probes; everything
heavier (upstream SHA in the version line, authoritative install-method
detection, the update-status check) is lazy-imported AFTER the first
line is already on screen, so perceived latency stays instant while the
output carries the full information that used to require the (removed)
``hermes version`` subcommand. Every lazy block degrades gracefully —
a broken/heavy import can never take the basic version output down.
"""
# Line 1: registry-owned banner label (includes "· upstream <sha>" for
# git installs). banner.py keeps rich/prompt_toolkit lazy, so this
# import is light; fall back to the plain label if anything fails.
try:
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
except Exception:
from hermes_cli import __release_date__, __version__
print(f"Hermes Agent v{__version__} ({__release_date__})")
print(f"Install directory: {project_root_str()}")
# Install method: authoritative resolver first (code-scoped stamp →
# managed → nix → git → pip; also self-heals poisoned shared-home
# 'docker' stamps). Fall back to the cheap stdlib stamp probe only if
# the resolver import/run fails.
try:
from pathlib import Path
from hermes_cli.config import detect_install_method
install_method = detect_install_method(Path(project_root_str()))
except Exception:
install_method = read_install_method()
if install_method:
print(f"Install method: {install_method}")
print(f"Python: {sys.version.split()[0]}")
openai_version = read_openai_version()
print(f"OpenAI SDK: {openai_version}" if openai_version else "OpenAI SDK: Not installed")
if not check_updates:
return
# Update status (synchronous — acceptable since the user asked for
# version info). Bounded by check_for_updates' own subprocess/network
# timeouts and its 6-hour cache; any failure prints nothing.
try:
from hermes_cli.banner import UPDATE_AVAILABLE_NO_COUNT, check_for_updates
from hermes_cli.config import recommended_update_command
behind = check_for_updates()
if behind == UPDATE_AVAILABLE_NO_COUNT:
print(f"Update available — run '{recommended_update_command()}'")
elif behind and behind > 0:
commits_word = "commit" if behind == 1 else "commits"
print(
f"Update available: {behind} {commits_word} behind — "
f"run '{recommended_update_command()}'"
)
elif behind == 0:
print("Up to date")
except Exception:
pass
def try_fast_version(argv: list[str] | None = None) -> bool:
"""Handle ``hermes --version`` before the heavy import wall.
Only ``--version``/``-V`` (the ``version`` subcommand was removed —
``--version`` now carries the full output incl. update status), and
never when container mode may need to route the command into the
container. Termux keeps the HERMES_TERMUX_DISABLE_FAST_CLI escape hatch.
"""
if argv is None:
argv = sys.argv[1:]
is_termux = is_termux_env()
if is_termux and os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
return False
if is_termux:
if not is_termux_fast_version_argv(argv):
return False
elif not is_global_fast_version_argv(argv):
return False
elif container_mode_may_be_active():
return False
print_fast_version_info()
return True
+779
View File
@@ -0,0 +1,779 @@
"""Windows subprocess compatibility helpers.
Hermes is developed on Linux / macOS and tested natively on Windows too.
Several common subprocess patterns break silently-or-loudly on Windows:
* ``["npm", "install", ...]`` — on Windows ``npm`` is ``npm.cmd``, a batch
shim. ``subprocess.Popen(["npm", ...])`` fails with WinError 193
("not a valid Win32 application") because CreateProcessW can't run a
``.cmd`` file without ``shell=True`` or PATHEXT resolution.
* ``start_new_session=True`` — on POSIX, this maps to ``os.setsid()`` and
actually detaches the child. On Windows it's silently ignored; the
Windows equivalent is the ``CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW``
creationflags bundle, which Python only applies when you pass it
explicitly.
* Console-window flashes — every ``subprocess.Popen`` of a ``.exe`` on
Windows spawns a cmd window briefly unless ``CREATE_NO_WINDOW`` is
passed. Cosmetic but jarring for background daemons.
This module centralizes the platform-branching logic so the rest of the
codebase doesn't sprinkle ``if sys.platform == "win32":`` everywhere.
**All helpers are no-ops on non-Windows** — calling them in Linux/macOS
code paths is safe by design. That's the "do no damage on POSIX"
guarantee.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from typing import Mapping, Sequence
__all__ = [
"IS_WINDOWS",
"resolve_node_command",
"split_command_line",
"suppress_platform_ver_console",
"windows_detach_flags",
"windows_detach_flags_without_breakaway",
"windows_hide_flags",
"windows_detach_popen_kwargs",
"bounded_git_probe",
"bounded_probe_run",
"noninteractive_git_env",
"NO_DRIVER_DIFF_FLAGS",
"pid_is_hermes",
]
# Flags that neutralize *attribute-scoped* diff drivers on any diff-rendering
# git command (``diff``, ``log -p``, ``show``, ``blame``). A malicious repo can
# name a driver in ``.gitattributes`` (``* diff=evil``) and point it at an
# arbitrary program via ``[diff "evil"] command=/textconv=`` in ``.git/config``.
# Because the attacker chooses the driver name, ``GIT_CONFIG_KEY`` overrides in
# ``noninteractive_git_env`` cannot enumerate and disable it — only these
# command-line flags do. ``--no-ext-diff`` kills ``command=``; ``--no-textconv``
# kills ``textconv=``. Both are required (verified empirically: each alone
# leaves the other live). Smudge/clean filters are neutralized by the env
# layer's ``core.hooksPath`` + running against the index without checkout.
NO_DRIVER_DIFF_FLAGS = ("--no-ext-diff", "--no-textconv")
# Subcommands that render diffs and therefore invoke ``.gitattributes``-scoped
# diff/textconv drivers. Only these accept ``NO_DRIVER_DIFF_FLAGS`` — ``status``
# and friends reject the flags (``unknown option``), so the helper must gate on
# this set rather than blanket-prepending.
_DIFF_RENDERING_SUBCOMMANDS = frozenset({"diff", "show", "log", "blame"})
def harden_git_argv(args: Sequence[str]) -> list[str]:
"""Return a copy of subcommand-first git *args* with diff-driver flags
inserted for diff-rendering subcommands.
*args* is the argument list WITHOUT the leading ``"git"`` (e.g.
``["diff", "HEAD"]`` or ``["-c", "core.quotePath=false", "diff", ...]``).
The first non-option token is treated as the subcommand; if it is one of
:data:`_DIFF_RENDERING_SUBCOMMANDS`, :data:`NO_DRIVER_DIFF_FLAGS` is
inserted immediately after it. Non-diff subcommands are returned unchanged.
Pair with :func:`noninteractive_git_env`: the env layer disables
fsmonitor/hooks/pager/editor/credential sinks, this closes the one class
(attacker-named attribute drivers) env overrides cannot reach.
"""
out = list(args)
# Options that consume the FOLLOWING token as their value, so that value is
# never mistaken for the subcommand (``-C diff`` is a path; ``-c diff=x`` is
# a config pair — neither is the diff subcommand).
_value_opts = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"}
i = 0
while i < len(out):
tok = out[i]
if tok in _value_opts:
i += 2
continue
if tok.startswith("-"):
i += 1
continue
if tok in _DIFF_RENDERING_SUBCOMMANDS:
return out[: i + 1] + list(NO_DRIVER_DIFF_FLAGS) + out[i + 1 :]
# First non-option token is the subcommand; if it isn't a diff renderer
# there is nothing to harden.
return out
return out
IS_WINDOWS = sys.platform == "win32"
# Private launcher-to-child metadata. This is diagnostic state, not user config.
_WINDOWS_GATEWAY_BREAKAWAY_ENV = "_HERMES_GATEWAY_BREAKAWAY"
def split_command_line(line: str) -> list[str]:
"""Split a user-supplied command line into tokens, Windows-safely.
``shlex.split(line)`` (posix=True) treats every backslash as an escape
character, so Windows paths are silently mangled: ``C:\\Users\\me\\out.txt``
becomes ``C:Usersmeout.txt`` — no error, just a wrong path that then
"succeeds" against a mangled relative filename (#83934) or makes a valid
hook script report "not executable" (#78293).
On Windows this uses ``posix=False``, which preserves backslashes while
still honoring double-quoted tokens ("path with spaces"). The trade-off
is that posix=False keeps surrounding quotes on quoted tokens, so we
strip one layer of matching double quotes per token — that matches how
Windows command lines are conventionally parsed. On POSIX the behavior
is exactly ``shlex.split``.
Raises ValueError for unbalanced quotes, same as ``shlex.split``.
"""
if not IS_WINDOWS:
import shlex
return shlex.split(line)
import shlex
tokens = shlex.split(line, posix=False)
out: list[str] = []
for tok in tokens:
if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in ("'", '"'):
tok = tok[1:-1]
out.append(tok)
return out
# -----------------------------------------------------------------------------
# Node ecosystem launcher resolution
# -----------------------------------------------------------------------------
def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]:
"""Resolve a Node-ecosystem command name to an absolute-path argv.
On Windows, commands like ``npm``, ``npx``, ``yarn``, ``pnpm``,
``playwright``, ``prettier`` ship as ``.cmd`` files (batch shims).
``subprocess.Popen(["npm", "install"])`` fails with WinError 193
because CreateProcessW doesn't execute batch files directly.
``shutil.which(name)`` *does* resolve ``.cmd`` via PATHEXT and returns
the fully-qualified path — which CreateProcessW accepts because the
extension tells Windows to route through ``cmd.exe /c``.
On POSIX ``shutil.which`` also returns a fully-qualified path when
found. That's a small change from bare-name resolution (the OS does
its own PATH search) but functionally identical and has the side
benefit of making the argv reproducible in logs.
Behavior when the command is not on PATH:
- On Windows: return the bare name — caller can still try with
``shell=True`` as a last resort, OR the subsequent Popen will
raise FileNotFoundError with a readable error we want to surface.
- On POSIX: same. Bare ``npm`` on a Linux box without npm installed
fails the same way it did before this function existed.
Args:
name: The command name to resolve (``npm``, ``npx``, ``node`` …).
argv: The remaining arguments. Must NOT include ``name`` itself —
this function builds the full argv list.
Returns:
A list suitable for passing to subprocess.Popen/run/call.
"""
resolved = shutil.which(name)
if resolved:
return [resolved, *argv]
return [name, *argv]
# -----------------------------------------------------------------------------
# Detached / hidden process creation
# -----------------------------------------------------------------------------
# Win32 CreationFlags — defined here rather than imported from subprocess
# because CREATE_NO_WINDOW and DETACHED_PROCESS aren't guaranteed to be
# present on stdlib subprocess on older Pythons or non-Windows builds.
_CREATE_NEW_PROCESS_GROUP = 0x00000200
# DETACHED_PROCESS is intentionally NOT part of any flag bundle here — do not
# re-add it. Two reasons (the recurring console-flash bug #54220 / #56747):
#
# 1. MSDN (Process Creation Flags): CREATE_NO_WINDOW "is ignored if used with
# either CREATE_NEW_CONSOLE or DETACHED_PROCESS". Combining them means
# DETACHED_PROCESS governs and the no-window bit is dead.
# 2. A DETACHED_PROCESS child has NO console at all, so every console-subsystem
# descendant it ever spawns (git, gh, cmd, node, wmic, powershell, …) must
# allocate its OWN console — a visible flash per spawn, including spawns
# inside third-party libraries that no per-call-site CREATE_NO_WINDOW sweep
# can reach. A CREATE_NO_WINDOW child instead OWNS a hidden console that
# all descendants inherit, making "no flashing windows" a property of the
# one daemon launch. Root cause isolated + A/B verified on Windows 11 by
# the desktop backend fix (commit aa2ae36c3f): with per-site hide flags
# neutered, naive git/gh/cmd spawns don't flash under a hidden-console
# parent and do flash under a console-less one.
_DETACHED_PROCESS = 0x00000008 # kept for reference; must stay out of bundles
_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:
"""Return Win32 creationflags that detach a child from the parent
console and process group without leaving it console-less. 0 on
non-Windows.
Pair with ``start_new_session=False`` (default) when calling
subprocess.Popen — on POSIX use ``start_new_session=True`` instead,
which maps to ``os.setsid()`` in the child.
Rationale:
- ``CREATE_NEW_PROCESS_GROUP`` — child has its own process group so
Ctrl+C in the parent console doesn't propagate.
- ``CREATE_NO_WINDOW`` — the child gets its own fresh console that is
never shown. This both detaches it from the parent's console
lifetime (closing the launching terminal doesn't CTRL_CLOSE it) AND
gives every console-subsystem descendant (git, gh, cmd, node, …) a
console to inherit, so they don't allocate visible flashing ones.
This deliberately replaces the old ``DETACHED_PROCESS`` approach:
MSDN specifies CREATE_NO_WINDOW is *ignored* when combined with
DETACHED_PROCESS, and a truly console-less daemon re-creates the
per-descendant console-flash bug (#54220/#56747) at every spawn —
see the note on ``_DETACHED_PROCESS`` above.
- ``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 though they have their own
console. 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
| _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
return _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
def windows_hide_flags() -> int:
"""Return Win32 creationflags that merely hide the child's console
window without detaching the child. 0 on non-Windows.
Use for short-lived console apps spawned as part of a larger
operation (``taskkill``, ``where``, version probes) where we want no
flash but also want to collect stdout/exit code synchronously.
The difference from :func:`windows_detach_flags`: no
``CREATE_NEW_PROCESS_GROUP`` / ``CREATE_BREAKAWAY_FROM_JOB`` — the
child stays in the parent's process group and job so Ctrl+C and job
teardown propagate normally, as a short-lived helper wants. Stdio
handles are inherited either way, so ``capture_output=True`` works
with both bundles.
"""
if not IS_WINDOWS:
return 0
return _CREATE_NO_WINDOW
def suppress_platform_ver_console() -> None:
"""Stub out ``platform._syscmd_ver`` on Windows so it can never flash a
console window. No-op on non-Windows.
CPython's ``platform.win32_ver()`` — reached by ``platform.uname()``,
``platform.version()``, and ``platform.platform()`` — unconditionally
shells out ``cmd /c ver`` via ``subprocess.check_output(..., shell=True)``
with no ``CREATE_NO_WINDOW``. From a windowless parent (the pythonw
gateway and every kanban worker it spawns) that allocates a fresh
*visible* console: one flashing ``cmd`` window per process, triggered by
any dependency that merely touches ``platform.uname()`` at import time.
With ``_syscmd_ver`` stubbed to return its inputs, ``win32_ver()`` hits
the documented ``ValueError`` fallback and reads the version from
``sys.getwindowsversion().platform_version`` — same information, queried
in-process, no subprocess, no window. Verified equivalent on
CPython 3.11 (``platform()`` → ``Windows-10-10.0.xxxxx-SP0`` either way).
Call early, before heavyweight imports — the flash typically happens
during a dependency's import, not from Hermes' own code.
"""
if not IS_WINDOWS:
return
try:
import platform
if hasattr(platform, "_syscmd_ver"):
def _quiet_syscmd_ver(system="", release="", version="",
supported_platforms=("win32", "win16", "dos")):
return system, release, version
platform._syscmd_ver = _quiet_syscmd_ver
except Exception:
# Purely cosmetic hardening — never let it break startup.
pass
def windows_detach_popen_kwargs() -> dict:
"""Return a dict of Popen kwargs that detach a child on Windows and
fall back to the POSIX equivalent (``start_new_session=True``) on
Linux/macOS.
Usage pattern:
.. code-block:: python
subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
close_fds=True,
**windows_detach_popen_kwargs(),
)
This replaces the unsafe-on-Windows pattern:
.. code-block:: python
subprocess.Popen(..., start_new_session=True)
which silently fails to detach on Windows (the flag is accepted but
has no effect — the child stays attached to the parent's console
and dies when the console closes).
"""
if IS_WINDOWS:
return {"creationflags": windows_detach_flags()}
return {"start_new_session": True}
# -----------------------------------------------------------------------------
# Non-interactive git environment (credential-prompt hang guard)
# -----------------------------------------------------------------------------
def noninteractive_git_env(
base: "Mapping[str, str] | None" = None,
) -> dict[str, str]:
"""Environment for *internal* git invocations that must never prompt.
Hermes shells out to git from many non-interactive contexts — MCP catalog
installs, plugin install/update, profile distribution staging, worktree
base fetches, desktop review-pane fetch/push. When the remote is private,
misconfigured, or requires auth, git's default behavior is to prompt on
the inherited terminal (or via an askpass helper), which silently hangs
the operation until its timeout — or forever at call sites without one.
Ported from openai/codex#34540 / #34612 ("detach non-interactive
subprocesses from stdin"): a background tool invocation must fail fast
with a readable error, not wait for input nobody can type.
Returns a copy of ``base`` (default ``os.environ``) with:
* ``GIT_TERMINAL_PROMPT=0`` — git fails with "terminal prompts disabled"
instead of prompting for credentials.
* ``GCM_INTERACTIVE=Never`` — Git Credential Manager (the default
credential helper on Windows installs) never pops its own dialog.
* isolated git config — inherited ``GIT_CONFIG_*`` overrides, global/system
config, pagers, editors, fsmonitor, external diff, and hooks are disabled
for the child process. A user's repo/global config should not be able to
hang or mutate Hermes's internal plumbing calls.
``GIT_ASKPASS`` / ``SSH_ASKPASS`` are deliberately left alone: when the
user has a *working* askpass helper or ssh-agent configured, auth should
still succeed non-interactively. The env only disables paths that block
on a human.
Pair with ``stdin=subprocess.DEVNULL`` so git (and any credential helper
it spawns) also can't read the parent's inherited stdin.
This is for internal plumbing calls only — the agent-facing terminal tool
has its own policy layer and user-visible PTY, where prompting can be
legitimate.
"""
env = dict(base if base is not None else os.environ)
env["GIT_TERMINAL_PROMPT"] = "0"
env["GCM_INTERACTIVE"] = "Never"
# Do not inherit caller-supplied config injection. We rebuild the
# GIT_CONFIG_COUNT block below so ambient -c values cannot re-enable
# pagers, hooks, fsmonitor, editors, or credential prompts.
for key in list(env):
if (
key == "GIT_CONFIG_PARAMETERS"
or key.startswith("GIT_CONFIG_KEY_")
or key.startswith("GIT_CONFIG_VALUE_")
):
env.pop(key, None)
env.pop("GIT_CONFIG_COUNT", None)
devnull = os.devnull
env["GIT_CONFIG_GLOBAL"] = devnull
env["GIT_CONFIG_SYSTEM"] = devnull
env["GIT_CONFIG_NOSYSTEM"] = "1"
env["GIT_PAGER"] = "cat"
env["PAGER"] = "cat"
env["GIT_EDITOR"] = "true"
config_overrides = {
"credential.helper": "",
"core.askPass": "",
"core.fsmonitor": "false",
"core.untrackedCache": "false",
"core.hooksPath": devnull,
"core.pager": "cat",
"core.editor": "true",
"sequence.editor": "true",
"diff.external": "",
}
env["GIT_CONFIG_COUNT"] = str(len(config_overrides))
for idx, (key, value) in enumerate(config_overrides.items()):
env[f"GIT_CONFIG_KEY_{idx}"] = key
env[f"GIT_CONFIG_VALUE_{idx}"] = value
return env
# -----------------------------------------------------------------------------
# Bounded, fail-open git probing (Windows post-kill deadlock guard)
# -----------------------------------------------------------------------------
def _process_start_time(pid: int) -> int | None:
"""Return the repository's stable process-start fingerprint, if available."""
try:
from gateway.status import get_process_start_time
return get_process_start_time(pid)
except Exception:
return None
def _text_names_hermes(text: str) -> bool:
"""True when *text* names Hermes at a path-segment / token boundary.
A bare ``"hermes" in text`` substring test would also match unrelated
processes whose paths merely contain the letters (``...\\shermesa\\...``),
which is exactly the false-positive class this guard exists to prevent.
Instead, split on path separators and whitespace and require a segment
that *starts with* ``hermes`` (``hermes``, ``hermes.exe``, ``hermes_cli``,
``hermes-agent``, ``hermes-runtime``) or the hidden-dir form
``.hermes``/``.hermes-runtime``.
"""
for token in re.split(r"[\\/\s=,;\"']+", text.lower()):
if token.startswith("hermes") or token.startswith(".hermes"):
return True
return False
def _process_command_is_hermes(pid: int) -> bool:
"""Best-effort check that *pid* currently runs Hermes code."""
try:
import psutil
process = psutil.Process(pid)
command = " ".join(process.cmdline() or [])
executable = process.exe() or ""
return _text_names_hermes(f"{command} {executable}")
except Exception:
return False
def pid_is_hermes(
pid: int,
*,
expected_start_time: int | None = None,
) -> bool:
"""Return whether it is safe to use ``taskkill`` for *pid*.
The PID must be valid, currently exist, and identify a Hermes process. When
the caller captured a start-time fingerprint before the destructive action,
the live process must still have the same ``(pid, start_time)`` identity.
Any ambiguity fails closed. Non-Windows callers have no ``taskkill`` path,
so a valid PID with no (or a matching) explicit expectation is accepted
there — but a caller-provided fingerprint that no longer matches is a
recycled PID on every platform and is always refused.
"""
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
return False
if not IS_WINDOWS:
if expected_start_time is None:
return True
try:
return _process_start_time(pid) == expected_start_time
except Exception:
return False
try:
current_start_time = _process_start_time(pid)
except Exception:
return False
if current_start_time is None:
return False
if (
expected_start_time is not None
and current_start_time != expected_start_time
):
return False
try:
return _process_command_is_hermes(pid)
except Exception:
return False
def kill_process_tree(proc: "subprocess.Popen") -> None:
"""Best-effort terminate *proc* and its descendants on both platforms.
``proc.kill()`` alone only terminates the direct child. On Windows a
suspended descendant (e.g. ``git.exe``) can survive holding duplicates of the
captured pipe handles, which keeps the pipes from reaching EOF and leaks two
reader threads + the process per fired timeout — ``taskkill /T /F`` takes the
whole tree down so the bounded drain that follows can actually reach EOF.
On POSIX the same class exists: killing the launcher leaves descendants
(credential helpers, ``git-remote-https``, hook children) running and
holding the pipe write ends. Callers spawn the child in its own process
group (``process_group=0``, Python ≥3.11), so when — and only
when — the child leads its own group (``pgid == pid``), the entire group is
signalled with ``os.killpg``. The ownership check means a fallback spawn
that shares our group can never cause us to kill unrelated processes.
Ported from openai/codex#36793 ("Terminate timed-out Git process trees");
generalized for the shell-hook runner via openai/codex#37527
("Terminate timed-out hook process trees").
All failures are swallowed — this is cleanup on an already-failing path, and
the caller's contract is to fail open. ``kill()`` can raise (access denied,
already reaped); an unhandled raise here would escape the caller's ``except``
handler and break that contract. The ``taskkill`` spawn itself cannot
re-enter the deadlock class it fixes: it captures no pipes (DEVNULL), so its
own timeout cleanup has no reader threads to join.
Delegates the tree-kill to :func:`agent.deadline.kill_process_tree`
(#85125 4d) — same taskkill /T /F on Windows and killpg-when-leader on
POSIX, plus a psutil descendant sweep that also reaches descendants that
``setsid``'d into their own sessions. On any import/delegation failure it
falls back to the original local implementation
(:func:`_legacy_kill_process_tree`), so the fail-open contract holds even
in stripped environments.
"""
try:
from agent.deadline import kill_process_tree as _deadline_kill_tree
_deadline_kill_tree(proc.pid)
except Exception:
_legacy_kill_process_tree(proc)
return
# Ensure Popen's own bookkeeping sees the exit (matches the legacy body:
# a direct kill() so communicate()/wait() cannot hang on a stale handle).
try:
proc.kill()
except OSError:
pass
def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
"""Pre-#85125 local tree-kill — fallback when agent.deadline is unavailable.
Kept verbatim so ``kill_process_tree`` can honor its swallow-everything
contract even when the delegation path itself fails (partial install,
import cycle during teardown).
"""
if not IS_WINDOWS:
# Group-kill first: verify the child actually leads its own process
# group before signalling it, so we never blast a shared group.
try:
import signal as _signal
pgid = os.getpgid(proc.pid)
if pgid == proc.pid:
os.killpg(pgid, _signal.SIGKILL) # windows-footgun: ok — inside `if not IS_WINDOWS` gate
except Exception:
pass
try:
proc.kill()
except OSError:
pass
if IS_WINDOWS:
# No identity guard here on purpose: *proc* is our own retained
# ``Popen`` handle. The child cannot be reaped (and its PID cannot be
# recycled) while we still hold the handle, so an identity check could
# only ever false-refuse a legitimate cleanup. The fail-closed
# ``pid_is_hermes`` guard is for BARE pids from state files or process
# scans, where recycling is real.
try:
subprocess.run(
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
timeout=2,
check=False,
creationflags=windows_hide_flags(),
)
except Exception:
pass
def bounded_probe_run(
argv: Sequence[str],
*,
timeout: float,
errors: str = "replace",
env: "Mapping[str, str] | None" = None,
) -> "subprocess.CompletedProcess[str] | None":
"""Deadlock-safe ``subprocess.run(argv, capture_output=True, timeout=...)``
for fail-open probe call sites. Returns a ``CompletedProcess`` when the
child finished within *timeout* (any exit code), or ``None`` on spawn
failure or timeout.
Why not ``subprocess.run``: on Windows, ``run()``'s post-timeout cleanup
calls an *unbounded* ``communicate()`` after killing the direct child.
Killing it can leave a descendant (``git.exe`` under a launcher shim,
``conhost.exe`` under wmic/powershell) holding duplicates of the captured
stdout/stderr handles, so the pipes never reach EOF and the reader-thread
join blocks forever. The wmic / ``Get-CimInstance Win32_Process`` gateway
scan hit exactly this during ``hermes update`` on slow-WMI machines
(#87134); the git probes hit it first (#68609 / #66037).
The bounded flow: an explicit ``communicate(timeout)``, then on any
failure a tree-kill (see :func:`kill_process_tree`) plus a bounded 1s
post-kill drain; if the pipes are still held after that, they're abandoned
(the orphaned reader threads are daemonic and cost nothing).
The spawn contract mirrors the ``run`` calls it replaces: PIPE/PIPE/DEVNULL,
``text`` with UTF-8 decoding (*errors* configurable — the process scans use
``"ignore"``), and the hidden-window ``creationflags`` on Windows only. On
POSIX the child is placed in its own process group (``process_group=0``,
Python ≥3.11) so timeout cleanup can take down descendants with the
launcher instead of orphaning them.
"""
_popen_kwargs: dict = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {"process_group": 0}
try:
proc = subprocess.Popen(
list(argv),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors=errors,
env=dict(env) if env is not None else None,
**_popen_kwargs,
)
except Exception:
return None
try:
stdout, stderr = proc.communicate(timeout=timeout)
except Exception:
# Timeout OR any other communicate() failure (torn-down pipe, decode
# error): terminate the child + descendants and drain bounded. Leaving
# it running would leak the same suspended-descendant class this guards.
kill_process_tree(proc)
try:
proc.communicate(timeout=1)
except Exception:
pass
return None
return subprocess.CompletedProcess(list(argv), proc.returncode, stdout, stderr)
def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
"""Run a short, throwaway ``git`` probe and return stripped stdout, or ``""``
on ANY failure (nonzero exit, timeout, spawn error, decode error).
This is the shared, deadlock-safe replacement for
``subprocess.run(["git", ...], timeout=...)`` at fail-open probe call sites
(``tui_gateway.git_probe.run_git``, ``agent.coding_context._git``).
**Security (GHSA-7x36-8jrh-v4pw):** these probes run automatically against
whatever directory the session sits in — the coding-workspace snapshot and
the gateway project-tree build fire ``git status`` / ``git branch`` before
any tool call, approval, or trust prompt. An index refresh executes the
repository-configured ``core.fsmonitor`` program, and other config keys
(hooks, pager, editor, credential helper) are execution sinks too. A repo
delivered as files with its ``.git`` directory intact (a shared zip, sync
folder, or USB stick — ``git clone`` never transfers ``.git/config``) would
otherwise get host code execution as the user. Every probe now runs under
:func:`noninteractive_git_env`, which pins those keys to inert values via
``GIT_CONFIG_*`` and ignores global/system config. Diff-rendering callers
additionally pass :data:`NO_DRIVER_DIFF_FLAGS` (attribute-scoped drivers
can't be disabled through env overrides).
Why not ``subprocess.run``: on Windows, ``run()``'s post-timeout cleanup
calls an *unbounded* ``communicate()`` after killing git. Killing the
PATH-resolved launcher can leave a suspended descendant ``git.exe`` holding
duplicates of the captured stdout/stderr handles, so the pipes never reach
EOF and the reader-thread join blocks forever. On the Desktop agent-build
path (``_start_agent_build → _session_info → branch() → run_git``) that turned
an optional branch label into ``agent initialization timed out``
(issues #68609 / #66037).
The bounded flow: an explicit ``communicate(timeout)``, then on any failure a
tree-kill (see :func:`_kill_git_process_tree`) plus a bounded 1s post-kill
drain; if the pipes are still held after that, they're abandoned (the orphaned
reader threads are daemonic and cost nothing).
The normal-path spawn contract mirrors the previous ``run`` call byte-for-byte:
PIPE/PIPE/DEVNULL, ``text`` with UTF-8 ``errors="replace"`` decoding, and the
hidden-window ``creationflags`` on Windows only. On POSIX the probe is
additionally placed in its own process group (``process_group=0``,
Python ≥3.11) so timeout cleanup can take down descendants — credential
helpers, ``git-remote-https``, hook children — with the launcher instead of
orphaning them (see :func:`_kill_git_process_tree`; port of
openai/codex#36793). ``process_group`` only changes which group the child
belongs to; it does not detach the terminal or alter the fast path.
"""
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
if result is None or result.returncode != 0:
return ""
return (result.stdout or "").strip()
# Backward-compat alias — existing call sites/tests import the historical name.
_kill_git_process_tree = kill_process_tree
+921
View File
@@ -0,0 +1,921 @@
"""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 math
import os
import time
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator, Optional
from hermes_constants import get_default_hermes_root, get_hermes_home
logger = logging.getLogger(__name__)
class ActiveSessionRegistryError(RuntimeError):
"""The liveness registry could not prove a safe ownership decision."""
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 format_age(seconds: float) -> str:
minutes = max(0, int(seconds // 60))
if minutes < 60:
return f"{minutes}m"
hours, minutes = divmod(minutes, 60)
return f"{hours}h" if not minutes else f"{hours}h{minutes}m"
def summarize_holders(entries: list[dict[str, Any]]) -> str:
"""Compact "who is holding the slots" phrase, e.g. ``desktop x4, cli``."""
if not entries:
return ""
counts: dict[str, int] = {}
for entry in entries:
surface = str(entry.get("surface") or "unknown")
counts[surface] = counts.get(surface, 0) + 1
held = ", ".join(
f"{surface} x{n}" if n > 1 else surface
for surface, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
)
started = [t for t in (_optional_float(e.get("started_at")) for e in entries) if t]
if started:
held += f", oldest {format_age(time.time() - min(started))} ago"
return held
def active_session_limit_message(
active_count: int,
max_sessions: int,
entries: Optional[list[dict[str, Any]]] = None,
) -> str:
# Name the holders: the slots are shared across CLI, desktop/TUI and the
# messaging gateway, so the surface that gets rejected is usually NOT the
# one squatting on them (idle desktop chats starving a Discord bot, say).
# Without this the message is unactionable and the only way to find out is
# reading runtime/active_sessions.json by hand.
held = summarize_holders(entries or [])
detail = f" Held by: {held}." if held else ""
return (
f"Hermes is at the active session limit ({active_count}/{max_sessions})."
f"{detail} Try again when another session finishes."
)
def _registry_home(registry_home: str | Path | None = None) -> Path:
return Path(registry_home) if registry_home is not None else Path(get_hermes_home())
# WHY A REFUSAL IS REFUSED, in a form a caller can branch on.
#
# The two refusals mean opposite things to an automated client. Capacity is
# "the machine is busy, come back later". Ownership is "this specific session
# has a live owner, and writing to it would interleave with theirs".
#
# Callers used to have only the human-readable message, so anything that needed
# to DECIDE had to match prose -- which silently changes meaning whenever the
# wording is improved. The reason is the contract; the message is for people.
SESSION_NOT_OWNED = "SESSION_NOT_OWNED"
MAX_CONCURRENT_SESSIONS = "MAX_CONCURRENT_SESSIONS"
# Ownership could not be PROVEN either way: the registry was unreadable or
# corrupt. Distinct from SESSION_NOT_OWNED on purpose -- "someone else owns
# this" and "I cannot tell who owns this" call for different operator action,
# and collapsing the second into a silent go-ahead is exactly the fail-open
# hole that let two writers share one session (#94595 review, blocker 2).
SESSION_COORDINATION_UNAVAILABLE = "SESSION_COORDINATION_UNAVAILABLE"
# Advertised through the gateway so a client can tell a build that enforces
# per-session exclusivity from one that does not.
#
# A module constant rather than a config flag, deliberately: it is true because
# try_acquire_active_session below performs the check atomically, so it cannot
# be turned on by an operator who has not got the enforcement, and cannot drift
# out of step with it without this file changing.
PER_SESSION_EXCLUSIVE_SUBMIT = True
class ActiveSessionRefusal(str):
"""A refusal message that also carries a machine-readable ``reason``.
A ``str`` subclass so every existing caller keeps working untouched -- they
format it, hand it back as a JSON-RPC message, or just test it for None --
while a caller that must act on WHICH refusal happened reads ``.reason``
instead of matching the wording.
"""
reason: str
def __new__(cls, message: str, reason: str) -> "ActiveSessionRefusal":
obj = super().__new__(cls, message)
obj.reason = reason
return obj
def _is_same_writer(entry: dict[str, Any], metadata: Optional[dict[str, Any]]) -> bool:
"""True when an existing lease belongs to the very caller now re-acquiring it.
Both halves are required. A pid alone would let two live sessions in one
process steal each other's lease -- which is a real hazard, since each holds
its own snapshot of the transcript. A live session id alone would let another
process with a coincidentally equal id do the same.
"""
try:
if int(entry.get("pid") or -1) != os.getpid():
return False
except (TypeError, ValueError):
return False
existing_live = str((entry.get("metadata") or {}).get("live_session_id") or "")
incoming_live = str((metadata or {}).get("live_session_id") or "")
if not existing_live or not incoming_live:
return False
return existing_live == incoming_live
def session_already_owned_message(session_id: str, entry: dict[str, Any]) -> str:
surface = str(entry.get("surface") or "another surface")
pid = entry.get("pid")
started = _optional_float(entry.get("started_at"))
age = f", running {format_age(time.time() - started)}" if started else ""
return (
f"Session {session_id} already has a live owner ({surface}, pid {pid}{age}). "
"Only one surface at a time may run a session, because a second one would "
"reason from a transcript that does not include the first one's work."
)
def _state_dir(registry_home: str | Path | None = None) -> Path:
return _registry_home(registry_home) / "runtime"
def _state_path(registry_home: str | Path | None = None) -> Path:
return _state_dir(registry_home) / "active_sessions.json"
def _lock_path(registry_home: str | Path | None = None) -> Path:
return _state_dir(registry_home) / "active_sessions.lock"
def _lease_paths(
lease: Optional["ActiveSessionLease"] = None,
registry_home: str | Path | None = None,
) -> tuple[Path, Path]:
if lease is not None and lease.state_path is not None and lease.lock_path is not None:
return lease.state_path, lease.lock_path
home = _registry_home(registry_home)
return _state_path(home), _lock_path(home)
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, *, strict: bool = False) -> list[dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
return []
except Exception as exc:
if strict:
raise ActiveSessionRegistryError(
f"active session registry unreadable: {path}"
) from exc
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):
if strict:
raise ActiveSessionRegistryError(
f"active session registry has invalid shape: {path}"
)
return []
valid = [entry for entry in entries if isinstance(entry, dict)]
if not strict:
return valid
if len(valid) != len(entries):
raise ActiveSessionRegistryError(
f"active session registry contains invalid entries: {path}"
)
seen_leases: set[str] = set()
for entry in valid:
lease_id = entry.get("lease_id")
session_id = entry.get("session_id")
pid = entry.get("pid")
if not isinstance(lease_id, str) or not lease_id.strip():
raise ActiveSessionRegistryError(
f"active session registry contains an invalid lease id: {path}"
)
if lease_id in seen_leases:
raise ActiveSessionRegistryError(
f"active session registry contains a duplicate lease id: {path}"
)
seen_leases.add(lease_id)
if not isinstance(session_id, str) or not session_id.strip():
raise ActiveSessionRegistryError(
f"active session registry contains an invalid session id: {path}"
)
if isinstance(pid, bool) or not isinstance(pid, (int, str)):
pid_int = 0
else:
try:
pid_int = int(pid)
except (TypeError, ValueError):
pid_int = 0
if pid_int <= 0:
raise ActiveSessionRegistryError(
f"active session registry contains an invalid pid: {path}"
)
surface = entry.get("surface")
if surface is not None and not isinstance(surface, str):
raise ActiveSessionRegistryError(
f"active session registry contains an invalid surface: {path}"
)
tracked = entry.get("track_liveness")
if tracked is not None and not isinstance(tracked, bool):
raise ActiveSessionRegistryError(
f"active session registry contains an invalid liveness marker: {path}"
)
metadata = entry.get("metadata")
if metadata is not None and not isinstance(metadata, dict):
raise ActiveSessionRegistryError(
f"active session registry contains invalid metadata: {path}"
)
process_start = entry.get("process_start_time")
parsed_process_start = _optional_float(process_start)
if process_start not in (None, "") and (
parsed_process_start is None or not math.isfinite(parsed_process_start)
):
raise ActiveSessionRegistryError(
f"active session registry contains an invalid process start time: {path}"
)
return valid
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")
try:
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"entries": entries}, fh, sort_keys=True)
os.replace(tmp, path)
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
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_liveness(pid: Any, process_start_time: Any = None) -> Optional[bool]:
"""Return True/False for live/dead, or None when liveness is unknowable."""
try:
pid_int = int(pid)
except (TypeError, ValueError):
return None
if pid_int <= 0:
return None
try:
from gateway.status import _pid_exists
exists = bool(_pid_exists(pid_int))
except Exception:
return None
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 None
return abs(current_start - expected_start) < 0.001
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]], *, strict: bool = False
) -> list[dict[str, Any]]:
live: list[dict[str, Any]] = []
for entry in entries:
tracked = bool(entry.get("track_liveness"))
if strict or tracked:
state = _pid_liveness(entry.get("pid"), entry.get("process_start_time"))
if state is None:
raise ActiveSessionRegistryError(
"active session owner liveness is unknown"
)
if state:
live.append(entry)
continue
if _pid_alive(entry.get("pid"), entry.get("process_start_time")):
live.append(entry)
return live
@dataclass
class ActiveSessionLease:
lease_id: str
session_id: str
surface: str
enabled: bool = True
released: bool = False
# Registry paths pinned at acquisition time. A lease acquired under the
# root ``HERMES_HOME`` must release against the same registry even when
# ``release()`` runs inside a profile home override (native multiplex
# routes turns under ``_profile_runtime_scope``), otherwise the root
# entry survives until process exit and the session cap fills with
# phantom leases (#85431).
state_path: Optional[Path] = None
lock_path: Optional[Path] = None
track_liveness: bool = False
def release(self) -> None:
if self.released or not self.enabled:
return
release_active_session(self)
def _lease_entry(
*,
lease_id: str,
session_id: str,
surface: str,
metadata: Optional[dict[str, Any]] = None,
track_liveness: bool = False,
) -> dict[str, Any]:
now = time.time()
entry: dict[str, Any] = {
"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 track_liveness:
entry["track_liveness"] = True
if metadata:
entry["metadata"] = {
str(k): v for k, v in metadata.items() if isinstance(k, str)
}
return entry
def try_acquire_active_session(
*,
session_id: str,
surface: str,
config: Any,
metadata: Optional[dict[str, Any]] = None,
registry_home: str | Path | None = None,
track_liveness: bool = False,
) -> tuple[Optional[ActiveSessionLease], Optional[str]]:
"""Acquire an active-session slot.
Per-session exclusivity is CORRECTNESS and is enforced unconditionally:
at most one live owner may run a given stored session, whether or not an
operator configured ``max_concurrent_sessions`` (#94595). The concurrency
cap remains a resource POLICY and applies only when configured. Liveness
tracking keeps richer desktop lifecycle semantics; ``registry_home`` lets
profile-scoped backends share the owning profile's registry even when
launched from another home.
Returns ``(lease, None)`` on success and ``(None, refusal)`` otherwise,
where ``refusal`` is an :class:`ActiveSessionRefusal` carrying a
machine-readable ``reason``. Ownership uncertainty fails CLOSED: when the
registry cannot be read, the caller gets ``SESSION_COORDINATION_UNAVAILABLE``
rather than a silent go-ahead that could reopen the double-writer state.
"""
max_sessions = resolve_max_concurrent_sessions(config)
lease_id = uuid.uuid4().hex
key = str(session_id or "")
# A session with no stored id yet cannot collide with another writer, and
# the strict registry schema (rightly) refuses entries with empty session
# ids. Nothing to fence, nothing to record: hand back a no-op lease.
if not key and not track_liveness:
return ActiveSessionLease(
lease_id=lease_id,
session_id=key,
surface=str(surface),
enabled=False,
), None
entry = _lease_entry(
lease_id=lease_id,
session_id=key,
surface=str(surface),
metadata=metadata,
track_liveness=track_liveness,
)
state_path, lock_path = _lease_paths(registry_home=registry_home)
with _FileLock(lock_path):
try:
raw_entries = _read_entries(state_path, strict=True)
entries = _prune_dead(raw_entries, strict=track_liveness)
except ActiveSessionRegistryError:
if track_liveness:
raise
# A capacity cap could afford to degrade open -- worst case, more
# sessions than the operator wanted. Exclusivity cannot: "could not
# prove ownership" must never be collapsed into "no owner exists",
# because that silently reopens the exact double-writer state this
# fence guarantees against. Refuse, and say which file to fix.
logger.warning(
"Active-session registry is unavailable; refusing the session "
"rather than risking a concurrent writer"
)
return None, ActiveSessionRefusal(
(
"Hermes could not read the active-session registry at "
f"{state_path}, so it cannot prove this session has no other "
"live owner. Fix or remove that file and try again."
),
SESSION_COORDINATION_UNAVAILABLE,
)
pruned = len(raw_entries) - len(entries)
if pruned:
logger.info("Pruned %d stale active session lease(s)", pruned)
# Correctness first, and under the same lock that just pruned the dead
# owners -- so an owner that died is never mistaken for one that is
# running, and a live one is never overlooked.
#
# An empty key is exempt: a session with no stored id yet cannot collide
# with another, and treating "" as an identity would make every unsaved
# draft exclude every other one.
if key:
for index, existing in enumerate(entries):
if str(existing.get("session_id") or "") != key:
continue
# THE SAME WRITER IS NOT A SECOND WRITER.
#
# A live session that lost its lease reference -- its record was
# rebuilt in place, so the object holding the lease is unreachable
# while the session itself is still the one being driven -- would
# otherwise be fenced out of its own session by its own leak, and
# permanently: pruning only removes entries whose PROCESS is dead,
# and this process is very much alive.
#
# Identity here is (pid, live session id). Two processes never
# match, because their pids differ. Two live sessions inside one
# process never match, because their live ids differ. Only the
# exact same writer re-acquiring its own session matches, and that
# is re-entrancy rather than a concurrent writer.
if _is_same_writer(existing, metadata):
entries[index] = entry
_write_entries(state_path, entries)
return ActiveSessionLease(
lease_id=lease_id,
session_id=key,
surface=str(surface),
state_path=state_path,
lock_path=lock_path,
track_liveness=track_liveness,
), None
_write_entries(state_path, entries)
logger.info(
"Refused active session %s: already held by pid=%s surface=%s",
key,
existing.get("pid"),
existing.get("surface"),
)
return None, ActiveSessionRefusal(
session_already_owned_message(key, existing),
SESSION_NOT_OWNED,
)
# Capacity second, and only when an operator asked for one.
if max_sessions is not None:
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, ActiveSessionRefusal(
active_session_limit_message(active_count, max_sessions, entries),
MAX_CONCURRENT_SESSIONS,
)
entries.append(entry)
_write_entries(state_path, entries)
return ActiveSessionLease(
lease_id=lease_id,
session_id=key,
surface=str(surface),
state_path=state_path,
lock_path=lock_path,
track_liveness=track_liveness,
), None
def release_active_session(lease: ActiveSessionLease) -> None:
# Prefer the registry the lease was acquired against: the caller may be
# running under a profile HERMES_HOME override (#85431).
state_path, lock_path = _lease_paths(lease)
with _FileLock(lock_path):
if lease.released:
return
try:
raw_entries = _read_entries(state_path, strict=True)
entries = _prune_dead(raw_entries, strict=lease.track_liveness)
except ActiveSessionRegistryError:
if lease.track_liveness:
raise
logger.warning(
"Active-session registry is unavailable; preserving it while "
"releasing an untracked lease"
)
lease.released = True
return
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)
lease.released = True
def transfer_active_session(
lease: ActiveSessionLease,
*,
session_id: str,
metadata: Optional[dict[str, Any]] = None,
) -> bool:
"""Move an existing lease to a new session id without dropping the slot."""
new_session_id = str(session_id or "")
if not new_session_id:
return False
if lease.released:
return False
if not lease.enabled:
lease.session_id = new_session_id
return True
state_path, lock_path = _lease_paths(lease)
with _FileLock(lock_path):
# release() may have won after the optimistic precheck but before this
# thread acquired the file lock. Never resurrect a durably removed lease.
if lease.released:
return False
try:
raw_entries = _read_entries(state_path, strict=True)
entries = _prune_dead(raw_entries, strict=lease.track_liveness)
except ActiveSessionRegistryError:
if lease.track_liveness:
raise
logger.warning(
"Active-session registry is unavailable; refusing to overwrite "
"it during lease transfer"
)
return False
updated = False
for entry in entries:
if str(entry.get("lease_id") or "") != lease.lease_id:
continue
entry["session_id"] = new_session_id
entry["updated_at"] = time.time()
if metadata:
entry["metadata"] = {
str(k): v for k, v in metadata.items() if isinstance(k, str)
}
updated = True
break
if not updated and lease.track_liveness:
entries.append(
_lease_entry(
lease_id=lease.lease_id,
session_id=new_session_id,
surface=lease.surface,
metadata=metadata,
track_liveness=True,
)
)
updated = True
if updated:
_write_entries(state_path, entries)
lease.session_id = new_session_id
return updated
# A lease this process wrote in the last few seconds may not be in the
# caller's ``own_live_lease_ids`` yet: ``try_acquire_active_session`` writes
# the registry entry under the file lock and the server attaches the lease to
# its session record only after that returns. A concurrent finalize that
# snapshotted its live ids in between would otherwise read the brand-new lease
# as an orphan and drop it. Real orphans are minutes old (#101415).
_SELF_ORPHAN_GRACE_SECONDS = 30.0
def _drop_self_orphans(
entries: list[dict[str, Any]], own_live_lease_ids: set[str] | None
) -> list[dict[str, Any]]:
"""Drop this process's leases only when its caller can vouch for owners."""
if own_live_lease_ids is None:
return entries
pid = os.getpid()
cutoff = time.time() - _SELF_ORPHAN_GRACE_SECONDS
return [
entry
for entry in entries
if entry.get("pid") != pid
or str(entry.get("lease_id") or "") in own_live_lease_ids
or (_optional_float(entry.get("started_at")) or 0.0) > cutoff
]
def _release_orphaned_leases_in_home(
registry_home: Path, live_lease_ids: set[str]
) -> int:
state_path = _state_path(registry_home)
if not state_path.exists():
return 0
with _FileLock(_lock_path(registry_home)):
try:
entries = _prune_dead(_read_entries(state_path, strict=True))
except ActiveSessionRegistryError:
logger.warning(
"Active-session registry is unavailable; skipping orphaned-lease sweep"
)
return 0
kept = _drop_self_orphans(entries, live_lease_ids)
dropped = len(entries) - len(kept)
if dropped:
_write_entries(state_path, kept)
return dropped
def release_orphaned_leases(live_lease_ids: set[str]) -> int:
"""Drop this process's registry entries that no live session owns.
``_prune_dead`` only reclaims leases whose owning process died. A server
that runs for days (``hermes dashboard`` / ``serve``) never trips that
check, so a lease whose session skipped teardown is held until restart.
The owning process is the only authority on which of its own leases are
real, so it drops the rest itself — exact, with no heartbeat write on the
turn path and no staleness threshold to tune.
"""
root = get_default_hermes_root()
homes = [root]
profiles_root = root / "profiles"
try:
homes.extend(
profile
for profile in profiles_root.iterdir()
if profile.is_dir() and not profile.name.startswith(".")
)
except OSError:
pass
dropped = 0
for home in homes:
try:
dropped += _release_orphaned_leases_in_home(home, live_lease_ids)
except OSError as exc:
logger.debug(
"orphaned-lease sweep failed for %s: %s", home, exc
)
return dropped
def active_session_registry_snapshot(
registry_home: str | Path | None = None,
) -> list[dict[str, Any]]:
"""Return the pruned active-session registry for diagnostics/tests."""
state_path, lock_path = _lease_paths(registry_home=registry_home)
with _FileLock(lock_path):
raw_entries = _read_entries(state_path, strict=True)
entries = _prune_dead(raw_entries)
if entries != raw_entries:
_write_entries(state_path, entries)
return entries
@contextmanager
def active_session_liveness_guard(
session_id: str,
*,
registry_home: str | Path | None = None,
own_live_lease_ids: set[str] | None = None,
) -> Iterator[bool]:
"""Hold the registry lock while reporting whether ``session_id`` is leased.
Keeping the lock across the caller's lifecycle mutation prevents a new
backend from acquiring a lease and reopening the row between the liveness
check and the corresponding ``end_session`` write.
"""
target = str(session_id or "")
state_path, lock_path = _lease_paths(registry_home=registry_home)
with _FileLock(lock_path):
entries = _prune_dead(_read_entries(state_path, strict=True), strict=True)
entries = _drop_self_orphans(entries, own_live_lease_ids)
_write_entries(state_path, entries)
yield bool(target) and any(
str(entry.get("session_id") or "") == target for entry in entries
)
@contextmanager
def release_active_session_liveness_guard(
lease: ActiveSessionLease,
session_id: str,
*,
own_live_lease_ids: set[str] | None = None,
) -> Iterator[bool]:
"""Remove ``lease`` and hold its registry lock through a lifecycle write.
This makes automatic cleanup one atomic ownership decision: the local
runtime disappears, sibling liveness is checked, and the caller may end the
durable row before any new backend can acquire/reopen it.
"""
if not lease.enabled or lease.released:
with active_session_liveness_guard(
session_id,
registry_home=_registry_home_for_lease(lease),
own_live_lease_ids=own_live_lease_ids,
) as active:
yield active
return
target = str(session_id or "")
state_path, lock_path = _lease_paths(lease)
with _FileLock(lock_path):
raw_entries = _read_entries(state_path, strict=True)
entries = _prune_dead(raw_entries, strict=True)
kept = [
entry
for entry in entries
if str(entry.get("lease_id") or "") != lease.lease_id
]
kept = _drop_self_orphans(kept, own_live_lease_ids)
if len(kept) != len(entries):
_write_entries(state_path, kept)
lease.released = True
yield bool(target) and any(
str(entry.get("session_id") or "") == target for entry in kept
)
def _registry_home_for_lease(lease: ActiveSessionLease) -> Path | None:
if lease.state_path is None:
return None
return lease.state_path.parent.parent
File diff suppressed because it is too large Load Diff
+571
View File
@@ -0,0 +1,571 @@
"""Compatibility helpers for Agent Plugins v1 portable directory packages.
This module validates the versioned portable format locally and translates its
supported components into records consumed by Hermes' existing skill and MCP
runtimes. It deliberately performs no schema fetching and imports no plugin
Python code.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Mapping, Tuple
from agent.skill_utils import yaml_load
PLUGIN_SCHEMA_V1 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
MCP_SCHEMA_V1 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
_PLUGIN_FIELDS = {
"$schema",
"name",
"version",
"description",
"author",
"homepage",
"repository",
"license",
"keywords",
"extensions",
}
_AUTHOR_FIELDS = {"name", "email", "url"}
_STDIO_FIELDS = {"type", "command", "args", "env", "cwd"}
_REMOTE_FIELDS = {"type", "url", "headers"}
_PLUGIN_NAME_RE = re.compile(
r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$"
)
_SKILL_NAME_RE = re.compile(r"^(?!.*--)[a-z0-9]+(?:-[a-z0-9]+)*$")
_PLACEHOLDER_RE = re.compile(r"\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}")
_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
class AgentPluginError(ValueError):
"""Fatal portable manifest validation failure."""
@dataclass(frozen=True)
class AgentPluginDiagnostic:
scope: str
message: str
@dataclass(frozen=True)
class AgentPluginSkill:
name: str
description: str
root: Path
skill_md: Path
frontmatter: Mapping[str, Any]
@dataclass(frozen=True)
class AgentPluginPackage:
name: str
version: str
description: str
root: Path
data_root: Path
manifest: Mapping[str, Any]
skills: Tuple[AgentPluginSkill, ...]
mcp_servers: Mapping[str, Dict[str, Any]]
diagnostics: Tuple[AgentPluginDiagnostic, ...]
def _inside(path: Path, root: Path) -> bool:
try:
path.resolve(strict=False).relative_to(root.resolve(strict=True))
return True
except (OSError, RuntimeError, ValueError):
return False
def _read_json_object(path: Path, *, label: str) -> dict:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise AgentPluginError(f"{label} is not valid readable JSON: {exc}") from exc
if not isinstance(value, dict):
raise AgentPluginError(f"{label} must contain a JSON object")
return value
def _validate_manifest(root: Path) -> tuple[dict, list[AgentPluginDiagnostic]]:
manifest_path = root / "plugin.json"
if not _inside(manifest_path, root) or not manifest_path.is_file():
raise AgentPluginError("plugin.json must be a regular file within the plugin root")
manifest = _read_json_object(manifest_path, label="plugin.json")
diagnostics: list[AgentPluginDiagnostic] = []
for field in sorted(set(manifest) - _PLUGIN_FIELDS):
diagnostics.append(
AgentPluginDiagnostic("manifest", f"ignored unknown top-level field: {field}")
)
manifest.pop(field)
if manifest.get("$schema") != PLUGIN_SCHEMA_V1:
raise AgentPluginError(
"plugin.json declares an unsupported or missing Agent Plugins schema"
)
name = manifest.get("name")
if (
not isinstance(name, str)
or not 1 <= len(name) <= 64
or _PLUGIN_NAME_RE.fullmatch(name) is None
):
raise AgentPluginError("plugin.json name does not satisfy v1 constraints")
for field in ("version", "description", "homepage", "repository", "license"):
if field in manifest and not isinstance(manifest[field], str):
raise AgentPluginError(f"plugin.json {field} must be a string")
if "keywords" in manifest:
keywords = manifest["keywords"]
if not isinstance(keywords, list) or any(
not isinstance(value, str) for value in keywords
):
raise AgentPluginError("plugin.json keywords must be an array of strings")
if "author" in manifest:
author = manifest["author"]
if not isinstance(author, dict):
raise AgentPluginError("plugin.json author must be an object")
unknown = set(author) - _AUTHOR_FIELDS
if unknown or any(not isinstance(value, str) for value in author.values()):
raise AgentPluginError(
"plugin.json author may contain only string name, email, and url fields"
)
if "extensions" in manifest:
extensions = manifest["extensions"]
if not isinstance(extensions, dict):
diagnostics.append(
AgentPluginDiagnostic(
"manifest", "ignored non-object extensions field"
)
)
manifest.pop("extensions")
elif any(not isinstance(value, dict) for value in extensions.values()):
raise AgentPluginError("plugin.json extension namespace values must be objects")
return manifest, diagnostics
def _valid_skill_frontmatter(
frontmatter: Mapping[str, Any], directory_name: str
) -> str | None:
name = frontmatter.get("name")
if (
not isinstance(name, str)
or name != directory_name
or not 1 <= len(name) <= 64
or _SKILL_NAME_RE.fullmatch(name) is None
):
return "name must match the directory and satisfy Agent Skills constraints"
description = frontmatter.get("description")
if not isinstance(description, str) or not 1 <= len(description) <= 1024:
return "description must be a non-empty string of at most 1024 characters"
if "license" in frontmatter and not isinstance(frontmatter["license"], str):
return "license must be a string"
if "compatibility" in frontmatter:
compatibility = frontmatter["compatibility"]
if not isinstance(compatibility, str) or not 1 <= len(compatibility) <= 500:
return "compatibility must be a string of 1 to 500 characters"
if "metadata" in frontmatter:
metadata = frontmatter["metadata"]
if not isinstance(metadata, dict) or any(
not isinstance(key, str) or not isinstance(value, str)
for key, value in metadata.items()
):
return "metadata must map string keys to string values"
if "allowed-tools" in frontmatter and not isinstance(
frontmatter["allowed-tools"], str
):
return "allowed-tools must be a string"
return None
def _discover_skills(
root: Path, diagnostics: list[AgentPluginDiagnostic]
) -> tuple[AgentPluginSkill, ...]:
skills_root = root / "skills"
if not skills_root.exists() and not skills_root.is_symlink():
return ()
if not _inside(skills_root, root) or not skills_root.is_dir():
diagnostics.append(
AgentPluginDiagnostic("skills", "skills must be an in-root directory")
)
return ()
skills: list[AgentPluginSkill] = []
try:
children = sorted(skills_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
diagnostics.append(AgentPluginDiagnostic("skills", f"cannot list skills: {exc}"))
return ()
for child in children:
skill_md = child / "SKILL.md"
if not child.is_dir() or not skill_md.exists():
continue
scope = f"skill:{child.name}"
if not _inside(skill_md, root) or not skill_md.is_file():
diagnostics.append(
AgentPluginDiagnostic(scope, "SKILL.md must be a regular in-root file")
)
continue
try:
content = skill_md.read_text(encoding="utf-8")
content = content.lstrip("\ufeff")
if not content.startswith("---"):
raise ValueError("missing YAML frontmatter")
end_match = re.search(r"\n---\s*\n", content[3:])
if end_match is None:
raise ValueError("unterminated YAML frontmatter")
try:
parsed = yaml_load(content[3 : end_match.start() + 3])
except Exception as exc:
raise ValueError(f"invalid YAML frontmatter: {exc}") from exc
if not isinstance(parsed, dict):
raise ValueError("YAML frontmatter must be an object")
frontmatter = parsed
except (OSError, UnicodeError, ValueError) as exc:
diagnostics.append(AgentPluginDiagnostic(scope, f"invalid SKILL.md: {exc}"))
continue
error = _valid_skill_frontmatter(frontmatter, child.name)
if error:
diagnostics.append(AgentPluginDiagnostic(scope, error))
continue
skills.append(
AgentPluginSkill(
name=child.name,
description=frontmatter["description"],
root=child.resolve(strict=True),
skill_md=skill_md.resolve(strict=True),
frontmatter=dict(frontmatter),
)
)
return tuple(skills)
def _expand(value: str, plugin_root: Path, data_root: Path) -> str:
replacements = {
"PLUGIN_ROOT": str(plugin_root),
"PLUGIN_DATA": str(data_root),
}
return _PLACEHOLDER_RE.sub(lambda match: replacements[match.group(1)], value)
def _resolve_scoped_path(
value: str,
plugin_root: Path,
data_root: Path,
*,
expand_placeholders: bool = True,
) -> Path:
expanded = _expand(value, plugin_root, data_root) if expand_placeholders else value
if value.startswith("./"):
base = plugin_root
candidate = base / expanded[2:]
elif value == "${PLUGIN_ROOT}" or value.startswith("${PLUGIN_ROOT}/"):
base = plugin_root
candidate = Path(expanded)
elif value == "${PLUGIN_DATA}" or value.startswith("${PLUGIN_DATA}/"):
base = data_root
candidate = Path(expanded)
else:
raise ValueError("path must start with ./, ${PLUGIN_ROOT}, or ${PLUGIN_DATA}")
resolved = candidate.resolve(strict=False)
try:
resolved.relative_to(base.resolve(strict=False))
except (OSError, RuntimeError, ValueError) as exc:
raise ValueError("path escapes its resolved root") from exc
return resolved
def _validate_headers(headers: object) -> bool:
if headers is None:
return True
if not isinstance(headers, dict):
return False
seen: set[str] = set()
for name, value in headers.items():
if (
not isinstance(name, str)
or _HEADER_NAME_RE.fullmatch(name) is None
or not isinstance(value, str)
or "\r" in value
or "\n" in value
or name.lower() in seen
):
return False
seen.add(name.lower())
return True
def _validate_remote_url(url: object) -> str:
"""Validate a portable remote MCP URL per the v1 spec and return it.
Rules (Agent Plugins v1 §7.2.1): absolute http(s) URL, no user
information, no fragment; non-loopback endpoints must use HTTPS. HTTP is
allowed only when the host is exactly ``localhost`` or an IP literal in a
loopback range. No placeholder or environment expansion is performed.
"""
from urllib.parse import urlsplit
if not isinstance(url, str) or not url:
raise ValueError("url must be a non-empty string")
try:
parsed = urlsplit(url)
except ValueError as exc:
raise ValueError(f"url is not parseable: {exc}") from exc
scheme = parsed.scheme.lower()
if scheme not in {"http", "https"}:
raise ValueError("url scheme must be http or https")
if parsed.username is not None or parsed.password is not None:
raise ValueError("url must not contain user information")
if parsed.fragment:
raise ValueError("url must not contain a fragment")
host = parsed.hostname
if not host:
raise ValueError("url must have a host")
if scheme == "http":
loopback = False
if host == "localhost":
loopback = True
else:
import ipaddress
try:
loopback = ipaddress.ip_address(host).is_loopback
except ValueError:
loopback = False
if not loopback:
raise ValueError("non-loopback url must use https")
return url
def _translate_remote(config: Mapping[str, Any]) -> Dict[str, Any]:
"""Translate a portable ``streamable-http`` entry into native MCP config.
The returned record targets Hermes' existing URL-based MCP runtime.
``strict_redirect_headers`` instructs the runtime to drop the configured
headers on any cross-origin redirect, which the v1 spec requires for
portable packages (configured headers must not be forwarded to a
different origin without explicit user authorization).
"""
if set(config) - _REMOTE_FIELDS:
raise ValueError("unknown remote field")
url = _validate_remote_url(config.get("url"))
if not _validate_headers(config.get("headers")):
raise ValueError("invalid headers")
translated: Dict[str, Any] = {
"url": url,
"strict_redirect_headers": True,
}
headers = config.get("headers")
if headers:
translated["headers"] = dict(headers)
return translated
def _translate_stdio(
config: Mapping[str, Any], plugin_root: Path, data_root: Path
) -> Dict[str, Any]:
if set(config) - _STDIO_FIELDS:
raise ValueError("unknown stdio field")
command = config.get("command")
if not isinstance(command, str) or not command or "\x00" in command:
raise ValueError("command must be a non-empty executable token")
if command.startswith("./"):
command_value = str(
_resolve_scoped_path(
command,
plugin_root,
data_root,
expand_placeholders=False,
)
)
elif any(character.isspace() for character in command):
raise ValueError("command must contain one executable token")
elif "/" in command or "\\" in command or command in {".", ".."}:
raise ValueError("command must be a bare executable or begin with ./")
else:
command_value = command
args = config.get("args", [])
if not isinstance(args, list) or any(not isinstance(value, str) for value in args):
raise ValueError("args must be an array of strings")
env = config.get("env", {})
if not isinstance(env, dict) or any(
not isinstance(key, str) or not isinstance(value, str)
for key, value in env.items()
):
raise ValueError("env must map string keys to string values")
env_keys = {key.upper() if os.name == "nt" else key for key in env}
if "PLUGIN_ROOT" in env_keys or "PLUGIN_DATA" in env_keys:
raise ValueError("PLUGIN_ROOT and PLUGIN_DATA are reserved")
cwd = config.get("cwd")
if cwd is None:
cwd_value = plugin_root
elif not isinstance(cwd, str):
raise ValueError("cwd must be a string")
else:
cwd_value = _resolve_scoped_path(cwd, plugin_root, data_root)
translated_env = {
key: _expand(value, plugin_root, data_root) for key, value in env.items()
}
translated_env["PLUGIN_ROOT"] = str(plugin_root)
translated_env["PLUGIN_DATA"] = str(data_root)
return {
"command": command_value,
"args": [_expand(value, plugin_root, data_root) for value in args],
"env": translated_env,
"cwd": str(cwd_value),
}
def _discover_mcp(
root: Path,
data_root: Path,
diagnostics: list[AgentPluginDiagnostic],
*,
create_data: bool = True,
) -> Dict[str, Dict[str, Any]]:
mcp_path = root / "mcp.json"
if not mcp_path.exists() and not mcp_path.is_symlink():
return {}
if not _inside(mcp_path, root) or not mcp_path.is_file():
diagnostics.append(
AgentPluginDiagnostic("mcp", "mcp.json must be a regular in-root file")
)
return {}
try:
config = _read_json_object(mcp_path, label="mcp.json")
except AgentPluginError as exc:
diagnostics.append(AgentPluginDiagnostic("mcp", str(exc)))
return {}
if set(config) != {"$schema", "mcpServers"}:
diagnostics.append(
AgentPluginDiagnostic("mcp", "mcp.json has an invalid top-level shape")
)
return {}
if config.get("$schema") != MCP_SCHEMA_V1:
diagnostics.append(
AgentPluginDiagnostic("mcp", "mcp.json declares an unsupported schema")
)
return {}
servers = config.get("mcpServers")
if not isinstance(servers, dict):
diagnostics.append(
AgentPluginDiagnostic("mcp", "mcpServers must be an object")
)
return {}
translated: Dict[str, Dict[str, Any]] = {}
for name, server in servers.items():
scope = f"mcp:{name}"
if not isinstance(name, str) or not name or not isinstance(server, dict):
diagnostics.append(AgentPluginDiagnostic(scope, "invalid server entry"))
continue
server_type = server.get("type")
if server_type == "stdio":
try:
translated_server = _translate_stdio(server, root, data_root)
if create_data:
data_root.mkdir(parents=True, exist_ok=True)
cwd_path = Path(translated_server["cwd"])
try:
cwd_path.relative_to(data_root)
except ValueError:
pass
else:
# The MCP client starts stdio servers with this cwd.
# Create only data-root descendants; plugin-root paths
# remain package-owned and are never made writable as a
# side effect of discovery.
cwd_path.mkdir(parents=True, exist_ok=True)
translated[name] = translated_server
except (OSError, ValueError) as exc:
diagnostics.append(AgentPluginDiagnostic(scope, str(exc)))
elif server_type == "streamable-http":
try:
translated[name] = _translate_remote(server)
except ValueError as exc:
diagnostics.append(AgentPluginDiagnostic(scope, str(exc)))
elif server_type == "sse":
if (
set(server) - _REMOTE_FIELDS
or not isinstance(server.get("url"), str)
or not server.get("url")
or not _validate_headers(server.get("headers"))
):
diagnostics.append(AgentPluginDiagnostic(scope, "invalid remote entry"))
else:
diagnostics.append(
AgentPluginDiagnostic(
scope,
f"portable {server_type} transport is not supported",
)
)
else:
diagnostics.append(AgentPluginDiagnostic(scope, "unknown MCP server type"))
return translated
def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage:
"""Validate and translate one installed Agent Plugins v1 package.
Fatal manifest errors raise :class:`AgentPluginError`. Component and entry
failures are returned as diagnostics and isolated to their owning scope.
"""
root = Path(plugin_root).resolve(strict=True)
if not root.is_dir():
raise AgentPluginError("plugin root must be a directory")
manifest, diagnostics = _validate_manifest(root)
resolved_data = Path(data_root).resolve(strict=False)
skills = _discover_skills(root, diagnostics)
mcp_servers = _discover_mcp(root, resolved_data, diagnostics)
return AgentPluginPackage(
name=manifest["name"],
version=manifest.get("version", ""),
description=manifest.get("description", ""),
root=root,
data_root=resolved_data,
manifest=dict(manifest),
skills=skills,
mcp_servers=mcp_servers,
diagnostics=tuple(diagnostics),
)
def read_agent_plugin_manifest(plugin_root: Path) -> tuple[dict, tuple[AgentPluginDiagnostic, ...]]:
"""Validate only root ``plugin.json`` without discovering components."""
root = Path(plugin_root).resolve(strict=True)
if not root.is_dir():
raise AgentPluginError("plugin root must be a directory")
manifest, diagnostics = _validate_manifest(root)
return manifest, tuple(diagnostics)
def has_enabled_agent_plugin_mcp(raw_config: Mapping[str, Any]) -> bool:
"""Compatibility wrapper for the shared PluginManager MCP probe.
Directory scanning belongs to :mod:`hermes_cli.plugins` so startup gating
and full plugin discovery cannot drift apart. Keep this import-compatible
entry point for callers that used the original helper.
"""
from hermes_cli.plugins import has_enabled_agent_plugin_mcp as _probe
return _probe(raw_config)
+88
View File
@@ -0,0 +1,88 @@
"""Shared persistent approval-mode command logic.
Approval mode is profile-scoped configuration, not conversation state. Changing
it affects subsequent terminal guard checks immediately because approval.py
loads config on each check; it must not rebuild a live agent or mutate its
system prompt/tool schema, preserving the prompt-cache prefix.
"""
from __future__ import annotations
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import dataclass
from io import StringIO
from typing import Optional
VALID_APPROVAL_MODES = ("manual", "smart", "off")
@dataclass(frozen=True)
class ApprovalModeResult:
ok: bool
mode: str
changed: bool
message: str
def _effective_mode() -> str:
"""Return the exact mode enforced by the terminal approval guard."""
from tools.approval import _get_approval_mode
return _get_approval_mode()
def run_approval_mode_command(requested_mode: Optional[str]) -> ApprovalModeResult:
"""Inspect or persist ``approvals.mode`` through canonical config APIs."""
current = _effective_mode()
requested = (requested_mode or "").strip().lower()
if not requested:
return ApprovalModeResult(
True,
current,
False,
f"Approval mode: {current} (persistent profile setting).",
)
if requested not in VALID_APPROVAL_MODES:
return ApprovalModeResult(
False,
current,
False,
"Usage: /approvals [manual|smart|off]",
)
# set_config_value is the canonical managed-scope/write-safety chokepoint.
# It reports managed policy through stderr + SystemExit, and the fail-closed
# write guard raises RuntimeError on an unparseable config.yaml; capture both
# for slash-command output instead of terminating the interactive worker.
from hermes_cli.config import set_config_value
output = StringIO()
try:
with redirect_stdout(output), redirect_stderr(output):
set_config_value("approvals.mode", requested)
except SystemExit:
detail = output.getvalue().strip() or "Approval mode is managed and cannot be changed."
return ApprovalModeResult(False, current, False, detail)
except Exception as exc:
return ApprovalModeResult(
False,
current,
False,
f"Failed to save approval mode: {exc}",
)
effective = _effective_mode()
if effective != requested:
return ApprovalModeResult(
False,
effective,
False,
f"Approval mode remains {effective}; the requested value did not become effective.",
)
return ApprovalModeResult(
True,
effective,
effective != current,
f"Approval mode: {effective} (persistent profile setting).",
)
+219
View File
@@ -0,0 +1,219 @@
"""Host-owned contract for plugin-provided human approval transports.
Transports only present an immutable, redacted request and return a correlated
human decision. They do not participate in command detection or authorization
policy. The host validates scope, request binding, and timeout fail-closed.
"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import logging
import queue
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Awaitable, Callable, Literal
logger = logging.getLogger(__name__)
_MAX_ACTIVE_TRANSPORT_WORKERS = 8
_transport_worker_slots = threading.BoundedSemaphore(_MAX_ACTIVE_TRANSPORT_WORKERS)
ApprovalChoice = Literal["once", "session", "always", "deny"]
ApprovalPresentFn = Callable[
["ApprovalRequest"], "ApprovalDecision | Awaitable[ApprovalDecision]"
]
@dataclass(frozen=True)
class ApprovalDecision:
"""A transport response bound to one exact host-created request."""
request_id: str
request_digest: str
choice: str
@dataclass(frozen=True)
class ApprovalRequest:
"""Immutable, display-only approval request passed to a transport plugin."""
schema_version: int
request_id: str
digest: str
command: str
description: str
pattern_key: str
pattern_keys: tuple[str, ...]
surface: str
timeout_seconds: float
allowed_choices: tuple[ApprovalChoice, ...]
@classmethod
def create(
cls,
*,
command: str,
description: str,
pattern_key: str,
pattern_keys: tuple[str, ...],
session_key: str,
surface: str,
allow_session: bool,
allow_permanent: bool,
timeout_seconds: float = 300,
) -> "ApprovalRequest":
request_id = uuid.uuid4().hex
choices: list[ApprovalChoice] = ["once"]
if allow_session:
choices.append("session")
if allow_permanent:
choices.append("always")
choices.append("deny")
canonical = {
"schema_version": 1,
"request_id": request_id,
"command": command,
"description": description,
"pattern_key": pattern_key,
"pattern_keys": list(pattern_keys),
"session_key": session_key,
"surface": surface,
"timeout_seconds": timeout_seconds,
"allowed_choices": choices,
}
digest = hashlib.sha256(
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return cls(
schema_version=1,
request_id=request_id,
digest=digest,
command=command,
description=description,
pattern_key=pattern_key,
pattern_keys=pattern_keys,
surface=surface,
timeout_seconds=timeout_seconds,
allowed_choices=tuple(choices),
)
def respond(self, choice: ApprovalChoice | str) -> ApprovalDecision:
"""Build the correlated response a transport should return."""
return ApprovalDecision(
request_id=self.request_id,
request_digest=self.digest,
choice=choice,
)
@dataclass(frozen=True)
class ApprovalTransportResult:
"""Normalized host result. Any failure is represented as a denial."""
choice: ApprovalChoice
failure: str | None = None
@dataclass(frozen=True)
class RegisteredApprovalTransport:
"""Plugin-owned registration retained by one profile's PluginManager."""
name: str
present: ApprovalPresentFn
plugin_id: str
profile_home: str
def invoke_approval_transport(
present: ApprovalPresentFn,
request: ApprovalRequest,
*,
timeout_seconds: float,
poll_interval: float = 1.0,
on_poll: Callable[[], None] | None = None,
is_interrupted: Callable[[], bool] | None = None,
) -> ApprovalTransportResult:
"""Run a sync or async transport on a bounded daemon worker.
Async callbacks are awaited with ``asyncio.run`` on that worker, never on a
gateway or TUI event loop. A callback must return before the host timeout;
late results are discarded and cannot authorize another request.
"""
if not _transport_worker_slots.acquire(blocking=False):
logger.warning("Approval transport worker capacity exhausted")
return ApprovalTransportResult("deny", "busy")
results: queue.Queue[tuple[str, object, float]] = queue.Queue(maxsize=1)
deadline = time.monotonic() + max(float(timeout_seconds), 0.0)
async def _await_value(value):
return await value
def _run() -> None:
try:
value = present(request)
if inspect.isawaitable(value):
value = asyncio.run(_await_value(value))
results.put_nowait(("result", value, time.monotonic()))
except BaseException as exc: # fail closed even for unusual callback exits
try:
results.put_nowait(("error", exc, time.monotonic()))
except queue.Full:
pass
finally:
_transport_worker_slots.release()
worker = threading.Thread(
target=_run,
name=f"approval-transport-{request.request_id[:8]}",
daemon=True,
)
try:
worker.start()
except BaseException:
_transport_worker_slots.release()
logger.warning("Could not start approval transport worker")
return ApprovalTransportResult("deny", "error")
while True:
if is_interrupted is not None and is_interrupted():
logger.info("Approval transport wait interrupted for %s", request.request_id)
return ApprovalTransportResult("deny", "interrupted")
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning("Approval transport timed out for request %s", request.request_id)
return ApprovalTransportResult("deny", "timeout")
try:
kind, value, completed_at = results.get(
timeout=min(max(float(poll_interval), 0.001), remaining)
)
break
except queue.Empty:
if on_poll is not None:
try:
on_poll()
except Exception:
logger.debug("Approval transport poll callback failed", exc_info=True)
if completed_at > deadline:
logger.warning("Approval transport timed out for request %s", request.request_id)
return ApprovalTransportResult("deny", "timeout")
if kind == "error":
logger.warning("Approval transport failed for request %s", request.request_id)
return ApprovalTransportResult("deny", "error")
if not isinstance(value, ApprovalDecision):
logger.warning("Approval transport returned an invalid decision type")
return ApprovalTransportResult("deny", "invalid")
if value.request_id != request.request_id or value.request_digest != request.digest:
logger.warning("Approval transport returned a stale or mismatched decision")
return ApprovalTransportResult("deny", "stale")
if value.choice not in request.allowed_choices:
logger.warning("Approval transport returned a disallowed choice")
return ApprovalTransportResult("deny", "invalid")
return ApprovalTransportResult(value.choice)
+487
View File
@@ -0,0 +1,487 @@
"""``hermes approvals suggest`` — mine approval history into allowlist proposals.
Hermes has no dedicated approval-decision ledger: ``always`` answers land in
``command_allowlist`` (config.yaml) via :func:`tools.approval.save_permanent_allowlist`,
while ``once``/``session`` approvals are in-memory only. What *does* persist
is the session DB (``~/.hermes/state.db``): every assistant ``terminal`` tool
call is stored with its arguments, and the paired ``role='tool'`` result
records whether the command was blocked/denied ("BLOCKED: User denied …",
"Asking the user for approval") or actually executed.
So this module mines *implied approvals*: a command that matches a
dangerous-command class (the same :func:`tools.approval.detect_dangerous_command`
classifier that triggers the prompt) AND whose tool result is not a
block/denial marker must have been approved by the user (once, session,
always, smart-approve, or yolo) before it ran. Frequently re-approved
patterns are exactly the prompts worth turning into one-time allowlist
policy — the port of Claude Code's ``/fewer-permission-prompts``.
Safety posture:
* **Never auto-applies.** The default run is a dry proposal; only an explicit
``--apply N[,M...]`` merges the chosen patterns into ``command_allowlist``
via the existing :func:`tools.approval.save_permanent_allowlist` path.
* **Hardline commands are never proposed** — anything matched by
:func:`tools.approval.detect_hardline_command` is dropped outright.
* **Destructive / privilege / credential / obfuscation classes are never
proposed**, no matter how often they were approved. ``rm -rf build/``
approved 100 times still never yields an ``rm`` allowlist entry. Only
benign, recoverable classes (container lifecycle, git force push, service
restarts, hermes self-management, …) are eligible.
* **Dangerous root binaries never become globs** (``rm *``, ``sudo *`` …).
"""
from __future__ import annotations
import json
import re
import sqlite3
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Iterator, Optional
# ---------------------------------------------------------------------------
# Safety exclusions
# ---------------------------------------------------------------------------
# Dangerous-class descriptions matching ANY of these are never proposed,
# regardless of approval frequency. Matched case-insensitively against the
# pattern-key/description strings produced by tools.approval's
# DANGEROUS_PATTERNS / execution-flag findings. Deliberately conservative:
# a benign class accidentally excluded costs the user one manual config edit;
# a destructive class accidentally proposed costs them data.
_UNSAFE_CLASS_PATTERNS = [
r"delete", # recursive delete, find -delete, branch force delete, ...
r"\brm\b", # xargs with rm, find -exec rm
r"destro", # git reset --hard (destroys ...), destructive
r"wipe",
r"format", # format filesystem
r"\bdisk\b",
r"block device",
r"fork bomb",
r"kill (?:all )?process", # force/regex/all process kills
r"kill all",
r"self-termination",
r"\bsudo\b",
r"privilege",
r"credential",
r"\bssh\b",
r"shell.rc",
r"system config",
r"system file",
r"\bsql\b", # SQL DROP / TRUNCATE / DELETE without WHERE
r"\bchown\b",
r"\bchmod\b",
r"writable", # world/other-writable permissions
r"overwrite",
r"in-place edit",
r"pipe", # pipe remote/decoded content to shell
r"obfuscation",
r"remote content",
r"remote script",
r"heredoc",
r"encoded", # PowerShell encoded command execution
r"command substitution",
r"process substitution",
r"\bdd\b",
r"shutdown",
r"reboot",
r"hardline",
]
_UNSAFE_CLASS_RE = re.compile("|".join(_UNSAFE_CLASS_PATTERNS), re.IGNORECASE)
# Root binaries that must never anchor a proposed command glob, even if the
# class survived the description filter. Prefix match for the mkfs family.
_UNSAFE_ROOT_BINARIES = {
"rm", "rmdir", "unlink", "shred", "dd", "fdisk", "parted", "wipefs",
"sudo", "doas", "su", "chmod", "chown", "chgrp",
"kill", "killall", "pkill",
"halt", "shutdown", "reboot", "poweroff", "init",
"del", "format", "truncate", "mkswap",
}
_UNSAFE_ROOT_PREFIXES = ("mkfs",)
# Substrings in a role='tool' result that mean the command did NOT execute
# with user consent (blocked, denied, timed out, or still pending). Kept in
# sync with the message templates in tools/approval.py.
_BLOCK_MARKERS = (
"BLOCKED (hardline)",
"BLOCKED: User denied",
"BLOCKED: Action ",
"BLOCKED: Command flagged as dangerous",
"BLOCKED: approval required",
"BLOCKED: Failed to send approval request",
"The user has NOT consented",
"Asking the user for approval",
"approval_required",
"BLOCKED by user deny rule",
)
@dataclass
class Proposal:
"""One ranked allowlist proposal."""
pattern: str # command glob ("git push *") or class key
kind: str # "glob" | "class"
count: int = 0
classes: set = field(default_factory=set)
examples: list = field(default_factory=list)
def add_example(self, command: str) -> None:
short = command.strip()
if len(short) > 100:
short = short[:97] + "..."
if short not in self.examples and len(self.examples) < 3:
self.examples.append(short)
# ---------------------------------------------------------------------------
# Scan: session DB -> (command, class description) records
# ---------------------------------------------------------------------------
def default_db_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "state.db"
def _connect_readonly(db_path: Path) -> sqlite3.Connection:
uri = f"file:{db_path}?mode=ro"
return sqlite3.connect(uri, uri=True)
def _iter_terminal_calls(
con: sqlite3.Connection, since_ts: float
) -> Iterator[tuple[str, str]]:
"""Yield ``(tool_call_id, command)`` for every terminal tool call."""
cur = con.execute(
"SELECT tool_calls FROM messages "
"WHERE role='assistant' AND tool_calls IS NOT NULL "
"AND tool_calls LIKE '%terminal%' AND timestamp >= ?",
(since_ts,),
)
while True:
rows = cur.fetchmany(2000)
if not rows:
break
for (raw,) in rows:
try:
calls = json.loads(raw)
except (TypeError, ValueError):
continue
if not isinstance(calls, list):
continue
for call in calls:
if not isinstance(call, dict):
continue
fn = call.get("function") or {}
if fn.get("name") != "terminal":
continue
try:
args = json.loads(fn.get("arguments") or "{}")
except (TypeError, ValueError):
continue
command = args.get("command")
if isinstance(command, str) and command.strip():
yield (call.get("id") or "", command)
def _blocked_tool_call_ids(con: sqlite3.Connection, since_ts: float) -> set:
"""Collect tool_call_ids whose result shows the command never ran freely."""
blocked: set = set()
cur = con.execute(
"SELECT tool_call_id, content FROM messages "
"WHERE role='tool' AND tool_call_id IS NOT NULL AND timestamp >= ? "
"AND (content LIKE '%BLOCKED%' OR content LIKE '%approval%')",
(since_ts,),
)
while True:
rows = cur.fetchmany(2000)
if not rows:
break
for tool_call_id, content in rows:
if not content:
continue
if any(marker in content for marker in _BLOCK_MARKERS):
blocked.add(tool_call_id)
return blocked
def scan_approval_history(
db_path: Optional[Path] = None, days: int = 90
) -> list[tuple[str, str]]:
"""Return ``(command, dangerous_class_description)`` records mined from
the session DB — dangerous-classified terminal commands that actually
executed (i.e. carried an implied user approval).
"""
from tools.approval import detect_dangerous_command, detect_hardline_command
path = Path(db_path) if db_path else default_db_path()
if not path.exists():
return []
since_ts = 0.0 if days <= 0 else time.time() - days * 86400
records: list[tuple[str, str]] = []
con = _connect_readonly(path)
try:
blocked = _blocked_tool_call_ids(con, since_ts)
for tool_call_id, command in _iter_terminal_calls(con, since_ts):
if tool_call_id in blocked:
continue
is_hardline, _desc = detect_hardline_command(command)
if is_hardline:
# Hardline commands are unconditionally blocked at runtime;
# never mine them (defense in depth against stale DB rows).
continue
is_dangerous, _key, description = detect_dangerous_command(command)
if not is_dangerous:
continue
records.append((command, description))
finally:
con.close()
return records
# ---------------------------------------------------------------------------
# Normalize -> aggregate -> rank -> exclude
# ---------------------------------------------------------------------------
def normalize_command(command: str) -> str:
"""Fold user/hermes home prefixes and collapse whitespace.
Reuses tools.approval's home-folding machinery so proposals are portable
across machines/users (``/home/alice/x`` -> ``~/x``).
"""
from tools.approval import (
_rewrite_resolved_hermes_home,
_rewrite_resolved_user_home,
)
folded = _rewrite_resolved_user_home(_rewrite_resolved_hermes_home(command))
return " ".join(folded.split())
def is_unsafe_class(description: str) -> bool:
"""True when a dangerous-class description must never be proposed."""
return bool(_UNSAFE_CLASS_RE.search(description or ""))
def _unsafe_root_binary(token: str) -> bool:
tok = token.lower().rsplit("/", 1)[-1]
if tok in _UNSAFE_ROOT_BINARIES:
return True
return any(tok.startswith(p) for p in _UNSAFE_ROOT_PREFIXES)
def derive_glob(normalized: str) -> Optional[str]:
"""Derive a narrow command glob (``git push *``) from a simple command.
Returns None for compound commands (shell operators — the runtime
allowlist matcher refuses those anyway) and for commands anchored on an
unsafe root binary.
"""
from tools.approval import _has_allowlist_shell_operator
if _has_allowlist_shell_operator(normalized):
return None
tokens = normalized.split()
if not tokens:
return None
if _unsafe_root_binary(tokens[0]):
return None
if len(tokens) == 1:
return tokens[0]
second = tokens[1]
if second.startswith("-") or any(ch in second for ch in "*?[$"):
return f"{tokens[0]} *"
return f"{tokens[0]} {second} *"
def build_proposals(
records: Iterable[tuple[str, str]],
existing: Optional[set] = None,
min_count: int = 2,
limit: int = 20,
) -> list[Proposal]:
"""Aggregate scan records into a ranked, safety-filtered proposal list.
Grain: a command glob (``git push *``) for simple commands; the
dangerous-class description itself (the same key an interactive
``[a]lways`` answer persists) for compound commands where no safe glob
can be derived.
"""
existing = existing or set()
by_pattern: dict[tuple[str, str], Proposal] = {}
for command, description in records:
if is_unsafe_class(description):
continue
normalized = normalize_command(command)
glob = derive_glob(normalized)
if glob is not None:
key = (glob, "glob")
else:
key = (description, "class")
pattern, kind = key
if pattern in existing:
continue
proposal = by_pattern.get(key)
if proposal is None:
proposal = by_pattern[key] = Proposal(pattern=pattern, kind=kind)
proposal.count += 1
proposal.classes.add(description)
proposal.add_example(normalized)
ranked = [p for p in by_pattern.values() if p.count >= max(min_count, 1)]
ranked.sort(key=lambda p: (-p.count, p.pattern))
return ranked[: max(limit, 1)]
# ---------------------------------------------------------------------------
# Apply / render
# ---------------------------------------------------------------------------
def parse_apply_indices(spec: str, total: int) -> list[int]:
"""Parse ``"1,3"`` into validated zero-based indices."""
indices: list[int] = []
for part in (spec or "").split(","):
part = part.strip()
if not part:
continue
try:
n = int(part)
except ValueError:
raise ValueError(f"invalid selection {part!r} — expected numbers like 1,3")
if n < 1 or n > total:
raise ValueError(f"selection {n} out of range (1..{total})")
if (n - 1) not in indices:
indices.append(n - 1)
if not indices:
raise ValueError("no valid selections in --apply")
return indices
def apply_proposals(proposals: list[Proposal], indices: list[int]) -> set:
"""Merge chosen proposal patterns into command_allowlist and persist."""
import tools.approval as approval_module
merged = set(approval_module.load_permanent_allowlist())
for idx in indices:
merged.add(proposals[idx].pattern)
approval_module.save_permanent_allowlist(merged)
# Keep the in-process allowlist consistent so a long-lived process sees
# the new entries immediately (mirrors the interactive 'always' path).
approval_module.load_permanent(merged)
return merged
def _render_text(proposals: list[Proposal], days: int) -> None:
window = "all history" if days <= 0 else f"last {days} days"
if not proposals:
print(
f"No allowlist candidates found in approval history ({window}).\n"
"Either nothing dangerous was approved often enough "
"(see --min-count/--days), or the approved classes are "
"excluded for safety."
)
return
print(f"Proposed command_allowlist additions (from approval history, {window}):\n")
for i, p in enumerate(proposals, 1):
kind = " (class key)" if p.kind == "class" else ""
print(f" {i}. {p.pattern} — approved {p.count}x{kind}")
for cls in sorted(p.classes):
print(f" class: {cls}")
for ex in p.examples:
print(f" e.g. {ex}")
print(
"\nNothing has been changed. Apply selected entries with:\n"
" hermes approvals suggest --apply 1,3\n"
"Entries are merged into command_allowlist in ~/.hermes/config.yaml."
)
def suggest_command(args) -> int:
"""Entry point for ``hermes approvals suggest``."""
db_path = Path(args.db) if getattr(args, "db", None) else default_db_path()
days = getattr(args, "days", 90)
if not db_path.exists():
print(f"Session database not found: {db_path}")
return 1
import tools.approval as approval_module
existing = set(approval_module.load_permanent_allowlist())
records = scan_approval_history(db_path, days=days)
proposals = build_proposals(
records,
existing=existing,
min_count=getattr(args, "min_count", 2),
limit=getattr(args, "limit", 20),
)
apply_spec = getattr(args, "apply_indices", None)
if apply_spec:
try:
indices = parse_apply_indices(apply_spec, len(proposals))
except ValueError as exc:
print(f"--apply error: {exc}")
return 1
merged = apply_proposals(proposals, indices)
applied = [proposals[i].pattern for i in indices]
if getattr(args, "json", False):
print(json.dumps({"applied": applied, "allowlist_size": len(merged)}))
else:
print("Added to command_allowlist:")
for pattern in applied:
print(f" + {pattern}")
print(f"\ncommand_allowlist now has {len(merged)} entries "
"(~/.hermes/config.yaml).")
return 0
if getattr(args, "json", False):
payload = {
"db": str(db_path),
"days": days,
"proposals": [
{
"n": i,
"pattern": p.pattern,
"kind": p.kind,
"count": p.count,
"classes": sorted(p.classes),
"examples": p.examples,
}
for i, p in enumerate(proposals, 1)
],
}
print(json.dumps(payload, indent=2))
return 0
_render_text(proposals, days)
return 0
def approvals_command(args) -> int:
"""Dispatch ``hermes approvals <subcommand>``."""
sub = getattr(args, "approvals_command", None)
if sub == "suggest":
return suggest_command(args)
if sub == "test":
from hermes_cli.approvals_test import approvals_test_command
return approvals_test_command(args)
print(
"usage: hermes approvals <subcommand>\n"
"\n"
"subcommands:\n"
" suggest Mine past approval decisions into a proposed\n"
" command_allowlist (dry by default; --apply N,M to merge)\n"
" test Dry-run the approval verdict for a command without\n"
" executing it (exit 0 allow / 2 ask / 3 deny)\n"
"\n"
"Run `hermes approvals <subcommand> -h` for details."
)
return 1
+178
View File
@@ -0,0 +1,178 @@
"""``hermes approvals test`` — dry-run approval verdict for a command.
Answers "what would the approval system do with this command?" WITHOUT
running it, prompting anyone, or persisting anything. It composes the REAL
runtime evaluators from ``tools.approval`` in the same order the runtime
guard (``check_all_command_guards``) applies them:
1. container-skip gate (isolated backends bypass all guards),
2. hardline blocklist (never bypassable, fires before yolo/off),
3. sudo-stdin guard (unconditional),
4. user ``approvals.deny`` rules (fire before yolo/off),
5. yolo / ``approvals.mode: off`` bypass,
6. permanent ``command_allowlist``,
7. dangerous-pattern detection → would ask for approval.
Because the same functions run — including ``_command_detection_variants``'s
normalization/de-obfuscation path — an obfuscated command (``r\\m -rf /``)
gets exactly the verdict its plain form would get at runtime, and the trace
shows the normalized variants that were actually evaluated.
Read-only invariants: the command is never executed, no approval prompt is
raised, nothing is written to config or approval history, no gateway
notification fires.
Exit codes (script-friendly):
0 allow (would run without a prompt)
1 usage error
2 ask-approval (would raise an interactive approval prompt)
3 deny (hardline blocklist, sudo-stdin guard, or user deny rule)
"""
from __future__ import annotations
import json
EXIT_ALLOW = 0
EXIT_USAGE = 1
EXIT_ASK = 2
EXIT_DENY = 3
_VERDICT_EXIT = {
"allow": EXIT_ALLOW,
"ask-approval": EXIT_ASK,
"hardline-deny": EXIT_DENY,
"user-deny": EXIT_DENY,
}
def evaluate_command(command: str, env_type: str = "local") -> dict:
"""Return the dry-run verdict for *command* on *env_type*.
Pure composition of the runtime evaluators — no execution, no prompt,
no persistence. Returns a dict with ``verdict``, ``exit_code``,
``rule`` (matching guard/pattern name or None), ``detail`` (human
explanation), and ``normalized_variants`` (the trace of normalized /
de-obfuscated forms the detectors actually evaluated).
"""
import tools.approval as approval
# Sync config-persisted "always" patterns so the allowlist check below
# sees what the runtime would see (load is read-only).
try:
approval.load_permanent_allowlist()
except Exception:
pass
variants = list(approval._command_detection_variants(command))
def result(verdict: str, rule=None, detail: str = "") -> dict:
return {
"command": command,
"env_type": env_type,
"verdict": verdict,
"exit_code": _VERDICT_EXIT[verdict],
"rule": rule,
"detail": detail,
"normalized_variants": variants,
}
# 1. Isolated container backends skip every guard (runtime parity:
# this fires BEFORE the hardline floor in check_all_command_guards).
if approval._should_skip_container_guards(env_type):
return result(
"allow",
detail=(f"env_type '{env_type}' is an isolated container backend; "
"the runtime skips all command guards for it"),
)
# 2. Hardline blocklist — never bypassable, even under yolo.
is_hardline, hardline_desc = approval.detect_hardline_command(command)
if is_hardline:
return result(
"hardline-deny", rule=hardline_desc,
detail="matches the hardline blocklist (never bypassable, "
"blocked even under --yolo / approvals.mode=off)",
)
# 3. Sudo stdin guard — unconditional, like the hardline floor.
is_sudo_guess, sudo_desc = approval._check_sudo_stdin_guard(command)
if is_sudo_guess:
return result(
"hardline-deny", rule=sudo_desc,
detail="sudo stdin guard (unconditional block)",
)
# 4. User-defined approvals.deny rules — fire before yolo/off.
deny_pattern = approval._match_user_deny_rule(command)
if deny_pattern is not None:
return result(
"user-deny", rule=deny_pattern,
detail="matches a user-defined approvals.deny rule in "
"config.yaml (blocked even under --yolo / mode=off)",
)
# 5. Yolo / approvals.mode=off bypass.
if (approval._YOLO_MODE_FROZEN
or approval.is_current_session_yolo_enabled()
or approval._get_approval_mode() == "off"):
return result(
"allow",
detail="approval bypass active (--yolo or approvals.mode: off); "
"only hardline/deny rules would block",
)
# 6. Permanent command_allowlist.
if approval._command_matches_permanent_allowlist(command):
return result(
"allow",
detail="matches command_allowlist in config.yaml "
"(permanently approved)",
)
# 7. Dangerous-pattern detection → would prompt.
is_dangerous, pattern_key, description = approval.detect_dangerous_command(command)
if is_dangerous:
return result(
"ask-approval", rule=description,
detail="matches a dangerous-command pattern; the runtime would "
f"raise an interactive approval prompt (pattern key: "
f"{pattern_key!r})",
)
return result("allow", detail="no guard matched; would run without a prompt")
def _render_text(verdict: dict) -> None:
print(f"command : {verdict['command']}")
print(f"env-type: {verdict['env_type']}")
print(f"verdict : {verdict['verdict']} (exit {verdict['exit_code']})")
if verdict["rule"]:
print(f"rule : {verdict['rule']}")
if verdict["detail"]:
print(f"detail : {verdict['detail']}")
print("normalized trace (variants the detectors evaluated):")
for v in verdict["normalized_variants"]:
print(f" - {v}")
def approvals_test_command(args) -> int:
"""Handle ``hermes approvals test <command...>``. Returns the exit code."""
words = list(getattr(args, "command_words", None) or [])
# argparse REMAINDER keeps a leading "--" separator; it is not part of
# the command being evaluated.
if words and words[0] == "--":
words = words[1:]
if not words:
print("usage: hermes approvals test [--env-type TYPE] [--json] -- <command...>")
return EXIT_USAGE
command = " ".join(words)
env_type = getattr(args, "env_type", None) or "local"
verdict = evaluate_command(command, env_type=env_type)
if getattr(args, "json", False):
print(json.dumps(verdict, indent=2))
else:
_render_text(verdict)
return verdict["exit_code"]
+152
View File
@@ -0,0 +1,152 @@
"""Safe ``tar.gz`` primitives shared by the profile and kanban transfer paths.
Both ``hermes profile export|import`` and ``hermes kanban export|import``
ship a directory to another machine and unpack whatever comes back. The
unpack side is the dangerous half: a hand-crafted archive can carry
``../`` members, absolute paths, symlinks, or device nodes, any of which
turn an import into an arbitrary-write primitive. These helpers are the
one place that logic lives so a second transfer surface can't ship a
second, subtly weaker extractor.
The writer is deliberately not :func:`shutil.make_archive`: that emits
PAX (Python's tarfile default since 3.8), whose fractional-mtime records
macOS Archive Utility rejects — double-clicking an exported profile threw
"Error 94 - Bad message." GNU format keeps long paths working (longlink
extensions) and stays integer-mtime, so Finder, bsdtar, and gnutar all
extract it.
"""
from __future__ import annotations
import os
import shutil
import tarfile
import tempfile
from pathlib import Path, PurePosixPath, PureWindowsPath
def normalize_archive_parts(member_name: str) -> list[str]:
"""Return safe path parts for an archive member, or raise.
Rejects absolute paths (POSIX and Windows, including drive letters),
empty names, and any ``..`` component. Backslashes are folded to
``/`` first so a Windows-authored archive can't smuggle a separator
past the POSIX parse.
"""
normalized_name = member_name.replace("\\", "/")
posix_path = PurePosixPath(normalized_name)
windows_path = PureWindowsPath(member_name)
if (
not normalized_name
or posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
):
raise ValueError(f"Unsafe archive member path: {member_name}")
parts = [part for part in posix_path.parts if part not in {"", "."}]
if not parts or any(part == ".." for part in parts):
raise ValueError(f"Unsafe archive member path: {member_name}")
return parts
def make_targz(base: str, root_dir: str, base_dir: str) -> str:
"""Create ``<base>.tar.gz`` of ``root_dir/base_dir`` in GNU tar format.
Writes to a sibling temp file and renames onto ``archive_path`` only
after the archive is fully written. ``tarfile.open`` on a path truncates
the destination the instant it opens, so writing there directly means a
failure partway through ``tf.add`` (disk full, permission loss,
interruption) destroys whatever was already at that path — including an
existing export the caller chose to overwrite.
"""
archive_path = f"{base}.tar.gz"
dest_dir = os.path.dirname(archive_path) or "."
fd, tmp_path = tempfile.mkstemp(
dir=dest_dir, prefix=".archive_", suffix=".tar.gz.tmp"
)
try:
with os.fdopen(fd, "wb") as f:
with tarfile.open(fileobj=f, mode="w:gz", format=tarfile.GNU_FORMAT) as tf:
tf.add(str(Path(root_dir) / base_dir), arcname=base_dir)
os.replace(tmp_path, archive_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return archive_path
def safe_extract_targz(archive: Path, destination: Path) -> None:
"""Extract ``archive`` into ``destination`` without path escapes or links.
Only directories and regular files are extracted; symlinks, hardlinks,
and device nodes raise rather than being silently skipped, so a
tampered archive fails the import instead of landing a partial tree.
"""
with tarfile.open(archive, "r:gz") as tf:
for member in tf.getmembers():
parts = normalize_archive_parts(member.name)
target = destination.joinpath(*parts)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
raise ValueError(
f"Unsupported archive member type: {member.name}"
)
target.parent.mkdir(parents=True, exist_ok=True)
extracted = tf.extractfile(member)
if extracted is None:
raise ValueError(f"Cannot read archive member: {member.name}")
with extracted, open(target, "wb") as dst:
shutil.copyfileobj(extracted, dst)
try:
os.chmod(target, member.mode & 0o777)
except OSError:
pass
def archive_root_dirs(archive: Path) -> set[str]:
"""Return the archive's top-level directory names.
Transfer archives carry exactly one root directory, which names the
thing being imported. Inspecting the archive before extraction lets
the caller resolve the target name (and refuse a malformed archive)
without first mutating a live tree.
"""
with tarfile.open(archive, "r:gz") as tf:
return {
parts[0]
for member in tf.getmembers()
for parts in [normalize_archive_parts(member.name)]
if len(parts) > 1 or member.isdir()
}
def copy_regular_files(src: Path, dst: Path) -> int:
"""Copy the regular files under ``src`` into ``dst``, skipping symlinks.
Used on the *export* side so a symlink planted in an attachments or
logs tree can't pull an arbitrary file into the archive. Returns the
number of files copied; a missing ``src`` copies nothing.
"""
if not src.is_dir():
return 0
copied = 0
for entry in sorted(src.rglob("*")):
if entry.is_symlink() or not entry.is_file():
continue
target = dst / entry.relative_to(src)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(entry, target)
copied += 1
return copied
+10308
View File
File diff suppressed because it is too large Load Diff
+913
View File
@@ -0,0 +1,913 @@
"""Credential-pool auth subcommands."""
from __future__ import annotations
from hermes_cli.cli_output import line_input
import math
import sys
import time
from types import SimpleNamespace
import uuid
from agent.credential_pool import (
AUTH_TYPE_API_KEY,
AUTH_TYPE_OAUTH,
CUSTOM_POOL_PREFIX,
SOURCE_MANUAL,
SOURCE_MANUAL_DEVICE_CODE,
STATUS_EXHAUSTED,
STRATEGY_FILL_FIRST,
STRATEGY_ROUND_ROBIN,
STRATEGY_RANDOM,
STRATEGY_LEAST_USED,
PooledCredential,
_exhausted_until,
_normalize_custom_pool_name,
get_pool_strategy,
label_from_token,
list_custom_pool_providers,
load_pool,
)
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.
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth"}
def _get_custom_provider_entries() -> list[dict]:
"""Return configured provider entries with legacy and canonical pool IDs."""
try:
from hermes_cli.config import get_compatible_custom_providers, load_config
config = load_config()
except Exception:
return []
result: list[dict] = []
for entry in get_compatible_custom_providers(config):
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
continue
normalized = dict(entry)
normalized["name"] = name.strip()
normalized["pool_key"] = (
f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}"
)
normalized["provider_key"] = str(
entry.get("provider_key", "") or ""
).strip()
result.append(normalized)
return result
def _get_custom_provider_names() -> list:
"""Return list of (display_name, pool_key, provider_key) tuples."""
return [
(entry["name"], entry["pool_key"], entry["provider_key"])
for entry in _get_custom_provider_entries()
]
def _configured_provider_entry(provider: str) -> dict | None:
"""Resolve a canonical ``providers.<key>`` entry."""
normalized = (provider or "").strip().lower()
if not normalized or normalized.startswith(CUSTOM_POOL_PREFIX):
return None
for entry in _get_custom_provider_entries():
provider_key = str(entry.get("provider_key") or "").strip().lower()
if provider_key and provider_key == normalized:
return entry
return None
def _resolve_custom_provider_input(raw: str) -> str | None:
"""Resolve legacy names and keyed providers to their credential-pool ID."""
normalized = (raw or "").strip().lower().replace(" ", "-")
if not normalized:
return None
# Direct match on 'custom:name' format
if normalized.startswith(CUSTOM_POOL_PREFIX):
return normalized
for entry in _get_custom_provider_entries():
display_name = entry["name"]
pool_key = entry["pool_key"]
provider_key = entry["provider_key"]
# ``providers:`` entries already have a durable runtime slug. Keep
# credentials under that slug instead of leaking the legacy
# ``custom:`` compatibility identity into auth.json and discovery.
normalized_provider_key = provider_key.strip().lower()
if normalized_provider_key and normalized_provider_key == normalized:
return normalized_provider_key
if _normalize_custom_pool_name(display_name) == normalized:
return normalized_provider_key or pool_key
return None
def _normalize_provider(provider: str) -> str:
normalized = (provider or "").strip().lower()
if normalized in {"or", "open-router"}:
return "openrouter"
if normalized in {"grok-oauth", "xai-oauth", "x-ai-oauth", "xai-grok-oauth"}:
return "xai-oauth"
# Check if it matches a custom provider name
custom_key = _resolve_custom_provider_input(normalized)
if custom_key:
return custom_key
return normalized
def _migrate_legacy_custom_pool_key(provider: str, legacy_key: str) -> None:
"""Move a keyed provider's old ``custom:`` pool into its runtime slug."""
with auth_mod._auth_store_lock():
auth_store = auth_mod._load_auth_store()
credential_pool = auth_store.get("credential_pool")
if not isinstance(credential_pool, dict):
return
legacy_entries = credential_pool.get(legacy_key)
if not isinstance(legacy_entries, list) or not legacy_entries:
return
current_entries = credential_pool.get(provider)
merged = list(current_entries) if isinstance(current_entries, list) else []
known_ids = {
entry.get("id")
for entry in merged
if isinstance(entry, dict) and entry.get("id")
}
for entry in legacy_entries:
entry_id = entry.get("id") if isinstance(entry, dict) else None
if entry_id and entry_id in known_ids:
continue
merged.append(entry)
if entry_id:
known_ids.add(entry_id)
credential_pool[provider] = merged
del credential_pool[legacy_key]
auth_mod._save_auth_store(auth_store)
try:
from hermes_cli.models import clear_provider_models_cache
clear_provider_models_cache(legacy_key)
except Exception:
pass
def _provider_base_url(provider: str) -> str:
if provider == "openrouter":
return OPENROUTER_BASE_URL
if provider.startswith(CUSTOM_POOL_PREFIX):
from agent.credential_pool import _get_custom_provider_config
cp_config = _get_custom_provider_config(provider)
if cp_config:
return str(cp_config.get("base_url") or "").strip()
return ""
configured = _configured_provider_entry(provider)
if configured is not None:
return str(configured.get("base_url") or "").strip()
pconfig = PROVIDER_REGISTRY.get(provider)
return pconfig.inference_base_url if pconfig else ""
def _is_known_provider(provider: str, configured_provider: dict | None) -> bool:
return (
provider in PROVIDER_REGISTRY
or provider == "openrouter"
or provider.startswith(CUSTOM_POOL_PREFIX)
or configured_provider is not None
)
def _oauth_default_label(provider: str, count: int) -> str:
return f"{provider}-oauth-{count}"
def _api_key_default_label(count: int) -> str:
return f"api-key-{count}"
def _display_source(source: str) -> str:
return source.split(":", 1)[1] if source.startswith("manual:") else source
def _classify_exhausted_status(entry) -> tuple[str, bool]:
code = getattr(entry, "last_error_code", None)
reason = str(getattr(entry, "last_error_reason", "") or "").strip().lower()
message = str(getattr(entry, "last_error_message", "") or "").strip().lower()
if code == 429 or any(token in reason for token in ("rate_limit", "usage_limit", "quota", "exhausted")) or any(
token in message for token in ("rate limit", "usage limit", "quota", "too many requests")
):
return "rate-limited", True
if code in {401, 403} or any(token in reason for token in ("invalid_token", "invalid_grant", "unauthorized", "forbidden", "auth")) or any(
token in message for token in ("unauthorized", "forbidden", "expired", "revoked", "invalid token", "authentication")
):
return "auth failed", False
return "exhausted", True
def _format_exhausted_status(entry) -> str:
if entry.last_status != STATUS_EXHAUSTED:
return ""
label, show_retry_window = _classify_exhausted_status(entry)
reason = getattr(entry, "last_error_reason", None)
reason_text = f" {reason}" if isinstance(reason, str) and reason.strip() else ""
code = f" ({entry.last_error_code})" if entry.last_error_code else ""
if not show_retry_window:
return f" {label}{reason_text}{code} (re-auth may be required)"
exhausted_until = _exhausted_until(entry)
if exhausted_until is None:
return f" {label}{reason_text}{code}"
remaining = max(0, int(math.ceil(exhausted_until - time.time())))
if remaining <= 0:
return f" {label}{reason_text}{code} (ready to retry)"
minutes, seconds = divmod(remaining, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
if days:
wait = f"{days}d {hours}h"
elif hours:
wait = f"{hours}h {minutes}m"
elif minutes:
wait = f"{minutes}m {seconds}s"
else:
wait = f"{seconds}s"
return f" {label}{reason_text}{code} ({wait} left)"
def auth_add_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", ""))
configured_provider = _configured_provider_entry(provider)
if not _is_known_provider(provider, configured_provider):
raise SystemExit(f"Unknown provider: {provider}")
if configured_provider is not None:
_migrate_legacy_custom_pool_key(provider, configured_provider["pool_key"])
requested_type = str(getattr(args, "auth_type", "") or "").strip().lower()
if requested_type in {AUTH_TYPE_API_KEY, "api-key"}:
requested_type = AUTH_TYPE_API_KEY
if not requested_type:
if provider.startswith(CUSTOM_POOL_PREFIX):
requested_type = AUTH_TYPE_API_KEY
else:
requested_type = AUTH_TYPE_OAUTH if provider in _OAUTH_CAPABLE_PROVIDERS else AUTH_TYPE_API_KEY
pool = load_pool(provider)
# Clear ALL suppressions for this provider — re-adding a credential is
# a strong signal the user wants auth re-enabled. This covers env:*
# (shell-exported vars), gh_cli (copilot), claude_code, qwen-cli,
# device_code (codex), etc. One consistent re-engagement pattern.
# Matches the Codex device_code re-link pattern that predates this.
if not provider.startswith(CUSTOM_POOL_PREFIX):
try:
from hermes_cli.auth import (
_load_auth_store,
unsuppress_credential_source,
)
suppressed = _load_auth_store().get("suppressed_sources", {})
for src in list(suppressed.get(provider, []) or []):
unsuppress_credential_source(provider, src)
except Exception:
pass
if requested_type == AUTH_TYPE_API_KEY:
token = (getattr(args, "api_key", None) or "").strip()
if not token:
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)
label = (getattr(args, "label", None) or "").strip()
if not label:
if sys.stdin.isatty():
label = line_input(f"Label (optional, default: {default_label}): ").strip() or default_label
else:
label = default_label
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_API_KEY,
priority=0,
source=SOURCE_MANUAL,
access_token=token,
base_url=_provider_base_url(provider),
)
pool.add_entry(entry)
print(f'Added {provider} credential #{len(pool.entries())}: "{label}"')
return
if provider == "anthropic":
from agent import anthropic_adapter as anthropic_mod
creds = anthropic_mod.run_hermes_oauth_login_pure()
if not creds:
raise SystemExit("Anthropic OAuth login did not return credentials.")
label = (getattr(args, "label", None) or "").strip() or label_from_token(
creds["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}:hermes_pkce",
access_token=creds["access_token"],
refresh_token=creds.get("refresh_token"),
expires_at_ms=creds.get("expires_at_ms"),
base_url=_provider_base_url(provider),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "nous":
# Codex-style auto-import: if a shared Nous credential lives at
# <hermes-root>/shared/nous_auth.json (written by any previous
# successful login), offer to import it instead of running the
# full device-code flow. This makes `hermes --profile <name>
# auth add nous --type oauth` a one-tap operation for users who
# run multiple profiles.
shared = auth_mod._read_shared_nous_state()
if shared:
try:
path = auth_mod._nous_shared_store_path()
except RuntimeError:
path = None
print()
if path:
print(f"Found existing Nous OAuth credentials at {path}")
else:
print("Found existing shared Nous OAuth credentials")
try:
do_import = input("Import these credentials? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
do_import = "y"
if do_import in {"", "y", "yes"}:
print("Rehydrating Nous session from shared credentials...")
rehydrated = auth_mod._try_import_shared_nous_state(
timeout_seconds=getattr(args, "timeout", None) or 15.0,
)
if rehydrated is not None:
custom_label = (getattr(args, "label", None) or "").strip() or None
entry = auth_mod.persist_nous_credentials(rehydrated, label=custom_label)
shown_label = entry.label if entry is not None else label_from_token(
rehydrated.get("access_token", ""), _oauth_default_label(provider, 1),
)
print(f'Imported {provider} OAuth credentials: "{shown_label}"')
return
# Rehydrate failed (expired refresh_token, portal down, etc.)
# — fall through to device-code flow.
print("Could not refresh shared credentials — falling back to device-code login.")
creds = auth_mod._nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None),
scope=getattr(args, "scope", None),
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=getattr(args, "timeout", None) or 15.0,
insecure=bool(getattr(args, "insecure", False)),
ca_bundle=getattr(args, "ca_bundle", None),
)
# Honor `--label <name>` so nous matches other providers' UX. The
# helper embeds this into providers.nous so that label_from_token
# doesn't overwrite it on every subsequent load_pool("nous").
custom_label = (getattr(args, "label", None) or "").strip() or None
entry = auth_mod.persist_nous_credentials(creds, label=custom_label)
shown_label = entry.label if entry is not None else label_from_token(
creds.get("access_token", ""), _oauth_default_label(provider, 1),
)
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
return
if provider == "openai-codex":
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
# qwen-oauth / minimax-oauth multi-account patterns, and the
# xai-oauth path below) 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=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
if provider == "xai-oauth":
creds = auth_mod._xai_oauth_device_code_login(
timeout_seconds=getattr(args, "timeout", None) or 20.0,
open_browser=not getattr(args, "no_browser", False),
)
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
# openai-codex / qwen-oauth / minimax-oauth patterns) instead of
# routing through the singleton ``_save_xai_oauth_tokens`` save path.
# The singleton round-trip collapsed every added account into the
# latest login: a second ``hermes auth add xai-oauth`` overwrote the
# first account's singleton-mirrored ``device_code`` entry rather than
# creating an independent one. ``manual:device_code`` entries refresh
# from their own token pair (``_sync_xai_oauth_entry_from_auth_store``
# only adopts the singleton for ``source=="device_code"``), 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=SOURCE_MANUAL_DEVICE_CODE,
access_token=creds["tokens"]["access_token"],
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url") or auth_mod.DEFAULT_XAI_OAUTH_BASE_URL,
last_refresh=creds.get("last_refresh"),
)
first_credential = not pool.entries()
pool.add_entry(entry)
# Adding the first xAI 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
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),
)
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:qwen_cli",
access_token=creds["api_key"],
base_url=creds.get("base_url"),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "minimax-oauth":
creds = auth_mod._minimax_oauth_login(
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=getattr(args, "timeout", None) or 15.0,
)
label = (getattr(args, "label", None) or "").strip() or label_from_token(
creds["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}:minimax_oauth",
access_token=creds["access_token"],
refresh_token=creds.get("refresh_token"),
base_url=creds.get("inference_base_url"),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")
def auth_list_command(args) -> None:
provider_filter = _normalize_provider(getattr(args, "provider", "") or "")
if provider_filter:
providers = [provider_filter]
else:
credential_pool = auth_mod._load_auth_store().get("credential_pool")
persisted_providers = (
credential_pool.keys() if isinstance(credential_pool, dict) else ()
)
configured_providers = (
entry["provider_key"]
for entry in _get_custom_provider_entries()
if entry["provider_key"]
)
providers = sorted({
*PROVIDER_REGISTRY.keys(),
"openrouter",
*list_custom_pool_providers(),
*configured_providers,
*persisted_providers,
})
for provider in providers:
pool = load_pool(provider)
entries = pool.entries()
if not entries:
continue
current = pool.peek()
print(f"{provider} ({len(entries)} credentials):")
for idx, entry in enumerate(entries, start=1):
marker = " "
if current is not None and entry.id == current.id:
marker = ""
status = _format_exhausted_status(entry)
source = _display_source(entry.source)
print(f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{status} {marker}".rstrip())
print()
_print_oauth_heal_notices()
def _print_oauth_heal_notices() -> None:
"""Tell the user when load_pool() just consolidated a forked OAuth grant."""
for note in auth_mod.consume_oauth_heal_notices():
print(f"note: {note}")
def auth_remove_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", ""))
target = getattr(args, "target", None)
if target is None:
target = getattr(args, "index", None)
pool = load_pool(provider)
index, matched, error = pool.resolve_target(target)
if matched is None or index is None:
raise SystemExit(f"{error} Provider: {provider}.")
removed = pool.remove_index(index)
if removed is None:
raise SystemExit(f'No credential matching "{target}" for provider {provider}.')
print(f"Removed {provider} credential #{index} ({removed.label})")
# Unified removal dispatch. Every credential source Hermes reads from
# (env vars, external OAuth files, auth.json blocks, custom config)
# has a RemovalStep registered in agent.credential_sources. The step
# handles its source-specific cleanup and we centralise suppression +
# user-facing output here so every source behaves identically from
# the user's perspective.
from agent.credential_sources import find_removal_step
from hermes_cli.auth import suppress_credential_source
step = find_removal_step(provider, removed.source)
if step is None:
# Unregistered source — e.g. "manual", which has nothing external
# to clean up. The pool entry is already gone; we're done.
return
result = step.remove_fn(provider, removed)
for line in result.cleaned:
print(line)
if result.suppress:
suppress_credential_source(provider, removed.source)
for line in result.hints:
print(line)
def auth_reset_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", ""))
pool = load_pool(provider)
count = pool.reset_statuses()
print(f"Reset status on {count} {provider} credentials")
def auth_status_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", "") or "")
if not provider:
raise SystemExit("Provider is required. Example: `hermes auth status spotify`.")
if provider in auth_mod.SINGLE_USE_REFRESH_POOL_PROVIDERS:
# load_pool() runs the forked-grant heal (#100339); do it before the
# status read so the report reflects the consolidated grant.
load_pool(provider)
status = auth_mod.get_auth_status(provider)
_print_oauth_heal_notices()
if not status.get("logged_in"):
reason = status.get("error")
if reason:
print(f"{provider}: logged out ({reason})")
else:
print(f"{provider}: logged out")
return
print(f"{provider}: logged in")
for key in ("auth_type", "client_id", "redirect_uri", "scope", "expires_at", "api_base_url"):
value = status.get(key)
if value:
print(f" {key}: {value}")
def auth_logout_command(args) -> None:
auth_mod.logout_command(SimpleNamespace(provider=getattr(args, "provider", None)))
def auth_spotify_command(args) -> None:
action = str(getattr(args, "spotify_action", "") or "login").strip().lower()
if action in {"", "login"}:
auth_mod.login_spotify_command(args)
return
if action == "status":
auth_status_command(SimpleNamespace(provider="spotify"))
return
if action == "logout":
auth_logout_command(SimpleNamespace(provider="spotify"))
return
raise SystemExit(f"Unknown Spotify auth action: {action}")
def _interactive_auth() -> None:
"""Interactive credential pool management when `hermes auth` is called bare."""
# Show current pool status first
print("Credential Pool Status")
print("=" * 50)
auth_list_command(SimpleNamespace(provider=None))
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
if has_aws_credentials():
auth_source = resolve_aws_auth_env_var() or "unknown"
region = resolve_bedrock_region()
print("bedrock (AWS SDK credential chain):")
print(f" Auth: {auth_source}")
print(f" Region: {region}")
try:
import boto3
sts = boto3.client("sts", region_name=region)
identity = sts.get_caller_identity()
arn = identity.get("Arn", "unknown")
print(f" Identity: {arn}")
except Exception:
print(" Identity: (could not resolve — boto3 STS call failed)")
print()
except ImportError:
pass # boto3 or bedrock_adapter not available
# Show Azure Foundry Entra ID status
try:
from hermes_cli.config import load_config
_cfg = load_config()
_model_cfg = _cfg.get("model") if isinstance(_cfg, dict) else None
if isinstance(_model_cfg, dict):
_cfg_provider = str(_model_cfg.get("provider") or "").strip().lower()
_cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower()
if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id":
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
_base_url = str(_model_cfg.get("base_url") or "").strip()
_entra = _model_cfg.get("entra") or {}
if not isinstance(_entra, dict):
_entra = {}
_scope = (
str(_entra.get("scope") or "").strip()
or SCOPE_AI_AZURE_DEFAULT
)
print("azure-foundry (Microsoft Entra ID):")
print(f" Endpoint: {_base_url or '(not configured)'}")
print(f" Scope: {_scope}")
if not has_azure_identity_installed():
print(" Status: ⚠ azure-identity not installed "
"(pip install azure-identity)")
else:
_entra_cfg = EntraIdentityConfig(
scope=_scope,
)
_info = describe_active_credential(config=_entra_cfg, timeout_seconds=10.0)
_env_sources = _info.get("env_sources") or []
if _info.get("ok"):
_tag = ", ".join(_env_sources) if _env_sources else "default chain"
print(f" Status: ✓ token acquired ({_tag})")
else:
_err = _info.get("error") or "credential chain exhausted"
print(f" Status: ⚠ {_err}")
_hint = _info.get("hint")
if _hint:
print(f" Hint: {_hint}")
print()
except Exception:
pass
print()
# Main menu
choices = [
"Add a credential",
"Remove a credential",
"Reset cooldowns for a provider",
"Set rotation strategy for a provider",
"Exit",
]
print("What would you like to do?")
for i, choice in enumerate(choices, 1):
print(f" {i}. {choice}")
try:
raw = input("\nChoice: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not raw or raw == str(len(choices)):
return
if raw == "1":
_interactive_add()
elif raw == "2":
_interactive_remove()
elif raw == "3":
_interactive_reset()
elif raw == "4":
_interactive_strategy()
def _pick_provider(prompt: str = "Provider") -> str:
"""Prompt for a provider name with auto-complete hints."""
known = sorted(set(list(PROVIDER_REGISTRY.keys()) + ["openrouter"]))
custom_names = _get_custom_provider_names()
if custom_names:
custom_display = [name for name, _key, _provider_key in custom_names]
print(f"\nKnown providers: {', '.join(known)}")
print(f"Custom endpoints: {', '.join(custom_display)}")
else:
print(f"\nKnown providers: {', '.join(known)}")
try:
raw = line_input(f"{prompt}: ").strip()
except (EOFError, KeyboardInterrupt):
raise SystemExit()
return _normalize_provider(raw)
def _interactive_add() -> None:
provider = _pick_provider("Provider to add credential for")
configured_provider = _configured_provider_entry(provider)
if not _is_known_provider(provider, configured_provider):
raise SystemExit(f"Unknown provider: {provider}")
# For OAuth-capable providers, ask which type
if provider in _OAUTH_CAPABLE_PROVIDERS:
print(f"\n{provider} supports both API keys and OAuth login.")
print(" 1. API key (paste a key from the provider dashboard)")
print(" 2. OAuth login (authenticate via browser)")
try:
type_choice = input("Type [1/2]: ").strip()
except (EOFError, KeyboardInterrupt):
return
if type_choice == "2":
auth_type = "oauth"
else:
auth_type = "api_key"
else:
auth_type = "api_key"
label = None
try:
typed_label = line_input("Label / account name (optional): ").strip()
except (EOFError, KeyboardInterrupt):
return
if typed_label:
label = typed_label
auth_add_command(SimpleNamespace(
provider=provider, auth_type=auth_type, label=label, api_key=None,
portal_url=None, inference_url=None, client_id=None, scope=None,
no_browser=False, timeout=None, insecure=False, ca_bundle=None,
))
def _interactive_remove() -> None:
provider = _pick_provider("Provider to remove credential from")
pool = load_pool(provider)
if not pool.has_credentials():
print(f"No credentials for {provider}.")
return
# Show entries with indices
for i, e in enumerate(pool.entries(), 1):
exhausted = _format_exhausted_status(e)
print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted} [id:{e.id}]")
try:
raw = line_input("Remove #, id, or label (blank to cancel): ").strip()
except (EOFError, KeyboardInterrupt):
return
if not raw:
return
auth_remove_command(SimpleNamespace(provider=provider, target=raw))
def _interactive_reset() -> None:
provider = _pick_provider("Provider to reset cooldowns for")
auth_reset_command(SimpleNamespace(provider=provider))
def _interactive_strategy() -> None:
provider = _pick_provider("Provider to set strategy for")
current = get_pool_strategy(provider)
strategies = [STRATEGY_FILL_FIRST, STRATEGY_ROUND_ROBIN, STRATEGY_LEAST_USED, STRATEGY_RANDOM]
print(f"\nCurrent strategy for {provider}: {current}")
print()
descriptions = {
STRATEGY_FILL_FIRST: "Use first key until exhausted, then next",
STRATEGY_ROUND_ROBIN: "Cycle through keys evenly",
STRATEGY_LEAST_USED: "Always pick the least-used key",
STRATEGY_RANDOM: "Random selection",
}
for i, s in enumerate(strategies, 1):
marker = "" if s == current else ""
print(f" {i}. {s:15s}{descriptions.get(s, '')}{marker}")
try:
raw = input("\nStrategy [1-4]: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not raw:
return
try:
idx = int(raw) - 1
strategy = strategies[idx]
except (ValueError, IndexError):
print("Invalid choice.")
return
from hermes_cli.config import load_config, save_config
cfg = load_config()
pool_strategies = cfg.get("credential_pool_strategies") or {}
if not isinstance(pool_strategies, dict):
pool_strategies = {}
pool_strategies[provider] = strategy
cfg["credential_pool_strategies"] = pool_strategies
save_config(cfg)
print(f"Set {provider} strategy to: {strategy}")
def auth_command(args) -> None:
action = getattr(args, "auth_action", "")
if action == "add":
auth_add_command(args)
return
if action == "list":
auth_list_command(args)
return
if action == "remove":
auth_remove_command(args)
return
if action == "reset":
auth_reset_command(args)
return
if action == "status":
auth_status_command(args)
return
if action == "logout":
auth_logout_command(args)
return
if action == "spotify":
auth_spotify_command(args)
return
# No subcommand — launch interactive mode
_interactive_auth()
+408
View File
@@ -0,0 +1,408 @@
"""Azure Foundry endpoint auto-detection.
Inspect a Microsoft Foundry / Azure OpenAI endpoint to determine:
- API transport (OpenAI-style ``chat_completions`` vs
Anthropic-style ``anthropic_messages``)
- Available models (best effort — Azure does not expose a deployment
listing via the inference API key, but Azure OpenAI v1 endpoints
return the resource's model catalog via ``GET /models``)
- Context length for each discovered/entered model, via the existing
:func:`agent.model_metadata.get_model_context_length` resolver.
Rationale:
Azure has no pure-API-key deployment-listing endpoint — per Microsoft,
deployment enumeration requires ARM management-plane auth. Azure
OpenAI v1 endpoints ``{resource}.openai.azure.com/openai/v1`` do return
a ``/models`` list, but it reflects the resource's *available* models
rather than the user's *deployed* deployment names. In practice it is
still a useful hint — the user picks a familiar model name and we look
up its context length from the catalog.
Authentication modes:
- ``api_key`` (default): the wizard passes an ``api_key`` string; the
probe sends both ``api-key:`` and ``Authorization: Bearer`` headers
so we hit any Azure deployment regardless of which header it expects.
- ``entra_id``: the wizard passes a ``token_provider`` callable from
:mod:`agent.azure_identity_adapter`. The probe mints exactly one
bearer JWT, sends **only** ``Authorization: Bearer <jwt>`` (never
``api-key:``), and never persists the token. This matches Microsoft's
documented contract for keyless inference.
The detector never crashes on errors (every HTTP call is wrapped in a
broad try/except). Callers get a :class:`DetectionResult` with whatever
information could be gathered, and fall back to manual entry for the
rest.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from urllib import request as urllib_request
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from hermes_cli.urllib_security import open_credentialed_url
logger = logging.getLogger(__name__)
# Default Azure OpenAI ``api-version`` to probe with. The v1 GA endpoint
# accepts requests without ``api-version`` entirely, so this is only used
# as a fallback for pre-v1 resources that still require it.
_AZURE_OPENAI_PROBE_API_VERSIONS = (
"2025-04-01-preview",
"2024-10-21", # oldest GA that supports /models
)
# Default Azure Anthropic ``api-version``. Matches the value used by
# ``agent/anthropic_adapter.py`` when building the Anthropic client.
_AZURE_ANTHROPIC_API_VERSION = "2025-04-15"
@dataclass
class DetectionResult:
"""Everything auto-detection could gather from a base URL + API key."""
#: Detected API transport: ``"chat_completions"``,
#: ``"anthropic_messages"``, or ``None`` when detection failed.
api_mode: Optional[str] = None
#: Deployment / model IDs returned by ``/models`` (best effort).
#: Empty when the endpoint doesn't expose the list with an API key.
models: list[str] = field(default_factory=list)
#: Lowercased host from the base URL (used for display messages).
hostname: str = ""
#: Human-readable reason the detector chose ``api_mode``. Useful
#: for explaining auto-detection to the user in the wizard.
reason: str = ""
#: ``True`` when ``/models`` returned a valid OpenAI-shaped payload.
models_probe_ok: bool = False
#: ``True`` when the URL was determined to be an Anthropic-style
#: endpoint (from path suffix or live probe).
is_anthropic: bool = False
def _resolve_credential(api_key: Any,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[Optional[str], str]:
"""Coerce wizard inputs into a (token, mode) pair.
Returns ``(token_or_None, mode)`` where ``mode`` is:
- ``"entra_id"`` when a callable token provider was supplied — the
returned token is a freshly minted bearer JWT, sent ONLY in
``Authorization: Bearer``.
- ``"api_key"`` when a string key was supplied — the returned token
is the raw API key, sent in BOTH ``api-key:`` and
``Authorization: Bearer`` headers (preserves the original
broad-compat probe behaviour).
- ``("", "api_key")`` when neither yields a value.
Bearer minting failures degrade to ``("", "entra_id")`` so the caller
can still report "detection incomplete" rather than crashing.
"""
# Token-provider path (callable wins when both supplied).
if token_provider is not None and callable(token_provider):
try:
token = token_provider()
return (str(token) if token else None), "entra_id"
except Exception as exc:
logger.debug("azure_detect: token_provider failed: %s", exc)
return None, "entra_id"
if callable(api_key) and not isinstance(api_key, str):
try:
token = api_key()
return (str(token) if token else None), "entra_id"
except Exception as exc:
logger.debug("azure_detect: api_key callable failed: %s", exc)
return None, "entra_id"
# API-key path.
if isinstance(api_key, str) and api_key:
return api_key, "api_key"
return None, "api_key"
def _apply_auth_headers(req: urllib_request.Request,
token: Optional[str],
mode: str) -> None:
"""Attach the right auth headers to ``req`` based on credential mode."""
if not token:
return
if mode == "entra_id":
# Bearer-only: do NOT also set api-key, which would log a JWT in
# a header slot intended for static keys.
req.add_header("Authorization", f"Bearer {token}")
else:
# Legacy broad-compat behaviour: send both headers so we land on
# any Azure resource regardless of which it accepts.
req.add_header("api-key", token)
req.add_header("Authorization", f"Bearer {token}")
def _http_get_json(url: str,
api_key: Any,
timeout: float = 6.0,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[int, Optional[dict]]:
"""GET a URL with the appropriate auth headers. Return
``(status_code, parsed_json_or_None)``. Never raises."""
token, mode = _resolve_credential(api_key, token_provider)
req = urllib_request.Request(url, method="GET")
_apply_auth_headers(req, token, mode)
req.add_header("User-Agent", "hermes-agent/azure-detect")
try:
with open_credentialed_url(req, timeout=timeout) as resp:
body = resp.read()
try:
return resp.status, json.loads(body.decode("utf-8", errors="replace"))
except Exception:
return resp.status, None
except HTTPError as exc:
return exc.code, None
except (URLError, TimeoutError, OSError) as exc:
logger.debug("azure_detect: GET %s failed: %s", url, exc)
return 0, None
except Exception as exc: # pragma: no cover — defensive
logger.debug("azure_detect: GET %s unexpected error: %s", url, exc)
return 0, None
def _strip_trailing_v1(url: str) -> str:
"""Strip trailing ``/v1`` or ``/v1/`` so we can construct sub-paths."""
return re.sub(r"/v1/?$", "", url.rstrip("/"))
def _looks_like_anthropic_path(url: str) -> bool:
"""Return True when the URL's path ends in ``/anthropic`` or
contains a ``/anthropic/`` segment. Used by Azure Foundry
resources that route Claude traffic through a dedicated path."""
try:
parsed = urlparse(url)
path = (parsed.path or "").lower().rstrip("/")
return path.endswith("/anthropic") or "/anthropic/" in path + "/"
except Exception:
return False
def _extract_model_ids(payload: dict) -> list[str]:
"""Extract a list of model IDs from an OpenAI-shaped ``/models``
response. Returns ``[]`` on any shape mismatch."""
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list):
return []
ids: list[str] = []
for item in data:
if not isinstance(item, dict):
continue
# OpenAI shape: {"id": "gpt-5.4", "object": "model", ...}
mid = item.get("id") or item.get("model") or item.get("name")
if isinstance(mid, str) and mid:
ids.append(mid)
return ids
def _probe_openai_models(base_url: str,
api_key: Any,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[bool, list[str]]:
"""Probe ``<base>/models`` for an OpenAI-shaped response.
Returns ``(ok, models)``. ``ok`` is True iff the endpoint accepted
us as an OpenAI-style caller (200 OK + OpenAI-shaped JSON body).
"""
base_url = base_url.rstrip("/")
# Azure OpenAI v1: {resource}.openai.azure.com/openai/v1 — no
# api-version required for GA paths, so probe without first.
candidates = [f"{base_url}/models"]
# Fallback: explicit api-version for pre-v1 resources
for v in _AZURE_OPENAI_PROBE_API_VERSIONS:
candidates.append(f"{base_url}/models?api-version={v}")
for url in candidates:
status, body = _http_get_json(url, api_key, token_provider=token_provider)
if status == 200 and body is not None:
ids = _extract_model_ids(body)
if ids:
logger.info(
"azure_detect: /models probe OK at %s (%d models)",
url, len(ids),
)
return True, ids
# 200 + empty list still counts as "OpenAI shape, no models
# listed" — let the user proceed with manual entry.
if isinstance(body, dict) and "data" in body:
return True, []
return False, []
def _probe_anthropic_messages(base_url: str,
api_key: Any,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> bool:
"""Send a zero-token request to ``<base>/v1/messages`` and check
whether the endpoint at least *recognises* the Anthropic Messages
shape (any 4xx that mentions ``messages`` or ``model``, or a 400
``invalid_request`` with an Anthropic error shape). Never completes
a real chat.
"""
base = _strip_trailing_v1(base_url)
url = f"{base}/v1/messages?api-version={_AZURE_ANTHROPIC_API_VERSION}"
payload = json.dumps({
"model": "probe",
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
}).encode("utf-8")
req = urllib_request.Request(url, method="POST", data=payload)
token, mode = _resolve_credential(api_key, token_provider)
_apply_auth_headers(req, token, mode)
req.add_header("anthropic-version", "2023-06-01")
req.add_header("content-type", "application/json")
req.add_header("User-Agent", "hermes-agent/azure-detect")
try:
with open_credentialed_url(req, timeout=6.0) as resp:
# Should never 200 — "probe" isn't a real deployment. But
# if it does, the endpoint definitely speaks Anthropic.
return resp.status < 500
except HTTPError as exc:
# 4xx with an Anthropic-shaped error body = Anthropic endpoint.
try:
body = exc.read().decode("utf-8", errors="replace")
lowered = body.lower()
if "anthropic" in lowered or '"type"' in lowered and '"error"' in lowered:
return True
# Pre-Azure-v1 Azure Foundry returns a plain 404 for
# Anthropic-style calls on non-Anthropic deployments. A
# 400 "model not found" IS Anthropic though.
if exc.code == 400 and ("messages" in lowered or "model" in lowered):
return True
return False
except Exception:
return False
except (URLError, TimeoutError, OSError):
return False
except Exception: # pragma: no cover
return False
def detect(base_url: str,
api_key: Any = "",
*,
token_provider: Optional[Callable[[], str]] = None,
) -> DetectionResult:
"""Inspect an Azure endpoint and describe its transport + models.
Call this from the wizard before asking the user to pick an API
mode manually. The caller should treat the returned
:class:`DetectionResult` as *advisory* — if ``api_mode`` is None,
fall back to asking the user.
``api_key`` may be a string (legacy API-key auth — sends both
``api-key:`` and ``Authorization: Bearer``) or a callable returning
a bearer JWT (Entra ID auth — sends ONLY ``Authorization: Bearer``).
``token_provider`` is an alternative explicit name for the callable
form; if both are supplied the callable wins.
"""
result = DetectionResult()
try:
parsed = urlparse(base_url)
result.hostname = (parsed.hostname or "").lower()
except Exception:
result.hostname = ""
# 1. Path sniff. Azure Foundry exposes Anthropic-style deployments
# under a dedicated ``/anthropic`` path.
if _looks_like_anthropic_path(base_url):
result.is_anthropic = True
result.api_mode = "anthropic_messages"
result.reason = "URL path ends in /anthropic → Anthropic Messages API"
return result
# 2. Try the OpenAI-style /models probe. If this works, the
# endpoint definitely speaks OpenAI wire.
ok, models = _probe_openai_models(base_url, api_key, token_provider=token_provider)
if ok:
result.models_probe_ok = True
result.models = models
result.api_mode = "chat_completions"
result.reason = (
f"GET /models returned {len(models)} model(s) — OpenAI-style endpoint"
if models
else "GET /models returned an OpenAI-shaped empty list — OpenAI-style endpoint"
)
return result
# 3. Fallback: probe the Anthropic Messages shape. Slower and more
# intrusive than /models, so only run it when the OpenAI probe
# failed.
if _probe_anthropic_messages(base_url, api_key, token_provider=token_provider):
result.is_anthropic = True
result.api_mode = "anthropic_messages"
result.reason = "Endpoint accepts Anthropic Messages shape"
return result
# Nothing matched. Caller falls back to manual selection.
result.reason = (
"Could not probe endpoint (private network, missing model list, or "
"non-standard path) — falling back to manual API-mode selection"
)
return result
def lookup_context_length(model: str,
base_url: str,
api_key: Any = "",
*,
token_provider: Optional[Callable[[], str]] = None,
) -> Optional[int]:
"""Thin wrapper around :func:`agent.model_metadata.get_model_context_length`
that returns ``None`` when only the fallback default (128k) would
fire, so the wizard can distinguish "we actually know this" from
"we guessed.
For Entra-ID mode pass a callable as ``api_key`` (or via
``token_provider=``); the wrapped resolver expects a string, so we
mint one bearer JWT here for the single lookup. The resolver itself
only reads catalog metadata over HTTP — no SDK client is built — so
the minted token is consumed for at most one /models probe.
"""
model_id = str(model or "").strip()
if not model_id:
return None
try:
from agent.model_metadata import (
DEFAULT_FALLBACK_CONTEXT,
get_model_context_length,
)
except Exception:
return None
# Resolve the credential once. For Entra mode this calls the token
# provider; for legacy api_key this is a no-op string pass-through.
token, mode = _resolve_credential(api_key, token_provider)
effective_key = token or ""
try:
n = get_model_context_length(model_id, base_url=base_url, api_key=effective_key)
except Exception as exc:
logger.debug("azure_detect: context length lookup failed: %s", exc)
return None
if isinstance(n, int) and n > 0 and n != DEFAULT_FALLBACK_CONTEXT:
return n
return None
__all__ = ["DetectionResult", "detect", "lookup_context_length"]
+2756
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
"""``!<command>`` shell mode for the interactive CLI.
Typing ``!git status`` at the composer runs the command directly in the
session's working directory. The model is never invoked: no user message, no
assistant message, no tool result enters the conversation history, so a bang
command costs zero tokens and cannot perturb role alternation or the prompt
cache.
A user-typed command still goes through the SAME dangerous-pattern approval
gate the terminal tool uses (``tools.approval.check_all_command_guards``),
reached here through ``tools.terminal_tool._check_all_guards`` so the CLI
approval callback and Docker host-access handling behave identically.
CLI-only by design: gateway/API/cron sessions have their own shells and no
composer, so :func:`bang_shell_enabled` gates the feature off there.
"""
from __future__ import annotations
import os
import subprocess
from typing import Optional
USAGE_HINT = "Usage: !<command> — run a shell command without spending a model turn (e.g. !git status)"
# Bang commands are interactive convenience, not agent work. Keep the ceiling
# well under the terminal tool's foreground cap: a user watching output can
# Ctrl+C, and an accidental `!sleep 999` should not wedge the composer.
DEFAULT_TIMEOUT = 120
def is_bang_command(text: Optional[str]) -> bool:
"""Return True when *text* is a ``!`` shell-mode submission.
Only a leading ``!`` (after surrounding whitespace) counts. A line that
merely *contains* ``!`` mid-text (``fix the bug!``, ``echo hi!``) is an
ordinary prompt and must reach the agent untouched.
"""
if not isinstance(text, str):
return False
return text.strip().startswith("!")
def parse_bang_command(text: str) -> str:
"""Return the shell command inside a bang submission (``""`` when bare).
``!ls`` → ``ls``; ``! ls -la`` → ``ls -la``; ``!!`` → ``!`` (a literal
second bang is part of the command, e.g. history expansion the user's
shell will handle); ``!`` alone → ``""``.
"""
if not isinstance(text, str):
return ""
stripped = text.strip()
if not stripped.startswith("!"):
return ""
return stripped[1:].strip()
def bang_shell_enabled() -> bool:
"""True only for interactive local CLI sessions.
Gateway, API, and cron sessions never reach the composer and their users
already have a shell; running arbitrary commands for them would be a
remote-execution surface with no approving human at the keyboard.
"""
try:
from utils import env_var_enabled
except Exception: # pragma: no cover - utils is always importable in-tree
def env_var_enabled(name, default=""): # type: ignore[misc]
return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"}
if env_var_enabled("HERMES_GATEWAY_SESSION"):
return False
if env_var_enabled("HERMES_CRON_SESSION"):
return False
if (os.getenv("HERMES_SESSION_PLATFORM") or "").strip():
return False
return True
def resolve_bang_cwd(session_key: Optional[str] = None) -> Optional[str]:
"""Return the directory a bang command should run in.
Mirrors the terminal tool's resolution order so ``!pwd`` matches where the
agent's own commands land: the session's recorded ``cd`` state first
(``terminal_tool.get_session_cwd``, updated after every agent command),
then the configured ``TERMINAL_CWD``/backend default. ``None`` means "let
the subprocess inherit the process cwd".
"""
try:
from tools.terminal_tool import _get_env_config, get_session_cwd
recorded = get_session_cwd(session_key)
if recorded:
return recorded
configured = (_get_env_config() or {}).get("cwd")
if configured:
return configured
except Exception:
pass
return None
def check_bang_approval(command: str) -> dict:
"""Run *command* through the terminal tool's approval gate.
Reuses ``tools.terminal_tool._check_all_guards`` — the exact function
``terminal_tool()`` calls before executing anything — so the hardline
blocklist, user deny rules, tirith findings, and the interactive
dangerous-command prompt all apply to user-typed bang commands too. A
command the agent would need approval for still needs approval when the
user types it; ``!`` is a latency/cost shortcut, not a security bypass.
Returns the gate's decision dict (``{"approved": bool, "message": ...}``).
Falls back to *approved* only when the gate itself cannot be imported,
which would mean a broken install rather than a policy decision.
"""
try:
from tools.terminal_tool import _check_all_guards
except Exception:
return {"approved": True, "message": None}
# env_type mirrors the terminal tool: bang commands always run locally in
# the CLI process, never inside a remote/sandbox backend.
return _check_all_guards(command, "local", has_host_access=False)
def _bang_env() -> dict:
"""Environment for a bang command, with Hermes-managed secrets filtered.
The CLI process holds every configured provider API key in ``os.environ``.
A bang command is user-typed, but it can still be a third-party script, so
reuse the same sanitizer ``quick_commands`` and the local terminal backend
use rather than handing the whole keyring to an arbitrary subprocess.
"""
try:
from tools.environments.local import _sanitize_subprocess_env
return _sanitize_subprocess_env(os.environ.copy())
except Exception:
return os.environ.copy()
def run_bang_command(
command: str,
*,
cwd: Optional[str] = None,
timeout: int = DEFAULT_TIMEOUT,
writer=None,
) -> int:
"""Execute *command* and stream its output, returning the exit code.
stdout and stderr are merged and written through *writer* (defaults to
``print``) as they arrive, so long-running commands show progress instead
of buffering to the end. Nothing is returned to a caller for insertion
into conversation history — the output exists only on the user's terminal.
"""
emit = writer or (lambda line: print(line, end="" if line.endswith("\n") else "\n"))
run_cwd = cwd if (cwd and os.path.isdir(os.path.expanduser(cwd))) else None
if run_cwd:
run_cwd = os.path.expanduser(run_cwd)
try:
from hermes_cli._subprocess_compat import windows_hide_flags
creationflags = windows_hide_flags()
except Exception:
creationflags = 0
try:
# shell=True is intentional and matches quick_commands: this is a
# command the human typed into their own composer, not model output.
proc = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
cwd=run_cwd,
env=_bang_env(),
creationflags=creationflags,
)
except Exception as exc:
emit(f"!: failed to run command: {exc}")
return 127
try:
if proc.stdout is not None:
for line in proc.stdout:
emit(line.rstrip("\n"))
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
emit(f"!: command timed out after {timeout}s")
return 124
except KeyboardInterrupt:
# Ctrl+C interrupts the command, not the Hermes session.
proc.kill()
emit("!: interrupted")
return 130
finally:
try:
if proc.stdout is not None:
proc.stdout.close()
except Exception:
pass
return int(proc.returncode or 0)
+1310
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway.
The conversational counterpart to the dashboard's Automation Blueprints form. Where a
surface has a screen, the user fills a form (dashboard / GUI app) and the API
calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a
chat line, the user picks a blueprint by name and the agent asks for what it
needs — pick a blueprint by name and the agent asks you for what it needs, one
question at a time (the messaging-assistant model: pick a blueprint → it asks you
a couple things → done).
Subcommand shapes:
/blueprint list the catalog
/blueprint <name> name-match a blueprint, then SEED THE AGENT to
ask the user for each value conversationally
/blueprint <name> slot=val … fill + create the cron job directly
(the deterministic dashboard / docs / power-
user shortcut — no agent turn)
The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all
resolve; an ambiguous query lists the candidates; an unknown one suggests the
closest. When it resolves, the handler returns an ``agent_seed`` — a natural-
language instruction built from the blueprint's typed slots + schedule/prompt
templates — that the calling surface feeds to the agent as a normal user turn
(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI:
a one-shot pending seed the main loop runs). The agent then asks for each slot
and calls the existing ``cronjob`` tool. No new tool, no second job engine.
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
survive.
"""
from __future__ import annotations
import difflib
import logging
import shlex
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@dataclass
class BlueprintCommandResult:
"""Outcome of a ``/blueprint`` invocation.
``text`` is always shown to the user. When ``agent_seed`` is set, the
calling surface should ALSO hand that seed to the agent as the user's next
turn (the blueprint was matched and now the agent gathers the slot values
conversationally). When ``agent_seed`` is None the command is fully handled
(catalog listing, direct create, or an error) and nothing is sent to the
agent.
"""
text: str
agent_seed: Optional[str] = None
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if explicit is not None:
return explicit
try:
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if platform and chat_id:
return {
"platform": platform,
"chat_id": chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
}
except Exception:
pass
return None
def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
"""Split ``slot=value`` tokens from bare tokens. Returns (values, leftovers)."""
values: Dict[str, str] = {}
leftovers = []
for tok in tokens:
if "=" in tok:
k, _, v = tok.partition("=")
k = k.strip()
if k:
values[k] = v.strip()
continue
leftovers.append(tok)
return values, leftovers
def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed blueprint name to a blueprint.
Returns ``(blueprint, candidates)``:
* exact key or unique prefix / fuzzy match -> ``(blueprint, [])``
* ambiguous (2+ plausible) -> ``(None, [candidates…])``
* no plausible match -> ``(None, [])``
Matching is forgiving because chat-line users type the name (unlike the
dashboard/Discord where it's picked): exact key first, then case-insensitive
prefix on key or title, then a difflib fuzzy pass.
"""
from cron.blueprint_catalog import CATALOG, get_blueprint
q = (query or "").strip().lower()
if not q:
return None, []
exact = get_blueprint(q)
if exact is not None:
return exact, []
# Prefix match on key or title word-start.
prefix = [
r for r in CATALOG
if r.key.lower().startswith(q)
or any(w.lower().startswith(q) for w in r.title.split())
]
if len(prefix) == 1:
return prefix[0], []
if len(prefix) > 1:
return None, prefix
# Substring match anywhere in key/title/description.
substr = [
r for r in CATALOG
if q in r.key.lower() or q in r.title.lower() or q in r.description.lower()
]
if len(substr) == 1:
return substr[0], []
if len(substr) > 1:
return None, substr
# Fuzzy on keys (typo tolerance).
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
if len(close) == 1:
return get_blueprint(close[0]), []
if len(close) > 1:
return None, [get_blueprint(k) for k in close]
return None, []
def _humanize_schedule(blueprint) -> str:
from cron.blueprint_catalog import _humanize_schedule as _h
try:
return _h(blueprint)
except Exception:
return "on a schedule"
def build_blueprint_seed(blueprint) -> str:
"""Build the natural-language fill-request the agent will act on.
The agent reads this as a normal user turn, asks the user for each unfilled
slot one at a time, then calls the ``cronjob`` tool with the
cron expression it builds from the blueprint's ``schedule_template`` and the
rendered prompt. Defaults are stated so the agent can offer them.
"""
from cron.blueprint_catalog import WEEKDAY_PRESETS
lines: List[str] = []
lines.append(
f"Set up the '{blueprint.title}' automation for me (automation blueprint "
f"'{blueprint.key}'). {blueprint.description}"
)
lines.append("")
lines.append(
"Ask me for each of these, one at a time, offering the default in "
"brackets if I don't have a preference:"
)
for s in blueprint.slots:
bits = [f"- {s.label} ({s.name})"]
if s.options:
bits.append(f" — one of: {', '.join(map(str, s.options))}")
if s.default not in (None, ""):
bits.append(f" [default: {s.default}]")
if s.optional:
bits.append(" (optional)")
if s.help:
bits.append(f"{s.help}")
lines.append("".join(bits))
lines.append("")
lines.append(
"Once you have my answers, create the job by calling the cronjob tool "
"with action='create'. Build the schedule as a cron expression from "
f"this template: `{blueprint.schedule_template}` "
"(fill {minute}/{hour} from the chosen time, {dow} from the weekday "
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
"interval). Use this exact prompt for the job (substituting my "
f"answers into any {{slot}} placeholders): \"{blueprint.prompt_template}\". "
"Confirm the schedule and what it will do before you create it."
)
return "\n".join(lines)
def _fmt_catalog() -> str:
from cron.blueprint_catalog import CATALOG
lines = ["Automation Blueprints — `/blueprint <name>` and I'll ask you what I need:\n"]
for r in CATALOG:
lines.append(f"{r.key}{r.title}")
lines.append(f" {r.description}")
lines.append(
"\nTip: `/blueprint <name>` walks you through it. Power users can "
"pass values inline, e.g. `/blueprint morning-brief time=08:00`."
)
return "\n".join(lines)
def _fmt_candidates(query: str, candidates: List[Any]) -> str:
lines = [f"'{query}' matches several blueprints — which one?\n"]
for r in candidates:
lines.append(f"{r.key}{r.title}")
lines.append("\nRun `/blueprint <name>` with one of the names above.")
return "\n".join(lines)
def _fmt_no_match(query: str) -> str:
from cron.blueprint_catalog import CATALOG
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
msg = f"No automation blueprint matches '{query}'."
if close:
msg += " Did you mean: " + ", ".join(close) + "?"
msg += " Run /blueprint to see the catalog."
return msg
def _manage_hint(surface: str) -> str:
"""Post-create management hint. /cron is a CLI-only slash command; on
gateway platforms the user manages jobs by asking the agent (cronjob tool)
or from the dashboard."""
if surface == "cli":
return "Manage it with /cron."
return "Ask me to list, pause, or remove it any time."
def handle_blueprint_command(
args: str,
*,
origin: Optional[Dict[str, Any]] = None,
surface: str = "cli",
) -> BlueprintCommandResult:
"""Dispatch a ``/blueprint`` invocation.
Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the
caller must feed it to the agent as the next user turn; otherwise the
command is fully handled and only ``text`` is shown.
``args`` is everything after ``/blueprint``. ``origin`` lets a directly
created job deliver back to the chat it was set up from. ``surface``
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints —
``/cron`` only exists on the CLI.
"""
try:
from cron.blueprint_catalog import fill_blueprint, BlueprintFillError
except Exception as e: # pragma: no cover - import guard
logger.debug("blueprint catalog import failed: %s", e)
return BlueprintCommandResult("Automation Blueprints are unavailable in this build.")
try:
tokens = shlex.split(args or "")
except ValueError:
tokens = (args or "").split()
# Bare -> list catalog.
if not tokens:
return BlueprintCommandResult(_fmt_catalog())
query = tokens[0]
values, _leftover = _parse_kv(tokens[1:])
blueprint, candidates = match_blueprint(query)
if blueprint is None:
if candidates:
return BlueprintCommandResult(_fmt_candidates(query, candidates))
return BlueprintCommandResult(_fmt_no_match(query))
# `<name>` with no inline slot values -> seed the agent to ask for them.
if not values:
seed = build_blueprint_seed(blueprint)
text = (
f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). "
"I'll ask you a couple of things…"
)
return BlueprintCommandResult(text, agent_seed=seed)
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
try:
spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin))
except BlueprintFillError as e:
return BlueprintCommandResult(
f"Can't set up '{blueprint.title}': {e}\n"
f"Or just run /blueprint {blueprint.key} and I'll ask you for the values."
)
try:
from cron.scheduler import (
CronSchedulerRegistrationError,
create_job_with_scheduler_registration,
)
job = create_job_with_scheduler_registration(**spec)
except CronSchedulerRegistrationError as e:
return BlueprintCommandResult(e.user_message())
except Exception as e:
logger.debug("blueprint create_job failed: %s", e)
return BlueprintCommandResult(f"Failed to create the job: {e}")
sched = job.get("schedule_display") or spec.get("schedule", "")
return BlueprintCommandResult(
f"Scheduled '{blueprint.title}'"
+ (f" ({sched})" if sched else "")
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
)
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
"""
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"
_code_identity_cache: Optional[dict] = None
def _resolve_git_head_sha(project_root: Path) -> Optional[str]:
"""Resolve the checkout's HEAD commit sha by reading .git directly.
Deliberately NOT ``git rev-parse`` in a subprocess: this helper runs
inside library paths (gateway runtime-status writes, update receipts)
where spawning processes is both slow and hostile to tests that mock
``subprocess.run`` tightly (call-count asserts, sequenced side effects).
Handles regular checkouts, worktrees/submodules (``.git`` file with a
``gitdir:`` pointer + ``commondir``), loose refs, and packed-refs.
Returns None on any failure.
"""
try:
git_path = project_root / ".git"
if git_path.is_file():
# Worktree/submodule: ".git" is a pointer file.
pointer = git_path.read_text(encoding="utf-8", errors="replace").strip()
if not pointer.startswith("gitdir:"):
return None
git_dir = Path(pointer[len("gitdir:"):].strip())
if not git_dir.is_absolute():
git_dir = (project_root / git_dir).resolve()
elif git_path.is_dir():
git_dir = git_path
else:
return None
# Refs live in the COMMON git dir for worktrees.
common_dir = git_dir
commondir_file = git_dir / "commondir"
if commondir_file.is_file():
rel = commondir_file.read_text(encoding="utf-8", errors="replace").strip()
common = Path(rel)
if not common.is_absolute():
common = (git_dir / common).resolve()
common_dir = common
head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
if not head.startswith("ref:"):
# Detached HEAD: the file holds the sha itself.
return head if len(head) == 40 else None
ref_name = head[len("ref:"):].strip()
loose = common_dir / ref_name
if loose.is_file():
sha = loose.read_text(encoding="utf-8", errors="replace").strip()
return sha if len(sha) == 40 else None
packed = common_dir / "packed-refs"
if packed.is_file():
for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith(("#", "^")):
continue
parts = line.split(" ", 1)
if len(parts) == 2 and parts[1].strip() == ref_name:
sha = parts[0].strip()
return sha if len(sha) == 40 else None
except Exception:
return None
return None
def get_code_identity(refresh: bool = False) -> dict:
"""Return the running checkout's code identity as a dict.
Shape: ``{"sha": full-or-short sha | None, "short_sha": str | None,
"version": pyproject version | None, "source": "git" | "build-file" |
"unknown"}``.
Resolution order mirrors the banner/dump callsites: live ``git
rev-parse`` for source installs, the baked ``.hermes_build_sha`` for
Docker images (no ``.git`` inside the published image), else unknown.
Cached per process — code identity cannot change while a process is
running (an updated checkout requires a restart to take effect, which
is exactly the property the fleet version verification relies on).
Never raises; every field degrades to ``None`` independently.
"""
global _code_identity_cache
if _code_identity_cache is not None and not refresh:
return dict(_code_identity_cache)
sha: Optional[str] = None
source = "unknown"
project_root = Path(__file__).parent.parent
resolved = _resolve_git_head_sha(project_root)
if resolved:
sha = resolved
source = "git"
if sha is None:
baked = get_build_sha(short=0)
if baked:
sha = baked
source = "build-file"
version: Optional[str] = None
try:
import tomllib
with open(project_root / "pyproject.toml", "rb") as fh: # windows-footgun: ok — binary mode, tomllib requires bytes
raw_version = tomllib.load(fh).get("project", {}).get("version")
version = str(raw_version) if raw_version else None
except Exception:
version = None
_code_identity_cache = {
"sha": sha,
"short_sha": sha[:8] if sha else None,
"version": version,
"source": source,
}
return dict(_code_identity_cache)
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
+230
View File
@@ -0,0 +1,230 @@
"""Implementation of the ``hermes bundles`` CLI subcommand.
Mirrors the structure of ``hermes_cli/skills_hub.py`` but for skill
bundles. Bundles are tiny YAML files that name a set of skills to load
together via a single ``/<bundle>`` slash command.
Subcommands:
- list: show all bundles
- show: dump one bundle's contents
- create: build a new bundle from arguments or interactively
- delete: remove a bundle
- reload: re-scan the bundles directory
"""
from __future__ import annotations
from hermes_cli.cli_output import line_input
import sys
from typing import List
from rich.console import Console
from rich.table import Table
from agent.skill_bundles import (
_bundles_dir,
delete_bundle,
get_bundle,
list_bundles,
reload_bundles,
save_bundle,
scan_bundles,
)
def _console() -> Console:
# Bind to stderr so piping `hermes bundles list | grep …` doesn't
# garble rich markup with table styling. Tables and headings still
# render to a terminal; pure text columns survive piping.
return Console()
def _cmd_list(args) -> None:
c = _console()
bundles = list_bundles()
if not bundles:
c.print(
f"[dim]No bundles installed yet. Create one with:\n"
f" hermes bundles create <name> --skill skill1 --skill skill2[/]\n"
f"Bundles directory: [bold]{_bundles_dir()}[/]"
)
return
table = Table(title=f"Skill Bundles ({len(bundles)})", show_lines=False)
table.add_column("Command", style="bold cyan")
table.add_column("Name", style="bold")
table.add_column("Skills", justify="right")
table.add_column("Description")
for info in bundles:
skill_count = len(info.get("skills", []))
table.add_row(
f"/{info['slug']}",
info["name"],
str(skill_count),
info.get("description") or "",
)
c.print(table)
c.print(f"\n[dim]Bundles directory: {_bundles_dir()}[/]")
def _cmd_show(args) -> None:
c = _console()
info = get_bundle(args.name)
if not info:
c.print(f"[bold red]Bundle {args.name!r} not found.[/]")
sys.exit(1)
c.print(f"[bold cyan]/{info['slug']}[/] [bold]{info['name']}[/]")
if info.get("description"):
c.print(f" {info['description']}")
c.print(f" [dim]File: {info['path']}[/]")
c.print(f" [bold]Skills ({len(info['skills'])}):[/]")
for s in info["skills"]:
c.print(f" - {s}")
if info.get("instruction"):
c.print(f" [bold]Instruction:[/]\n {info['instruction']}")
def _cmd_create(args) -> None:
c = _console()
name = args.name
skills: List[str] = list(args.skill or [])
description = args.description or ""
instruction = args.instruction or ""
overwrite = bool(args.force)
if not skills:
# Interactive prompt for skills if none were passed on the CLI.
c.print(
"[dim]No skills passed via --skill. Enter one skill name per line.\n"
"Submit an empty line to finish.[/]"
)
try:
while True:
line = line_input("skill> ").strip()
if not line:
break
skills.append(line)
except (EOFError, KeyboardInterrupt):
c.print("\n[yellow]Cancelled.[/]")
sys.exit(1)
if not skills:
c.print("[bold red]A bundle must reference at least one skill.[/]")
sys.exit(1)
try:
path = save_bundle(
name,
skills,
description=description,
instruction=instruction,
overwrite=overwrite,
)
except FileExistsError as exc:
c.print(f"[bold red]{exc}[/]\n[dim]Pass --force to overwrite.[/]")
sys.exit(1)
except ValueError as exc:
c.print(f"[bold red]{exc}[/]")
sys.exit(1)
c.print(f"[bold green]Created bundle:[/] {path}")
info = get_bundle(name)
if info:
c.print(
f" Invoke with: [bold cyan]/{info['slug']}[/] "
f"(loads {len(info['skills'])} skills)"
)
def _cmd_delete(args) -> None:
c = _console()
try:
path = delete_bundle(args.name)
except FileNotFoundError as exc:
c.print(f"[bold red]{exc}[/]")
sys.exit(1)
c.print(f"[bold green]Deleted bundle:[/] {path}")
def _cmd_reload(args) -> None:
c = _console()
diff = reload_bundles()
if diff["added"]:
c.print(f"[bold green]Added ({len(diff['added'])}):[/]")
for entry in diff["added"]:
c.print(f" + {entry['name']}{entry.get('description', '')}")
if diff["removed"]:
c.print(f"[bold red]Removed ({len(diff['removed'])}):[/]")
for entry in diff["removed"]:
c.print(f" - {entry['name']}")
if not diff["added"] and not diff["removed"]:
c.print(f"[dim]No changes. {diff['total']} bundle(s) loaded.[/]")
else:
c.print(f"[dim]Total bundles now: {diff['total']}[/]")
def register_cli(subparser) -> None:
"""Build the ``hermes bundles`` argparse tree.
Called from ``hermes_cli/main.py`` where it owns the top-level
``bundles`` subparser. Keeping registration here means the bundles
subcommand's argparse tree lives next to its handlers.
"""
subs = subparser.add_subparsers(dest="bundles_action")
p_list = subs.add_parser("list", help="List installed skill bundles")
p_list.set_defaults(_bundles_handler=_cmd_list)
p_show = subs.add_parser("show", help="Show one bundle's contents")
p_show.add_argument("name", help="Bundle name")
p_show.set_defaults(_bundles_handler=_cmd_show)
p_create = subs.add_parser(
"create",
help="Create a new skill bundle",
description=(
"Create a new bundle. Skills can be passed via --skill (repeat for "
"multiple) or entered interactively when omitted."
),
)
p_create.add_argument("name", help="Bundle name (becomes the /slash command)")
p_create.add_argument(
"--skill", "-s", action="append", default=[],
help="Skill name to include (repeat for multiple)",
)
p_create.add_argument(
"--description", "-d", default="",
help="Human-readable description shown in /help and `hermes bundles list`",
)
p_create.add_argument(
"--instruction", "-i", default="",
help="Extra guidance prepended to the loaded skill content",
)
p_create.add_argument(
"--force", "-f", action="store_true",
help="Overwrite an existing bundle with the same name",
)
p_create.set_defaults(_bundles_handler=_cmd_create)
p_delete = subs.add_parser("delete", help="Delete a skill bundle")
p_delete.add_argument("name", help="Bundle name")
p_delete.set_defaults(_bundles_handler=_cmd_delete)
p_reload = subs.add_parser(
"reload", help="Re-scan the bundles directory and report changes"
)
p_reload.set_defaults(_bundles_handler=_cmd_reload)
# Ensure a fresh scan when any bundles subcommand runs.
scan_bundles()
def bundles_command(args) -> None:
"""Dispatch ``hermes bundles <subcommand>`` to the right handler."""
handler = getattr(args, "_bundles_handler", None)
if handler is None:
# No subcommand given — default to list.
_cmd_list(args)
return
handler(args)
+255
View File
@@ -0,0 +1,255 @@
"""Interactive prompt callbacks for terminal_tool integration.
These bridge terminal_tool's interactive prompts (clarify, sudo, approval)
into prompt_toolkit's event loop. Each function takes the HermesCLI instance
as its first argument and uses its state (queues, app reference) to coordinate
with the TUI.
"""
import queue
import time as _time
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
def clarify_callback(cli, question, choices, multi_select=False):
"""Prompt for clarifying question through the TUI.
Sets up the interactive selection UI, then blocks until the user
responds. Returns the user's choice or a timeout message.
When ``multi_select`` is True, shows checkboxes and the user can
select multiple options with Space, confirming with Enter.
"""
from cli import CLI_CONFIG
from tools.clarify_gateway import resolve_clarify_timeout
# Canonical clarify timeout, shared with the gateway/TUI path. `<= 0`
# means unlimited (never auto-skip mid-think) → a null deadline.
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
effective_multi = multi_select and not is_open_ended
cli._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
"multi_select": effective_multi,
"selected_indices": set() if effective_multi else None,
"response_queue": response_queue,
}
cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
cli._clarify_freetext = is_open_ended
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._clarify_deadline = None
return result
except queue.Empty:
# None deadline = unlimited: never auto-skip, just keep polling.
if cli._clarify_deadline is not None:
remaining = cli._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = None
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
"""Prompt for a secret value through the TUI (e.g. API keys for skills).
Returns a dict with keys: success, stored_as, validated, skipped, message.
The secret is stored in ~/.hermes/.env and never exposed to the model.
"""
if not getattr(cli, "_app", None):
if not hasattr(cli, "_secret_state"):
cli._secret_state = None
if not hasattr(cli, "_secret_deadline"):
cli._secret_deadline = 0
try:
value = masked_secret_prompt(f"{prompt} (hidden, ESC or empty Enter to skip): ")
except (EOFError, KeyboardInterrupt):
value = ""
if not value:
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
return {
"success": True,
"reason": "cancelled",
"stored_as": var_name,
"validated": False,
"skipped": True,
"message": "Secret setup was skipped.",
}
stored = save_env_value_secure(var_name, value)
_dhh = display_hermes_home()
cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
return {
**stored,
"skipped": False,
"message": "Secret stored securely. The secret value was not exposed to the model.",
}
timeout = 120
response_queue = queue.Queue()
cli._secret_state = {
"var_name": var_name,
"prompt": prompt,
"metadata": metadata or {},
"response_queue": response_queue,
}
cli._secret_deadline = _time.monotonic() + timeout
if hasattr(cli, "_ring_bell"):
cli._ring_bell(prompt=True, context=f"secret needed ({var_name})")
# Avoid storing stale draft input as the secret when Enter is pressed.
if hasattr(cli, "_clear_secret_input_buffer"):
try:
cli._clear_secret_input_buffer()
except Exception:
pass
elif hasattr(cli, "_app") and cli._app:
try:
cli._app.current_buffer.reset()
except Exception:
pass
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
value = response_queue.get(timeout=1)
cli._secret_state = None
cli._secret_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
if not value:
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
return {
"success": True,
"reason": "cancelled",
"stored_as": var_name,
"validated": False,
"skipped": True,
"message": "Secret setup was skipped.",
}
stored = save_env_value_secure(var_name, value)
_dhh = display_hermes_home()
cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
return {
**stored,
"skipped": False,
"message": "Secret stored securely. The secret value was not exposed to the model.",
}
except queue.Empty:
remaining = cli._secret_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._secret_state = None
cli._secret_deadline = 0
if hasattr(cli, "_clear_secret_input_buffer"):
try:
cli._clear_secret_input_buffer()
except Exception:
pass
elif hasattr(cli, "_app") and cli._app:
try:
cli._app.current_buffer.reset()
except Exception:
pass
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — secret capture cancelled{_RST}")
return {
"success": True,
"reason": "timeout",
"stored_as": var_name,
"validated": False,
"skipped": True,
"message": "Secret setup timed out and was skipped.",
}
def approval_callback(cli, command: str, description: str) -> str:
"""Prompt for dangerous command approval through the TUI.
Shows a selection UI with choices: once / session / always / deny.
When the command is longer than 70 characters, a "view" option is
included so the user can reveal the full text before deciding.
Uses cli._approval_lock to serialize concurrent requests (e.g. from
parallel delegation subtasks) so each prompt gets its own turn.
"""
lock = getattr(cli, "_approval_lock", None)
if lock is None:
import threading
cli._approval_lock = threading.Lock()
lock = cli._approval_lock
with lock:
from cli import CLI_CONFIG
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 300)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
choices.append("view")
cli._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
return result
except queue.Empty:
remaining = cli._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "timeout"
+281
View File
@@ -0,0 +1,281 @@
"""`hermes checkpoints` CLI subcommand.
Gives users direct visibility and control over the filesystem checkpoint
store at ``~/.hermes/checkpoints/``. Actions:
hermes checkpoints # same as `status`
hermes checkpoints status # total size, project count, breakdown
hermes checkpoints list # per-project checkpoint counts + workdir
hermes checkpoints prune [opts] # force a sweep (ignores the 24h marker)
hermes checkpoints clear [-f] # nuke the entire base (asks first)
hermes checkpoints clear-legacy # delete just the legacy-* archives
Examples::
hermes checkpoints
hermes checkpoints prune --retention-days 3 --max-size-mb 200
hermes checkpoints clear -f
None of these require the agent to be running. Safe to call any time.
"""
from __future__ import annotations
import argparse
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from hermes_cli.sizefmt import format_bytes as _fmt_bytes
def _fmt_ts(ts: Any) -> str:
try:
return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")
except (TypeError, ValueError):
return ""
def _fmt_age(ts: Any) -> str:
try:
age = time.time() - float(ts)
except (TypeError, ValueError):
return ""
if age < 0:
return "now"
if age < 60:
return f"{int(age)}s ago"
if age < 3600:
return f"{int(age / 60)}m ago"
if age < 86400:
return f"{int(age / 3600)}h ago"
return f"{int(age / 86400)}d ago"
def cmd_status(args: argparse.Namespace) -> int:
from tools.checkpoint_manager import store_status
info = store_status()
base = info["base"]
print(f"Checkpoint base: {base}")
print(f"Total size: {_fmt_bytes(info['total_size_bytes'])}")
print(f" store/ {_fmt_bytes(info['store_size_bytes'])}")
print(f" legacy-* {_fmt_bytes(info['legacy_size_bytes'])}")
print(f"Projects: {info['project_count']}")
projects = sorted(
info["projects"],
key=lambda p: (p.get("last_touch") or 0),
reverse=True,
)
if projects:
print()
print(f" {'WORKDIR':<60} {'COMMITS':>7} {'LAST TOUCH':>12} STATE")
for p in projects[: args.limit if hasattr(args, "limit") and args.limit else 20]:
wd = p.get("workdir") or "(unknown)"
if len(wd) > 60:
wd = "" + wd[-59:]
exists = p.get("exists")
state = "live" if exists else "orphan"
commits = p.get("commits", 0)
last = _fmt_age(p.get("last_touch"))
print(f" {wd:<60} {commits:>7} {last:>12} {state}")
legacy = info.get("legacy_archives", [])
if legacy:
print()
print(f"Legacy archives ({len(legacy)}):")
for arch in sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True):
print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
print()
print("Clear with: hermes checkpoints clear-legacy")
return 0
def cmd_list(args: argparse.Namespace) -> int:
# `list` is just a terser status — already covered.
return cmd_status(args)
def cmd_prune(args: argparse.Namespace) -> int:
from tools.checkpoint_manager import prune_checkpoints, store_status
retention_days = args.retention_days
max_size_mb = args.max_size_mb
delete_orphans = not args.keep_orphans
# When set, restricts orphan deletion to exactly the identities shown in
# the confirmation preview below (v2 project hashes / pre-v2 shadow repo
# paths). `None` means "no restriction" — used for --force, where there
# is no preview to bind to.
orphan_allowlist: Optional[set] = None
if delete_orphans and not args.force:
info = store_status()
orphans = [
p for p in info.get("projects", [])
if not p.get("exists")
]
pre_v2_orphans = [
p for p in info.get("pre_v2_projects", [])
if not p.get("exists")
]
if orphans or pre_v2_orphans:
print(f"This will permanently delete {len(orphans) + len(pre_v2_orphans)} "
"orphan checkpoint project(s) whose workdir is not currently reachable:")
print()
for p in orphans:
wd = p.get("workdir") or "(unknown)"
print(f" {wd} ({p.get('commits', 0)} commit(s))")
for p in pre_v2_orphans:
wd = p.get("workdir") or "(unknown)"
print(f" {wd} (pre-v2 shadow repo)")
print()
print("A workdir can be unreachable because the project was deleted,")
print("or because an external volume / network share / VPN is down.")
print("Pass --keep-orphans to prune stale entries only.")
if not _confirm("Delete these orphan projects?"):
print("Aborted.")
return 1
# Bind the deletion to exactly what was just displayed (and, when
# non-empty, confirmed) — a project that becomes orphaned only
# *after* this preview (e.g. its workdir disappears while waiting on
# input()) must not be swept up under this same run. This is set
# unconditionally for every non-force run: an EMPTY preview binds to
# an EMPTY allowlist, so a zero-orphan preview can never authorize
# deletion of orphans discovered by the later rescan.
orphan_allowlist = {p["hash"] for p in orphans}
orphan_allowlist.update(p["path"] for p in pre_v2_orphans)
print("Pruning checkpoint store…")
print(f" retention_days: {retention_days}")
print(f" delete_orphans: {delete_orphans}")
print(f" max_total_size_mb: {max_size_mb}")
print()
result = prune_checkpoints(
retention_days=retention_days,
delete_orphans=delete_orphans,
max_total_size_mb=max_size_mb,
orphan_allowlist=orphan_allowlist,
)
print(f"Scanned: {result['scanned']}")
print(f"Deleted orphan: {result['deleted_orphan']}")
print(f"Deleted stale: {result['deleted_stale']}")
print(f"Errors: {result['errors']}")
print(f"Bytes reclaimed: {_fmt_bytes(result['bytes_freed'])}")
return 0
def _confirm(prompt: str) -> bool:
try:
resp = input(f"{prompt} [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
return resp in {"y", "yes"}
def cmd_clear(args: argparse.Namespace) -> int:
from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status
info = store_status()
if info["total_size_bytes"] == 0 and not Path(CHECKPOINT_BASE).exists():
print("Nothing to clear — checkpoint base does not exist.")
return 0
print(f"This will delete the ENTIRE checkpoint base at {info['base']}")
print(f" size: {_fmt_bytes(info['total_size_bytes'])}")
print(f" projects: {info['project_count']}")
print(f" legacy dirs: {len(info.get('legacy_archives', []))}")
print()
print("All /rollback history for every working directory will be lost.")
if not args.force and not _confirm("Proceed?"):
print("Aborted.")
return 1
result = clear_all()
if result["deleted"]:
print(f"Cleared. Reclaimed {_fmt_bytes(result['bytes_freed'])}.")
return 0
print("Could not clear checkpoint base (see logs).")
return 2
def cmd_clear_legacy(args: argparse.Namespace) -> int:
from tools.checkpoint_manager import clear_legacy, store_status
info = store_status()
legacy = info.get("legacy_archives", [])
if not legacy:
print("No legacy archives to clear.")
return 0
total = sum(a.get("size_bytes", 0) for a in legacy)
print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:")
for arch in legacy:
print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
print()
print("Legacy archives hold pre-v2 per-project shadow repos, moved aside")
print("during the single-store migration. Delete when you're confident")
print("you don't need the old /rollback history.")
if not args.force and not _confirm("Delete all legacy archives?"):
print("Aborted.")
return 1
result = clear_legacy()
print(f"Deleted {result['deleted']} archive(s), reclaimed {_fmt_bytes(result['bytes_freed'])}.")
return 0
def register_cli(parser: argparse.ArgumentParser) -> None:
"""Wire subcommands onto the ``hermes checkpoints`` parser."""
parser.set_defaults(func=cmd_status) # bare `hermes checkpoints` → status
subs = parser.add_subparsers(dest="checkpoints_command", metavar="COMMAND")
p_status = subs.add_parser(
"status",
help="Show total size, project count, and per-project breakdown",
)
p_status.add_argument("--limit", type=int, default=20,
help="Max projects to list (default 20)")
p_status.set_defaults(func=cmd_status)
p_list = subs.add_parser(
"list",
help="Alias for 'status'",
)
p_list.add_argument("--limit", type=int, default=20)
p_list.set_defaults(func=cmd_list)
p_prune = subs.add_parser(
"prune",
help="Delete orphan/stale checkpoints and GC the store",
)
p_prune.add_argument("--retention-days", type=int, default=7,
help="Drop projects whose last_touch is older than N days (default 7)")
p_prune.add_argument("--max-size-mb", type=int, default=500,
help="After orphan/stale prune, drop oldest commits "
"per project until total size <= this (default 500)")
p_prune.add_argument("--keep-orphans", action="store_true",
help="Skip deleting projects whose workdir no longer exists")
p_prune.add_argument("-f", "--force", action="store_true",
help="Skip the orphan-deletion confirmation prompt")
p_prune.set_defaults(func=cmd_prune)
p_clear = subs.add_parser(
"clear",
help="Delete the entire checkpoint base (all /rollback history)",
)
p_clear.add_argument("-f", "--force", action="store_true",
help="Skip confirmation prompt")
p_clear.set_defaults(func=cmd_clear)
p_legacy = subs.add_parser(
"clear-legacy",
help="Delete only the legacy-<ts>/ archives from v1 migration",
)
p_legacy.add_argument("-f", "--force", action="store_true",
help="Skip confirmation prompt")
p_legacy.set_defaults(func=cmd_clear_legacy)
+815
View File
@@ -0,0 +1,815 @@
"""hermes claw — OpenClaw migration commands.
Usage:
hermes claw migrate # Preview then migrate (always shows preview first)
hermes claw migrate --dry-run # Preview only, no changes
hermes claw migrate --yes # Skip confirmation prompt
hermes claw migrate --preset full --overwrite --migrate-secrets # Full run w/ secrets
hermes claw migrate --no-backup # Skip pre-migration snapshot
hermes claw cleanup # Archive leftover OpenClaw directories
hermes claw cleanup --dry-run # Preview what would be archived
"""
import importlib.util
import logging
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
from hermes_constants import get_optional_skills_dir
from hermes_cli.setup import (
Colors,
color,
print_header,
print_info,
print_success,
print_error,
prompt_yes_no,
)
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
_OPENCLAW_SCRIPT = (
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
/ "migration"
/ "openclaw-migration"
/ "scripts"
/ "openclaw_to_hermes.py"
)
# Fallback: user may have installed the skill from the Hub
_OPENCLAW_SCRIPT_INSTALLED = (
get_hermes_home()
/ "skills"
/ "migration"
/ "openclaw-migration"
/ "scripts"
/ "openclaw_to_hermes.py"
)
# Known OpenClaw directory names (current + legacy)
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot")
def _detect_openclaw_processes() -> list[str]:
"""Detect running OpenClaw processes and services.
Returns a list of human-readable descriptions of what was found.
An empty list means nothing was detected.
"""
found: list[str] = []
# -- systemd service (Linux) ------------------------------------------
if sys.platform != "win32":
try:
result = subprocess.run(
["systemctl", "--user", "is-active", "openclaw-gateway.service"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
)
if result.stdout.strip() == "active":
found.append("systemd service: openclaw-gateway.service")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# -- process scan ------------------------------------------------------
if sys.platform == "win32":
# bounded_probe_run: a plain subprocess.run(timeout=...) can hang
# forever on Windows in post-timeout cleanup when a conhost.exe
# descendant holds duplicated pipe handles (#87134) — and a hang is
# not an exception, so the try/except here can't save the caller.
from hermes_cli._subprocess_compat import bounded_probe_run
try:
for exe in ("openclaw.exe", "clawd.exe"):
result = bounded_probe_run(
["tasklist", "/FI", f"IMAGENAME eq {exe}"],
timeout=5,
)
if result is not None and exe in (result.stdout or "").lower():
found.append(f"process: {exe}")
# Node.js-hosted OpenClaw — tasklist doesn't show command lines,
# so fall back to PowerShell.
ps_cmd = (
'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | '
'Where-Object { $_.CommandLine -match "openclaw|clawd" } | '
'Select-Object -First 1 ProcessId'
)
result = bounded_probe_run(
["powershell", "-NoProfile", "-Command", ps_cmd],
timeout=5,
)
if result is not None and (result.stdout or "").strip():
found.append(f"node.exe process with openclaw in command line (PID {result.stdout.strip()})")
except Exception:
pass
else:
try:
result = subprocess.run(
["pgrep", "-f", "openclaw"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3,
)
if result.returncode == 0:
pids = result.stdout.strip().split()
found.append(f"openclaw process(es) (PIDs: {', '.join(pids)})")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return found
def _warn_if_openclaw_running(auto_yes: bool) -> None:
"""Warn if OpenClaw is still running before migration.
Telegram, Discord, and Slack only allow one active connection per bot
token. Migrating while OpenClaw is running causes both to fight for the
same token.
"""
running = _detect_openclaw_processes()
if not running:
return
print()
print_error("OpenClaw appears to be running:")
for detail in running:
print_info(f" * {detail}")
print_info(
"Messaging platforms (Telegram, Discord, Slack) only allow one "
"active session per bot token. If you continue, both OpenClaw and "
"Hermes may try to use the same token, causing disconnects."
)
print_info("Recommendation: stop OpenClaw before migrating.")
print()
if auto_yes:
return
if not sys.stdin.isatty():
print_info("Non-interactive session — continuing to preview only.")
return
if not prompt_yes_no("Continue anyway?", default=False):
print_info("Migration cancelled. Stop OpenClaw and try again.")
sys.exit(0)
def _warn_if_gateway_running(auto_yes: bool) -> None:
"""Check if a Hermes gateway is running with connected platforms.
Migrating bot tokens while the gateway is polling will cause conflicts
(e.g. Telegram 409 "terminated by other getUpdates request"). Warn the
user and let them decide whether to continue.
"""
from gateway.status import get_running_pid, read_runtime_status
if not get_running_pid():
return
data = read_runtime_status() or {}
platforms = data.get("platforms") or {}
connected = [name for name, info in platforms.items()
if isinstance(info, dict) and info.get("state") == "connected"]
if not connected:
return
print()
print_error(
"Hermes gateway is running with active connections: "
+ ", ".join(connected)
)
print_info(
"Migrating bot tokens while the gateway is active will cause "
"conflicts (Telegram, Discord, and Slack only allow one active "
"session per token)."
)
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.")
sys.exit(0)
# State files commonly found in OpenClaw workspace directories — listed
# during cleanup to help the user decide whether to archive
_WORKSPACE_STATE_GLOBS = (
"*/todo.json",
"*/sessions/*",
"*/memory/*.json",
"*/logs/*",
)
def _find_migration_script() -> Path | None:
"""Find the openclaw_to_hermes.py script in known locations."""
for candidate in [_OPENCLAW_SCRIPT, _OPENCLAW_SCRIPT_INSTALLED]:
if candidate.exists():
return candidate
return None
def _load_migration_module(script_path: Path):
"""Dynamically load the migration script as a module."""
spec = importlib.util.spec_from_file_location("openclaw_to_hermes", script_path)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
# Register in sys.modules so @dataclass can resolve the module
# (Python 3.11+ requires this for dynamically loaded modules)
sys.modules[spec.name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(spec.name, None)
raise
return mod
def _find_openclaw_dirs() -> list[Path]:
"""Find all OpenClaw directories on disk."""
found = []
for name in _OPENCLAW_DIR_NAMES:
candidate = Path.home() / name
if candidate.is_dir():
found.append(candidate)
return found
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
"""Scan an OpenClaw directory for workspace state files.
Returns a list of (path, description) tuples.
"""
findings: list[tuple[Path, str]] = []
if not source_dir.exists():
return findings
# Direct state files in the root
for name in ("todo.json", "sessions", "logs"):
candidate = source_dir / name
if candidate.exists():
kind = "directory" if candidate.is_dir() else "file"
findings.append((candidate, f"Root {kind}: {name}"))
# State files inside workspace directories
try:
children = sorted(source_dir.iterdir())
except OSError:
return findings
for child in children:
if not child.is_dir() or child.name.startswith("."):
continue
# Check for workspace-like subdirectories
for state_name in ("todo.json", "sessions", "logs", "memory"):
state_path = child / state_name
if state_path.exists():
kind = "directory" if state_path.is_dir() else "file"
rel = state_path.relative_to(source_dir).as_posix()
findings.append((state_path, f"Workspace {kind}: {rel}"))
return findings
def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path:
"""Rename an OpenClaw directory to .pre-migration.
Returns the archive path.
"""
timestamp = datetime.now().strftime("%Y%m%d")
archive_name = f"{source_dir.name}.pre-migration"
archive_path = source_dir.parent / archive_name
# If archive already exists, add timestamp
if archive_path.exists():
archive_name = f"{source_dir.name}.pre-migration-{timestamp}"
archive_path = source_dir.parent / archive_name
# If still exists (multiple runs same day), add counter
counter = 2
while archive_path.exists():
archive_name = f"{source_dir.name}.pre-migration-{timestamp}-{counter}"
archive_path = source_dir.parent / archive_name
counter += 1
if not dry_run:
source_dir.rename(archive_path)
return archive_path
def claw_command(args):
"""Route hermes claw subcommands."""
action = getattr(args, "claw_action", None)
if action == "migrate":
_cmd_migrate(args)
elif action in {"cleanup", "clean"}:
_cmd_cleanup(args)
else:
print("Usage: hermes claw <command> [options]")
print()
print("Commands:")
print(" migrate Migrate settings from OpenClaw to Hermes")
print(" cleanup Archive leftover OpenClaw directories after migration")
print()
print("Run 'hermes claw <command> --help' for options.")
def _cmd_migrate(args):
"""Run the OpenClaw → Hermes migration."""
# Check current and legacy OpenClaw directories
explicit_source = getattr(args, "source", None)
if explicit_source:
source_dir = Path(explicit_source)
else:
source_dir = Path.home() / ".openclaw"
if not source_dir.is_dir():
# Try legacy directory names
for legacy in (".clawdbot", ".moltbot"):
candidate = Path.home() / legacy
if candidate.is_dir():
source_dir = candidate
break
dry_run = getattr(args, "dry_run", False)
preset = getattr(args, "preset", "full")
overwrite = getattr(args, "overwrite", False)
migrate_secrets = getattr(args, "migrate_secrets", False)
workspace_target = getattr(args, "workspace_target", None)
skill_conflict = getattr(args, "skill_conflict", "skip")
no_backup = getattr(args, "no_backup", False)
# Secrets are never included implicitly — they must be explicitly requested
# via --migrate-secrets, even under --preset full. This mirrors OpenClaw's
# migrate-hermes posture (two-phase: run once without secrets, rerun with
# --include-secrets) and prevents a --preset full invocation from silently
# importing API keys that the user may not have intended to copy.
print()
print(
color(
"┌─────────────────────────────────────────────────────────┐",
Colors.MAGENTA,
)
)
print(
color(
"│ ⚕ Hermes — OpenClaw Migration │",
Colors.MAGENTA,
)
)
print(
color(
"└─────────────────────────────────────────────────────────┘",
Colors.MAGENTA,
)
)
# Check source directory
if not source_dir.is_dir():
print()
print_error(f"OpenClaw directory not found: {source_dir}")
print_info("Make sure your OpenClaw installation is at the expected path.")
print_info("You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
return
# Find the migration script
script_path = _find_migration_script()
if not script_path:
print()
print_error("Migration script not found.")
print_info("Expected at one of:")
print_info(f" {_OPENCLAW_SCRIPT}")
print_info(f" {_OPENCLAW_SCRIPT_INSTALLED}")
print_info("Make sure the openclaw-migration skill is installed.")
return
# Show what we're doing
hermes_home = get_hermes_home()
auto_yes = getattr(args, "yes", False)
print()
print_header("Migration Settings")
print_info(f"Source: {source_dir}")
print_info(f"Target: {hermes_home}")
print_info(f"Preset: {preset}")
print_info(f"Overwrite: {'yes' if overwrite else 'no (skip conflicts)'}")
print_info(f"Secrets: {'yes (allowlisted only)' if migrate_secrets else 'no'}")
if skill_conflict != "skip":
print_info(f"Skill conflicts: {skill_conflict}")
if workspace_target:
print_info(f"Workspace: {workspace_target}")
print()
# Check if OpenClaw is still running — migrating tokens while both are
# active will cause conflicts (e.g. Telegram 409).
_warn_if_openclaw_running(auto_yes)
# Check if a Hermes gateway is running with connected platforms.
_warn_if_gateway_running(auto_yes)
# Ensure config.yaml exists before migration tries to read it
config_path = get_config_path()
if not config_path.exists():
save_config(load_config())
# Load the migration module
try:
mod = _load_migration_module(script_path)
if mod is None:
print_error("Could not load migration script.")
return
except Exception as e:
print()
print_error(f"Could not load migration script: {e}")
logger.debug("OpenClaw migration error", exc_info=True)
return
selected = mod.resolve_selected_options(None, None, preset=preset)
ws_target = Path(workspace_target).resolve() if workspace_target else None
# ── Phase 1: Always preview first ──────────────────────────
try:
preview = mod.Migrator(
source_root=source_dir.resolve(),
target_root=hermes_home.resolve(),
execute=False,
workspace_target=ws_target,
overwrite=overwrite,
migrate_secrets=migrate_secrets,
output_dir=None,
selected_options=selected,
preset_name=preset,
skill_conflict_mode=skill_conflict,
)
preview_report = preview.migrate()
except Exception as e:
print()
print_error(f"Migration preview failed: {e}")
logger.debug("OpenClaw migration preview error", exc_info=True)
return
preview_summary = preview_report.get("summary", {})
preview_count = preview_summary.get("migrated", 0)
preview_conflicts = preview_summary.get("conflict", 0)
# "Nothing to migrate" means nothing migrated AND nothing blocked by
# conflicts. If there are conflicts, we still want to show the plan and
# surface the refusal/--overwrite guidance instead of silently bailing.
if preview_count == 0 and preview_conflicts == 0:
print()
print_info("Nothing to migrate from OpenClaw.")
_print_migration_report(preview_report, dry_run=True)
return
print()
if preview_count > 0:
print_header(f"Migration Preview — {preview_count} item(s) would be imported")
else:
print_header(
f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported"
)
print_info("No changes have been made yet. Review the list below:")
_print_migration_report(preview_report, dry_run=True)
# If --dry-run, stop here
if dry_run:
return
# ── Phase 1b: Refuse if the plan has conflicts and --overwrite is not set ─
# Modelled on OpenClaw's assertConflictFreePlan() — apply is a safe no-op
# on conflicts unless the user explicitly opts in to overwriting. Without
# this guard, the user would answer "yes, proceed" and silently end up
# with a migration that skipped every conflicting item.
if preview_conflicts > 0 and not overwrite:
print()
print_error(
f"Plan has {preview_conflicts} conflict(s). Refusing to apply."
)
print_info(
"Each conflict is an item whose target already exists in ~/.hermes/. "
"Re-run with --overwrite to replace conflicting targets (item-level "
"backups are written to the migration report directory)."
)
print_info("Or re-run with --dry-run to review the full plan.")
return
# ── Phase 2: Confirm and execute ───────────────────────────
print()
if not auto_yes:
if not sys.stdin.isatty():
print_info("Non-interactive session — preview only.")
print_info("To execute, re-run with: hermes claw migrate --yes")
return
if not prompt_yes_no("Proceed with migration?", default=True):
print_info("Migration cancelled.")
return
# ── Phase 2b: Pre-apply backup of the Hermes home ─────────
# Delegates to hermes_cli.backup.create_pre_migration_backup(), which
# shares implementation with the pre-update backup (same exclusion
# rules, same SQLite safe-copy, zip format) so the archive is
# restorable with `hermes import`. Mirrors OpenClaw's
# createPreMigrationBackup posture — one atomic restore point before
# any mutation, auto-pruned to the last 5 pre-migration zips.
backup_archive: Optional[Path] = None
if not no_backup:
try:
from hermes_cli.backup import create_pre_migration_backup, _format_size
backup_archive = create_pre_migration_backup(hermes_home=hermes_home)
if backup_archive:
size_str = _format_size(backup_archive.stat().st_size)
print()
print_success(f"Pre-migration backup: {backup_archive} ({size_str})")
print_info(f"Restore with: hermes import {backup_archive.name}")
except Exception as e:
print()
print_error(f"Could not create pre-migration backup: {e}")
print_info(
"Re-run with --no-backup to skip, or free up disk space under the Hermes home."
)
logger.debug("Pre-migration backup error", exc_info=True)
return
try:
migrator = mod.Migrator(
source_root=source_dir.resolve(),
target_root=hermes_home.resolve(),
execute=True,
workspace_target=ws_target,
overwrite=overwrite,
migrate_secrets=migrate_secrets,
output_dir=None,
selected_options=selected,
preset_name=preset,
skill_conflict_mode=skill_conflict,
)
report = migrator.migrate()
except Exception as e:
print()
print_error(f"Migration failed: {e}")
logger.debug("OpenClaw migration error", exc_info=True)
if backup_archive:
print_info(f"A pre-migration backup is available at: {backup_archive}")
print_info(f"Restore with: hermes import {backup_archive.name}")
return
# Print results
_print_migration_report(report, dry_run=False)
# Source directory is left untouched — archiving is not the migration
# tool's responsibility. Users who want to clean up can run
# 'hermes claw cleanup' separately.
def _cmd_cleanup(args):
"""Archive leftover OpenClaw directories after migration.
Scans for OpenClaw directories that still exist after migration and offers
to rename them to .pre-migration to free disk space.
"""
dry_run = getattr(args, "dry_run", False)
auto_yes = getattr(args, "yes", False)
explicit_source = getattr(args, "source", None)
print()
print(
color(
"┌─────────────────────────────────────────────────────────┐",
Colors.MAGENTA,
)
)
print(
color(
"│ ⚕ Hermes — OpenClaw Cleanup │",
Colors.MAGENTA,
)
)
print(
color(
"└─────────────────────────────────────────────────────────┘",
Colors.MAGENTA,
)
)
# Find OpenClaw directories
if explicit_source:
dirs_to_check = [Path(explicit_source)]
else:
dirs_to_check = _find_openclaw_dirs()
if not dirs_to_check:
print()
print_success("No OpenClaw directories found. Nothing to clean up.")
return
# Warn if OpenClaw is still running — archiving while the service is
# active causes it to recreate an empty skeleton directory (#8502).
running = _detect_openclaw_processes()
if running:
print()
print_error("OpenClaw appears to be still running:")
for detail in running:
print_info(f" * {detail}")
print_info(
"Archiving .openclaw/ while the service is active may cause it to "
"immediately recreate an empty skeleton directory, destroying your config."
)
print_info("Stop OpenClaw first: systemctl --user stop openclaw-gateway.service")
print()
if not auto_yes:
if not sys.stdin.isatty():
print_info("Non-interactive session — aborting. Stop OpenClaw and re-run.")
return
if not prompt_yes_no("Proceed anyway?", default=False):
print_info("Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup")
return
total_archived = 0
for source_dir in dirs_to_check:
print()
print_header(f"Found: {source_dir}")
# Scan for state files
state_files = _scan_workspace_state(source_dir)
# Show directory stats
try:
workspace_dirs = [
d for d in source_dir.iterdir()
if d.is_dir() and not d.name.startswith(".")
and any((d / name).exists() for name in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md"))
]
except OSError:
workspace_dirs = []
if workspace_dirs:
print_info(f"Workspace directories: {len(workspace_dirs)}")
for ws in workspace_dirs[:5]:
items = []
if (ws / "todo.json").exists():
items.append("todo.json")
if (ws / "sessions").is_dir():
items.append("sessions/")
if (ws / "SOUL.md").exists():
items.append("SOUL.md")
if (ws / "MEMORY.md").exists():
items.append("MEMORY.md")
detail = ", ".join(items) if items else "empty"
print(f" {ws.name}/ ({detail})")
if len(workspace_dirs) > 5:
print(f" ... and {len(workspace_dirs) - 5} more")
if state_files:
print()
print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW))
for path, desc in state_files[:8]:
print(f" {desc}")
if len(state_files) > 8:
print(f" ... and {len(state_files) - 8} more")
print()
if dry_run:
archive_path = _archive_directory(source_dir, dry_run=True)
print_info(f"Would archive: {source_dir}{archive_path}")
elif not auto_yes and not sys.stdin.isatty():
print_info(f"Non-interactive session — would archive: {source_dir}")
print_info("To execute, re-run with: hermes claw cleanup --yes")
elif auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True):
try:
archive_path = _archive_directory(source_dir)
print_success(f"Archived: {source_dir}{archive_path}")
total_archived += 1
except OSError as e:
print_error(f"Could not archive: {e}")
print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration")
else:
print_info("Skipped.")
# Summary
print()
if dry_run:
_n_dirs = len(dirs_to_check)
print_info(
f"Dry run complete. {_n_dirs} "
f"{'directory' if _n_dirs == 1 else 'directories'} would be archived."
)
print_info("Run without --dry-run to archive them.")
elif total_archived:
print_success(
f"Cleaned up {total_archived} OpenClaw "
f"{'directory' if total_archived == 1 else 'directories'}."
)
print_info("Directories were renamed, not deleted. You can undo by renaming them back.")
else:
print_info("No directories were archived.")
def _print_migration_report(report: dict, dry_run: bool):
"""Print a formatted migration report."""
summary = report.get("summary", {})
migrated = summary.get("migrated", 0)
skipped = summary.get("skipped", 0)
conflicts = summary.get("conflict", 0)
errors = summary.get("error", 0)
print()
if dry_run:
print_header("Dry Run Results")
print_info("No files were modified. This is a preview of what would happen.")
else:
print_header("Migration Results")
print()
# Detailed items
items = report.get("items", [])
if items:
# Group by status
migrated_items = [i for i in items if i.get("status") == "migrated"]
skipped_items = [i for i in items if i.get("status") == "skipped"]
conflict_items = [i for i in items if i.get("status") == "conflict"]
error_items = [i for i in items if i.get("status") == "error"]
if migrated_items:
label = "Would migrate" if dry_run else "Migrated"
print(color(f"{label}:", Colors.GREEN))
for item in migrated_items:
kind = item.get("kind", "unknown")
dest = item.get("destination", "")
if dest:
dest_short = str(dest).replace(str(Path.home()), "~")
print(f" {kind:<22s}{dest_short}")
else:
print(f" {kind}")
print()
if conflict_items:
print(color(" ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW))
for item in conflict_items:
kind = item.get("kind", "unknown")
reason = item.get("reason", "already exists")
print(f" {kind:<22s} {reason}")
print()
if skipped_items:
print(color(" ─ Skipped:", Colors.DIM))
for item in skipped_items:
kind = item.get("kind", "unknown")
reason = item.get("reason", "")
print(f" {kind:<22s} {reason}")
print()
if error_items:
print(color(" ✗ Errors:", Colors.RED))
for item in error_items:
kind = item.get("kind", "unknown")
reason = item.get("reason", "unknown error")
print(f" {kind:<22s} {reason}")
print()
# Summary line
parts = []
if migrated:
action = "would migrate" if dry_run else "migrated"
parts.append(f"{migrated} {action}")
if conflicts:
parts.append(f"{conflicts} conflict(s)")
if skipped:
parts.append(f"{skipped} skipped")
if errors:
parts.append(f"{errors} error(s)")
if parts:
print_info(f"Summary: {', '.join(parts)}")
else:
print_info("Nothing to migrate.")
# Output directory
output_dir = report.get("output_dir")
if output_dir:
print_info(f"Full report saved to: {output_dir}")
if dry_run:
print()
print_info("To execute the migration, run without --dry-run:")
print_info(f" hermes claw migrate --preset {report.get('preset', 'full')}")
elif migrated:
print()
print_success("Migration complete!")
# Warn if API keys were skipped (migrate_secrets not enabled)
skipped_keys = [
i for i in report.get("items", [])
if i.get("kind") == "provider-keys" and i.get("status") == "skipped"
]
if skipped_keys:
print()
print(color(" ⚠ API keys were NOT migrated (secrets migration is disabled by default).", Colors.YELLOW))
print(color(" Your OPENROUTER_API_KEY and other provider keys must be added manually.", Colors.YELLOW))
print()
print_info("To migrate API keys, re-run with:")
print_info(" hermes claw migrate --migrate-secrets")
print()
print_info("Or add your key manually:")
print_info(" hermes config set OPENROUTER_API_KEY sk-or-v1-...")
+962
View File
@@ -0,0 +1,962 @@
"""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
from utils import base_url_host_matches
def _single_query_clarify_callback(question: str, choices=None, multi_select=False) -> str:
"""Clarify has no interactive surface in a single-query (-q) turn.
``hermes chat -q`` runs one turn without ever building the
prompt_toolkit application, so the interactive clarify modal can never
be painted or answered the CLI callback would poll its response queue
until ``agent.clarify_timeout`` expires (default 3600 s, 0 = unlimited)
while the gateway/cron/kanban-dispatcher caller sees a silent hang. The
oneshot path answers immediately via ``_oneshot_clarify_callback``;
single-query turns need the same headless behavior (#94943)."""
if choices:
if multi_select:
return (
f"[single-query mode: no user available to answer {question!r}. "
f"Pick the best subset from {choices} using your own judgment "
f"and continue.]"
)
return (
f"[single-query mode: no user available to answer {question!r}. "
f"Pick the best option from {choices} using your own judgment "
f"and continue.]"
)
return (
f"[single-query mode: no user available to answer {question!r}. Make "
f"the most reasonable assumption you can and continue.]"
)
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:
from hermes_cli.fallback_config import resolve_entry_api_key
_fb_kwargs = {"requested": _fb_provider}
if _fb.get("base_url"):
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
_fb_api_key = resolve_entry_api_key(_fb)
if _fb_api_key:
_fb_kwargs["explicit_api_key"] = _fb_api_key
runtime = resolve_runtime_provider(**_fb_kwargs)
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 not base_url_host_matches(base_url, "openrouter.ai")
)
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:
_prov = (resolved_provider or self.requested_provider or "").strip()
if _prov and _prov != "auto":
print(f"\n⚠️ No API key found for provider '{_prov}'.")
else:
print("\n⚠️ No inference provider is configured.")
print(" Run 'hermes model' to choose a provider, or "
"'hermes setup' for first-time 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 _runtime_credentials_ready(self) -> bool:
"""Silently probe whether any inference provider can be resolved.
Unlike ``_ensure_runtime_credentials`` this never prints and never
mutates CLI state it exists so the interactive first-run path can
detect a completely unconfigured install *before* the user types a
message into a chat that cannot work (#62935-adjacent UX class:
keyless first run must route into onboarding, not a broken chat).
"""
from hermes_cli.runtime_provider import resolve_runtime_provider
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:
return False
if not isinstance(runtime, dict):
return False
api_key = runtime.get("api_key")
base_url = runtime.get("base_url")
if callable(api_key) and not isinstance(api_key, str):
return bool(base_url)
if isinstance(api_key, str) and api_key:
return bool(base_url)
# Keyless custom/local endpoints (ollama, llama.cpp, vLLM…) are fine.
return bool(
isinstance(base_url, str)
and base_url
and not base_url_host_matches(base_url, "openrouter.ai")
)
def _offer_first_run_setup(self) -> bool:
"""Offer the provider picker when no provider is configured at all.
Called from the interactive startup path when
``_runtime_credentials_ready()`` is False and stdin is a TTY. Runs the
exact same flow as ``hermes model`` (which fronts Quick Setup / Nous
Portal OAuth as the first, recommended option) so there is a single
source of truth for provider onboarding. Returns True when a provider
was configured.
"""
from cli import _cprint, logger
_cprint("")
_cprint("⚕ No inference provider is configured yet — let's fix that.")
_cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; "
"no API key needed) and a model.")
try:
answer = input(" Set up a provider now? [Y/n]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
answer = "n"
if answer in {"n", "no"}:
_cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.")
return False
try:
from hermes_cli.main import select_provider_and_model
select_provider_and_model()
except (KeyboardInterrupt, EOFError, SystemExit):
print()
_cprint(" Setup cancelled. Run 'hermes model' any time.")
return False
except Exception as exc:
logger.debug("first-run provider setup failed: %s", exc)
_cprint(f" ⚠️ Provider setup failed: {exc}")
_cprint(" Run 'hermes model' to try again.")
return False
# Re-sync CLI state from what the picker persisted so the very next
# turn uses the new provider without a restart.
try:
from hermes_cli.config import load_config
_model_cfg = (load_config().get("model") or {})
if isinstance(_model_cfg, dict):
_new_provider = (_model_cfg.get("provider") or "").strip()
if _new_provider:
self.requested_provider = _new_provider
_new_model = (
_model_cfg.get("default") or _model_cfg.get("model") or ""
).strip()
if _new_model:
self.model = _new_model
except Exception as exc:
logger.debug("first-run config re-sync failed: %s", exc)
# Force credential re-resolution + agent rebuild on next use.
self.agent = None
self._active_agent_route_signature = None
if self._runtime_credentials_ready():
_cprint(" ✓ Provider configured — you're ready to chat.")
return True
_cprint(" Provider setup didn't complete. Run 'hermes model' to retry.")
return False
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,
"requested_provider": getattr(
self, "requested_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["requested_provider"],
runtime["base_url"],
runtime["api_mode"],
runtime["command"],
tuple(runtime["args"]),
),
}
service_tier = getattr(self, "service_tier", None)
if service_tier != "priority":
# None (normal) or auto/cold — the bounded window is applied per
# request by agent.fast_mode, not pinned into request_overrides.
route["request_overrides"] = None
return route
try:
overrides = resolve_fast_mode_overrides(
route["model"],
provider=runtime["provider"],
base_url=runtime["base_url"],
)
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
# Join the background preloaded-skills load (cli.py cmd_chat starts
# it when --skills/-s is passed) BEFORE the agent snapshots
# self.system_prompt below. No-op when nothing was requested.
self.finalize_preloaded_skills()
_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 ensure_mcp_discovery_before_agent_build
ensure_mcp_discovery_before_agent_build(
logger=logger,
single_query=getattr(self, "_single_query_mode", False),
)
# 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
prior_resume_error = getattr(self, "_resume_history_error", None)
if prior_resume_error:
return False
# This path loads only the TIP session's rows (no ancestors),
# so guard with a tip-only count — the full-lineage count would
# over-reject heavily-compressed sessions with a small tip.
resume_limit_error = self._resume_history_limit_error(tip_only=True)
if resume_limit_error:
self._resume_history_error = resume_limit_error
if _quiet_mode:
print(f"Cannot resume session: {resume_limit_error}", file=sys.stderr)
else:
ChatConsole().print(
f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}"
)
return False
restored = self._session_db.get_messages_as_conversation(
self.session_id, repair_alternation=True
)
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)
self._restore_session_yolo(session_meta, quiet=_quiet_mode)
self._restore_session_model(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.reopen_session(self.session_id)
except Exception:
pass
try:
runtime = runtime_override or {
"api_key": self.api_key,
"base_url": self.base_url,
"provider": self.provider,
"requested_provider": getattr(
self, "requested_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"),
requested_provider=runtime.get("requested_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,
run_budget_seconds=getattr(self, "run_budget_seconds", None),
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,
# A -q turn never builds the prompt_toolkit application, so
# the interactive modal can never be painted or answered —
# answer headless instead of polling until clarify_timeout
# (#94943; mirrors _oneshot_clarify_callback on the -z path).
clarify_callback=(
_single_query_clarify_callback
if getattr(self, "_single_query_mode", False)
else 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,
reaction_callback=self._on_reaction,
)
# Store reference for atexit memory provider shutdown.
# NOTE: this MUST write to the ``cli`` module's global, not a
# local module global. ``_run_cleanup`` (in cli.py) reads
# ``cli._active_agent_ref`` to decide whether to fire the memory
# provider's ``on_session_end`` hook. When this code lived in
# cli.py a bare ``global _active_agent_ref`` worked; after the
# god-file extraction into this mixin a ``global`` here would bind
# *this module's* namespace, leaving ``cli._active_agent_ref`` None
# forever — so memory shutdown never ran on /exit (#49287).
import cli as _cli
_cli._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("requested_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:
console = ChatConsole()
console.print(f"[bold red]Failed to initialize agent: {e}[/]")
from hermes_constants import partial_update_hint
for line in partial_update_hint(e):
console.print(line)
return False
def _resume_history_limit_error(self, tip_only: bool = False):
"""Return a safe-resume error without materializing transcript rows.
``tip_only`` matches call sites that load only the tip session's rows
(``get_messages_as_conversation`` without ancestors) counting the
full lineage there would over-reject heavily-compressed sessions
whose tip is small. Generic guard failures fail OPEN (resume
proceeds) only a genuine over-limit result blocks.
"""
if not self._session_db:
return None
from hermes_state import (
SessionResumeTooLargeError,
)
try:
safety_check = getattr(self._session_db, "assert_resume_safe", None)
if not callable(safety_check):
return None
if tip_only:
safety_check(self.session_id, tip_only=True)
else:
safety_check(self.session_id)
except SessionResumeTooLargeError as exc:
return str(exc)
except Exception as exc:
logger.warning(
"Resume safety check failed for %s (proceeding without guard): %s",
self.session_id, exc,
)
return None
return None
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
resume_limit_error = self._resume_history_limit_error()
if resume_limit_error:
self._resume_history_error = resume_limit_error
self._console_print(
f"[bold red]Cannot resume session:[/] {resume_limit_error}"
)
return False
model_history, display_history = self._session_db.get_resume_conversations(self.session_id)
restored = model_history
if restored:
restored = [m for m in restored if m.get("role") != "session_meta"]
self.conversation_history = restored
self._resume_display_history = [
m for m in display_history if m.get("role") != "session_meta"
]
from agent.context_compressor import is_user_originated_turn
# Count only user-originated turns (#80622): legacy compaction
# handoffs are durable role=user rows without display_kind.
msg_count = len(
[
m
for m in self._resume_display_history
if is_user_originated_turn(m)
]
)
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)
self._restore_session_yolo(session_meta)
self._restore_session_model(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.reopen_session(self.session_id)
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
from tools.ansi_strip import sanitize_display_text as _sanitize_display_text
display_history = getattr(self, "_resume_display_history", self.conversation_history)
if not display_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 display_history:
role = msg.get("role", "")
display_kind = msg.get("display_kind")
content = msg.get("content")
tool_calls = msg.get("tool_calls") or []
if display_kind == "hidden":
continue
if display_kind == "model_switch":
entries.append(("event", "model changed"))
continue
if display_kind == "async_delegation_complete":
entries.append(("event", "background delegation completed"))
continue
if display_kind == "auto_continue":
entries.append(("event", "resumed interrupted turn"))
continue
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)
# Stored history is untrusted for display: strip escape
# sequences/control chars so replaying a message can't
# clear the screen, retitle the window, or restyle the
# recap panel (see tools/ansi_strip.sanitize_display_text).
text = _sanitize_display_text(text)
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 = _sanitize_display_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 == "event":
lines.append(f"{text}\n", style="dim italic")
elif role == "user":
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
# Show first line inline, indent rest
msg_lines = text.splitlines() or [""]
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() or [""]
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() or [""]
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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
"""Shared CLI output helpers for Hermes CLI modules.
Extracts the identical ``print_info/success/warning/error`` and ``prompt()``
functions previously duplicated across setup.py, tools_config.py,
mcp_config.py, and memory_setup.py.
"""
import sys
from hermes_cli.colors import Colors, color
from hermes_cli.secret_prompt import masked_secret_prompt
# ─── Print Helpers ────────────────────────────────────────────────────────────
def print_info(text: str) -> None:
"""Print a dim informational message."""
print(color(f" {text}", Colors.DIM))
def print_success(text: str) -> None:
"""Print a green success message with ✓ prefix."""
print(color(f"{text}", Colors.GREEN))
def print_warning(text: str) -> None:
"""Print a yellow warning message with ⚠ prefix."""
print(color(f"{text}", Colors.YELLOW))
def print_error(text: str) -> None:
"""Print a red error message with ✗ prefix."""
print(color(f"{text}", Colors.RED))
def print_header(text: str) -> None:
"""Print a bold yellow header."""
print(color(f"\n {text}", Colors.YELLOW))
# ─── Input Prompts ────────────────────────────────────────────────────────────
def line_input(prompt_text: str) -> str:
"""Read non-secret text with normal cursor-editing keys on a real TTY.
Setup and model-selection commands run outside the interactive chat's
prompt-toolkit application, so they can safely use a short-lived prompt
here. Redirected input and output retain the built-in ``input`` behavior
used by scripts, tests, and numbered fallbacks.
"""
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return input(prompt_text)
try:
from prompt_toolkit import prompt as prompt_toolkit_prompt
from prompt_toolkit.formatted_text import ANSI
except ImportError:
return input(prompt_text)
try:
return prompt_toolkit_prompt(ANSI(prompt_text))
except (KeyboardInterrupt, EOFError):
raise
except Exception:
# Some terminals report isatty() == True yet reject registering stdin
# with the asyncio event-loop selector (observed on macOS, where kqueue
# raises EINVAL / "Invalid argument" for fd 0). prompt_toolkit cannot
# attach its input there, so fall back to the built-in line reader,
# which needs no selector and works in cooked mode. Any prompt_toolkit
# runtime failure (OSError, ValueError, RuntimeError) degrades the same
# way — the wizard proceeds instead of crashing.
return input(prompt_text)
def prompt(
question: str,
default: str | None = None,
password: bool = False,
) -> str:
"""Prompt the user for input with optional default and password masking.
Replaces the four independent ``_prompt()`` / ``prompt()`` implementations
in setup.py, tools_config.py, mcp_config.py, and memory_setup.py.
Returns the user's input (stripped), or *default* if the user presses Enter.
Returns empty string on Ctrl-C or EOF.
"""
suffix = f" [{default}]" if default else ""
display = color(f" {question}{suffix}: ", Colors.YELLOW)
try:
if password:
value = masked_secret_prompt(display)
else:
value = line_input(display)
value = value.strip()
return value if value else (default or "")
except (KeyboardInterrupt, EOFError):
print()
return ""
def prompt_yes_no(question: str, default: bool = True) -> bool:
"""Prompt for a yes/no answer. Returns bool."""
hint = "Y/n" if default else "y/N"
answer = prompt(f"{question} ({hint})")
if not answer:
return default
return answer.lower().startswith("y")
+568
View File
@@ -0,0 +1,568 @@
"""Clipboard image extraction for macOS, Windows, Linux, and WSL2.
Provides a single function `save_clipboard_image(dest)` that checks the
system clipboard for image data, saves it to *dest* as PNG, and returns
True on success. No external Python dependencies uses only OS-level
CLI tools that ship with the platform (or are commonly installed).
Platform support:
macOS osascript (always available), pngpaste (if installed)
Windows PowerShell via WinForms, Get-Clipboard, file-drop fallback
WSL2 powershell.exe via WinForms, Get-Clipboard, file-drop fallback
Linux wl-paste (Wayland), xclip (X11)
"""
import base64
import logging
import os
import subprocess
import sys
from pathlib import Path
from hermes_constants import is_wsl as _is_wsl
logger = logging.getLogger(__name__)
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def save_clipboard_image(dest: Path) -> bool:
"""Extract an image from the system clipboard and save it as PNG.
Returns True if an image was found and saved, False otherwise.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
if sys.platform == "darwin":
return _macos_save(dest)
if sys.platform == "win32":
return _windows_save(dest)
return _linux_save(dest)
def has_clipboard_image() -> bool:
"""Quick check: does the clipboard currently contain an image?
Lighter than save_clipboard_image doesn't extract or write anything.
"""
if sys.platform == "darwin":
return _macos_has_image()
if sys.platform == "win32":
return _windows_has_image()
# Match _linux_save fallthrough order: WSL → Wayland → X11
if _is_wsl() and _wsl_has_image():
return True
if os.environ.get("WAYLAND_DISPLAY") and _wayland_has_image():
return True
return _xclip_has_image()
# ── Text write (native tools, mirrors ui-tui/src/lib/clipboard.ts) ──────
def _powershell_write_script(b64: str) -> str:
# PowerShell decodes piped stdin with the system ANSI code page (e.g.
# CP936), not UTF-8, so stdin-based writes mangle CJK/emoji. Base64 the
# UTF-8 bytes and decode inside PowerShell instead — same approach as
# the TUI's writeClipboardText.
return (
"Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString("
f"[System.Convert]::FromBase64String('{b64}')))"
)
def _write_clipboard_commands() -> list:
"""Return (cmd_argv, use_stdin) candidates in platform fallback order."""
if sys.platform == "darwin":
return [(["pbcopy"], True)]
if sys.platform == "win32":
return [(["powershell", "-NoProfile", "-NonInteractive"], False)]
attempts = []
if _is_wsl():
attempts.append((["powershell.exe", "-NoProfile", "-NonInteractive"], False))
if os.environ.get("WAYLAND_DISPLAY"):
attempts.append((["wl-copy", "--type", "text/plain"], True))
attempts.append((["xclip", "-selection", "clipboard", "-in"], True))
attempts.append((["xsel", "--clipboard", "--input"], True))
return attempts
def is_remote_shell_session(env=None) -> bool:
"""True when running inside an SSH session.
Mirrors ui-tui/src/lib/terminalSetup.ts isRemoteShellSession(). Over
SSH, native clipboard tools write the REMOTE machine's clipboard (or
an X-forwarded one), which is almost never what the user wants
OSC 52 reaches the LOCAL terminal emulator instead.
"""
e = os.environ if env is None else env
return bool(
e.get("SSH_CONNECTION") or e.get("SSH_TTY") or e.get("SSH_CLIENT")
)
def write_clipboard_text(text: str) -> bool:
"""Write *text* to the system clipboard via native platform tools.
Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts):
macOS pbcopy Windows/WSL PowerShell Set-Clipboard wl-copy
xclip xsel. Returns True if any backend succeeded; callers should
fall back to OSC 52 on False.
"""
for argv, use_stdin in _write_clipboard_commands():
try:
if use_stdin:
proc = subprocess.run(
argv, input=text.encode("utf-8"),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10,
)
else:
b64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
proc = subprocess.run(
argv + ["-Command", _powershell_write_script(b64)],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10,
)
if proc.returncode == 0:
return True
except (OSError, subprocess.SubprocessError):
continue
return False
# ── macOS ────────────────────────────────────────────────────────────────
def _macos_save(dest: Path) -> bool:
"""Try pngpaste first (fast, handles more formats), fall back to osascript."""
return _macos_pngpaste(dest) or _macos_osascript(dest)
def _macos_has_image() -> bool:
"""Check if macOS clipboard contains image data."""
try:
info = subprocess.run(
["osascript", "-e", "clipboard info"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3,
)
return "«class PNGf»" in info.stdout or "«class TIFF»" in info.stdout
except Exception:
return False
def _macos_pngpaste(dest: Path) -> bool:
"""Use pngpaste (brew install pngpaste) — fastest, cleanest."""
try:
r = subprocess.run(
["pngpaste", str(dest)],
capture_output=True, timeout=3,
)
if r.returncode == 0 and dest.exists() and dest.stat().st_size > 0:
return True
except FileNotFoundError:
pass # pngpaste not installed
except Exception as e:
logger.debug("pngpaste failed: %s", e)
return False
def _macos_osascript(dest: Path) -> bool:
"""Use osascript to extract PNG data from clipboard (always available)."""
if not _macos_has_image():
return False
# Extract as PNG
script = (
'try\n'
' set imgData to the clipboard as «class PNGf»\n'
f' set f to open for access POSIX file "{dest}" with write permission\n'
' write imgData to f\n'
' close access f\n'
'on error\n'
' return "fail"\n'
'end try\n'
)
try:
r = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
)
if r.returncode == 0 and "fail" not in r.stdout and dest.exists() and dest.stat().st_size > 0:
return True
except Exception as e:
logger.debug("osascript clipboard extract failed: %s", e)
return False
# ── Shared PowerShell scripts (native Windows + WSL2) ─────────────────────
# .NET System.Windows.Forms.Clipboard — used by both native Windows (powershell)
# and WSL2 (powershell.exe) paths.
_PS_CHECK_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"[System.Windows.Forms.Clipboard]::ContainsImage()"
)
_PS_EXTRACT_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"Add-Type -AssemblyName System.Drawing;"
"$img = [System.Windows.Forms.Clipboard]::GetImage();"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);"
"[System.Convert]::ToBase64String($ms.ToArray())"
)
_PS_CHECK_IMAGE_GET_CLIPBOARD = (
"try { "
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
"if ($null -ne $img) { 'True' } else { 'False' }"
"} catch { 'False' }"
)
_PS_EXTRACT_IMAGE_GET_CLIPBOARD = (
"try { "
"Add-Type -AssemblyName System.Drawing;"
"Add-Type -AssemblyName PresentationCore;"
"Add-Type -AssemblyName WindowsBase;"
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"if ($img -is [System.Drawing.Image]) {"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)"
"} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {"
"$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;"
"$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));"
"$enc.Save($ms)"
"} else { exit 2 }"
"[System.Convert]::ToBase64String($ms.ToArray())"
"} catch { exit 1 }"
)
_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'"
_PS_CHECK_FILEDROP_IMAGE = (
"try { "
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
"if ($null -ne $hit) { 'True' } else { 'False' }"
"} catch { 'False' }"
)
_PS_EXTRACT_FILEDROP_IMAGE = (
"try { "
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
"if ($null -eq $hit) { exit 1 }"
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))"
"} catch { exit 1 }"
)
_POWERSHELL_HAS_IMAGE_SCRIPTS = (
_PS_CHECK_IMAGE,
_PS_CHECK_IMAGE_GET_CLIPBOARD,
_PS_CHECK_FILEDROP_IMAGE,
)
_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = (
_PS_EXTRACT_IMAGE,
_PS_EXTRACT_IMAGE_GET_CLIPBOARD,
_PS_EXTRACT_FILEDROP_IMAGE,
)
def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
return subprocess.run(
[exe, "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout,
)
def _write_base64_image(dest: Path, b64_data: str) -> bool:
image_bytes = base64.b64decode(b64_data, validate=True)
dest.write_bytes(image_bytes)
return dest.exists() and dest.stat().st_size > 0
def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool:
for script in _POWERSHELL_HAS_IMAGE_SCRIPTS:
try:
r = _run_powershell(exe, script, timeout=timeout)
if r.returncode == 0 and "True" in r.stdout:
return True
except FileNotFoundError:
logger.debug("%s not found — clipboard unavailable", exe)
return False
except Exception as e:
logger.debug("%s clipboard image check failed: %s", label, e)
return False
def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool:
for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS:
try:
r = _run_powershell(exe, script, timeout=timeout)
if r.returncode != 0:
continue
b64_data = r.stdout.strip()
if not b64_data:
continue
if _write_base64_image(dest, b64_data):
return True
except FileNotFoundError:
logger.debug("%s not found — clipboard unavailable", exe)
return False
except Exception as e:
logger.debug("%s clipboard image extraction failed: %s", label, e)
dest.unlink(missing_ok=True)
return False
# ── Native Windows ────────────────────────────────────────────────────────
# Native Windows uses ``powershell`` (Windows PowerShell 5.1, always present)
# or ``pwsh`` (PowerShell 7+, optional). Discovery is cached per-process.
def _find_powershell() -> str | None:
"""Return the first available PowerShell executable, or None."""
for name in ("powershell", "pwsh"):
try:
r = subprocess.run(
[name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
)
if r.returncode == 0 and "ok" in r.stdout:
return name
except FileNotFoundError:
continue
except Exception:
continue
return None
# Cache the resolved PowerShell executable (checked once per process)
_ps_exe: str | None | bool = False # False = not yet checked
def _get_ps_exe() -> str | None:
global _ps_exe
if _ps_exe is False:
_ps_exe = _find_powershell()
return _ps_exe
def _windows_has_image() -> bool:
"""Check if the Windows clipboard contains an image."""
ps = _get_ps_exe()
if ps is None:
return False
return _powershell_has_image(ps, timeout=5, label="Windows")
def _windows_save(dest: Path) -> bool:
"""Extract clipboard image on native Windows via PowerShell → base64 PNG."""
ps = _get_ps_exe()
if ps is None:
logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
return False
return _powershell_save_image(ps, dest, timeout=15, label="Windows")
# ── Linux ────────────────────────────────────────────────────────────────
def _linux_save(dest: Path) -> bool:
"""Try clipboard backends in priority order: WSL → Wayland → X11."""
if _is_wsl():
if _wsl_save(dest):
return True
# Fall through — WSLg might have wl-paste or xclip working
if os.environ.get("WAYLAND_DISPLAY"):
if _wayland_save(dest):
return True
return _xclip_save(dest)
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
# Reuses _PS_CHECK_IMAGE / _PS_EXTRACT_IMAGE defined above.
def _wsl_has_image() -> bool:
"""Check if Windows clipboard has an image (via powershell.exe)."""
return _powershell_has_image("powershell.exe", timeout=8, label="WSL")
def _wsl_save(dest: Path) -> bool:
"""Extract clipboard image via powershell.exe → base64 → decode to PNG."""
return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL")
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
def _wayland_has_image() -> bool:
"""Check if Wayland clipboard has image content."""
try:
r = subprocess.run(
["wl-paste", "--list-types"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3,
)
return r.returncode == 0 and any(
t.startswith("image/") for t in r.stdout.splitlines()
)
except FileNotFoundError:
logger.debug("wl-paste not installed — Wayland clipboard unavailable")
except Exception:
pass
return False
def _wayland_save(dest: Path) -> bool:
"""Use wl-paste to extract clipboard image (Wayland sessions)."""
try:
# Check available MIME types
types_r = subprocess.run(
["wl-paste", "--list-types"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3,
)
if types_r.returncode != 0:
return False
types = types_r.stdout.splitlines()
# Prefer PNG, fall back to other image formats
mime = None
for preferred in ("image/png", "image/jpeg", "image/bmp",
"image/gif", "image/webp"):
if preferred in types:
mime = preferred
break
if not mime:
return False
# Extract the image data
with open(dest, "wb") as f:
subprocess.run(
["wl-paste", "--type", mime],
stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True,
)
if not dest.exists() or dest.stat().st_size == 0:
dest.unlink(missing_ok=True)
return False
# save_clipboard_image() promises a PNG output path. Wayland can offer
# JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before
# returning success.
if mime != "image/png":
if not _convert_to_png(dest) or not _is_png_file(dest):
dest.unlink(missing_ok=True)
return False
return True
except FileNotFoundError:
logger.debug("wl-paste not installed — Wayland clipboard unavailable")
except Exception as e:
logger.debug("wl-paste clipboard extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False
def _convert_to_png(path: Path) -> bool:
"""Convert an image file to PNG in-place (requires Pillow or ImageMagick)."""
# Try Pillow first (likely installed in the venv)
try:
from PIL import Image
img = Image.open(path)
img.save(path, "PNG")
return True
except ImportError:
pass
except Exception as e:
logger.debug("Pillow BMP→PNG conversion failed: %s", e)
# Fall back to ImageMagick convert
tmp = path.with_suffix(".bmp")
try:
path.rename(tmp)
r = subprocess.run(
["convert", str(tmp), "png:" + str(path)],
capture_output=True, timeout=5,
)
if r.returncode == 0 and path.exists() and path.stat().st_size > 0:
tmp.unlink(missing_ok=True)
return True
else:
# Convert failed — restore the original file
tmp.rename(path)
except FileNotFoundError:
logger.debug("ImageMagick not installed — cannot convert BMP to PNG")
if tmp.exists() and not path.exists():
tmp.rename(path)
except Exception as e:
logger.debug("ImageMagick BMP→PNG conversion failed: %s", e)
if tmp.exists() and not path.exists():
tmp.rename(path)
# Can't convert — BMP is still usable as-is for most APIs
return path.exists() and path.stat().st_size > 0
def _is_png_file(path: Path) -> bool:
"""Return True when *path* starts with the PNG file signature."""
try:
with path.open("rb") as f:
return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE
except OSError:
return False
# ── X11 (xclip) ─────────────────────────────────────────────────────────
def _xclip_has_image() -> bool:
"""Check if X11 clipboard has image content."""
try:
r = subprocess.run(
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3,
)
return r.returncode == 0 and "image/png" in r.stdout
except FileNotFoundError:
pass
except Exception:
pass
return False
def _xclip_save(dest: Path) -> bool:
"""Use xclip to extract clipboard image (X11 sessions)."""
# Check if clipboard has image content
try:
targets = subprocess.run(
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3,
)
if "image/png" not in targets.stdout:
return False
except FileNotFoundError:
logger.debug("xclip not installed — X11 clipboard image paste unavailable")
return False
except Exception:
return False
# Extract PNG data
try:
with open(dest, "wb") as f:
subprocess.run(
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True,
)
if dest.exists() and dest.stat().st_size > 0:
return True
except Exception as e:
logger.debug("xclip image extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False
+282
View File
@@ -0,0 +1,282 @@
"""Codex model discovery from API, local cache, and config."""
from __future__ import annotations
import base64
import json
import logging
from pathlib import Path
from typing import List, Optional
import os
logger = logging.getLogger(__name__)
DEFAULT_CODEX_MODELS: List[str] = [
# GPT-5.6 series (Sol/Terra/Luna). The public API exposes "-pro"
# variants, but the ChatGPT Codex OAuth backend rejects them with HTTP 400,
# so the curated offline fallback must not surface those dead choices.
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4-mini",
"gpt-5.4",
"gpt-5.3-codex",
# gpt-5.3-codex-spark is in research preview and is exposed *only* via
# the Codex CLI / OAuth backend (chatgpt.com/backend-api/codex/models)
# for ChatGPT Pro subscribers. It is NOT available in the public OpenAI
# API, so it intentionally stays out of the "openai" provider catalog
# in hermes_cli/models.py — only the openai-codex (OAuth) provider
# surfaces it. The Codex backend reports ``supported_in_api: false`` for
# this slug; that flag describes API availability, not Codex backend
# availability, so the fetch/cache code paths below intentionally do
# not filter on it. PR #12994 removed this entry on the assumption it
# was unsupported — that was wrong; restored here. Keep it in the
# 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",
# 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.6-sol", ("gpt-5.5", "gpt-5.4")),
("gpt-5.6-terra", ("gpt-5.5", "gpt-5.4")),
("gpt-5.6-luna", ("gpt-5.5", "gpt-5.4")),
("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-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",)),
]
def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
"""Add Clawdbot-style synthetic forward-compat Codex models.
If a newer Codex slug isn't returned by live discovery, surface it when an
older compatible template model is present. This mirrors Clawdbot's
synthetic catalog / forward-compat behavior for GPT-5 Codex variants.
"""
ordered: List[str] = []
seen: set[str] = set()
for model_id in model_ids:
if model_id not in seen:
ordered.append(model_id)
seen.add(model_id)
for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
if synthetic_model in seen:
continue
if any(template in seen for template in template_models):
ordered.append(synthetic_model)
seen.add(synthetic_model)
return ordered
def _add_context_variants(model_ids: List[str]) -> List[str]:
"""Insert ``-900k`` large-context picker variants after eligible base slugs.
The ChatGPT Codex backend advertises 272K for the gpt-5.4 / gpt-5.6
families but accepts ~911K (live-verified Aug 2026). The base slugs keep
the cheaper advertised 272K limit by default; each verified slug gets an
explicit ``<slug>-900k`` picker entry that opts into the large window.
The suffix is Hermes-side only it is stripped before the model id hits
the wire (agent/transports/codex.py, agent/auxiliary_client.py).
"""
from agent.model_metadata import (
CODEX_CONTEXT_VARIANT_SUFFIX,
has_codex_context_variant,
)
out: List[str] = []
present = set(model_ids)
for model_id in model_ids:
out.append(model_id)
variant = model_id + CODEX_CONTEXT_VARIANT_SUFFIX
if variant in present or variant in out:
continue
if has_codex_context_variant(model_id):
out.append(variant)
return out
def _finalize_codex_models(model_ids: List[str]) -> List[str]:
"""Forward-compat synthesis + large-context variant synthesis."""
return _add_context_variants(_add_forward_compat_models(model_ids))
def _extract_chatgpt_account_id(access_token: str) -> Optional[str]:
"""Best-effort extraction of ``chatgpt_account_id`` from the OAuth JWT.
The Codex backend requires the ``ChatGPT-Account-Id`` header for the
per-account catalog. Without it, ``GET /backend-api/codex/models``
returns ``{"models":[]}`` (HTTP 200) which masquerades as "no
models available" and silently degrades the picker to the curated
fallback list. The request-side path in ``auxiliary_client.py``
already extracts the same claim; this mirrors that logic here so the
probe sees the same catalog the request path will actually use.
Returns ``None`` on any parse error the probe then degrades
gracefully to the unauthenticated fallback list instead of crashing.
"""
try:
parts = access_token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
acct_id = (
claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
if isinstance(claims, dict)
else None
)
return acct_id if isinstance(acct_id, str) and acct_id else None
except Exception:
return None
def _fetch_models_from_api(access_token: str) -> List[str]:
"""Fetch available models from the Codex API. Returns visible models sorted by priority."""
try:
import httpx
headers = {"Authorization": f"Bearer {access_token}"}
acct_id = _extract_chatgpt_account_id(access_token)
if acct_id:
headers["ChatGPT-Account-Id"] = acct_id
resp = httpx.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers=headers,
timeout=10,
)
if resp.status_code != 200:
return []
data = resp.json()
entries = data.get("models", []) if isinstance(data, dict) else []
except Exception as exc:
logger.debug("Failed to fetch Codex models from API: %s", exc)
return []
sortable = []
for item in entries:
if not isinstance(item, dict):
continue
slug = item.get("slug")
if not isinstance(slug, str) or not slug.strip():
continue
slug = slug.strip()
# Codex CLI's catalog uses ``supported_in_api`` for the public OpenAI
# API, not for the OAuth-backed Codex backend that this provider uses.
# Some valid Codex CLI models (for example gpt-5.3-codex-spark) are
# marked false here but are still accepted by the Codex route.
visibility = item.get("visibility", "")
if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}:
continue
priority = item.get("priority")
rank = int(priority) if isinstance(priority, (int, float)) else 10_000
sortable.append((rank, slug))
sortable.sort(key=lambda x: (x[0], x[1]))
return _finalize_codex_models([slug for _, slug in sortable])
def _read_default_model(codex_home: Path) -> Optional[str]:
config_path = codex_home / "config.toml"
if not config_path.exists():
return None
try:
import tomllib
except Exception:
return None
try:
payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
except Exception:
return None
model = payload.get("model") if isinstance(payload, dict) else None
if isinstance(model, str) and model.strip():
return model.strip()
return None
def _read_cache_models(codex_home: Path) -> List[str]:
cache_path = codex_home / "models_cache.json"
if not cache_path.exists():
return []
try:
raw = json.loads(cache_path.read_text(encoding="utf-8"))
except Exception:
return []
entries = raw.get("models") if isinstance(raw, dict) else None
sortable = []
if isinstance(entries, list):
for item in entries:
if not isinstance(item, dict):
continue
slug = item.get("slug")
if not isinstance(slug, str) or not slug.strip():
continue
slug = slug.strip()
# Do not filter on ``supported_in_api`` here. It describes the
# public OpenAI API, while Hermes openai-codex talks to the same
# OAuth-backed Codex backend as Codex CLI.
visibility = item.get("visibility")
if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}:
continue
priority = item.get("priority")
rank = int(priority) if isinstance(priority, (int, float)) else 10_000
sortable.append((rank, slug))
sortable.sort(key=lambda item: (item[0], item[1]))
deduped: List[str] = []
for _, slug in sortable:
if slug not in deduped:
deduped.append(slug)
return deduped
def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
"""Return available Codex model IDs, trying API first, then local sources.
Resolution order: API (live, if token provided) > config.toml default >
local cache > hardcoded defaults.
"""
codex_home_str = os.getenv("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
codex_home = Path(codex_home_str).expanduser()
ordered: List[str] = []
# Try live API if we have a token
if access_token:
api_models = _fetch_models_from_api(access_token)
if api_models:
return _finalize_codex_models(api_models)
# Fall back to local sources
default_model = _read_default_model(codex_home)
if default_model:
ordered.append(default_model)
for model_id in _read_cache_models(codex_home):
if model_id not in ordered:
ordered.append(model_id)
for model_id in DEFAULT_CODEX_MODELS:
if model_id not in ordered:
ordered.append(model_id)
return _finalize_codex_models(ordered)
@@ -0,0 +1,757 @@
"""Migrate Hermes' MCP server config and Codex's installed curated plugins
to the format Codex expects in ~/.codex/config.toml.
When the user enables the codex_app_server runtime, the codex subprocess
runs its own MCP client and its own plugin runtime (Linear, Atlassian,
Asana, plus per-account ChatGPT apps via app/list). For both of those to
be useful, the user's choices need to be visible to codex too. This
module:
1. Reads Hermes' YAML and writes equivalent [mcp_servers.<name>]
entries to ~/.codex/config.toml.
2. Queries codex's `plugin/list` for the openai-curated marketplace
and writes [plugins."<name>@<marketplace>"] entries for any plugin
the user has installed=true on their codex CLI. (This is what
OpenClaw calls "migrate native codex plugins" the YouTube-video-
worthy bit Pash highlighted: Canva, GitHub, Calendar, Gmail
pre-configured.)
3. Writes a [permissions] default profile so users on this runtime
don't get an approval prompt on every write attempt.
What translates (MCP servers):
Hermes mcp_servers.<n>.command/args/env codex stdio transport
Hermes mcp_servers.<n>.url/headers codex streamable_http transport
Hermes mcp_servers.<n>.timeout codex tool_timeout_sec
Hermes mcp_servers.<n>.connect_timeout codex startup_timeout_sec
What does NOT translate (warned + skipped):
Hermes-specific keys (sampling, etc.) codex's MCP client has no
equivalent. Listed in the per-server skipped[] field of the report.
What's NOT migrated (intentional):
AGENTS.md codex respects this file natively in its cwd. Hermes' own
AGENTS.md (project-level) is already in the worktree, so codex picks
it up without translation. No code needed.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Marker comments wrapping the managed section so re-runs can detect
# what's ours and what's user-edited. Both must appear or strip is a no-op.
MIGRATION_MARKER = (
"# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section"
)
MIGRATION_END_MARKER = (
"# end hermes-agent managed section"
)
@dataclass
class MigrationReport:
"""Outcome of a migration pass."""
target_path: Optional[Path] = None
migrated: list[str] = field(default_factory=list)
skipped_keys_per_server: dict[str, list[str]] = field(default_factory=dict)
migrated_plugins: list[str] = field(default_factory=list)
plugin_query_error: Optional[str] = None
wrote_permissions_default: Optional[str] = None
errors: list[str] = field(default_factory=list)
written: bool = False
dry_run: bool = False
def summary(self) -> str:
lines = []
if self.dry_run:
lines.append(f"(dry run) Would write {self.target_path}")
elif self.written:
lines.append(f"Wrote {self.target_path}")
if self.migrated:
lines.append(f"Migrated {len(self.migrated)} MCP server(s):")
for name in self.migrated:
skipped = self.skipped_keys_per_server.get(name, [])
note = (
f" (skipped: {', '.join(skipped)})" if skipped else ""
)
lines.append(f" - {name}{note}")
else:
lines.append("No MCP servers found in Hermes config.")
if self.migrated_plugins:
lines.append(
f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):"
)
for name in self.migrated_plugins:
lines.append(f" - {name}")
elif self.plugin_query_error:
lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}")
if self.wrote_permissions_default:
lines.append(
f"Wrote default_permissions = "
f"{self.wrote_permissions_default!r}"
)
for err in self.errors:
lines.append(f"{err}")
return "\n".join(lines)
# Hermes keys that codex's MCP schema doesn't support — dropped during
# migration with a warning. Anything not on the keep list AND not the
# transport keys is added to skipped.
_KNOWN_HERMES_KEYS = {
# transport — stdio
"command", "args", "env", "cwd",
# transport — http
"url", "headers", "transport",
# timeouts
"timeout", "connect_timeout",
# general
"enabled", "description",
}
# Subset that have a direct codex equivalent.
_KEYS_DROPPED_WITH_WARNING = {
# Hermes' sampling subsection — codex MCP has no equivalent
"sampling",
}
def _translate_one_server(
name: str, hermes_cfg: dict
) -> tuple[Optional[dict], list[str]]:
"""Translate one Hermes MCP server config to the codex inline-table dict
representation. Returns (codex_entry, skipped_keys).
codex_entry is a dict ready for TOML serialization, or None when the
server can't be translated (e.g. neither command nor url present)."""
if not isinstance(hermes_cfg, dict):
return None, []
skipped: list[str] = []
out: dict[str, Any] = {}
has_command = bool(hermes_cfg.get("command"))
has_url = bool(hermes_cfg.get("url"))
if has_command and has_url:
skipped.append("url (both command and url set; preferring stdio)")
has_url = False
if has_command:
# Stdio transport
out["command"] = str(hermes_cfg["command"])
args = hermes_cfg.get("args") or []
if args:
out["args"] = [str(a) for a in args]
env = hermes_cfg.get("env") or {}
if env:
# Codex expects string values
out["env"] = {str(k): str(v) for k, v in env.items()}
cwd = hermes_cfg.get("cwd")
if cwd:
out["cwd"] = str(cwd)
elif has_url:
# streamable_http transport (codex covers both http and SSE here)
out["url"] = str(hermes_cfg["url"])
headers = hermes_cfg.get("headers") or {}
if headers:
out["http_headers"] = {str(k): str(v) for k, v in headers.items()}
# Hermes' transport: sse hint is informational; codex auto-negotiates
if hermes_cfg.get("transport") == "sse":
skipped.append("transport=sse (codex auto-negotiates)")
else:
return None, ["no command or url field"]
# Timeouts
if "timeout" in hermes_cfg:
try:
out["tool_timeout_sec"] = float(hermes_cfg["timeout"])
except (TypeError, ValueError):
skipped.append("timeout (not numeric)")
if "connect_timeout" in hermes_cfg:
try:
out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"])
except (TypeError, ValueError):
skipped.append("connect_timeout (not numeric)")
# Enabled flag (codex defaults to true so we only emit when explicitly false)
if hermes_cfg.get("enabled") is False:
out["enabled"] = False
# Detect keys we explicitly drop with warning
for key in hermes_cfg:
if key in _KEYS_DROPPED_WITH_WARNING:
skipped.append(f"{key} (no codex equivalent)")
elif key not in _KNOWN_HERMES_KEYS:
skipped.append(f"{key} (unknown Hermes key)")
return out, skipped
def _format_toml_value(value: Any) -> str:
"""Minimal TOML value formatter for the value types we emit.
We only emit strings, numbers, booleans, and tables of those no nested
arrays of tables. This covers everything codex's MCP schema accepts."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, str):
# Escape per TOML basic-string rules. Order matters: backslash
# first so the other escapes don't get re-escaped.
# Control characters (newline, tab, etc.) must use \-escapes
# because TOML basic strings don't allow literal control chars
# — passing them through would produce invalid TOML that codex
# would refuse to load. Paths usually don't contain control
# chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could
# in pathological cases.
escaped = (
value
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\b", "\\b")
.replace("\t", "\\t")
.replace("\n", "\\n")
.replace("\f", "\\f")
.replace("\r", "\\r")
)
return f'"{escaped}"'
if isinstance(value, list):
items = ", ".join(_format_toml_value(v) for v in value)
return f"[{items}]"
if isinstance(value, dict):
items = ", ".join(
f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items()
)
return "{ " + items + " }" if items else "{}"
raise ValueError(f"Unsupported TOML value type: {type(value).__name__}")
def _quote_key(key: str) -> str:
"""Return key bare-or-quoted depending on whether it's a valid bare key."""
if all(c.isalnum() or c in "-_" for c in key) and key:
return key
escaped = key.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def render_codex_toml_section(
servers: dict[str, dict],
plugins: Optional[list[dict]] = None,
default_permission_profile: Optional[str] = None,
) -> str:
"""Render the managed [mcp_servers.<n>] / [plugins.<id>] / [permissions]
block for ~/.codex/config.toml.
Args:
servers: dict of MCP server name translated codex inline-table
plugins: optional list of {name, marketplace, enabled} for native
Codex plugins to enable. (E.g. the Linear / Atlassian / Asana
curated plugins, or per-account ChatGPT apps.)
default_permission_profile: when set, write `[permissions] default`
so the user doesn't get an approval prompt on every write
attempt. Common values: "workspace-write", "read-only",
"full-access".
"""
out = [MIGRATION_MARKER]
if not servers and not plugins and not default_permission_profile:
out.append("# (no MCP servers, plugins, or permissions configured by Hermes)")
out.append(MIGRATION_END_MARKER)
return "\n".join(out) + "\n"
if default_permission_profile:
# Codex's config schema: `default_permissions` is a top-level
# string referencing a profile name. Built-in profile names start
# with ":" (":workspace-write", ":read-only", ":full-access"). The
# [permissions] table is for *user-defined* named profiles with
# structured fields — not what we want.
normalized = (
default_permission_profile
if default_permission_profile.startswith(":")
else f":{default_permission_profile}"
)
out.append("")
out.append(f"default_permissions = {_format_toml_value(normalized)}")
if servers:
for name in sorted(servers.keys()):
cfg = servers[name]
out.append("")
out.append(f"[mcp_servers.{_quote_key(name)}]")
for k, v in cfg.items():
out.append(f"{_quote_key(k)} = {_format_toml_value(v)}")
if plugins:
for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"):
name = plugin.get("name") or ""
marketplace = plugin.get("marketplace") or "openai-curated"
enabled = bool(plugin.get("enabled", True))
qualified = f"{name}@{marketplace}"
out.append("")
out.append(f'[plugins.{_quote_key(qualified)}]')
out.append(f"enabled = {_format_toml_value(enabled)}")
out.append("")
out.append(MIGRATION_END_MARKER)
return "\n".join(out) + "\n"
def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str:
"""Insert Hermes' managed Codex TOML block while keeping root keys root-scoped.
TOML has no syntax to return to the document root after a table header.
Therefore appending a root key like `default_permissions = ...` after a
user table such as `[features]` actually creates `features.default_permissions`,
which Codex rejects. Insert the managed block before the first table header
so its root keys remain top-level, while preserving user content verbatim.
"""
if not user_text.strip():
return managed_block
lines = user_text.splitlines(keepends=True)
first_table_idx: Optional[int] = None
for idx, line in enumerate(lines):
stripped = line.lstrip()
if stripped.startswith("["):
first_table_idx = idx
break
if first_table_idx is None:
prefix = user_text.rstrip("\n")
return f"{prefix}\n\n{managed_block}" if prefix else managed_block
prefix = "".join(lines[:first_table_idx]).rstrip("\n")
suffix = "".join(lines[first_table_idx:]).lstrip("\n")
if prefix:
return f"{prefix}\n\n{managed_block}\n{suffix}"
return f"{managed_block}\n{suffix}"
def _strip_unmanaged_plugin_tables(toml_text: str) -> str:
"""Remove ``[plugins."<name>@<marketplace>"]`` tables that live OUTSIDE the
managed block.
Codex itself writes these tables when the user runs ``codex plugins enable``
directly (i.e. before Hermes' migrate has ever touched the file). When we
later run migrate, ``_query_codex_plugins()`` reports the same plugins via
the live ``plugin/list`` RPC and we re-emit them inside the managed block.
The result without this strip is duplicate ``[plugins."X@Y"]`` table
headers codex's strict TOML parser then refuses to load the file.
We own the ``[plugins.*]`` namespace once migrate has run, so dropping any
pre-existing ``[plugins.*]`` tables is safe: ``plugin/list`` is the source
of truth for what's actually installed. The caller is expected to only
invoke this strip when ``plugin/list`` succeeded otherwise we'd lose
plugins the user installed via ``codex`` without a way to re-emit them.
Behavior:
* Lines beginning with ``[plugins.`` start a swallow region that ends at
the next non-``[plugins.`` table header or end-of-file.
* Content inside the managed block is untouched (callers should run
``_strip_existing_managed_block`` first so the managed block has
already been removed when this runs).
"""
lines = toml_text.splitlines(keepends=True)
out: list[str] = []
in_plugin_table = False
for line in lines:
stripped = line.lstrip()
# Only treat a line as a table header when it has the shape
# ``[...]`` (optionally followed by a comment). Multi-line array
# continuations like ``["nested"],`` also start with ``[`` after
# lstrip but are not headers — without this guard they would
# falsely flip ``in_plugin_table`` to False mid-table and leak
# array fragments into the output.
if _looks_like_table_header(stripped):
in_plugin_table = stripped.startswith("[plugins.")
if in_plugin_table:
continue
if in_plugin_table:
# Swallow keys/comments/blanks until the next table header.
continue
out.append(line)
return "".join(out)
def _looks_like_table_header(stripped_line: str) -> bool:
"""Return True if ``stripped_line`` is a TOML table header.
A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables),
optionally followed by a comment. The closing ``]`` (or ``]]``) must
appear on the same line, and no key-assignment ``=`` can precede it.
This distinguishes real headers from multi-line array continuation
lines that also start with ``[`` after ``lstrip()``.
"""
if not stripped_line.startswith("["):
return False
# Drop trailing comment so e.g. ``[features] # note`` still matches.
head = stripped_line.split("#", 1)[0].rstrip()
if not head.endswith("]"):
return False
# ``key = [x]`` would have an ``=`` before the bracket; a header doesn't.
bracket_idx = head.index("]")
return "=" not in head[: bracket_idx + 1]
def _strip_existing_managed_block(toml_text: str) -> str:
"""Remove any prior managed section so re-runs idempotently replace it.
The managed section is everything between MIGRATION_MARKER (start) and
MIGRATION_END_MARKER (end), inclusive of both markers. User-edited
sections above or below are preserved verbatim.
Backward compatibility: if the start marker is found but no end marker
follows, we fall back to the heuristic that swallows lines until we
hit a section that's not [mcp_servers.*]/[plugins.*]/[permissions]/
a `default_permissions =` key. This matches what older versions of
this code wrote so re-runs don't break configs from prior Hermes
versions."""
lines = toml_text.splitlines(keepends=True)
out: list[str] = []
in_managed = False
saw_end_marker = False
for line in lines:
line_stripped_nl = line.rstrip("\n")
if line_stripped_nl == MIGRATION_MARKER:
in_managed = True
saw_end_marker = False
continue
if in_managed:
if line_stripped_nl == MIGRATION_END_MARKER:
in_managed = False
saw_end_marker = True
continue
stripped = line.lstrip()
if not saw_end_marker and stripped.startswith("[") and not (
stripped.startswith("[mcp_servers")
or stripped.startswith("[plugins")
or stripped.startswith("[permissions]")
or stripped.startswith("[permissions.")
):
# Old-format managed block without end marker: bail back
# to user content as soon as we see a non-managed section.
in_managed = False
out.append(line)
continue
# Otherwise swallow the line.
continue
out.append(line)
return "".join(out)
def _query_codex_plugins(
codex_home: Optional[Path] = None,
timeout: float = 8.0,
) -> tuple[list[dict], Optional[str]]:
"""Query codex's `plugin/list` for installed curated plugins.
Spawns `codex app-server` briefly, sends initialize + plugin/list,
extracts plugins where installed=true. Returns (plugins, error).
Plugins is a list of {name, marketplace, enabled} dicts ready for
render_codex_toml_section().
On any failure (codex not installed, RPC error, timeout) returns
([], error_message). Migration treats this as non-fatal MCP
servers and permissions still write through.
"""
try:
from agent.transports.codex_app_server import CodexAppServerClient
except Exception as exc:
return [], f"transport unavailable: {exc}"
try:
with CodexAppServerClient(
codex_home=str(codex_home) if codex_home else None
) as client:
client.initialize(client_name="hermes-migration")
resp = client.request("plugin/list", {}, timeout=timeout)
except Exception as exc:
return [], f"plugin/list query failed: {exc}"
out: list[dict] = []
seen: set[tuple[str, str]] = set()
marketplaces = resp.get("marketplaces") or []
if not isinstance(marketplaces, list):
return [], "plugin/list response missing 'marketplaces'"
for marketplace in marketplaces:
if not isinstance(marketplace, dict):
continue
market_name = str(marketplace.get("name") or "openai-curated")
plugins = marketplace.get("plugins") or []
if not isinstance(plugins, list):
continue
for plugin in plugins:
if not isinstance(plugin, dict):
continue
installed = bool(plugin.get("installed", False))
if not installed:
continue
# Skip plugins codex itself reports as unavailable (broken
# install, missing OAuth, removed from marketplace, etc.).
# Cf. openclaw/openclaw#80815 — OpenClaw learned to gate
# migration on app readiness to avoid writing config that
# would fail at activation time. Our migration writes to
# codex's config.toml directly, so a broken plugin would
# surface as a codex error on first use. Skipping it here
# keeps the migrated config clean and the user's first
# codex turn from failing.
availability = str(plugin.get("availability") or "").upper()
if availability and availability != "AVAILABLE":
logger.debug(
"skipping plugin %s: availability=%s",
plugin.get("name"), availability,
)
continue
name = str(plugin.get("name") or "")
if not name:
continue
key = (name, market_name)
if key in seen:
continue
seen.add(key)
# Carry forward whatever 'enabled' codex reports — defaults to
# true for installed plugins. This is the same shape OpenClaw
# writes when migrating native codex plugins.
out.append({
"name": name,
"marketplace": market_name,
"enabled": bool(plugin.get("enabled", True)),
})
return out, None
def _looks_like_test_tempdir(path: str) -> bool:
"""Heuristic: does ``path`` look like a pytest/transient tempdir?
pytest tempdirs live under ``pytest-of-<user>/pytest-<n>/`` (created via
``tmp_path`` / ``tmp_path_factory``) and are reaped between sessions.
macOS routes ``/tmp`` through ``/private/var/folders/<>/T`` which is
what pytest's tempdir factory uses by default. If a HERMES_HOME pointing
at one of those paths is burned into ``~/.codex/config.toml``, every
codex-routed hermes-tools call fails silently once the directory is GC'd.
We err on the side of refusing losing a (very unlikely) real
``~/.hermes`` symlink that happens to live under ``/private/var/folders``
is much less harmful than silently bricking codex's tool surface.
"""
if not path:
return False
needles = (
"pytest-of-",
"/pytest-",
"/tmp/pytest",
"/private/var/folders/", # macOS tempdir root
)
normalized = path.lower()
return any(needle in normalized for needle in needles)
def _build_hermes_tools_mcp_entry() -> dict:
"""Build the codex stdio-transport entry that launches Hermes' own
tool surface as an MCP server. Codex's subprocess will call back into
this for browser/web/delegate_task/vision/memory/skills tools.
The command runs the worktree's Python via the current sys.executable
so a hermes installed under /opt/, /usr/local/, or a venv all work.
HERMES_HOME and PYTHONPATH are passed through so the spawned process
sees the same config + module layout the user is running."""
import sys
env: dict[str, str] = {}
# HERMES_HOME passes through IF SET so the MCP subprocess sees the same
# config / auth / sessions DB as the parent CLI. Read from os.environ
# (not get_hermes_home()) on purpose: when the env var is unset we want
# codex's subprocess to inherit whatever HERMES_HOME its launcher sets
# at runtime (systemd unit, gateway, kanban dispatcher, custom shell),
# rather than burning the migrate-time resolved default into config.toml
# — that would override the launcher's HERMES_HOME and pin the subprocess
# to the wrong profile.
#
# The pytest-tempdir guard below catches the issue #26250 Bug C scenario:
# a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would
# otherwise leak a transient pytest tempdir into the user's real
# ~/.codex/config.toml and silently brick codex once the tempdir is GC'd.
hermes_home = os.environ.get("HERMES_HOME") or ""
if hermes_home and _looks_like_test_tempdir(hermes_home):
hermes_home = ""
if hermes_home:
env["HERMES_HOME"] = hermes_home
# PYTHONPATH passes through so a worktree-launched hermes finds the
# branch's modules instead of the installed package.
pythonpath = os.environ.get("PYTHONPATH")
if pythonpath:
env["PYTHONPATH"] = pythonpath
# Quiet mode + redaction defaults so the MCP wire stays clean.
env["HERMES_QUIET"] = "1"
env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true")
out: dict[str, Any] = {
"command": sys.executable,
"args": ["-m", "agent.transports.hermes_tools_mcp_server"],
}
if env:
out["env"] = env
# Generous timeouts — browser_navigate or delegate_task can take a
# while; we don't want codex's MCP client to give up too early.
out["startup_timeout_sec"] = 30.0
out["tool_timeout_sec"] = 600.0
return out
def migrate(
hermes_config: dict,
*,
codex_home: Optional[Path] = None,
dry_run: bool = False,
discover_plugins: bool = True,
default_permission_profile: Optional[str] = ":workspace",
expose_hermes_tools: bool = True,
) -> MigrationReport:
"""Translate Hermes mcp_servers config + Codex curated plugins into
~/.codex/config.toml.
Args:
hermes_config: full ~/.hermes/config.yaml dict
codex_home: override CODEX_HOME (defaults to ~/.codex)
dry_run: skip the actual write; report what would happen
discover_plugins: when True (default), query `plugin/list` against
the live codex CLI to migrate any installed curated plugins
into [plugins."<name>@<marketplace>"] entries. Set False to
skip the subprocess spawn (for tests or restricted environments).
default_permission_profile: when set (default ":workspace"), write
top-level `default_permissions = "<name>"` so users on this
runtime don't get an approval prompt on every write attempt.
Built-in codex profile names are ":workspace", ":read-only",
":danger-no-sandbox" (note the leading ":"). Also accepts a
user-defined profile name (no leading ":") that the user has
configured in their own [permissions.<name>] table. Set None
to leave permissions unset and let codex use its compiled-in
default (which is read-only).
expose_hermes_tools: when True (default), register Hermes' own
tool surface (web_search, browser_*, delegate_task, vision,
memory, skills, etc.) as an MCP server in ~/.codex/config.toml
so the codex subprocess can call back into Hermes for tools
codex doesn't have built in. Set False to opt out.
"""
report = MigrationReport(dry_run=dry_run)
codex_home = codex_home or Path.home() / ".codex"
target = codex_home / "config.toml"
report.target_path = target
hermes_servers = (hermes_config or {}).get("mcp_servers") or {}
if not isinstance(hermes_servers, dict):
report.errors.append(
"mcp_servers in Hermes config is not a dict; cannot migrate."
)
return report
translated: dict[str, dict] = {}
for name, cfg in hermes_servers.items():
out, skipped = _translate_one_server(str(name), cfg or {})
if out is None:
report.errors.append(
f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}"
)
continue
translated[str(name)] = out
if skipped:
report.skipped_keys_per_server[str(name)] = skipped
report.migrated.append(str(name))
# Discover installed Codex curated plugins. Best-effort — never blocks
# the migration if codex is unreachable or the RPC fails.
plugins: list[dict] = []
plugin_query_succeeded = False
if discover_plugins and not dry_run:
plugins, plugin_err = _query_codex_plugins(codex_home=codex_home)
if plugin_err:
report.plugin_query_error = plugin_err
else:
# plugin/list returned authoritatively (even if the list is empty).
# That means we own [plugins.*] for this re-render and can safely
# strip any pre-existing tables outside the managed block.
plugin_query_succeeded = True
for p in plugins:
report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}")
# Track whether we wrote a default permission profile so the report
# surfaces it to the user.
if default_permission_profile:
report.wrote_permissions_default = default_permission_profile
# Inject Hermes' own tool surface as an MCP server so the spawned
# codex subprocess can call back into Hermes for the tools codex
# doesn't ship with — web_search, browser_*, delegate_task, vision,
# memory, skills, session_search, image_generate, text_to_speech.
# The server itself is agent/transports/hermes_tools_mcp_server.py
# and is launched on demand by codex (stdio MCP).
if expose_hermes_tools:
translated["hermes-tools"] = _build_hermes_tools_mcp_entry()
if "hermes-tools" not in report.migrated:
report.migrated.append("hermes-tools")
# Build the new managed block
managed_block = render_codex_toml_section(
translated, plugins=plugins,
default_permission_profile=default_permission_profile,
)
# Read existing codex config if any, strip the prior managed block,
# append the new one.
if target.exists():
try:
existing = target.read_text(encoding="utf-8")
except Exception as exc:
report.errors.append(f"could not read {target}: {exc}")
return report
without_managed = _strip_existing_managed_block(existing)
# Bug B: when plugin/list ran authoritatively, codex's own
# [plugins."<name>@<marketplace>"] tables outside our managed block
# would survive _strip_existing_managed_block and then collide with
# the entries we re-emit inside the managed block — producing
# duplicate-table-header parse errors on codex's next startup. Drop
# those pre-existing tables since plugin/list is the source of truth.
if plugin_query_succeeded:
without_managed = _strip_unmanaged_plugin_tables(without_managed)
new_text = _insert_managed_block_at_top_level(without_managed, managed_block)
else:
new_text = managed_block
if dry_run:
return report
try:
codex_home.mkdir(parents=True, exist_ok=True)
# Atomic write: write to a temp file in the same directory then
# rename. Same-directory rename is atomic on POSIX and ReplaceFile
# on Windows. Avoids leaving a half-written config.toml that
# codex would refuse to load if we crash mid-write.
import tempfile
tmp_fd, tmp_path_str = tempfile.mkstemp(
prefix=".config.toml.", dir=str(codex_home)
)
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh:
fh.write(new_text)
tmp_path.replace(target)
except Exception:
# Clean up the temp file if the rename didn't happen.
try:
if tmp_path.exists():
tmp_path.unlink()
except Exception:
pass
raise
report.written = True
except Exception as exc:
report.errors.append(f"could not write {target}: {exc}")
return report
+279
View File
@@ -0,0 +1,279 @@
"""Shared logic for the /codex-runtime slash command.
Toggles `model.openai_runtime` between "auto" (= chat_completions, Hermes'
default) and "codex_app_server" (= hand turns to a codex subprocess).
Both CLI (cli.py) and gateway (gateway/run.py) call into this module so the
behavior stays identical across surfaces.
The actual runtime resolution happens in hermes_cli.runtime_provider's
_maybe_apply_codex_app_server_runtime() helper, which reads the persisted
config value. This module just persists the value and reports the change.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
VALID_RUNTIMES = ("auto", "codex_app_server")
@dataclass
class CodexRuntimeStatus:
"""Result of a /codex-runtime invocation. Callers render this however
suits their surface (CLI uses Rich panels, gateway sends a text message)."""
success: bool
new_value: Optional[str] = None
old_value: Optional[str] = None
message: str = ""
requires_new_session: bool = False
codex_binary_ok: bool = True
codex_version: Optional[str] = None
def parse_args(arg_string: str) -> tuple[Optional[str], list[str]]:
"""Parse the slash-command argument string. Returns (value, errors).
No args return current state (value=None)
'auto' / 'codex_app_server' / 'on' / 'off' return that value
anything else error
"""
raw = (arg_string or "").strip().lower()
if not raw:
return None, []
# Accept human-friendly synonyms
if raw in {"on", "codex", "enable"}:
return "codex_app_server", []
if raw in {"off", "default", "disable", "hermes"}:
return "auto", []
if raw in VALID_RUNTIMES:
return raw, []
return None, [
f"Unknown runtime {raw!r}. Use one of: auto, codex_app_server, on, off"
]
def get_current_runtime(config: dict) -> str:
"""Read the current `model.openai_runtime` value from a config dict.
Returns 'auto' for unset / empty / unrecognized values."""
if not isinstance(config, dict):
return "auto"
model_cfg = config.get("model") or {}
if not isinstance(model_cfg, dict):
return "auto"
value = str(model_cfg.get("openai_runtime") or "").strip().lower()
if value in VALID_RUNTIMES:
return value
return "auto"
def set_runtime(config: dict, new_value: str) -> str:
"""Mutate the config dict in place to persist the new runtime value.
Returns the previous value for callers that want to report a delta."""
if new_value not in VALID_RUNTIMES:
raise ValueError(
f"invalid runtime {new_value!r}; must be one of {VALID_RUNTIMES}"
)
old = get_current_runtime(config)
if not isinstance(config.get("model"), dict):
config["model"] = {}
config["model"]["openai_runtime"] = new_value
return old
def check_codex_binary_ok() -> tuple[bool, Optional[str]]:
"""Best-effort verification that codex CLI is installed at acceptable
version. Returns (ok, version_or_message)."""
try:
from agent.transports.codex_app_server import check_codex_binary
return check_codex_binary()
except Exception as exc: # pragma: no cover
return False, f"codex check failed: {exc}"
def apply(
config: dict,
new_value: Optional[str],
*,
persist_callback=None,
) -> CodexRuntimeStatus:
"""Top-level entry point used by both CLI and gateway handlers.
Args:
config: in-memory config dict (will be mutated when new_value is set)
new_value: desired runtime; None means "show current state only"
persist_callback: optional callable taking the mutated config dict
and persisting it to disk. Skipped when None (used by tests).
Returns: CodexRuntimeStatus describing the outcome.
"""
current = get_current_runtime(config)
# Cache the codex binary check for this apply() call. Subprocess spawn
# is cheap (~50ms for `codex --version`), but we'd otherwise call it up
# to 3 times in the enable path (read-only/state, gate, success message).
# None = not yet checked; (bool, str) = result.
_binary_check: Optional[tuple[bool, Optional[str]]] = None
def _check_binary_cached() -> tuple[bool, Optional[str]]:
nonlocal _binary_check
if _binary_check is None:
_binary_check = check_codex_binary_ok()
return _binary_check
# Read-only call: just report state
if new_value is None:
ok, ver = _check_binary_cached()
msg = (
f"openai_runtime: {current}\n"
f"codex CLI: {'OK ' + ver if ok else 'not available — ' + (ver or 'install with `npm i -g @openai/codex`')}"
)
return CodexRuntimeStatus(
success=True,
new_value=current,
old_value=current,
message=msg,
codex_binary_ok=ok,
codex_version=ver if ok else None,
)
# No-config-change paths. For `auto` we return immediately — disabling
# doesn't touch ~/.codex/. For `codex_app_server`, we fall through to
# the migration block below: the config value is already correct, but
# the world state (managed block in ~/.codex/config.toml, hermes-tools
# MCP callback, plugin discovery) may be stale or missing — common
# footgun when users pre-set `openai_runtime: codex_app_server` in
# config.yaml without ever running the slash command. The migration is
# idempotent by design (it replaces its own managed block in place), so
# re-running is cheap and safe.
reapplying_enable = new_value == current == "codex_app_server"
if new_value == current and not reapplying_enable:
return CodexRuntimeStatus(
success=True,
new_value=current,
old_value=current,
message=f"openai_runtime already set to {current}",
)
# If switching ON, verify codex CLI is installed before persisting —
# an opt-in toggle that silently fails on the first turn is the
# worst possible UX. Block here with a clear install hint.
if new_value == "codex_app_server":
ok, ver_or_msg = _check_binary_cached()
if not ok:
return CodexRuntimeStatus(
success=False,
new_value=None,
old_value=current,
message=(
"Cannot enable codex_app_server runtime: "
f"{ver_or_msg or 'codex CLI not available'}\n"
"Install with: npm i -g @openai/codex"
),
codex_binary_ok=False,
codex_version=None,
)
if not reapplying_enable:
set_runtime(config, new_value)
if persist_callback is not None:
try:
persist_callback(config)
except Exception as exc:
logger.exception("failed to persist openai_runtime change")
return CodexRuntimeStatus(
success=False,
new_value=new_value,
old_value=current,
message=f"updated config in memory but persist failed: {exc}",
)
if reapplying_enable:
msg_lines = [
f"openai_runtime already set to {current} — re-applying migration"
]
else:
msg_lines = [f"openai_runtime: {current}{new_value}"]
if new_value == "codex_app_server":
ok, ver = _check_binary_cached()
if ok:
msg_lines.append(f"codex CLI: {ver}")
# Auto-migrate Hermes' MCP servers + Codex's installed curated
# plugins into ~/.codex/config.toml so the spawned codex subprocess
# sees the same tool surface AND can call back into Hermes for
# browser/web/delegate_task/vision/memory tools (#7 fix).
# Failures are non-fatal — the runtime change still proceeds.
try:
from hermes_cli.codex_runtime_plugin_migration import migrate
mig_report = migrate(config)
# Tools/MCP servers (excluding the hermes-tools callback,
# which is internal plumbing — surface separately).
user_servers = [
s for s in mig_report.migrated if s != "hermes-tools"
]
if user_servers:
msg_lines.append(
f"Migrated {len(user_servers)} MCP server(s): "
f"{', '.join(user_servers)}"
)
# Native Codex plugin migration (Linear, GitHub, etc.)
if mig_report.migrated_plugins:
msg_lines.append(
f"Migrated {len(mig_report.migrated_plugins)} native "
f"Codex plugin(s): {', '.join(mig_report.migrated_plugins)}"
)
elif mig_report.plugin_query_error:
msg_lines.append(
f"Codex plugin discovery skipped: "
f"{mig_report.plugin_query_error}"
)
# Permissions + Hermes tool callback are always-on production
# bits the user benefits from knowing about.
if mig_report.wrote_permissions_default:
msg_lines.append(
f"Default sandbox: {mig_report.wrote_permissions_default} "
f"(no approval prompt on every write)"
)
if "hermes-tools" in mig_report.migrated:
msg_lines.append(
"Hermes tool callback registered: codex can now use "
"web_search, web_extract, browser_*, vision_analyze, "
"image_generate, skill_view, skills_list, text_to_speech, "
"kanban_* (worker + orchestrator) via MCP."
)
msg_lines.append(
" (delegate_task, memory, session_search, todo run "
"only on the default Hermes runtime — they need the "
"agent loop context.)"
)
msg_lines.append(f" (config: {mig_report.target_path})")
for err in mig_report.errors:
msg_lines.append(f"⚠ MCP migration: {err}")
except Exception as exc:
msg_lines.append(f"⚠ MCP migration skipped: {exc}")
msg_lines.append(
"OpenAI/Codex turns now run through `codex app-server` "
"(terminal/file ops/patching inside Codex; "
"Hermes tools available via MCP callback)."
)
msg_lines.append(
"Effective on next session — current cached agent keeps "
"the prior runtime to preserve prompt cache."
)
else:
msg_lines.append("OpenAI/Codex turns will use the default Hermes runtime.")
msg_lines.append("Effective on next session.")
return CodexRuntimeStatus(
success=True,
new_value=new_value,
old_value=current,
message="\n".join(msg_lines),
requires_new_session=True,
)
+38
View File
@@ -0,0 +1,38 @@
"""Shared ANSI color utilities for Hermes CLI modules."""
import os
import sys
def should_use_color() -> bool:
"""Return True when colored output is appropriate.
Respects the NO_COLOR environment variable (https://no-color.org/)
and TERM=dumb, in addition to the existing TTY check.
"""
if os.environ.get("NO_COLOR") is not None:
return False
if os.environ.get("TERM") == "dumb":
return False
if not sys.stdout.isatty():
return False
return True
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
def color(text: str, *codes) -> str:
"""Apply color codes to text (only when color output is appropriate)."""
if not should_use_color():
return text
return "".join(codes) + text + Colors.RESET
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
"""Shell completion script generation for hermes CLI.
Walks the live argparse parser tree to generate accurate, always-up-to-date
completion scripts no hardcoded subcommand lists, no extra dependencies.
Supports bash, zsh, and fish.
"""
from __future__ import annotations
import argparse
from typing import Any
def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]:
"""Recursively extract subcommands and flags from a parser.
Uses _SubParsersAction._choices_actions to get canonical names (no aliases)
along with their help text.
"""
flags: list[str] = []
subcommands: dict[str, Any] = {}
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
# _choices_actions has one entry per canonical name; aliases are
# omitted, which keeps completion lists clean.
seen: set[str] = set()
for pseudo in action._choices_actions:
name = pseudo.dest
if name in seen:
continue
seen.add(name)
subparser = action.choices.get(name)
if subparser is None:
continue
info = _walk(subparser)
info["help"] = _clean(pseudo.help or "")
subcommands[name] = info
elif action.option_strings:
flags.extend(o for o in action.option_strings if o.startswith("-"))
return {"flags": flags, "subcommands": subcommands}
def _clean(text: str, maxlen: int = 60) -> str:
"""Strip shell-unsafe characters and truncate."""
return text.replace("'", "").replace('"', "").replace("\\", "")[:maxlen]
# ---------------------------------------------------------------------------
# Bash
# ---------------------------------------------------------------------------
def generate_bash(parser: argparse.ArgumentParser) -> str:
tree = _walk(parser)
top_cmds = " ".join(sorted(tree["subcommands"]))
cases: list[str] = []
for cmd in sorted(tree["subcommands"]):
info = tree["subcommands"][cmd]
if cmd == "profile" and info["subcommands"]:
# Profile subcommand: complete actions, then profile names for
# actions that accept a profile argument.
subcmds = " ".join(sorted(info["subcommands"]))
profile_actions = "use delete show alias rename export"
cases.append(
f" profile)\n"
f" case \"$prev\" in\n"
f" profile)\n"
f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
f" return\n"
f" ;;\n"
f" {profile_actions.replace(' ', '|')})\n"
f" COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n"
f" return\n"
f" ;;\n"
f" esac\n"
f" ;;"
)
elif info["subcommands"]:
subcmds = " ".join(sorted(info["subcommands"]))
cases.append(
f" {cmd})\n"
f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
f" return\n"
f" ;;"
)
elif info["flags"]:
flags = " ".join(info["flags"])
cases.append(
f" {cmd})\n"
f" COMPREPLY=($(compgen -W \"{flags}\" -- \"$cur\"))\n"
f" return\n"
f" ;;"
)
cases_str = "\n".join(cases)
return f"""# Hermes Agent bash completion
# Add to ~/.bashrc:
# eval "$(hermes completion bash)"
_hermes_profiles() {{
local profiles_dir="$HOME/.hermes/profiles"
local profiles="default"
if [ -d "$profiles_dir" ]; then
for f in "$profiles_dir"/*/; do
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
done
fi
echo "$profiles"
}}
_hermes_completion() {{
local cur prev
COMPREPLY=()
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
# Complete profile names after -p / --profile
if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
return
fi
if [[ $COMP_CWORD -ge 2 ]]; then
case "${{COMP_WORDS[1]}}" in
{cases_str}
esac
fi
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=($(compgen -W "{top_cmds}" -- "$cur"))
fi
}}
complete -F _hermes_completion hermes
"""
# ---------------------------------------------------------------------------
# Zsh
# ---------------------------------------------------------------------------
def generate_zsh(parser: argparse.ArgumentParser) -> str:
tree = _walk(parser)
top_cmds_lines: list[str] = []
for cmd in sorted(tree["subcommands"]):
help_text = _clean(tree["subcommands"][cmd].get("help", ""))
top_cmds_lines.append(f" '{cmd}:{help_text}'")
top_cmds_str = "\n".join(top_cmds_lines)
sub_cases: list[str] = []
for cmd in sorted(tree["subcommands"]):
info = tree["subcommands"][cmd]
if not info["subcommands"]:
continue
if cmd == "profile":
# Profile subcommand: complete actions, then profile names for
# actions that accept a profile argument.
sub_lines: list[str] = []
for sc in sorted(info["subcommands"]):
sh = _clean(info["subcommands"][sc].get("help", ""))
sub_lines.append(f" '{sc}:{sh}'")
sub_str = "\n".join(sub_lines)
sub_cases.append(
f" profile)\n"
f" case ${{line[2]}} in\n"
f" use|delete|show|alias|rename|export)\n"
f" _hermes_profiles\n"
f" ;;\n"
f" *)\n"
f" local -a profile_cmds\n"
f" profile_cmds=(\n"
f"{sub_str}\n"
f" )\n"
f" _describe 'profile command' profile_cmds\n"
f" ;;\n"
f" esac\n"
f" ;;"
)
else:
sub_lines = []
for sc in sorted(info["subcommands"]):
sh = _clean(info["subcommands"][sc].get("help", ""))
sub_lines.append(f" '{sc}:{sh}'")
sub_str = "\n".join(sub_lines)
safe = cmd.replace("-", "_")
sub_cases.append(
f" {cmd})\n"
f" local -a {safe}_cmds\n"
f" {safe}_cmds=(\n"
f"{sub_str}\n"
f" )\n"
f" _describe '{cmd} command' {safe}_cmds\n"
f" ;;"
)
sub_cases_str = "\n".join(sub_cases)
return f"""#compdef hermes
# Hermes Agent zsh completion
# Add to ~/.zshrc:
# eval "$(hermes completion zsh)"
_hermes_profiles() {{
local -a profiles
profiles=(default)
if [[ -d "$HOME/.hermes/profiles" ]]; then
profiles+=($HOME/.hermes/profiles/*(N/:t))
fi
_describe 'profile' profiles
}}
_hermes() {{
local context state line
typeset -A opt_args
_arguments -C \\
'(-)'{{-h,--help}}'[Show help and exit]' \\
'(-)'{{-V,--version}}'[Show version and exit]' \\
'(-)'{{-p,--profile}}'[Profile name]:profile:_hermes_profiles' \\
'1:command:->commands' \\
'*::arg:->args'
case $state in
commands)
local -a subcmds
subcmds=(
{top_cmds_str}
)
_describe 'hermes command' subcmds
;;
args)
case ${{line[1]}} in
{sub_cases_str}
esac
;;
esac
}}
compdef _hermes hermes
"""
# ---------------------------------------------------------------------------
# Fish
# ---------------------------------------------------------------------------
def generate_fish(parser: argparse.ArgumentParser) -> str:
tree = _walk(parser)
top_cmds = sorted(tree["subcommands"])
top_cmds_str = " ".join(top_cmds)
lines: list[str] = [
"# Hermes Agent fish completion",
"# Add to your config:",
"# hermes completion fish | source",
"",
"# Helper: list available profiles",
"function __hermes_profiles",
" echo default",
" if test -d $HOME/.hermes/profiles",
" for d in $HOME/.hermes/profiles/*/",
" basename $d",
" end",
" end",
"end",
"",
"# Disable file completion by default",
"complete -c hermes -f",
"",
"# Complete profile names after -p / --profile",
"complete -c hermes -f -s p -l profile"
" -d 'Profile name' -xa '(__hermes_profiles)'",
"",
"# Top-level subcommands",
]
for cmd in top_cmds:
info = tree["subcommands"][cmd]
help_text = _clean(info.get("help", ""))
lines.append(
f"complete -c hermes -f "
f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' "
f"-a {cmd} -d '{help_text}'"
)
lines.append("")
lines.append("# Subcommand completions")
profile_name_actions = {"use", "delete", "show", "alias", "rename", "export"}
for cmd in top_cmds:
info = tree["subcommands"][cmd]
if not info["subcommands"]:
continue
lines.append(f"# {cmd}")
for sc in sorted(info["subcommands"]):
sinfo = info["subcommands"][sc]
sh = _clean(sinfo.get("help", ""))
lines.append(
f"complete -c hermes -f "
f"-n '__fish_seen_subcommand_from {cmd}' "
f"-a {sc} -d '{sh}'"
)
# For profile subcommand, complete profile names for relevant actions
if cmd == "profile":
for action in sorted(profile_name_actions):
lines.append(
f"complete -c hermes -f "
f"-n '__fish_seen_subcommand_from {action}; "
f"and __fish_seen_subcommand_from profile' "
f"-a '(__hermes_profiles)' -d 'Profile name'"
)
lines.append("")
return "\n".join(lines)
+6626
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+933
View File
@@ -0,0 +1,933 @@
"""Table-driven config migration registry.
This module holds the per-version migration steps that used to live as a
768-line ladder of ``if current_ver < N:`` blocks inside
``hermes_cli.config.migrate_config``. Each step is a function
``_migrate_to_N(results, quiet)`` whose body is copied verbatim from the
original block; only the shared skeleton (the version gate and the strict
ascending ordering) lives in the :func:`run_migrations` driver.
Semantics preserved exactly from the original ladder:
* ``current_ver`` is computed ONCE by the caller (``check_config_version``)
and never advances while the ladder runs every step compares against the
same initial value. The driver replicates that: it applies every registry
entry whose target version is ``> current_ver``, in ascending order.
* Each step re-reads the raw on-disk config itself (``read_raw_config``) and
persists via ``_persist_migration`` steps therefore observe the writes of
earlier steps through the filesystem, which is why strict ascending order
is mandatory.
* All ``results['config_added']`` / ``results['warnings']`` appends and all
conditional ``print`` output stay inside the step functions, byte-identical
to the original blocks.
Import direction / cycle avoidance:
``hermes_cli.config`` imports :func:`run_migrations` lazily (inside
``migrate_config``), and every step function here resolves its helpers
(``read_raw_config``, ``_persist_migration``, ``get_env_value``, ) lazily
through the live ``hermes_cli.config`` module object at call time via
:func:`_cfg`. There is deliberately NO module-level import of
``hermes_cli.config`` here, so no circular import can form and, just as
importantly, tests that monkeypatch helpers on ``hermes_cli.config`` (e.g.
``patch("hermes_cli.config.read_raw_config", ...)``) keep working, because
the steps always go through the module attribute rather than a bound-early
reference.
"""
from __future__ import annotations
import copy
from typing import Any, Callable, Dict, List, Tuple
#: Auto-migration support floor. Configs whose on-disk ``_config_version`` is
#: below this are NOT auto-migrated any more (policy decision, July 2026):
#: v12 predates roughly two years of releases, and carrying the sub-v12
#: migration steps (plus the env bridges they consumed, e.g.
#: HERMES_TOOL_PROGRESS*) forever is not worth it. Below-floor configs are
#: left byte-for-byte untouched — the process continues with the config as-is
#: (defaults deep-merged at read time, matching the non-fatal posture used
#: for unparseable configs) and a clear message tells the user how to
#: proceed. The removed steps were the <12 targets: v4 (tool-progress .env →
#: config.yaml), v5 (timezone seed), v9 (clear ANTHROPIC_TOKEN).
SUPPORT_FLOOR_VERSION = 12
def support_floor_message() -> str:
"""Human-facing explanation shown when a config is below the floor."""
from hermes_constants import display_hermes_home
return (
f"This config predates version {SUPPORT_FLOOR_VERSION} (~2 years old) "
"and can no longer be auto-migrated. Back up "
f"{display_hermes_home()}/config.yaml and run `hermes setup` to "
f"regenerate, or manually set _config_version: {SUPPORT_FLOOR_VERSION} "
"after reviewing the changelog."
)
def _cfg():
"""Return the live ``hermes_cli.config`` module (lazy, cycle-free)."""
from hermes_cli import config
return config
def _migrate_to_12(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 11 → 12: migrate custom_providers list → providers dict ──
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
_custom_provider_entry_to_provider_config = _c._custom_provider_entry_to_provider_config
config = read_raw_config()
custom_list = config.get("custom_providers")
if isinstance(custom_list, list) and custom_list:
providers_dict = config.get("providers", {})
if not isinstance(providers_dict, dict):
providers_dict = {}
migrated_count = 0
for entry in custom_list:
if not isinstance(entry, dict):
continue
old_name = entry.get("name", "")
old_url = entry.get("base_url", "") or entry.get("url", "") or entry.get("api", "") or ""
if not old_url:
continue # skip entries with no URL
# Generate a kebab-case key from the display name
key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
# Remove consecutive hyphens and trailing hyphens
while "--" in key:
key = key.replace("--", "-")
key = key.strip("-")
if not key:
# Fallback: derive from URL hostname
try:
from urllib.parse import urlparse
parsed = urlparse(old_url)
key = (parsed.hostname or "endpoint").replace(".", "-")
except Exception:
key = f"endpoint-{migrated_count}"
# Don't overwrite existing entries
base_key = key
suffix = migrated_count
while key in providers_dict:
key = f"{base_key}-{suffix}"
suffix += 1
new_entry = _custom_provider_entry_to_provider_config(
entry,
provider_key=key,
)
if new_entry is None:
continue
if not old_name:
new_entry.pop("name", None)
if new_entry.get("api_key") in {"no-key", "no-key-required", ""}:
new_entry.pop("api_key", None)
providers_dict[key] = new_entry
migrated_count += 1
if migrated_count > 0:
config["providers"] = providers_dict
# Remove the old list — runtime reads via get_compatible_custom_providers()
config.pop("custom_providers", None)
_persist_migration(config)
if not quiet:
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
for key in list(providers_dict.keys())[-migrated_count:]:
ep = providers_dict[key]
print(f"{key}: {ep.get('api', '')}")
def _migrate_to_13(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──
# These env vars were written by the old setup wizard but nothing reads
# them anymore (config.yaml is the sole source of truth since March 2026).
# Stale entries cause user confusion — see issue report.
_c = _cfg()
get_env_value = _c.get_env_value
save_env_value = _c.save_env_value
for dead_var in ("LLM_MODEL", "OPENAI_MODEL"):
try:
old_val = get_env_value(dead_var)
if old_val:
save_env_value(dead_var, "")
if not quiet:
print(f" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)")
except Exception:
pass
def _migrate_to_14(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 13 → 14: migrate legacy flat stt.model to provider section ──
# Old configs (and cli-config.yaml.example) had a flat `stt.model` key
# that was provider-agnostic. When the provider was "local" this caused
# OpenAI model names (e.g. "whisper-1") to be fed to faster-whisper,
# crashing with "Invalid model size". Move the value into the correct
# provider-specific section and remove the flat key.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
# Read raw config (no defaults merged) to check what the user actually
# wrote, then apply changes to the merged config for saving.
raw = read_raw_config()
raw_stt = raw.get("stt", {})
if isinstance(raw_stt, dict) and "model" in raw_stt:
legacy_model = raw_stt["model"]
provider = raw_stt.get("provider", "local")
config = read_raw_config()
stt = config.get("stt", {})
# Remove the legacy flat key
stt.pop("model", None)
# Place it in the appropriate provider section only if the
# user didn't already set a model there
if provider in {"local", "local_command"}:
# Don't migrate an OpenAI model name into the local section
_local_models = {
"tiny.en", "tiny", "base.en", "base", "small.en", "small",
"medium.en", "medium", "large-v1", "large-v2", "large-v3",
"large", "distil-large-v2", "distil-medium.en",
"distil-small.en", "distil-large-v3", "distil-large-v3.5",
"large-v3-turbo", "turbo",
}
if legacy_model in _local_models:
# Check raw config — only set if user didn't already
# have a nested local.model
raw_local = raw_stt.get("local", {})
if not isinstance(raw_local, dict) or "model" not in raw_local:
local_cfg = stt.setdefault("local", {})
local_cfg["model"] = legacy_model
# else: drop it — it was an OpenAI model name, local section
# already defaults to "base" via DEFAULT_CONFIG
else:
# Cloud provider — put it in that provider's section only
# if user didn't already set a nested model
raw_provider = raw_stt.get(provider, {})
if not isinstance(raw_provider, dict) or "model" not in raw_provider:
provider_cfg = stt.setdefault(provider, {})
provider_cfg["model"] = legacy_model
config["stt"] = stt
_persist_migration(config)
if not quiet:
print(" ✓ Migrated legacy stt.model to provider-specific config")
def _migrate_to_16(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
display = config.get("display", {})
if not isinstance(display, dict):
display = {}
old_overrides = display.get("tool_progress_overrides")
if isinstance(old_overrides, dict) and old_overrides:
platforms = display.get("platforms", {})
if not isinstance(platforms, dict):
platforms = {}
for plat, mode in old_overrides.items():
if plat not in platforms:
platforms[plat] = {}
if "tool_progress" not in platforms[plat]:
platforms[plat]["tool_progress"] = mode
display["platforms"] = platforms
config["display"] = display
_persist_migration(config)
if not quiet:
migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
def _migrate_to_17(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 16 → 17: remove legacy compression.summary_* keys ──
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
comp = config.get("compression", {})
if isinstance(comp, dict):
s_model = comp.pop("summary_model", None)
s_provider = comp.pop("summary_provider", None)
s_base_url = comp.pop("summary_base_url", None)
migrated_keys = []
# Migrate non-empty, non-default values to auxiliary.compression
if s_model and str(s_model).strip():
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("model"):
aux_comp["model"] = str(s_model).strip()
migrated_keys.append(f"model={s_model}")
if s_provider and str(s_provider).strip() not in {"", "auto"}:
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("provider") or aux_comp.get("provider") == "auto":
aux_comp["provider"] = str(s_provider).strip()
migrated_keys.append(f"provider={s_provider}")
if s_base_url and str(s_base_url).strip():
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("base_url"):
aux_comp["base_url"] = str(s_base_url).strip()
migrated_keys.append(f"base_url={s_base_url}")
if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None:
config["compression"] = comp
_persist_migration(config)
if not quiet:
if migrated_keys:
print(f" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}")
else:
print(" ✓ Removed unused compression.summary_* keys")
def _migrate_to_21(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
# The loader now requires plugins to appear in ``plugins.enabled`` before
# loading. Existing installs had all discovered plugins loading by default
# (minus anything in ``plugins.disabled``). To avoid silently breaking
# those setups on upgrade, populate ``plugins.enabled`` with the set of
# currently-installed user plugins that aren't already disabled.
#
# Bundled plugins (shipped in the repo itself) are NOT grandfathered —
# they ship off for everyone, including existing users, so any user who
# wants one has to opt in explicitly.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
get_hermes_home = _c.get_hermes_home
fast_safe_load = _c.fast_safe_load
config = read_raw_config()
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
plugins_cfg = {}
# Only migrate if the enabled allow-list hasn't been set yet.
if "enabled" not in plugins_cfg:
disabled = plugins_cfg.get("disabled", []) or []
if not isinstance(disabled, list):
disabled = []
disabled_set = set(disabled)
# Scan ``$HERMES_HOME/plugins/`` for currently installed user plugins.
grandfathered: List[str] = []
try:
user_plugins_dir = get_hermes_home() / "plugins"
if user_plugins_dir.is_dir():
for child in sorted(user_plugins_dir.iterdir()):
if not child.is_dir():
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
if not manifest_file.exists():
continue
try:
with open(manifest_file, encoding="utf-8") as _mf:
manifest = fast_safe_load(_mf) or {}
except Exception:
manifest = {}
name = manifest.get("name") or child.name
if name in disabled_set:
continue
grandfathered.append(name)
except Exception:
grandfathered = []
plugins_cfg["enabled"] = grandfathered
config["plugins"] = plugins_cfg
_persist_migration(config)
results["config_added"].append(
f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)"
)
if not quiet:
if grandfathered:
print(
f" ✓ Plugins now opt-in: grandfathered "
f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
)
else:
print(
" ✓ Plugins now opt-in: no existing plugins to grandfather. "
"Use `hermes plugins enable <name>` to activate."
)
def _migrate_to_23(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
# The curator (background skill maintenance) was added in PR #16049, but
# existing configs from before that PR (or before the April 2026
# unification under `auxiliary.curator`) never wrote the curator section
# to disk. The runtime deep-merge in `load_config()` fills defaults at
# read time, so the curator *functions*; but users can't see/edit the
# settings in their `config.yaml`, and `hermes curator status` has no
# stable logs dir to point at until the first run mkdir's it.
#
# This migration:
# 1. Writes the `curator` top-level section to config.yaml (enabled,
# interval_hours, min_idle_hours, stale_after_days, archive_after_days)
# — only keys the user hasn't already overridden.
# 2. Writes the `auxiliary.curator` aux-task slot (provider, model,
# base_url, api_key, timeout, extra_body) — canonical slot for
# routing the curator fork to a cheaper aux model.
# 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
# on top of ensure_hermes_home() — old profiles that predate this
# migration still benefit).
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
get_hermes_home = _c.get_hermes_home
DEFAULT_CONFIG = _c.DEFAULT_CONFIG
try:
curator_dir = get_hermes_home() / "logs" / "curator"
curator_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
results["warnings"].append(f"Could not create {curator_dir}: {e}")
config = read_raw_config()
touched = False
# (1) Top-level curator section — only add missing keys
_curator_defaults = DEFAULT_CONFIG.get("curator", {})
raw_curator = config.get("curator")
if not isinstance(raw_curator, dict):
raw_curator = {}
added_curator: List[str] = []
for k, v in _curator_defaults.items():
if k not in raw_curator:
raw_curator[k] = copy.deepcopy(v)
added_curator.append(k)
if added_curator:
config["curator"] = raw_curator
touched = True
# (2) auxiliary.curator task slot
_aux_curator_defaults = (
DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
)
raw_aux = config.get("auxiliary")
if not isinstance(raw_aux, dict):
raw_aux = {}
raw_aux_curator = raw_aux.get("curator")
if not isinstance(raw_aux_curator, dict):
raw_aux_curator = {}
added_aux: List[str] = []
for k, v in _aux_curator_defaults.items():
if k not in raw_aux_curator:
raw_aux_curator[k] = copy.deepcopy(v)
added_aux.append(k)
if added_aux:
raw_aux["curator"] = raw_aux_curator
config["auxiliary"] = raw_aux
touched = True
if touched:
_persist_migration(config)
if added_curator:
results["config_added"].append(
f"curator ({len(added_curator)} default key(s))"
)
if not quiet:
print(
" ✓ Curator settings now available "
f"({', '.join(added_curator)}) — edit via `hermes config set`"
)
if added_aux:
results["config_added"].append(
f"auxiliary.curator ({len(added_aux)} default key(s))"
)
if not quiet:
print(
" ✓ auxiliary.curator settings now available "
f"({', '.join(added_aux)}) — edit via `hermes config set`"
)
def _migrate_to_25(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
# The model picker now refreshes its curated list hourly so freshly
# published model-catalog.json deploys reach users without a day-long
# stale window. Only rewrite the OLD default (24) — never clobber a
# value the user deliberately customized.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_mc = config.get("model_catalog")
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
raw_mc["ttl_hours"] = 1
config["model_catalog"] = raw_mc
_persist_migration(config)
results["config_added"].append("model_catalog.ttl_hours 24→1")
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
def _migrate_to_29(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 28 → 29: rename memory/skills write_mode → write_approval ──
# The tri-state write_mode (on|off|approve) was replaced by a clear boolean
# write_approval (default false = gate off, writes flow freely; true =
# require approval). Only an explicit "approve" carried gating intent, so
# it maps to true; everything else (on/off/unset) → false. The old
# "off = block all writes" mode is dropped — memory_enabled: false disables
# memory entirely. Only rewrite a key the user actually persisted; never
# invent one.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
touched = False
for subsystem in ("memory", "skills"):
sub = config.get(subsystem)
if not isinstance(sub, dict) or "write_mode" not in sub:
continue
old = sub.pop("write_mode")
old_norm = old.strip().lower() if isinstance(old, str) else old
sub["write_approval"] = (old_norm == "approve")
config[subsystem] = sub
touched = True
results["config_added"].append(
f"{subsystem}.write_mode → write_approval={sub['write_approval']}"
)
if touched:
_persist_migration(config)
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
# ── Version 29 → 30: curator.consolidate defaults to false ──
# Consolidation (the LLM umbrella-building fork) is opt-in, OFF by default;
# the deterministic inactivity prune still runs whenever the curator is
# enabled. No write is needed: the schema default (curator.consolidate=false)
# is supplied by load_config()'s deep-merge at read time, and persisting a
# default-valued key would only bloat a lean config (it gets stripped on
# save anyway). Existing installs that WANT the old always-consolidate
# behavior set it to true explicitly via `hermes config set`.
# (No registry entry: this version bump has no migration step.)
def _migrate_to_31(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 30 → 31: switch verify_on_stop OFF (one-time) ──
# verify_on_stop defaulted to the "auto" sentinel (surface-aware: on for
# interactive coding surfaces). In practice the verification narrative was
# more noise than signal — it even fired on doc/markdown/skill edits with
# nothing to verify. The new default is OFF. This migration switches
# existing installs off ONCE, but only when the user never expressed an
# explicit preference: we rewrite the value only if it's missing or still
# the "auto" sentinel. An explicit true/false the user set is preserved.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_agent = config.get("agent")
if not isinstance(raw_agent, dict):
raw_agent = {}
cur = raw_agent.get("verify_on_stop")
is_auto_sentinel = (
isinstance(cur, str) and cur.strip().lower() == "auto"
)
# Only flip the non-committal states; leave explicit bool/on/off alone.
if cur is None or is_auto_sentinel:
raw_agent["verify_on_stop"] = False
config["agent"] = raw_agent
_persist_migration(config)
results["config_added"].append("agent.verify_on_stop=false")
if not quiet:
print(
" ✓ Turned off verify-on-stop (agent.verify_on_stop: false). "
"Set it to true to re-enable, or \"auto\" for the legacy "
"surface-aware behavior."
)
def _migrate_to_32(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 31 → 32: flip the BAKED-IN literal true to OFF (one-time) ──
# The v30→v31 flip above only caught missing/"auto" values. But the very
# first ship of verify-on-stop (config v30, commit 2f1a47b90) defaulted
# DEFAULT_CONFIG["agent"]["verify_on_stop"] to a literal True, and
# migrate_config persists defaults with strip_defaults=False — so every
# install that updated through v30 got `verify_on_stop: true` written into
# config.yaml as a literal. v31's guard deliberately preserves an explicit
# bool, so it skipped that whole population and left them ON. That literal
# true was never a user choice: the feature had no off-switch worth setting
# it against until v31 introduced one, so a true persisted before v32 is
# always the old machine default. Flip it off once here. A true the user
# sets AFTER v32 (config already at version 32) is never touched.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_agent = config.get("agent")
if isinstance(raw_agent, dict) and raw_agent.get("verify_on_stop") is True:
raw_agent["verify_on_stop"] = False
config["agent"] = raw_agent
_persist_migration(config)
results["config_added"].append("agent.verify_on_stop=false")
if not quiet:
print(
" ✓ Turned off verify-on-stop (agent.verify_on_stop: false) — "
"the old default was written into your config as a literal "
"true. Set it to true again to re-enable, or \"auto\" for the "
"legacy surface-aware behavior."
)
def _migrate_to_33(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 32 → 33: unify delegation concurrency caps ──
# delegation.max_async_children is deprecated: max_concurrent_children now
# caps both a single batch's parallelism and concurrent background
# delegation units. Fold a raised max_async_children into
# max_concurrent_children (take the max so nobody loses headroom), then
# drop the stale key.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_deleg = config.get("delegation")
if isinstance(raw_deleg, dict) and "max_async_children" in raw_deleg:
old_async = raw_deleg.pop("max_async_children")
try:
old_async_i = int(old_async)
except (TypeError, ValueError):
old_async_i = None
if old_async_i is not None and old_async_i > 3:
try:
cur_children = int(raw_deleg.get("max_concurrent_children", 3))
except (TypeError, ValueError):
cur_children = 3
if old_async_i > cur_children:
raw_deleg["max_concurrent_children"] = old_async_i
results["config_added"].append(
f"delegation.max_concurrent_children={old_async_i} "
f"(folded from deprecated max_async_children)"
)
config["delegation"] = raw_deleg
_persist_migration(config)
if not quiet:
print(
" ✓ Removed deprecated delegation.max_async_children — "
"delegation.max_concurrent_children now caps background "
"delegations too."
)
def _migrate_to_34(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 33 → 34: one-time personality reset (post-#81946 unification) ──
# Personality persistence used to be split per surface: the TUI/desktop
# wrote the NAME to display.personality while the CLI/gateway wrote the
# rendered TEXT into agent.system_prompt (and their "/personality none"
# only blanked the text, leaving the name behind). When #81946 made
# display.personality authoritative everywhere, stale names written years
# ago resurrected personalities users had already turned off ("kawaii
# defaults on after updating"). There is no way to know which of the two
# divergent fields reflects the user's intent, so reset the selection to
# none once and tell the user how to re-enable it. Two scrubs:
#
# 1. display.personality → "" (announce the old name).
# 2. agent.system_prompt → "" ONLY when it verbatim-equals the rendered
# text of a known personality — that shape was written by the old
# CLI/gateway /personality, never typed by hand. Any other text is a
# user-owned manual prompt and is never touched.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
from hermes_cli.personality import (
available_personalities,
normalize_personality_name,
prompt_text,
render_personality_prompt,
)
config = read_raw_config()
touched = False
raw_display = config.get("display")
old_name = ""
if isinstance(raw_display, dict):
old_name = normalize_personality_name(raw_display.get("personality", ""))
if old_name:
raw_display["personality"] = ""
config["display"] = raw_display
touched = True
raw_agent = config.get("agent")
scrubbed_text = False
if isinstance(raw_agent, dict):
manual = prompt_text(raw_agent.get("system_prompt", ""))
if manual:
rendered = {
render_personality_prompt(defn)
for defn in available_personalities(config).values()
}
if manual in rendered:
raw_agent["system_prompt"] = ""
config["agent"] = raw_agent
touched = True
scrubbed_text = True
if touched:
_persist_migration(config)
results["config_added"].append("display.personality=none (one-time reset)")
if not quiet:
if old_name:
print(
f" ✓ Personality reset to none (was '{old_name}'). Personality "
"state was previously saved inconsistently across surfaces and "
"could re-enable a personality you had turned off. "
f"Run /personality {old_name} to turn it back on."
)
if scrubbed_text:
print(
" ✓ Removed personality text from agent.system_prompt (written "
"by an older /personality). That field is now reserved for "
"manual system prompts; personalities live in display.personality."
)
def _migrate_to_35(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 34 → 35: background process notifications → concise ──
# The old default mode 'all' pushed the raw output tail of every finished
# background process into the chat ("[Background process proc_x finished
# with exit code 0~ Here's the final output: ...]" walls). The new
# 'concise' mode renders a one-line status message instead (with a short
# output tail on failures) and is the new default. Move users still on
# 'all' — the old implicit default, almost never chosen on purpose — to
# 'concise'. Explicit non-default choices (result / error / off) are the
# user's own and are preserved. Users with the key unset inherit the new
# default automatically at read time (no write needed).
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_display = config.get("display")
if isinstance(raw_display, dict):
raw_val = raw_display.get("background_process_notifications")
if isinstance(raw_val, str) and raw_val.strip().lower() == "all":
raw_display["background_process_notifications"] = "concise"
config["display"] = raw_display
_persist_migration(config)
results["config_added"].append(
"display.background_process_notifications=concise (was: all)"
)
if not quiet:
print(
" ✓ Background process notifications switched from 'all' to "
"'concise' — completions now show a one-line status message "
"instead of the raw output dump. Set "
"display.background_process_notifications: all to restore "
"the old behavior."
)
def _migrate_to_36(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 35 → 36: raise the subagent iteration cap default 50 → 250 ──
# delegation.max_iterations is the per-subagent tool-call budget. The old
# default of 50 truncated substantial delegated work (leaf agents spend
# ~15-20 turns on recon before producing output, then ran out mid-task).
# The shipped default is now 250. Configs still pinned at exactly the old
# default 50 — almost always the inherited default rather than a deliberate
# choice — are lifted to 250 so existing installs get the same headroom on
# update. Any OTHER explicit value (a deliberate override, high or low) is
# the user's own and is preserved; unset inherits 250 at read time.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_deleg = config.get("delegation")
if isinstance(raw_deleg, dict) and raw_deleg.get("max_iterations") == 50:
raw_deleg["max_iterations"] = 250
config["delegation"] = raw_deleg
_persist_migration(config)
results["config_added"].append("delegation.max_iterations=250 (was: 50)")
if not quiet:
print(
" ✓ Raised delegation.max_iterations from 50 to 250 — subagents "
"now get a larger per-child tool-call budget so delegated work "
"finishes instead of truncating. Set delegation.max_iterations "
"back to 50 to restore the old cap."
)
def _migrate_to_37(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 36 → 37: raise the delegation concurrency default 3 → 10 ──
# delegation.max_concurrent_children caps how many children run in parallel
# per batch (and concurrent background delegation units). The old default of
# 3 needlessly serialized independent fan-outs (e.g. reviewing N PRs at
# once). The shipped default is now 10, which stays at/below the high-cost
# warning threshold. Configs still pinned at exactly the old default 3 —
# almost always the inherited default rather than a deliberate choice — are
# lifted to 10 so existing installs get the wider fan-out on update. Any
# OTHER explicit value (a deliberate override) is preserved; unset inherits
# 10 at read time.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_deleg = config.get("delegation")
if isinstance(raw_deleg, dict) and raw_deleg.get("max_concurrent_children") == 3:
raw_deleg["max_concurrent_children"] = 10
config["delegation"] = raw_deleg
_persist_migration(config)
results["config_added"].append("delegation.max_concurrent_children=10 (was: 3)")
if not quiet:
print(
" ✓ Raised delegation.max_concurrent_children from 3 to 10 — "
"independent delegated children now fan out wider in parallel. "
"Each child consumes API tokens independently; set "
"delegation.max_concurrent_children back to 3 to restore the old cap."
)
def _migrate_to_38(results: Dict[str, Any], quiet: bool) -> None:
# Version 37 → 38: the bundled observability/nemo_relay plugin was
# removed when Relay lifecycle ownership moved into the agent core.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
from hermes_cli.relay_plugin_cutover import legacy_relay_plugin_keys
config = read_raw_config()
plugins = config.get("plugins")
if not isinstance(plugins, dict):
return
enabled = plugins.get("enabled")
removed = legacy_relay_plugin_keys(enabled)
if not removed or not isinstance(enabled, list):
return
plugins["enabled"] = [value for value in enabled if value not in removed]
config["plugins"] = plugins
_persist_migration(config)
message = (
"Removed legacy Relay plugin from plugins.enabled: "
f"{', '.join(removed)}. Configure native Relay plugins with "
"HERMES_NEMO_RELAY_PLUGINS_TOML."
)
results["warnings"].append(message)
if not quiet:
print(f"{message}")
def _migrate_to_39(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 38 → 39: remove the retired `bfl` toolset from saved lists ──
# The six bfl_flux3_* core tools shipped for a free FLUX 3 promotional
# period that has since ended server-side, leaving every Nous-signed-in
# install paying ~2.7K tokens of schema per API call for tools that can
# only refuse. They were removed in favor of the standard video_gen
# provider surface (`video_generate`, `hermes tools` → Video Generation).
# Strip the toolset key wherever the auto-backfill or a picker save wrote
# it, so stale config can't resurrect an unknown toolset.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
changed = False
for section in ("platform_toolsets", "known_builtin_toolsets"):
mapping = config.get(section)
if not isinstance(mapping, dict):
continue
for platform, toolsets in mapping.items():
if isinstance(toolsets, list) and "bfl" in toolsets:
mapping[platform] = [ts for ts in toolsets if ts != "bfl"]
changed = True
if changed:
config[section] = mapping
if changed:
_persist_migration(config)
results["config_added"].append("removed retired 'bfl' toolset from saved toolset lists")
if not quiet:
print(
" ✓ Removed the retired BFL FLUX 3 toolset from saved toolset "
"lists — video generation now lives under `hermes tools` → "
"Video Generation (Nous Subscription or FAL)."
)
def _migrate_to_40(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 39 → 40: model_catalog.ttl_hours → ttl_minutes (default 20) ──
# The picker catalogs now refresh every 20 minutes (and the gateway
# refreshes them in the background on that cadence). Only the OLD default
# (ttl_hours: 1, written by the v25 migration) is dropped so the new
# default applies; any other explicit ttl_hours is a deliberate choice
# and stays honoured by the loader.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
config = read_raw_config()
raw_mc = config.get("model_catalog")
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 1 and "ttl_minutes" not in raw_mc:
del raw_mc["ttl_hours"]
config["model_catalog"] = raw_mc
_persist_migration(config)
results["config_added"].append("model_catalog.ttl_hours 1 → ttl_minutes 20 (default)")
if not quiet:
print(" ✓ Model catalog now refreshes every 20 minutes (model_catalog.ttl_minutes)")
#: Registry of (target_version, migration_fn), strictly ascending. The driver
#: applies every entry whose target version is greater than the on-disk
#: observe earlier steps' writes via read_raw_config() (filesystem state).
MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
# v12 is the support floor: configs already AT v12 (or newer) still get
# every remaining step below. Only configs BELOW 12 are refused by the
# floor gate in run_migrations().
(12, _migrate_to_12),
(13, _migrate_to_13),
(14, _migrate_to_14),
# v15 only added a schema default; runtime merging supplies it without a
# write. Registering a migration would falsely report or materialise it.
(16, _migrate_to_16),
(17, _migrate_to_17),
(21, _migrate_to_21),
(23, _migrate_to_23),
(25, _migrate_to_25),
(29, _migrate_to_29),
(31, _migrate_to_31),
(32, _migrate_to_32),
(33, _migrate_to_33),
(34, _migrate_to_34),
(35, _migrate_to_35),
(36, _migrate_to_36),
(37, _migrate_to_37),
(38, _migrate_to_38),
(39, _migrate_to_39),
(40, _migrate_to_40),
)
def run_migrations(current_ver: int, results: Dict[str, Any], quiet: bool) -> None:
"""Apply every registered migration whose target version exceeds *current_ver*.
Replicates the original ladder's semantics exactly: *current_ver* is the
on-disk schema version captured ONCE (via ``check_config_version()``)
before any step runs, and it does not advance between steps each step
is gated on the same initial value, exactly like the original sequential
``if current_ver < N:`` blocks. Steps run in strict ascending registry
order and mutate ``results`` in place. The final ``_config_version`` bump
is NOT performed here; it stays in ``migrate_config`` (persisted once,
after the informational missing-config scan), matching the original flow.
"""
for target_ver, migration_fn in MIGRATIONS:
if current_ver < target_ver:
migration_fn(results, quiet)
File diff suppressed because it is too large Load Diff
+627
View File
@@ -0,0 +1,627 @@
"""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 desired 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. Older installs only have gateway_state;
# newer lifecycle commands persist desired_state separately so a transient
# runtime state (draining/startup_failed) does not erase the operator's
# durable start/stop intent across pod/container recreation.
_AUTOSTART_STATES = frozenset({"running"})
# Transient runtime sub-states of a RUNNING gateway. A gateway only ever
# reaches these while it is up and serving, so they are NOT an operator stop
# and NOT a failed boot:
# - `draining` — written by the drain watcher / scale-to-zero go-dormant
# path when an in-flight quiesce begins (gateway/run.py).
# - `degraded` — written when the gateway comes up with some platforms
# queued for retry, then "falls through to the normal
# running state" (gateway/run.py #5196): the process is up,
# serving cron + whatever platforms connected, and the
# reconnect watcher takes the rest from there.
#
# When a gateway is hard-killed *while in one of these states* (a container/VM
# recreate SIGTERMs it before `_stop_impl` reaches its terminal-state persist),
# the last value left in gateway_state.json is the transient sub-state. With no
# explicit `desired_state` to fall back to, treating that literal value as the
# autostart intent would leave the gateway DOWN on every subsequent boot — the
# gateway never comes back, the dashboard is up but messaging stays dark
# (observed on a relay-opted-in staging instance stranded at `draining`,
# 2026-06; `degraded` is the same wedge class). Map these transient sub-states
# to `running` so a stranded marker reads as the run-intent it actually
# represents. This mirrors gateway/run.py's #42675 handling, which persists
# `running` (not the mid-shutdown `draining`) when an unexpected signal tears
# the gateway down — extended here to the case where the gateway died before it
# could persist anything at all.
#
# `starting` / `startup_failed` are deliberately NOT included: those mean the
# gateway died mid-boot or failed to come up, so auto-restarting them would
# reintroduce the crash-loop the down-marker guard exists to prevent.
_TRANSIENT_RUNNING_STATES = frozenset({"draining", "degraded"})
# 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
# How the profile's previous gateway life ended: "clean" (exit path ran),
# "unclean" (sentinel still says running — SIGKILL/OOM/VM death), or
# "unknown" (no sentinel / never ran). See gateway.lifecycle_ledger
# (NS-608): at container boot this is the one place that can stamp
# "the previous container life ended violently" into a durable,
# volume-persisted log line.
prior_exit: str = "unknown"
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] = []
# A multiplexing root/default gateway owns inbound platform connections
# for every profile. Named slots must still be registered (so explicit
# lifecycle management remains available), but booting them from their
# persisted run intent would create additional multiplex owners.
# Keep the boot reconciler aligned with the gateway that will own these
# slots. The runtime resolver gives a recognized environment override
# precedence over config.yaml and otherwise preserves the configured value.
from gateway.config import load_gateway_config
from utils import is_truthy_value
try:
multiplex_profiles = load_gateway_config().multiplex_profiles
except Exception:
log.warning(
"Unable to load gateway configuration during container boot; "
"using the GATEWAY_MULTIPLEX_PROFILES override if set.",
exc_info=True,
)
multiplex_profiles = is_truthy_value(
os.environ.get("GATEWAY_MULTIPLEX_PROFILES"),
)
# 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_desired_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",
prior_exit=_read_prior_exit_label(hermes_home),
))
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_desired_state(entry)
should_start = (
not multiplex_profiles and 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",
prior_exit=_read_prior_exit_label(entry),
))
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",
"desired_state": "running",
"timestamp": int(time.time()),
"migrated_from": "legacy-container-cmd",
}) + "\n", encoding="utf-8")
return "running"
def _read_container_argv() -> tuple[str, ...]:
"""Best-effort read of the container's main program argv.
Under s6-overlay v2, PID 1 is ``/init`` and its argv contains the
``main-wrapper.sh`` path. Under s6-overlay v3, PID 1 is
``s6-svscan`` and the actual command (``rc.init top main-wrapper.sh
...``) lives on a different PID. We try PID 1 first (fast path,
covers v2 and pre-s6 images), then fall back to scanning
``/proc/*/cmdline`` for a process whose argv contains
``main-wrapper.sh`` (the rc.init-launched PID in v3).
"""
# Fast path: PID 1 is the command itself (s6-overlay v2 / tini).
try:
raw = Path("/proc/1/cmdline").read_bytes()
argv = tuple(
part.decode("utf-8", "replace") for part in raw.split(b"\0") if part
)
if any("main-wrapper.sh" in part for part in argv):
return argv
except OSError:
pass
# Slow path: s6-overlay v3 — PID 1 is s6-svscan; find the
# rc.init-launched process whose argv contains main-wrapper.sh.
try:
proc_dir = Path("/proc")
for entry in proc_dir.iterdir():
if not entry.name.isdigit():
continue
try:
raw = (entry / "cmdline").read_bytes()
except OSError:
continue
argv = tuple(
part.decode("utf-8", "replace")
for part in raw.split(b"\0")
if part
)
if any("main-wrapper.sh" in part for part in argv):
return argv
except OSError:
pass
return ()
def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:
"""Strip the s6/wrapper prefix off the container argv, leaving the hermes args.
Two container-command argv shapes are handled:
* **s6-overlay v2 / tini:** PID 1 argv is
``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]``.
* **s6-overlay v3:** PID 1 is ``s6-svscan`` and the command lives on the
rc.init-launched process as ``/bin/sh -e
/run/s6/basedir/scripts/rc.init top /opt/hermes/docker/main-wrapper.sh
<subcommand> [args...]`` (see :func:`_read_container_argv`).
Rather than peel each leading token positionally (which silently breaks
the moment s6 changes its launcher shape again exactly what happened
in the v2v3 bump), drop everything up to and including the
``main-wrapper.sh`` token: that wrapper path is the stable boundary the
image owns, and the subcommand always follows it. Pre-s6 / direct
``hermes`` invocations carry no wrapper, so fall back to peeling a bare
``init`` prefix. The wrapper re-execs ``hermes <subcommand>``, so an
explicit leading ``hermes`` is peeled too. Shared by the legacy-gateway
and dashboard role detectors.
"""
args = list(argv)
# Preferred boundary: everything through main-wrapper.sh is launcher
# prefix. Covers s6-overlay v2 (`/init …main-wrapper.sh …`) and v3
# (`/bin/sh -e …rc.init top …main-wrapper.sh …`) with one rule.
wrapper_idx = next(
(i for i, a in enumerate(args) if a.endswith("main-wrapper.sh")),
None,
)
if wrapper_idx is not None:
args = args[wrapper_idx + 1 :]
elif args and Path(args[0]).name == "init":
# Defensive: an `init` prefix with no wrapper token in argv.
args = args[1:]
# Non-PID-1 entrypoints go through the dispatch shim instead of /init.
if args and args[0].endswith("entrypoint-dispatch.sh"):
args = args[1:]
# The wrapper re-execs `hermes <subcommand>`; peel an explicit hermes.
if args and Path(args[0]).name == "hermes":
args = args[1:]
return args
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
"""Return True for Docker commands equivalent to `gateway run`."""
args = _strip_container_argv_prefix(argv)
if "--no-supervise" in args:
return False
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
def _is_dashboard_container(argv: Sequence[str]) -> bool:
"""Return True when the container's command is the dashboard.
A dashboard-only container (``hermes dashboard ...``) never spawns or
supervises per-profile gateways that is the gateway container's job.
Reconciling profile gateway s6 slots there is not just wasted work: when
the gateway and dashboard containers share a bind-mounted HERMES_HOME,
both race to ``flock()`` the same ``logs/gateways/<profile>/lock`` files,
producing "Resource busy" failures and an s6-log restart storm. So the
dashboard container skips reconciliation entirely.
Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator
flag: the role is a fact about the container's command, not a tunable,
and a flag can be forgotten in a hand-written compose/k8s manifest
reintroducing the exact storm this prevents. Mirrors the argv handling
in :func:`_is_legacy_gateway_run_request`.
"""
args = _strip_container_argv_prefix(argv)
return bool(args) and args[0] == "dashboard"
def _read_desired_state(profile_dir: Path) -> str | None:
"""Read the persisted gateway desired state for reconciliation.
Newer state files carry ``desired_state``: operator intent written by
s6 lifecycle commands. Older files only carry ``gateway_state``; keep
that as a compatibility fallback so existing running/stopped profiles
preserve their behavior until the next explicit start/stop.
When falling back to ``gateway_state`` (no explicit ``desired_state``),
a transient running sub-state (``draining``) is normalised to ``running``
see ``_TRANSIENT_RUNNING_STATES``. A gateway hard-killed mid-drain
leaves ``draining`` as its last persisted value; without this it would be
treated as a non-autostart state and the gateway would stay DOWN forever.
An explicit ``desired_state`` is always honoured verbatim (it is the
operator's durable intent), so this normalisation only affects the
legacy/transient fallback path.
Missing or unparseable files count as "no desired 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:
data = json.loads(state_file.read_text(encoding="utf-8"))
desired_state = data.get("desired_state")
if desired_state is not None:
return desired_state
gateway_state = data.get("gateway_state")
if gateway_state in _TRANSIENT_RUNNING_STATES:
return "running"
return 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 _read_prior_exit_label(profile_dir: Path) -> str:
"""How the profile's previous gateway life ended (clean/unclean/unknown).
Thin, exception-free wrapper over
:func:`gateway.lifecycle_ledger.read_prior_exit_label` cont-init runs
in a minimal environment and forensics must never block reconciliation
(NS-608)."""
try:
from gateway.lifecycle_ledger import read_prior_exit_label
return read_prior_exit_label(profile_dir)
except Exception:
return "unknown"
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}"
# Dot-prefix the staging dir so s6-svscan skips it while half-built
# (s6-svscan ignores scandir entries whose name starts with ".").
# A non-dotted ``.tmp`` staging name is supervised AS ROOT by any
# concurrent ``s6-svscanctl -a`` rescan the moment it has a valid
# ``type``/``run``, creating a root-owned ``supervise/`` that makes
# ``_seed_supervise_skeleton`` EACCES — see the matching comment in
# ``S6ServiceManager.register_profile_gateway``. The atomic
# ``tmp_dir.replace(service_dir)`` below renames to the dotless live
# name, so the published slot is unchanged.
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", encoding="utf-8")
# 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={}), encoding="utf-8")
run.chmod(0o755)
finish = tmp_dir / "finish"
finish.write_text(S6ServiceManager._render_finish_script(), encoding="utf-8")
finish.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), encoding="utf-8")
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} prior_exit={a.prior_exit}\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."""
# A dashboard-only container never spawns or supervises per-profile
# gateways, so reconciling their s6 slots here is pure waste — and
# actively harmful: when the gateway and dashboard containers share a
# bind-mounted HERMES_HOME, both race to flock() the same s6-log lock
# files under logs/gateways/<profile>/lock, producing "Resource busy"
# failures and a restart storm. Detect the role from PID 1 argv and
# skip reconciliation in the dashboard container. No operator flag:
# the role is a fact about the container's command, and a flag can be
# forgotten in a hand-written manifest, reintroducing the storm.
if _is_dashboard_container(_read_container_argv()):
print(
"reconcile: skipping (dashboard container — does not need "
"per-profile gateways)"
)
return 0
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())
+203
View File
@@ -0,0 +1,203 @@
"""Warn when an in-session model switch will trigger preflight compression on the next turn.
Addresses part of #23767 ("user-facing guardrail when switching from a
high-context provider to a substantially lower-context provider"). The other
proposed fixes from that issue (hard preflight token guard, metadata cache
invalidation on switch, compression safety invariant, oversized tool-output
handling) are tracked separately.
Mirrors the expensive-model guard pattern: merge into ``ModelSwitchResult.warning_message``
so Herm TUI, CLI, and gateway surfaces that already show switch warnings pick it up.
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
from hermes_cli.model_switch import ModelSwitchResult, resolve_display_context_length
def _append_warning(result: ModelSwitchResult, text: str) -> None:
if result.warning_message:
result.warning_message = f"{result.warning_message} | {text}"
else:
result.warning_message = text
def _threshold_tokens(context_length: int, threshold_percent: float) -> int:
return max(int(context_length * threshold_percent), MINIMUM_CONTEXT_LENGTH)
def _estimate_tokens(agent: Any, messages: Optional[List[dict]]) -> Optional[int]:
cc = getattr(agent, "context_compressor", None)
if cc is None:
return None
if messages is not None:
protect = int(getattr(cc, "protect_first_n", 3)) + int(
getattr(cc, "protect_last_n", 20)
) + 1
if len(messages) <= protect:
return None
try:
from agent.model_metadata import estimate_request_tokens_rough
system_prompt = getattr(agent, "_cached_system_prompt", None) or ""
tools = getattr(agent, "tools", None)
return int(
estimate_request_tokens_rough(
messages,
system_prompt=system_prompt,
tools=tools or None,
)
)
except Exception:
pass
last = int(getattr(cc, "last_prompt_tokens", 0) or 0)
if last > 0:
return last
session_prompt = int(getattr(agent, "session_prompt_tokens", 0) or 0)
return session_prompt if session_prompt > 0 else None
def merge_preflight_compression_warning(
result: ModelSwitchResult,
*,
agent: Any = None,
messages: Optional[List[dict]] = None,
custom_providers: list | None = None,
config_context_length: int | None = None,
configured_model: str | None = None,
configured_provider: str | None = None,
configured_base_url: str | None = None,
) -> None:
"""If the next user message will likely preflight-compress, append a warning."""
if not result.success or agent is None:
return
if not getattr(agent, "compression_enabled", True):
return
cc = getattr(agent, "context_compressor", None)
if cc is None:
return
# Classic CLI historically omitted custom_providers here while the /model
# confirmation display threaded agent._custom_providers — so the shrink
# warning fell through to the hardcoded catalog (e.g. "qwen" → 131072)
# even when custom_providers[].models.<id>.context_length was 1M.
if custom_providers is None:
custom_providers = getattr(agent, "_custom_providers", None)
old_ctx = int(getattr(cc, "context_length", 0) or 0)
new_ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or getattr(agent, "base_url", "") or "",
api_key=result.api_key or getattr(agent, "api_key", "") or "",
model_info=result.model_info,
custom_providers=custom_providers,
config_context_length=config_context_length,
configured_model=(
configured_model
if configured_model is not None
else getattr(agent, "model", None)
),
configured_provider=(
configured_provider
if configured_provider is not None
else getattr(agent, "provider", None)
),
configured_base_url=(
configured_base_url
if configured_base_url is not None
else getattr(agent, "base_url", None)
),
)
if not new_ctx:
return
estimate = _estimate_tokens(agent, messages)
if estimate is None:
return
pct = float(getattr(cc, "threshold_percent", 0.5))
new_threshold = _threshold_tokens(new_ctx, pct)
if estimate < new_threshold:
return
if int(getattr(cc, "_ineffective_compression_count", 0) or 0) >= 2:
return
parts: list[str] = []
if old_ctx and new_ctx < old_ctx:
parts.append(
f"Context window shrinks ({old_ctx:,}{new_ctx:,}). "
)
parts.append(
f"Session is ~{estimate:,} tokens; "
f"{result.new_model} allows {new_ctx:,} "
f"(auto-compress at ~{new_threshold:,}). "
f"Your next message will run preflight compression before the model replies."
)
_append_warning(result, "".join(parts))
def enrich_model_switch_warnings_for_gateway(
result: ModelSwitchResult,
runner: Any,
*,
session_key: str,
source: Any,
custom_providers: list | None = None,
load_gateway_config: Callable[[], dict] | None = None,
) -> None:
"""Gateway helper: cached agent + session DB messages."""
lock = getattr(runner, "_agent_cache_lock", None)
cache = getattr(runner, "_agent_cache", None)
agent = None
if lock is not None and cache is not None:
with lock:
entry = cache.get(session_key)
if entry and entry[0] is not None:
agent = entry[0]
if agent is None:
return
cfg_ctx = None
configured_model = None
configured_provider = None
configured_base_url = None
if load_gateway_config is not None:
try:
cfg = load_gateway_config()
model_cfg = cfg.get("model", {}) if isinstance(cfg, dict) else {}
if isinstance(model_cfg, dict) and model_cfg.get("context_length") is not None:
cfg_ctx = int(model_cfg["context_length"])
configured_model = model_cfg.get("default") or model_cfg.get("model")
configured_provider = model_cfg.get("provider")
configured_base_url = model_cfg.get("base_url")
except Exception:
pass
messages = None
db = getattr(runner, "_session_db", None)
store = getattr(runner, "session_store", None)
if db is not None and store is not None:
try:
entry = store.get_or_create_session(source)
messages = db.get_messages_as_conversation(entry.session_id)
except Exception:
pass
merge_preflight_compression_warning(
result,
agent=agent,
messages=messages,
custom_providers=custom_providers,
config_context_length=cfg_ctx,
configured_model=configured_model,
configured_provider=configured_provider,
configured_base_url=configured_base_url,
)
+828
View File
@@ -0,0 +1,828 @@
"""GitHub Copilot authentication utilities.
Implements the OAuth device code flow used by the Copilot CLI and handles
token validation/exchange for the Copilot API.
Token type support (per GitHub docs):
gho_ OAuth token (default via copilot login)
github_pat_ Fine-grained PAT (needs Copilot Requests permission)
ghu_ GitHub App token (via environment variable)
ghp_ Classic PAT NOT SUPPORTED
Credential search order (matching Copilot CLI behaviour):
1. COPILOT_GITHUB_TOKEN env var
2. GH_TOKEN env var
3. GITHUB_TOKEN env var
4. gh auth token CLI fallback
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
logger = logging.getLogger(__name__)
# OAuth device code flow constants — VS Code's GitHub App client ID.
# The previous opencode OAuth App ID (Ov23li8tweQw6odWQebz) produces gho_*
# tokens that cannot be exchanged for Copilot API JWTs (404 on
# /copilot_internal/v2/token). VS Code's App ID produces ghu_* tokens
# that support exchange, which is required to access internal-only models
# (e.g. claude-opus-4.6-1m) and enterprise endpoints.
# Tested on Individual and Enterprise accounts.
COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98"
# Token type prefixes
_CLASSIC_PAT_PREFIX = "ghp_"
_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_")
# Env var search order (matches Copilot CLI)
COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
# Polling constants
_DEVICE_CODE_POLL_INTERVAL = 5 # seconds
_DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds
def validate_copilot_token(token: str) -> tuple[bool, str]:
"""Validate that a token is usable with the Copilot API.
Returns (valid, message).
"""
token = token.strip()
if not token:
return False, "Empty token"
if token.startswith(_CLASSIC_PAT_PREFIX):
return False, (
"Classic Personal Access Tokens (ghp_*) are not supported by the "
"Copilot API. Use one of:\n"
" → `copilot login` or `hermes model` to authenticate via OAuth\n"
" → A fine-grained PAT (github_pat_*) with Copilot Requests permission\n"
" → `gh auth login` with the default device code flow (produces gho_* tokens)"
)
return True, "OK"
def resolve_copilot_token() -> tuple[str, str]:
"""Resolve a GitHub token suitable for Copilot API use.
Returns (token, source) where source describes where the token came from.
Raises ValueError if only a classic PAT is available.
"""
# 1. Check env vars in priority order
any_env_var_set = False
for env_var in COPILOT_ENV_VARS:
val = os.getenv(env_var, "").strip()
if val:
any_env_var_set = True
valid, msg = validate_copilot_token(val)
if not valid:
logger.warning(
"Token from %s is not supported: %s", env_var, msg
)
continue
return val, env_var
# 2. Fall back to gh auth token — but ONLY when no Copilot env var was
# explicitly set. When the user exported GITHUB_TOKEN (even an
# unsupported classic PAT), their intent is to use *that* token, not
# to silently substitute one from the gh CLI credential store.
# Skipping the subprocess here also avoids a slow `gh auth token`
# call (up to 5s timeout on Windows) on every cold start that scans
# Copilot auth state — a measurable contributor to the ~14s
# cold-start stall (#60800). The user can run `copilot login` or
# set a supported token (gho_*/github_pat_*/ghu_) explicitly.
if any_env_var_set:
logger.debug(
"Copilot env var(s) set but none held a supported token; "
"skipping `gh auth token` fallback to honor explicit env-var "
"intent (and avoid the subprocess cost on cold start, #60800)."
)
return "", ""
token = _try_gh_cli_token()
if token:
valid, msg = validate_copilot_token(token)
if not valid:
raise ValueError(
f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}"
)
return token, "gh auth token"
return "", ""
def _gh_cli_candidates() -> list[str]:
"""Return candidate ``gh`` binary paths, including common Homebrew installs."""
candidates: list[str] = []
resolved = shutil.which("gh")
if resolved:
candidates.append(resolved)
for candidate in (
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
str(Path.home() / ".local" / "bin" / "gh"),
):
if candidate in candidates:
continue
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
candidates.append(candidate)
return candidates
# ``gh auth token`` result cache. The probe shells out to the gh CLI, and when
# gh has no credential store for this HOME (fresh profile, desktop-spawned
# backend, CI) it can block for its full 5s subprocess timeout — on keyring /
# D-Bus prompts rather than returning immediately. Provider inventory builds
# (``/api/model/options``, ``hermes tools``) probe Copilot auth several times
# per request, so an uncached miss turns one settings-page load into a 4×5s
# stall that exceeds the Desktop renderer's 15s IPC budget and paints an error
# (observed Aug 2026: Models/Providers settings pages timing out on every
# open). Successes and failures are both cached; a short TTL keeps a freshly
# run ``gh auth login`` discoverable without restarting the backend.
_GH_CLI_TOKEN_CACHE_TTL_SECONDS = 300.0
_gh_cli_token_cache: tuple[float, Optional[str]] | None = None
def _invalidate_gh_cli_token_cache() -> None:
"""Reset the ``gh auth token`` probe cache (used by tests and re-auth flows)."""
global _gh_cli_token_cache
_gh_cli_token_cache = None
def _try_gh_cli_token() -> Optional[str]:
"""Return a token from ``gh auth token`` when the GitHub CLI is available.
When COPILOT_GH_HOST is set, passes ``--hostname`` so gh returns the
correct host's token. Also strips GITHUB_TOKEN / GH_TOKEN from the
subprocess environment so ``gh`` reads from its own credential store
(hosts.yml) instead of just echoing the env var back.
The result (including a miss) is cached for a short TTL see the cache
comment above. Callers that just re-authenticated can call
``_invalidate_gh_cli_token_cache()`` to re-probe immediately.
"""
global _gh_cli_token_cache
now = time.monotonic()
if _gh_cli_token_cache is not None:
cached_at, cached_token = _gh_cli_token_cache
if now - cached_at < _GH_CLI_TOKEN_CACHE_TTL_SECONDS:
return cached_token
token = _probe_gh_cli_token()
_gh_cli_token_cache = (now, token)
return token
def _probe_gh_cli_token() -> Optional[str]:
"""Uncached ``gh auth token`` subprocess probe (see ``_try_gh_cli_token``)."""
hostname = os.getenv("COPILOT_GH_HOST", "").strip()
# Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN
clean_env = {k: v for k, v in os.environ.items()
if k not in {"GITHUB_TOKEN", "GH_TOKEN"}}
# Never let gh open an interactive prompt from a backend process.
clean_env.setdefault("GH_PROMPT_DISABLED", "1")
clean_env.setdefault("GH_NO_UPDATE_NOTIFIER", "1")
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
for gh_path in _gh_cli_candidates():
cmd = [gh_path, "auth", "token"]
if hostname:
cmd += ["--hostname", hostname]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=5,
env=clean_env,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
)
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
continue
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
# ─── OAuth Device Code Flow ────────────────────────────────────────────────
def copilot_device_code_login(
*,
host: str = "github.com",
timeout_seconds: float = 300,
) -> Optional[str]:
"""Run the GitHub OAuth device code flow for Copilot.
Prints instructions for the user, polls for completion, and returns
the OAuth access token on success, or None on failure/cancellation.
This replicates the flow used by opencode and the Copilot CLI.
"""
import urllib.request
import urllib.parse
domain = host.rstrip("/")
device_code_url = f"https://{domain}/login/device/code"
access_token_url = f"https://{domain}/login/oauth/access_token"
# Step 1: Request device code
data = urllib.parse.urlencode({
"client_id": COPILOT_OAUTH_CLIENT_ID,
"scope": "read:user",
}).encode()
req = urllib.request.Request(
device_code_url,
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "HermesAgent/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
device_data = json.loads(resp.read().decode())
except Exception as exc:
logger.error("Failed to initiate device authorization: %s", exc)
print(f" ✗ Failed to start device authorization: {exc}")
return None
verification_uri = device_data.get("verification_uri", "https://github.com/login/device")
user_code = device_data.get("user_code", "")
device_code = device_data.get("device_code", "")
interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1)
if not device_code or not user_code:
print(" ✗ GitHub did not return a device code.")
return None
# Step 2: Show instructions
print()
print(f" Open this URL in your browser: {verification_uri}")
print(f" Enter this code: {user_code}")
print()
print(" Waiting for authorization...", end="", flush=True)
# Step 3: Poll for completion
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
poll_data = urllib.parse.urlencode({
"client_id": COPILOT_OAUTH_CLIENT_ID,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}).encode()
poll_req = urllib.request.Request(
access_token_url,
data=poll_data,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "HermesAgent/1.0",
},
)
try:
with urllib.request.urlopen(poll_req, timeout=10) as resp:
result = json.loads(resp.read().decode())
except Exception:
print(".", end="", flush=True)
continue
if result.get("access_token"):
print("")
return result["access_token"]
error = result.get("error", "")
if error == "authorization_pending":
print(".", end="", flush=True)
continue
elif error == "slow_down":
# RFC 8628: add 5 seconds to polling interval
server_interval = result.get("interval")
if isinstance(server_interval, (int, float)) and server_interval > 0:
interval = int(server_interval)
else:
interval += 5
print(".", end="", flush=True)
continue
elif error == "expired_token":
print()
print(" ✗ Device code expired. Please try again.")
return None
elif error == "access_denied":
print()
print(" ✗ Authorization was denied.")
return None
elif error:
print()
print(f" ✗ Authorization failed: {error}")
return None
print()
print(" ✗ Timed out waiting for authorization.")
return None
# ─── Copilot Token Exchange ────────────────────────────────────────────────
# Module-level cache for exchanged Copilot API tokens.
# Maps raw_token_fingerprint -> (api_token, expires_at_epoch, base_url).
_jwt_cache: dict[str, tuple[str, float, Optional[str]]] = {}
_JWT_REFRESH_MARGIN_SECONDS = 120 # refresh 2 min before expiry
# Token exchange endpoint and headers (matching VS Code / Copilot CLI)
_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
_EDITOR_VERSION = "vscode/1.104.1"
_EXCHANGE_USER_AGENT = "GitHubCopilotChat/0.26.7"
# Transient-failure hardening for the token exchange. Gateway startup often
# races network readiness (launchd relaunch, DHCP/VPN settling); a single-shot
# exchange that fails there silently degrades to the RAW GitHub token, which the
# Copilot server routes to the "copilot-language-server" integrator whose model
# allowlist omits enterprise-only models (e.g. claude-opus-4.8) → HTTP 400 on
# every turn until the next restart. Retry a few times, and persist the last
# good exchanged JWT to disk so a restart during a blip reuses the still-valid
# ~30-min token instead of degrading.
_EXCHANGE_MAX_ATTEMPTS = 3
_EXCHANGE_BACKOFF_BASE_SECONDS = 1.5 # sleeps ~1.5s, ~3.0s between attempts
_JWT_DISK_FILENAME = ".copilot_jwt.json"
_JWT_DISK_MAX_BYTES = 1_048_576 # 1 MiB cap on the persisted JWT store read
# Negative cache for failed exchanges. Without it, every load_pool("copilot")
# call re-runs the full exchange — and on a permanently-rejected token
# (HTTP 403: account not Copilot-entitled, expired grant, org policy) the
# retry backoff burned ~4.5s of time.sleep() on EVERY provider-discovery
# pass. The /model picker, delegation child spawns, and the web dashboard
# all walk that path, so a single bad Copilot token made all of them crawl.
# Maps raw-token fingerprint -> epoch until which exchange attempts are
# skipped (raise immediately). Success clears the entry.
_exchange_failure_cache: dict[str, float] = {}
# Single-flight guard per token fingerprint: concurrent callers (the dashboard
# polls /api/credentials/pool every few seconds, each poll off-loop) wait on
# the ONE in-flight exchange and then hit the positive/negative cache, instead
# of each spawning their own hung resolver thread during a DNS outage.
_exchange_locks: dict[str, threading.Lock] = {}
_exchange_locks_guard = threading.Lock()
def _exchange_lock_for(fp: str) -> threading.Lock:
with _exchange_locks_guard:
lock = _exchange_locks.get(fp)
if lock is None:
lock = _exchange_locks[fp] = threading.Lock()
return lock
_EXCHANGE_FAILURE_TTL_TRANSIENT_SECONDS = 60.0 # network blips: retry soon
_EXCHANGE_FAILURE_TTL_PERMANENT_SECONDS = 1800.0 # 401/403/404: won't heal
# HTTP statuses that indicate the token itself is rejected — retrying with
# backoff is pointless (the retry loop exists for startup network races,
# not for auth rejections) and sleeping on them just blocks the caller.
_EXCHANGE_PERMANENT_HTTP_STATUSES = frozenset({401, 403, 404})
def _token_fingerprint(raw_token: str) -> str:
"""Short fingerprint of a raw token for cache keying (avoids storing full token)."""
import hashlib
return hashlib.sha256(raw_token.encode()).hexdigest()[:16]
def _read_jwt_store(path: Path) -> Optional[dict]:
"""Bounded read of the on-disk JWT store → dict, or None if unusable.
Single chokepoint for every read of the persisted store (load, eviction,
save-merge). A well-formed store is a few KB; a file over the 1 MiB cap or
with non-dict content is treated as unusable so a corrupt/oversized file
can't balloon memory or get rewritten back out.
"""
try:
if path.stat().st_size > _JWT_DISK_MAX_BYTES:
logger.debug(
"Persisted Copilot JWT store exceeds %d bytes; ignoring", _JWT_DISK_MAX_BYTES
)
return None
loaded = json.loads(path.read_text(encoding="utf-8"))
return loaded if isinstance(loaded, dict) else None
except Exception as exc:
logger.debug("Failed to read persisted Copilot JWT store: %s", exc)
return None
def evict_cached_exchanged_token(raw_token: str) -> None:
"""Drop any cached exchanged JWT for ``raw_token`` (in-process + on-disk).
Used by the runtime stale-credential recovery path: when a live request
starts failing with a Copilot ``model_not_available_for_integrator`` /
``model_not_supported`` 400, the cached exchanged token (or a degraded raw
fallback that was cached in its place) is stale. Evicting both cache tiers
forces the next ``exchange_copilot_token`` call to hit the network and mint
a fresh token instead of returning the poisoned cache entry.
"""
if not raw_token:
return
fp = _token_fingerprint(raw_token)
_jwt_cache.pop(fp, None)
# Also clear any negative-cache entry: eviction is an explicit "force a
# fresh exchange" signal from the stale-credential recovery path, so the
# next exchange_copilot_token() must be allowed to hit the network.
_exchange_failure_cache.pop(fp, None)
path = _jwt_disk_path()
if not path or not path.exists():
return
try:
store = _read_jwt_store(path)
if store is not None and fp in store:
del store[fp]
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(store), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except Exception:
pass
os.replace(tmp, path)
except Exception as exc:
logger.debug("Failed to evict cached Copilot JWT: %s", exc)
def _jwt_disk_path() -> Optional[Path]:
"""Path to the on-disk exchanged-JWT cache (profile-aware), or None."""
try:
from hermes_constants import get_hermes_home
return Path(get_hermes_home()) / _JWT_DISK_FILENAME
except Exception:
return None
def _load_jwt_from_disk(fp: str) -> Optional[tuple[str, float, Optional[str]]]:
"""Load a persisted exchanged JWT for ``fp`` → (api_token, expires_at, base_url)."""
path = _jwt_disk_path()
if not path or not path.exists():
return None
try:
# Bound the read: this file is a small JSON map of fingerprint → token.
# An oversized/corrupt store is treated as unusable — the caller
# re-exchanges (bound shared with eviction/save via _read_jwt_store).
store = _read_jwt_store(path)
entry = store.get(fp) if store is not None else None
if not isinstance(entry, dict):
return None
api_token = entry.get("api_token", "")
expires_at = float(entry.get("expires_at", 0) or 0)
base_url = entry.get("base_url")
if api_token and expires_at:
return api_token, expires_at, base_url
except Exception as exc:
logger.debug("Failed to load persisted Copilot JWT: %s", exc)
return None
def _save_jwt_to_disk(
fp: str, api_token: str, expires_at: float, base_url: Optional[str]
) -> None:
"""Persist an exchanged JWT (0o600), pruning expired entries."""
path = _jwt_disk_path()
if not path:
return
try:
store: dict = {}
if path.exists():
store = _read_jwt_store(path) or {}
now = time.time()
store = {
k: v
for k, v in store.items()
if isinstance(v, dict) and float(v.get("expires_at", 0) or 0) > now
}
store[fp] = {
"api_token": api_token,
"expires_at": expires_at,
"base_url": base_url,
}
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(store), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except Exception:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except Exception:
pass
except Exception as exc:
logger.debug("Failed to persist Copilot JWT: %s", exc)
# Hard wall-clock cap for the token-exchange HTTP call. urllib's ``timeout``
# only bounds socket operations AFTER DNS resolution succeeds; getaddrinfo
# blocks in C and ignores it entirely, so on a networkless Windows host the
# resolver can hang for many minutes (observed: a 17-minute event-loop stall
# on 2026-08-22 that took the whole backend down with it).
_DNS_GRACE_SECONDS = 5.0
def _urlopen_bounded(req, timeout: float):
"""urlopen() with a hard wall-clock cap of timeout + _DNS_GRACE_SECONDS.
Runs the call on a daemon thread and abandons it if the cap fires, so a
DNS/getaddrinfo hang cannot block the caller indefinitely. Raises the
worker's exception, or TimeoutError when the cap fires.
"""
import urllib.request
box: dict = {}
abandoned = threading.Event()
def _worker() -> None:
try:
resp = urllib.request.urlopen(req, timeout=timeout)
except BaseException as exc: # re-raised on the caller's thread
box["exc"] = exc
return
if abandoned.is_set():
# The caller already timed out; nobody will read this response,
# so release its socket instead of leaking it with the thread.
try:
resp.close()
except Exception:
pass
return
box["resp"] = resp
t = threading.Thread(
target=_worker, name="copilot-token-exchange", daemon=True
)
t.start()
t.join(timeout + _DNS_GRACE_SECONDS)
if t.is_alive():
abandoned.set()
raise TimeoutError(
"copilot token exchange exceeded hard cap of "
f"{timeout + _DNS_GRACE_SECONDS:.0f}s (DNS/getaddrinfo hang?)"
)
if "exc" in box:
raise box["exc"]
if "resp" not in box:
raise TimeoutError("copilot token exchange worker died without result")
return box["resp"]
def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float, Optional[str]]:
"""Exchange a raw GitHub token for a short-lived Copilot API token.
Calls ``GET https://api.github.com/copilot_internal/v2/token`` with
the raw GitHub token and returns ``(api_token, expires_at, base_url)``.
The returned token is a semicolon-separated string (not a standard JWT)
used as ``Authorization: Bearer <token>`` for Copilot API requests.
``base_url`` is the account-specific API host: the authoritative
``endpoints.api`` advertised by the exchange (enterprise/proxied
accounts), falling back to a host derived from the token's ``proxy-ep``
field. Individual accounts have neither, so ``base_url`` is None.
Results are cached in-process and reused until close to expiry.
Raises ``ValueError`` on failure.
"""
fp = _token_fingerprint(raw_token)
# Fast paths outside the lock: a valid in-process JWT needs no exchange,
# and a recent failure means queueing behind the in-flight holder (up to
# ~50 s) would only park an executor thread to learn the same answer.
cached = _jwt_cache.get(fp)
if cached and time.time() < cached[1] - _JWT_REFRESH_MARGIN_SECONDS:
return cached
_fail_until = _exchange_failure_cache.get(fp, 0.0)
if time.time() < _fail_until:
raise ValueError(
"Copilot token exchange recently failed; skipping re-attempt "
f"for another {int(_fail_until - time.time())}s"
)
# Note: a waiter's own ``timeout`` is not honoured across the lock wait —
# by design of single-flight, it observes the holder's outcome instead.
with _exchange_lock_for(fp):
return _exchange_copilot_token_locked(raw_token, fp, timeout=timeout)
def _exchange_copilot_token_locked(
raw_token: str, fp: str, *, timeout: float
) -> tuple[str, float, Optional[str]]:
import urllib.request
# Re-check the caches under the lock: a concurrent caller may have just
# completed (or just failed) the exchange we were queued behind.
cached = _jwt_cache.get(fp)
if cached:
api_token, expires_at, base_url = cached
if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS:
return api_token, expires_at, base_url
# Then the on-disk cache: a fresh process (e.g. gateway restart) has an
# empty in-process cache but may have a still-valid persisted JWT. Reusing
# it avoids a network round-trip at startup — precisely when the network is
# most likely to be flaky and the single-shot exchange would degrade to the
# raw token.
disk_cached = _load_jwt_from_disk(fp)
if disk_cached:
api_token, expires_at, base_url = disk_cached
if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS:
_jwt_cache[fp] = (api_token, expires_at, base_url)
return api_token, expires_at, base_url
# Negative cache: a recent exchange failure for this token means the
# network round-trip (and its retry backoff) would just repeat. Fail
# fast so provider discovery / picker opens don't block on a token we
# already know is rejected or unreachable.
_fail_until = _exchange_failure_cache.get(fp, 0.0)
if time.time() < _fail_until:
raise ValueError(
"Copilot token exchange recently failed; skipping re-attempt "
f"for another {int(_fail_until - time.time())}s"
)
req = urllib.request.Request(
_TOKEN_EXCHANGE_URL,
method="GET",
headers={
"Authorization": f"token {raw_token}",
"User-Agent": _EXCHANGE_USER_AGENT,
"Accept": "application/json",
"Editor-Version": _EDITOR_VERSION,
},
)
# Retry with backoff. Startup network races (launchd relaunch, VPN/DHCP
# settling) make the first attempt flaky; without this the sole failure
# silently degrades to the raw token for the whole process lifetime.
# Permanent HTTP rejections (401/403/404 — token not Copilot-entitled,
# revoked, or org-blocked) skip the retry loop entirely: backoff exists
# for transient network races, and sleeping on an auth rejection just
# blocks the caller for ~4.5s with an identical outcome.
data = None
last_exc: Optional[Exception] = None
permanent_failure = False
for attempt in range(_EXCHANGE_MAX_ATTEMPTS):
try:
with _urlopen_bounded(req, timeout) as resp:
data = json.loads(resp.read().decode())
break
except Exception as exc: # noqa: BLE001 — retry all, re-raise below
last_exc = exc
status = getattr(exc, "code", None) or getattr(exc, "status", None)
if status in _EXCHANGE_PERMANENT_HTTP_STATUSES:
permanent_failure = True
logger.debug(
"Copilot token exchange rejected (HTTP %s); not retrying",
status,
)
break
if attempt < _EXCHANGE_MAX_ATTEMPTS - 1:
sleep_s = _EXCHANGE_BACKOFF_BASE_SECONDS * (attempt + 1)
logger.debug(
"Copilot token exchange attempt %d/%d failed (%s); retrying in %.1fs",
attempt + 1, _EXCHANGE_MAX_ATTEMPTS, exc, sleep_s,
)
time.sleep(sleep_s)
if data is None:
ttl = (
_EXCHANGE_FAILURE_TTL_PERMANENT_SECONDS
if permanent_failure
else _EXCHANGE_FAILURE_TTL_TRANSIENT_SECONDS
)
_exchange_failure_cache[fp] = time.time() + ttl
raise ValueError(
f"Copilot token exchange failed after {_EXCHANGE_MAX_ATTEMPTS} attempts: {last_exc}"
) from last_exc
_exchange_failure_cache.pop(fp, None)
api_token = data.get("token", "")
expires_at = data.get("expires_at", 0)
if not api_token:
raise ValueError("Copilot token exchange returned empty token")
# Convert expires_at to float if needed
expires_at = float(expires_at) if expires_at else time.time() + 1800
# Resolve the account-specific API base URL. GitHub advertises the
# authoritative endpoint under ``endpoints.api`` in the exchange response
# (it differs for Copilot Enterprise / proxied accounts). When the
# response omits it, fall back to deriving the host from the ``proxy-ep``
# field embedded in the exchanged token. Individual accounts have neither,
# so ``base_url`` stays None and callers use the registry default.
base_url: Optional[str] = None
endpoints = data.get("endpoints")
if isinstance(endpoints, dict):
api_endpoint = str(endpoints.get("api") or "").strip().rstrip("/")
if api_endpoint:
base_url = api_endpoint
if not base_url:
base_url = _derive_base_url_from_proxy_ep(api_token)
_jwt_cache[fp] = (api_token, expires_at, base_url)
_save_jwt_to_disk(fp, api_token, expires_at, base_url)
logger.debug(
"Copilot token exchanged, expires_at=%s, base_url=%s",
expires_at,
base_url,
)
return api_token, expires_at, base_url
def _derive_base_url_from_proxy_ep(token: str) -> Optional[str]:
"""Derive the Copilot API base URL from a proxy-ep field in the token.
The exchanged Copilot token is a semicolon-separated string like
``tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;...``.
This extracts ``proxy-ep`` and converts it to an API base URL by
replacing the leading ``proxy.`` with ``api.``.
Returns ``https://{api_hostname}`` or None if proxy-ep is absent.
"""
import re
m = re.search(r'(?:^|;)\s*proxy-ep=([^;\s]+)', token)
if not m:
return None
proxy_ep = m.group(1)
# Strip scheme if present
for prefix in ("https://", "http://"):
if proxy_ep.startswith(prefix):
proxy_ep = proxy_ep[len(prefix):]
break
proxy_ep = proxy_ep.rstrip("/")
# Replace leading "proxy." with "api."
if proxy_ep.startswith("proxy."):
api_host = "api." + proxy_ep[len("proxy."):]
else:
api_host = proxy_ep
return f"https://{api_host}"
def get_copilot_api_token(raw_token: str) -> tuple[str, Optional[str]]:
"""Exchange a raw GitHub token for a Copilot API token, with fallback.
Convenience wrapper: returns ``(api_token, base_url)`` on success, or
``(raw_token, None)`` if the exchange fails (e.g. network error, unsupported
account type). This preserves existing behaviour for accounts that don't
need exchange while enabling access to internal-only models for those that do.
``base_url`` is the account-specific API endpoint advertised by the
exchange (``endpoints.api``, with a ``proxy-ep`` fallback), or None for
individual accounts.
"""
if not raw_token:
return raw_token, None
try:
api_token, _, base_url = exchange_copilot_token(raw_token)
return api_token, base_url
except Exception as exc:
logger.debug("Copilot token exchange failed, using raw token: %s", exc)
return raw_token, None
# ─── Copilot API Headers ───────────────────────────────────────────────────
def copilot_request_headers(
*,
is_agent_turn: bool = True,
is_vision: bool = False,
) -> dict[str, str]:
"""Build the standard headers for Copilot API requests.
Replicates the header set used by opencode and the Copilot CLI.
"""
headers: dict[str, str] = {
"Editor-Version": "vscode/1.104.1",
"User-Agent": "HermesAgent/1.0",
"Copilot-Integration-Id": "vscode-chat",
"Openai-Intent": "conversation-edits",
"x-initiator": "agent" if is_agent_turn else "user",
}
if is_vision:
headers["Copilot-Vision-Request"] = "true"
return headers
+313
View File
@@ -0,0 +1,313 @@
"""Unified provider-credential lifecycle across every store Hermes reads.
A provider API key can live in up to THREE stores at once:
1. ``~/.hermes/.env`` the canonical secret store
2. ``~/.hermes/auth.json``
``credential_pool.<provider>[*]`` env-seeded pool entries
(``source == "env:<VAR>"``) persisted by the pool loader
3. ``~/.hermes/config.yaml`` inline mirrors written by the
custom-endpoint flows (``model.api_key``, ``auxiliary.<task>.api_key``,
``custom_providers[*].api_key``)
Historically the desktop/dashboard endpoints (PUT/DELETE ``/api/env``) and the
TUI-gateway RPCs only mutated store 1. That divergence is the root cause of a
whole bug family:
* #51071 / #59761 — deleting a key removes it from ``.env`` but the stale
``credential_pool`` entry (and ``provider_models_cache.json`` row)
survives, so the provider keeps appearing in the model picker, even
across restarts (the pool loader is additive-only).
* #62269 — updating a key rewrites ``.env`` but leaves the OLD key in a
higher-precedence ``config.yaml`` mirror (``model.api_key`` wins over
env at client construction), producing persistent 401s with a key the
UI no longer shows.
This module is the single choke point: every surface that saves or removes a
provider credential should route through :func:`save_provider_env_credential`
/ :func:`remove_provider_env_credential` so all three stores stay consistent.
OAuth preservation contract: removal only prunes credential-pool entries whose
``source`` is exactly ``env:<VAR>``. OAuth/device-code/manual/borrowed entries
(``device_code``, ``manual*``, ``gh_cli``, ``claude_code``, ``oauth``, ) and
the ``providers.<id>`` OAuth token blocks in auth.json are never touched
deleting an API key must not revoke an OAuth grant for the same provider.
Secrecy contract: no function in this module logs, prints, or returns a
credential value. Results carry key NAMES and config PATHS only.
"""
from __future__ import annotations
from typing import Any, Dict, List
__all__ = [
"save_provider_env_credential",
"remove_provider_env_credential",
"purge_env_credential_references",
]
def _providers_for_env_var(env_var: str) -> List[str]:
"""Provider ids whose registered api_key_env_vars include ``env_var``."""
try:
from hermes_cli.auth import PROVIDER_REGISTRY
except Exception:
return []
hits: List[str] = []
for pid, cfg in PROVIDER_REGISTRY.items():
try:
if env_var in (cfg.api_key_env_vars or ()):
hits.append(pid)
except Exception:
continue
return hits
def _prune_env_pool_entries(env_var: str) -> List[str]:
"""Drop ``credential_pool`` entries seeded from ``env:<env_var>``.
Operates across ALL providers in the pool (the source string names the
env var unambiguously, and shared vars like GITHUB_TOKEN may seed more
than one provider). Entries with any other source OAuth, device-code,
manual, borrowed-CLI are preserved verbatim, as are the
``providers.<id>`` OAuth blocks.
Returns the list of provider ids that had entries pruned.
"""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
source = f"env:{env_var}"
pruned: List[str] = []
with _auth_store_lock():
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
return pruned
changed = False
for provider in list(pool.keys()):
entries = pool[provider]
if not isinstance(entries, list):
continue
kept = [
entry
for entry in entries
if not (isinstance(entry, dict) and entry.get("source") == source)
]
if len(kept) == len(entries):
continue
changed = True
pruned.append(provider)
if kept:
pool[provider] = kept
else:
del pool[provider]
if changed:
_save_auth_store(auth_store)
return pruned
def _scrub_config_yaml_mirrors(old_value: str, new_value: str | None) -> List[str]:
"""Reconcile config.yaml api_key mirrors that hold ``old_value``.
Value-matched on purpose: we only touch a config entry when it provably
holds the SAME credential that just changed in ``.env`` an independent
key the user configured for a different endpoint is left alone.
``new_value=None`` removes the mirror field; a string replaces it.
Operates on the RAW user config (never the defaults-merged view) so the
write doesn't bake defaults into the user's file. Returns the dotted
paths that were updated (names only never values).
"""
if not old_value:
return []
from utils import atomic_yaml_write, fast_safe_load
from hermes_cli.config import (
get_config_path,
require_readable_config_before_write,
)
config_path = get_config_path()
if not config_path.exists():
return []
try:
with open(config_path, encoding="utf-8") as f:
user_config = fast_safe_load(f) or {}
except Exception:
return []
if not isinstance(user_config, dict):
return []
touched: List[str] = []
def _fix(
section: Any,
key_path: str,
fields: tuple[str, ...] = ("api_key", "api"),
) -> None:
if not isinstance(section, dict):
return
# "api" is the legacy alias for model.api_key kept by older configs.
# NOTE: in the keyed ``providers`` schema ``api`` means the base_url,
# not a credential (see ``get_compatible_custom_providers``), so that
# section passes ``fields=("api_key",)`` to avoid touching a base_url.
for field in fields:
current = section.get(field)
if isinstance(current, str) and current == old_value:
if new_value:
section[field] = new_value
else:
section.pop(field, None)
touched.append(f"{key_path}.{field}")
_fix(user_config.get("model"), "model")
aux = user_config.get("auxiliary")
if isinstance(aux, dict):
for task, slot_cfg in aux.items():
_fix(slot_cfg, f"auxiliary.{task}")
custom = user_config.get("custom_providers")
if isinstance(custom, list):
for idx, entry in enumerate(custom):
_fix(entry, f"custom_providers.{idx}")
elif isinstance(custom, dict):
for name, entry in custom.items():
_fix(entry, f"custom_providers.{name}")
# The keyed ``providers`` schema (v12+) is where the dashboard/desktop
# write custom-endpoint credentials — ``providers.<id>.api_key``. It is a
# real inline secret and higher-precedence than the env var, so a stale
# copy left here shadows a rotation (persistent 401 with a key the UI no
# longer shows, #62269) and survives a removal that promised to clear the
# credential from EVERY store. Scrub only ``api_key``; ``api`` here is the
# base_url alias, not a credential.
keyed_providers = user_config.get("providers")
if isinstance(keyed_providers, dict):
for provider_id, entry in keyed_providers.items():
_fix(entry, f"providers.{provider_id}", fields=("api_key",))
if touched:
require_readable_config_before_write(config_path)
atomic_yaml_write(config_path, user_config, sort_keys=False)
return touched
def purge_env_credential_references(
env_var: str, *, clear_models_cache: bool = True
) -> Dict[str, Any]:
"""Remove non-.env references to an env-var credential.
Prunes ``credential_pool`` env-seeded entries and (optionally) the
affected providers' rows in ``provider_models_cache.json`` so the model
picker stops advertising a provider whose key is gone (#59761).
"""
pruned = _prune_env_pool_entries(env_var)
providers = sorted(set(pruned) | set(_providers_for_env_var(env_var)))
# Make the removal sticky the same way `hermes auth remove` does: a
# lingering shell export (or another live process's os.environ) would
# otherwise re-seed the pool entry on the next load_pool(). The matching
# save path lifts the suppression on an explicit re-add.
try:
from hermes_cli.auth import suppress_credential_source
for provider in providers:
suppress_credential_source(provider, f"env:{env_var}")
except Exception:
pass
if clear_models_cache and providers:
try:
from hermes_cli.models import clear_provider_models_cache
for provider in providers:
clear_provider_models_cache(provider)
except Exception:
# Cache cleanup is best-effort — a failure here must not block
# the credential removal itself.
pass
return {"pool_pruned": pruned, "providers": providers}
def save_provider_env_credential(env_var: str, value: str) -> Dict[str, Any]:
"""Save/update a credential in ``.env`` and reconcile every mirror.
After the ``.env`` write, any config.yaml mirror that held the PREVIOUS
value of this var (``model.api_key`` etc.) is updated to the new value so
a stale higher-precedence copy cannot shadow the rotation (#62269).
Suppressed ``env:<VAR>`` pool sources are re-enabled so a deliberate
re-add through the UI behaves like ``hermes auth add``.
The save also forces an immediate ``load_pool()`` for every provider
registered against this env var so the env-seeded ``credential_pool``
entry is materialized to ``auth.json`` right now the live runtime reads
from the pool, and before #96058 the Desktop "Save" action only touched
``.env`` while ``auth.json``'s mtime stayed unchanged, so an OpenCode Go
(or any other env-backed provider) request kept 401'ing until the user
ran ``hermes auth add <provider> --type api-key`` separately. This makes
the Desktop save's effect on disk match what ``hermes auth add`` does.
"""
from hermes_cli.config import load_env, save_env_value
old_value = load_env().get(env_var)
save_env_value(env_var, value)
config_updates: List[str] = []
if value and old_value and old_value != value:
config_updates = _scrub_config_yaml_mirrors(old_value, value)
# A prior UI/CLI removal may have suppressed this env source; a fresh
# save is an explicit re-add, so lift the suppression for every provider
# that reads this var.
try:
from hermes_cli.auth import unsuppress_credential_source
for provider in _providers_for_env_var(env_var):
unsuppress_credential_source(provider, f"env:{env_var}")
except Exception:
pass
# Materialize the env-seeded credential_pool entry to auth.json NOW so the
# next request authenticates against the just-saved key. ``load_pool`` is
# idempotent and additive-only for env sources (#9331), so re-running it
# is safe even when the pool already had this entry. Best-effort: a
# failure here must not mask the successful .env write above.
try:
from agent.credential_pool import load_pool
for provider in _providers_for_env_var(env_var):
load_pool(provider)
except Exception:
pass
return {"ok": True, "key": env_var, "config_updates": config_updates}
def remove_provider_env_credential(env_var: str) -> Dict[str, Any]:
"""Remove a credential from EVERY store it lives in.
Clears the ``.env`` entry (and process env), prunes env-seeded
``credential_pool`` entries, drops the affected providers' model-cache
rows, and removes any config.yaml mirror holding the same value.
OAuth/device-code/manual credentials are preserved (see module docstring).
``found`` is True when ANY store held the credential callers that
previously 404'd on ".env miss" should key off this instead so a stale
pool-only entry can still be cleaned up through the same button.
"""
from hermes_cli.config import load_env, remove_env_value
old_value = load_env().get(env_var)
removed_from_env = remove_env_value(env_var)
refs = purge_env_credential_references(env_var)
config_scrubbed = _scrub_config_yaml_mirrors(old_value, None) if old_value else []
return {
"ok": True,
"key": env_var,
"removed": removed_from_env,
"pool_pruned": refs["pool_pruned"],
"providers": refs["providers"],
"config_scrubbed": config_scrubbed,
"found": bool(removed_from_env or refs["pool_pruned"] or config_scrubbed),
}
+1106
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
"""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,
TokenPrincipal,
LoginStart,
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
assert_protocol_compliance,
classify_jwks_lookup_error,
)
from hermes_cli.dashboard_auth.registry import (
register_provider,
get_provider,
list_providers,
list_token_providers,
list_session_providers,
clear_providers,
)
__all__ = [
"DashboardAuthProvider",
"Session",
"TokenPrincipal",
"LoginStart",
"InvalidCodeError",
"InvalidCredentialsError",
"ProviderError",
"RefreshExpiredError",
"assert_protocol_compliance",
"classify_jwks_lookup_error",
"register_provider",
"get_provider",
"list_providers",
"list_token_providers",
"list_session_providers",
"clear_providers",
]
+95
View File
@@ -0,0 +1,95 @@
"""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"
TOKEN_AUTH_SUCCESS = "token_auth_success"
TOKEN_AUTH_FAILURE = "token_auth_failure"
# RFC 8252 native-app (system-browser + loopback + PKCE) flow.
NATIVE_AUTHORIZE_START = "native_authorize_start"
NATIVE_CODE_ISSUED = "native_code_issued"
NATIVE_TOKEN_SUCCESS = "native_token_success"
NATIVE_TOKEN_FAILURE = "native_token_failure"
def _resolve_log_path() -> Path:
"""``$HERMES_HOME/logs/dashboard-auth.log``.
Uses ``hermes_constants.get_hermes_home()`` (a leaf module no import
cycle) so profile overrides and the native-Windows ``%LOCALAPPDATA%``
fallback are honored.
"""
from hermes_constants import get_hermes_home
return get_hermes_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)
+353
View File
@@ -0,0 +1,353 @@
"""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 TokenPrincipal:
"""A verified non-interactive (service-to-service) caller.
The token analog of :class:`Session`. Where a ``Session`` represents an
interactive human identity behind a session cookie, a ``TokenPrincipal``
represents a machine/service caller that authenticated by presenting a
bearer token in the ``Authorization`` request header on a single
request no login, no cookie, no refresh.
Returned by :meth:`DashboardAuthProvider.verify_token` and attached to
``request.state.token_principal`` by the token-auth middleware seam so a
route handler can see *who* called it.
Fields:
* ``principal`` stable identifier for the caller (e.g. the provider
name, a service account id, or an agent id). Opaque to the seam.
* ``provider`` the ``name`` of the provider that verified the token.
* ``scopes`` capability strings this principal is authorised for.
Empty tuple means "unscoped" (the provider vouches for the caller but
attaches no capability list); a route MAY enforce a required scope.
"""
principal: str
provider: str
scopes: tuple[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) with a TTL 10 minutes (the
login lifetime).
SameSite: use ``Lax`` by default. The one exception is the PKCE state
cookie, which is ``SameSite=None; Secure`` over HTTPS it is set on
the ``/auth/login`` 302 and has to survive the cross-site redirect
chain back from the IDP, which Chromium drops intermittently under
``Lax`` (crbug 40508226). Over plain HTTP it stays ``Lax``, since
``SameSite=None`` requires ``Secure``. See
:func:`hermes_cli.dashboard_auth.cookies.set_pkce_cookie`.
"""
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):
"""This provider rejects the refresh token as dead or invalid.
In a multi-provider deployment this does not prove token ownership, so
middleware may try remaining providers. It clears cookies and forces
re-login only after every reachable provider rejects the token.
"""
def classify_jwks_lookup_error(exc: BaseException) -> Exception:
"""Map a ``PyJWKClient.get_signing_key_from_jwt`` failure to the protocol.
Only a genuine transport failure (the IDP's JWKS endpoint could not be
fetched) is a :class:`ProviderError` middleware turns that into 503
"auth provider unreachable" so a flaky IDP never forces a logout.
Everything else means the token itself cannot be verified by this
provider and is an :class:`InvalidCodeError` (``verify_session`` returns
``None``, the middleware tries the next provider / refresh / 401):
* ``jwt.DecodeError`` the bearer is not a JWT at all (an opaque peer
key, a legacy session token, garbage). #94558: hosted agents answered
every non-JWT bearer with a fast 503 ``Auth provider 'nous'
unreachable`` even though Portal was healthy, because "cannot parse"
and "cannot reach" were folded into one branch.
* ``jwt.PyJWKSetError`` the JWKS was fetched fine but holds no key for
this token's ``kid`` (rotated/foreign key). The provider was reached;
the token is simply not one of ours.
``PyJWKClientConnectionError`` is the only ``PyJWKClientError`` subclass
that denotes unreachability; a bare ``PyJWKClientError`` (unexpected
JWKS shape) is kept as a provider fault since the IDP misbehaved.
"""
try:
import jwt
except Exception: # pragma: no cover - jwt is a hard dep of these providers
return ProviderError(f"JWKS lookup failed: {exc!r}")
if isinstance(exc, jwt.PyJWKClientConnectionError):
return ProviderError(f"JWKS lookup failed: {exc}")
if isinstance(exc, (jwt.DecodeError, jwt.PyJWKSetError)):
return InvalidCodeError(f"token not verifiable by this provider: {exc}")
if isinstance(exc, jwt.PyJWKClientError):
return ProviderError(f"JWKS lookup failed: {exc}")
if isinstance(exc, jwt.InvalidTokenError):
return InvalidCodeError(f"token not verifiable by this provider: {exc}")
return ProviderError(f"JWKS lookup failed: {exc!r}")
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 invalid for that provider. Middleware tries the remaining
providers because an opaque foreign token can be indistinguishable
from an expired one; it forces re-login only after every reachable
provider rejects the token. Raises ``ProviderError`` on network
failure; middleware still tries remaining providers, but returns 503
without clearing cookies if none succeeds and any was unavailable.
* ``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
# When True, this provider can verify a non-interactive bearer token
# (``verify_token``) presented on a single request by a service-to-service
# caller — no login, no cookie, no refresh. This is the generic
# API-token capability flag, mirroring ``supports_password``: a route
# opts into token auth (see ``token_auth`` middleware seam) and the
# gate consults every ``supports_token`` provider in turn until one
# recognises the token. OAuth/password providers leave this False and
# are completely unaffected. The drain bearer-secret plugin is the
# first consumer, but the capability is deliberately generic so any
# future machine-credential provider drops in without core changes.
supports_token: bool = False
# When True, this provider does the interactive cookie-session flow (login,
# verify, refresh). The login page, /auth/login, and the gate's
# verify/refresh loops consult only supports_session providers, so a
# token-only credential (e.g. drain) is never offered a login. Mirrors
# supports_token.
supports_session: bool = True
@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 verify_token(self, *, token: str) -> "Optional[TokenPrincipal]":
"""Verify a non-interactive bearer token; return its principal.
The token analog of ``verify_session``. Only consulted when
``supports_token`` is True. Called by the ``token_auth`` middleware
seam for every request to a token-authable route, in registration
order, until one provider returns a non-None principal.
Contract (mirrors ``verify_session`` stacking semantics):
* Return a :class:`TokenPrincipal` if this provider recognises and
accepts the token.
* Return ``None`` for a token this provider does NOT recognise
never raise, so the seam can fall through to the next provider.
A malformed/expired/wrong token is "not recognised" ``None``.
* Raise ``ProviderError`` ONLY for a genuine backing-store outage
(the provider can neither confirm nor deny). The seam treats this
like ``verify_session``: remember it, keep trying other providers,
and surface 503 only if NO provider accepts the token AND at least
one was unreachable.
Implementations MUST use a constant-time comparison
(``hmac.compare_digest``) when matching a shared secret so the
endpoint isn't a timing oracle.
The default raises ``NotImplementedError`` so a provider that sets
``supports_token`` but forgets to implement this fails loudly rather
than silently accepting every caller.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support token auth "
"(set supports_token = True and override verify_token)"
)
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__)}"
)
+525
View File
@@ -0,0 +1,525 @@
"""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)
The two session cookies are ``SameSite=Lax`` and live under the prefix's
Path. The PKCE cookie is the exception: ``SameSite=None`` over HTTPS,
falling back to ``Lax`` on plain HTTP (where ``SameSite=None`` is invalid
without ``Secure``). It is set on the ``/auth/login`` 302 and must survive
the cross-site redirect chain out to the IDP and back to
``/auth/callback``; Chromium intermittently drops ``Lax`` cookies set on a
302 in such a chain (crbug 40508226), which surfaces as "Missing PKCE
state cookie". ``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.
NOTE: uvicorn only honours ``X-Forwarded-Proto`` from a peer inside its
``forwarded_allow_ips`` (default: ``127.0.0.1``). A TLS terminator that
reaches the dashboard from a non-loopback address e.g. a reverse proxy
in its own container is not trusted, so the request still looks like
HTTP here and these cookies are written in their HTTP shape.
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
import base64
import binascii
import json
import re
from typing import Literal, Optional, Tuple
from urllib.parse import unquote
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"
# Provider that minted the session. This non-secret routing hint prevents a
# refresh token from being handed to the wrong provider when several dashboard
# auth plugins are enabled (for example Basic + Nous OAuth).
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
PKCE_COOKIE = "hermes_session_pkce"
# One-shot loop-guard marker for the auto-SSO redirect (Phase 1,
# cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth
# redirect on an unauthenticated document load; its mere PRESENCE on the next
# unauthenticated load tells the gate "we already bounced once" so a genuinely
# absent portal session degrades to the /login page instead of ping-ponging.
# Carries no secret — it's a boolean breadcrumb — but is set HttpOnly/Lax/Secure
# like the others for consistency. Short TTL so a user who returns later gets a
# fresh silent attempt rather than a permanently-disabled one.
SSO_ATTEMPT_COOKIE = "hermes_sso_attempt"
# 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
# Auto-SSO loop-guard marker TTL. Just long enough to cover one redirect
# round trip to the portal and back (a few seconds in practice); kept at 60s
# so a slow portal hop or a manual back-button still trips the guard, while a
# user returning minutes later gets a fresh silent attempt rather than being
# stuck on /login forever. The marker is also cleared explicitly on a
# successful callback and whenever the gate falls back to /login.
_SSO_ATTEMPT_MAX_AGE = 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_provider_cookie(
response: Response,
*,
provider: str,
use_https: bool,
prefix: str = "",
) -> None:
"""Persist the non-secret provider routing hint for token refresh."""
if not provider:
return
response.set_cookie(
_resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix),
provider,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
def set_session_cookies(
response: Response,
*,
access_token: str,
refresh_token: str,
access_token_expires_in: int,
use_https: bool,
prefix: str = "",
provider: 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),
)
set_session_provider_cookie(
response,
provider=provider,
use_https=use_https,
prefix=prefix,
)
def _clear_cookie_variants(
response: Response,
bare_name: str,
*,
prefix: str,
https_samesite: Literal["lax", "strict", "none"],
bare_attrs: dict,
) -> None:
"""Emit Max-Age=0 deletions for every plausible name variant of a cookie.
Cookie-prefix rules make the deletion shape load-bearing: a Set-Cookie
for a ``__Host-``/``__Secure-`` name is rejected outright by the
browser unless it carries ``Secure`` (and ``__Host-`` additionally
requires ``Path=/``), so those deletions always carry the attributes
their name demands. The bare-name deletion mirrors the shape the
setter uses (``bare_attrs``) under RFC 6265bis a deletion sent from
a secure origin may omit ``Secure`` and still delete a Secure cookie,
while a ``Secure`` deletion on a plain-HTTP origin can be ignored, so
matching the setter is the shape that works on both origins.
"""
for variant in _NAME_VARIANTS:
if variant == "__Host-":
# __Host- demands Secure AND Path=/ or the header is invalid.
response.set_cookie(
f"{variant}{bare_name}", "", max_age=0,
path="/", httponly=True, samesite=https_samesite,
secure=True,
)
elif variant == "__Secure-":
response.set_cookie(
f"{variant}{bare_name}", "", max_age=0,
path=_cookie_path(prefix), httponly=True,
samesite=https_samesite, secure=True,
)
else:
response.set_cookie(
bare_name, "", max_age=0, **bare_attrs,
)
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.
"""
bare_attrs = {
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
}
for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE):
_clear_cookie_variants(
response, name,
prefix=prefix, https_samesite="lax", bare_attrs=bare_attrs,
)
def _pkce_attrs(*, use_https: bool, prefix: str) -> dict:
"""Cookie attributes for the PKCE cookie's set AND clear paths.
Single source of truth so a deletion always matches the shape the
setter emitted for the same origin a shape mismatch means the
browser silently keeps the stale cookie.
"""
attrs = _common_attrs(use_https=use_https, prefix=prefix)
if use_https:
attrs["samesite"] = "none"
return attrs
def encode_pkce_payload(parts: dict[str, str]) -> str:
"""Serialise PKCE segments to the wire value: ``base64url(JSON)``.
The urlsafe base64 alphabet (``A-Za-z0-9-_``, padding stripped) is a
strict subset of the RFC 6265 cookie-octet set no ``;`` (attribute
terminator), no ``"`` and no ``\\`` (the chars that make Python's
http.cookies emit the quoted ``\\073`` form, which strict cookie-aware
proxy hops such as Go's net/http reject outright). The ``=`` padding
is stripped because http.cookies treats ``=`` as outside its legal
unquoted set and would re-wrap the value in the quoted form this
codec exists to avoid; the parser restores the padding. JSON carries
the segments, so no delimiter can ever collide with segment values
the delimiter/quoting bug class this codec replaces (see
:func:`parse_pkce_payload` for the two legacy formats it superseded).
"""
raw = json.dumps(parts, separators=(",", ":"), sort_keys=True)
return (
base64.urlsafe_b64encode(raw.encode("utf-8"))
.decode("ascii")
.rstrip("=")
)
def set_pkce_cookie(
response: Response,
*,
payload: dict[str, str],
use_https: bool,
prefix: str = "",
) -> None:
# SameSite=None when HTTPS: the PKCE cookie is set on the /auth/login
# 302 response (redirecting to the IDP) and must survive the cross-site
# redirect chain (same-site → IDP → same-site callback). Chromium has a
# long-standing bug (crbug 40508226) where SameSite=Lax cookies set on a
# 302 in a cross-site redirect chain are intermittently dropped, causing
# "Missing PKCE state cookie" on the callback. SameSite=None + Secure
# sidesteps the bug — these cookies are explicitly designed for cross-site
# delivery and Chromium processes them reliably during redirects.
# Loopback HTTP degrades to Lax (SameSite=None requires Secure).
#
# Value encoding: ``payload`` is the segment dict
# (``{"provider": …, "state": …, "verifier": …, "next": …}``) and goes
# on the wire as base64url(JSON) via encode_pkce_payload() — plain
# RFC 6265 cookie-octets end to end, so every cookie-aware hop
# (browsers, Go net/http proxies, Python parsers) passes the value
# through untouched. Readers decode via parse_pkce_payload(), which
# also keeps a compatibility ladder for cookies minted by the two
# earlier wire formats during a rolling upgrade.
response.set_cookie(
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
encode_pkce_payload(payload),
max_age=_PKCE_MAX_AGE,
**_pkce_attrs(use_https=use_https, prefix=prefix),
)
def clear_pkce_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Emit Max-Age=0 deletions for every plausible PKCE cookie variant.
A deletion is only honoured when its shape is acceptable to the
browser on the current origin: a ``Secure`` deletion can be dropped
on a plain-HTTP origin, while the ``__Host-``/``__Secure-`` name
variants REQUIRE ``Secure`` to be valid at all. So the bare-name
deletion mirrors the setter's shape for the active origin (Lax
without ``Secure`` over HTTP; ``SameSite=None; Secure`` over HTTPS,
matching :func:`set_pkce_cookie`), and the prefixed variants which
can only ever have been set on an HTTPS origin always carry
``Secure; SameSite=None``.
"""
_clear_cookie_variants(
response, PKCE_COOKIE,
prefix=prefix, https_samesite="none",
bare_attrs=_pkce_attrs(use_https=use_https, prefix=prefix),
)
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_session_provider(request: Request) -> Optional[str]:
"""Return the provider routing hint associated with the session cookies."""
return _read_with_fallback(request, SESSION_PROVIDER_COOKIE)
def read_pkce_cookie(request: Request) -> Optional[str]:
return _read_with_fallback(request, PKCE_COOKIE)
# base64url wire values are exactly the urlsafe alphabet (padding is
# stripped by the encoder; the decoder restores it). Used as a cheap
# pre-filter before attempting the JSON decode so legacy wire forms
# (which always contain ``%`` or ``;``) never even reach the base64
# decoder.
_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]+={0,2}$")
def parse_pkce_payload(raw: str) -> dict[str, str]:
"""Decode + parse a PKCE cookie value into its segment dict.
Single inverse of :func:`set_pkce_cookie` /
:func:`encode_pkce_payload`. EVERY reader of the PKCE cookie must go
through this helper a reader that interprets the raw wire value
itself parses zero segments and silently disables whatever check it
was feeding (provider dispatch, CSRF state, native-flow broker
binding).
Compatibility ladder the PKCE cookie has a 10-minute TTL and is
opaque + server-set, so during a rolling upgrade a cookie minted by
one server version can arrive at another. Three formats, tried in
order; each rung is unambiguous:
1. **base64url(JSON)** (current): the wire value is pure urlsafe
base64 that decodes to a JSON object. Legacy forms can never
match they always contain ``%`` (URL-encoded, #99176) or a raw
``;`` (oldest flat form), both outside the base64url alphabet.
2. **Oldest flat form** (pre-#99176): raw ``;`` between segments
(``provider=;state=;verifier=``). Split as-is WITHOUT
unquoting the payload the ``next`` segment carries its own
single URL-encoding, and unquoting here would turn a ``%3B``
inside it into a bogus delimiter and truncate the post-login
target. Neither newer format can contain a raw ``;``.
3. **URL-encoded flat form** (#99176): the whole flat payload passed
through ``quote(payload, safe="")`` no raw ``;`` possible
(it is ``%3B``); unquote once, then split.
Rollout directions: OLD cookie NEW server is handled here (rungs
2 and 3 parse both legacy forms correctly). NEW cookie OLD server
(a rollback, or a mixed fleet routing the callback to a not-yet-
upgraded instance) fails the OAuth state check the old reader
can't find a ``state`` segment in the base64url blob — and the user
simply retries login against the now-consistent fleet; no data loss,
nothing minted.
"""
if _B64URL_RE.match(raw):
try:
padded = raw + "=" * (-len(raw) % 4)
decoded = json.loads(
base64.urlsafe_b64decode(padded.encode("ascii"))
)
except (binascii.Error, ValueError, UnicodeDecodeError):
decoded = None
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
if ";" in raw:
# Oldest flat form: already flat, split as-is (no unquote).
return dict(
seg.split("=", 1) for seg in raw.split(";") if "=" in seg
)
# #99176 URL-encoded flat form: unquote once, then split.
return dict(
seg.split("=", 1) for seg in unquote(raw).split(";") if "=" in seg
)
def set_sso_attempt_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Set the one-shot auto-SSO loop-guard marker (Phase 1).
Written by the gate the moment it auto-initiates the portal OAuth
redirect on an unauthenticated document load. The value is a constant
(``"1"``) only its presence matters. Short Max-Age so a stale marker
can't permanently suppress a future silent attempt.
"""
response.set_cookie(
_resolved_name(SSO_ATTEMPT_COOKIE, use_https=use_https, prefix=prefix),
"1",
max_age=_SSO_ATTEMPT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
def read_sso_attempt_cookie(request: Request) -> Optional[str]:
"""Return the auto-SSO marker value if present (any variant), else None."""
return _read_with_fallback(request, SSO_ATTEMPT_COOKIE)
def clear_sso_attempt_cookie(response: Response, *, prefix: str = "") -> None:
"""Emit Max-Age=0 deletions for the auto-SSO marker, every name variant.
Called on a successful callback and whenever the gate falls back to
/login, so the marker never lingers to suppress a later silent attempt.
"""
_clear_cookie_variants(
response, SSO_ATTEMPT_COOKIE,
prefix=prefix, https_samesite="lax",
bare_attrs={
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
},
)
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"
+537
View File
@@ -0,0 +1,537 @@
"""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_session_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 &middot; 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;
}
a { color: var(--midground); }
</style>
</head>
<body>
<main>
<h1>Sign-in unavailable</h1>
<p>This dashboard is bound to a non-loopback host but no authentication
providers are available.</p>
<p>Configure the bundled username/password provider or an OAuth provider.
See the <a href="https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard#authentication-gated-mode">dashboard
authentication documentation</a> for setup instructions.</p>
<p>For auth-free local use, bind to <code>127.0.0.1</code> and connect through
an SSH tunnel or Tailscale.</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_session_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>'
)
+591
View File
@@ -0,0 +1,591 @@
"""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_session_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
ProviderError,
RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
clear_sso_attempt_cookie,
read_session_cookies,
read_session_provider,
read_sso_attempt_cookie,
set_session_provider_cookie,
set_sso_attempt_cookie,
)
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/native/authorize",
"/auth/native/token",
"/auth/native/refresh",
"/auth/password-login",
"/auth/logout",
"/login",
"/api/auth/providers",
"/api/mcp/oauth/callback/",
"/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 _ordered_session_providers(
provider_hint: str | None,
) -> list[DashboardAuthProvider]:
"""Prefer the hinted provider without making the hint authoritative.
The cookie can outlive a provider rename/removal or become stale after a
deployment change. A stable sort moves a matching provider to the front
while preserving registration order for every remaining candidate; an
unknown hint therefore leaves the normal scan unchanged.
"""
providers = list_session_providers()
if provider_hint:
providers.sort(key=lambda provider: provider.name != provider_hint)
return providers
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 _auto_sso_response(request: Request) -> Response | None:
"""Maybe auto-initiate the portal OAuth redirect on an unauth HTML load.
Returns a 302 ``/auth/login`` (the existing OAuth-initiation route)
when ALL of the following hold, else ``None`` (caller falls back to the
ordinary ``/login`` interstitial):
* the request is an HTML document navigation, not an ``/api/*`` fetch
(a fetch() would follow the 302 into the cross-origin OAuth dance
opaquely same reason ``_unauth_response`` never redirects APIs);
* exactly ONE interactive provider is registered with two or more we
can't pick for the user, so the ``/login`` chooser must render; with
zero there's nothing to redirect to;
* that provider is OAuth-style, not a password form provider. Password
providers must render ``/login`` so the user can enter credentials;
* the one-shot loop-guard marker is ABSENT. Its presence means we
already bounced to the portal once and came back still
unauthenticated (no portal session) auto-redirecting again would
ping-pong, so we fall through to ``/login`` and clear the marker.
The portal ``/oauth/authorize`` auto-approves any current member of the
dashboard's org and is a silent 302 when the user already holds a portal
session, so for the common case (clicked a dashboard link while signed
in to the portal) this removes the interstitial CLICK entirely. It
removes a click, not a security check: the redirect lands on
``/auth/login`` which runs the unchanged PKCE auth-code flow.
"""
path = request.url.path
# APIs never auto-redirect (see _unauth_response). Only document loads.
if path.startswith("/api/"):
return None
# Already bounced once and still no session → portal has no session for
# this user. Stop here, clear the marker, let /login render.
if read_sso_attempt_cookie(request):
from hermes_cli.dashboard_auth.prefix import prefix_from_request
resp = _unauth_response(request, reason="no_cookie")
clear_sso_attempt_cookie(resp, prefix=prefix_from_request(request))
return resp
# list_session_providers() already filters on supports_session=True, so
# token-only credentials (drain/service providers) are never candidates.
providers = list_session_providers()
if len(providers) != 1:
# Zero → nothing to redirect to. Two+ → user must choose at /login.
return None
from hermes_cli.dashboard_auth.prefix import prefix_from_request
provider = providers[0]
if getattr(provider, "supports_password", False):
return None
prefix = prefix_from_request(request)
next_param = _safe_next_target(request)
from urllib.parse import quote
auth_login = f"{prefix}/auth/login?provider={quote(provider.name, safe='')}"
if next_param:
auth_login = f"{auth_login}&next={next_param}"
resp = RedirectResponse(url=auth_login, status_code=302)
# Drop the one-shot marker so a return trip that's STILL unauthenticated
# (portal had no session) trips the guard above next time instead of
# looping. Detect HTTPS for the Secure flag the same way the auth routes
# do; bind Path via the active prefix.
from hermes_cli.dashboard_auth.cookies import detect_https
set_sso_attempt_cookie(
resp, use_https=detect_https(request), prefix=prefix,
)
audit_log(
AuditEvent.LOGIN_START,
provider=provider.name,
reason="auto_sso",
ip=_client_ip(request),
)
return resp
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="")
def _extract_bearer(request: Request) -> str:
"""Return the ``Authorization: Bearer <token>`` value, or ""."""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def _verify_bearer(request: Request, *, access_token: str):
"""Verify a native-app bearer access token via the session-provider stack.
Returns the :class:`Session` on success, or ``None`` if no provider
recognises the token (expired/invalid/unknown). Mirrors the cookie path's
verify loop, including the "one provider unreachable ⇒ don't force
re-login" semantics: a transient IDP outage returns a 503 rather than a
401, so the desktop retries instead of dropping the user to full re-login.
Unlike the cookie path there is no server-side refresh the desktop owns
its refresh token and rotates via ``/auth/native/refresh``.
"""
unreachable_provider: str | None = None
for provider in list_session_providers():
try:
session = provider.verify_session(access_token=access_token)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during bearer verify: %s",
provider.name, e,
)
if unreachable_provider is None:
unreachable_provider = provider.name
continue
if session is not None:
return session
if unreachable_provider is not None:
# Signal transient outage to the caller via a sentinel exception the
# middleware turns into 503. Raising keeps the "don't logout on a
# flaky IDP" contract identical to the cookie path.
raise ProviderError(unreachable_provider)
return None
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)
# A request already authenticated by the token-auth seam (a service caller
# on a registered token route) carries ``token_authenticated`` — it is NOT
# a cookie session and must not be bounced to /login. Pass it through; the
# seam already attached ``request.state.token_principal``.
if getattr(request.state, "token_authenticated", False):
return await call_next(request)
path = request.url.path
if _path_is_public(path):
return await call_next(request)
# RFC 8252 native-app bearer path (goal: no session cookies). The desktop
# authenticates REST with ``Authorization: Bearer <access_token>`` — the
# SAME provider-minted access token the cookie flow stores in
# ``hermes_session_at``. Verify it with the identical ``verify_session``
# provider stack and attach the Session; on success we're done, with no
# cookie set or read. A missing/expired/invalid bearer falls through to
# the cookie path (a request may legitimately carry neither). Token
# rotation for this path is the desktop's job via /auth/native/refresh —
# the gate never sets a cookie here, so the transparent cookie-rotation
# below must not run for a bearer caller.
bearer = _extract_bearer(request)
if bearer:
try:
bearer_session = _verify_bearer(request, access_token=bearer)
except ProviderError as e:
# At least one provider's IDP/JWKS was unreachable and none
# verified the token — transient outage, not bad credentials.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
if bearer_session is not None:
request.state.session = bearer_session
return await call_next(request)
# A bearer was presented but didn't verify (expired/invalid/unknown).
# Return the structured 401 so the desktop knows to refresh or
# re-login, rather than falling through to the cookie/login redirect.
return _unauth_response(request, reason="invalid_or_expired_session")
at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh. Before falling back to the /login interstitial, try to
# silently bounce the user through the portal OAuth flow: the portal
# auto-approves org members and 302s straight back when they already
# hold a portal session, so the interstitial click is pure friction
# for the common case. The one-shot loop-guard inside _auto_sso_response
# prevents a ping-pong when the portal genuinely has no session.
auto = _auto_sso_response(request)
if auto is not None:
return auto
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 _ordered_session_providers(provider_hint):
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; only after every provider rejects
# the RT do we fall through to clear-and-relogin.
try:
refreshed = _attempt_refresh(
request,
refresh_token=_rt,
provider_hint=provider_hint,
)
except ProviderError as e:
# At least one provider could not confirm or reject the RT, and no
# other provider refreshed it. Preserve the cookies and surface a
# transient outage instead of turning uncertainty into a logout.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
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),
provider=refreshing_provider,
)
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
response = await call_next(request)
if not provider_hint and session.provider:
from hermes_cli.dashboard_auth.cookies import detect_https
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_provider_cookie(
response,
provider=session.provider,
use_https=detect_https(request),
prefix=prefix_from_request(request),
)
return response
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, provider_hint: str | None = None):
"""Try to rotate an expired session via the refresh token.
The provider hint only changes candidate order. ``RefreshExpiredError``
rejects the token for that candidate, but cannot prove ownership because
providers such as Basic raise it for foreign opaque tokens too. Likewise,
``ProviderError`` only makes that candidate unavailable. Both are audited
and the remaining providers are tried. Returns ``None`` only when there is
no RT or every reachable provider rejects it. If no provider succeeds and
at least one raised ``ProviderError``, re-raises with that provider's name
so the caller can return 503 without clearing potentially valid cookies.
"""
if not refresh_token:
return None
unavailable_provider: str | None = None
for provider in _ordered_session_providers(provider_hint):
try:
new_session = provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
continue
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),
)
if unavailable_provider is None:
unavailable_provider = provider.name
continue
if new_session is not None:
return new_session, provider.name
if unavailable_provider is not None:
raise ProviderError(unavailable_provider)
return None
+306
View File
@@ -0,0 +1,306 @@
"""Gateway-brokered RFC 8252 (OAuth 2.0 for Native Apps) authorization store.
The desktop app is a *native* OAuth client that wants to sign in to a gated
gateway **without an embedded webview and without relying on browser session
cookies**. It cannot be a direct OAuth client of the upstream IDP (Nous
Portal): the Portal ``client_id`` is per-gateway-instance
(``agent:{instance_id}``) and the Portal validates that the ``redirect_uri``
ends in ``/auth/callback`` on the gateway's own public origin — a desktop
loopback ``127.0.0.1`` redirect is rejected. So the **gateway brokers** the
flow: it is the authorization server *to the desktop*, and an OAuth client *to
the Portal*. This is still a textbook RFC 8252 deployment system browser,
loopback redirect, PKCE, tokens returned to the app (never cookies).
Wire shape (all gateway-side state lives in this module):
1. Desktop generates its OWN PKCE pair ``(cv_d, cc_d)`` and a ``state``, opens
a loopback listener on ``127.0.0.1:<port>``, and opens the system browser
to the gateway's ``GET /auth/native/authorize?...`` carrying ``cc_d``,
``state``, and its loopback ``redirect_uri``.
2. The gateway ``authorize`` route stashes a **pending authorization**
(``register_pending``) keyed by an opaque ``broker_state`` and runs the
EXISTING upstream PKCE flow (``provider.start_login`` Portal
``/oauth/authorize`` gateway ``/auth/callback``). The desktop's
``cc_d`` / ``state`` / loopback ``redirect_uri`` ride through the upstream
round trip inside the gateway's own PKCE cookie, so no desktop secret is
ever exposed to the Portal.
3. On the upstream callback the gateway holds a verified :class:`Session`. It
**mints a one-time gateway authorization code** (``complete_pending``)
bound to the desktop's ``cc_d``, and 302s the browser to the desktop's
``redirect_uri?code=<gw_code>&state=<state>``.
4. The desktop's loopback listener catches ``gw_code``, then POSTs
``/auth/native/token`` with ``gw_code`` + its ``cv_d``. The gateway
verifies ``SHA256(cv_d) == cc_d`` (``redeem_code``), consumes the code
(single use), and returns the upstream ``access_token`` /
``refresh_token`` / ``expires_at`` **in the JSON body**.
5. The desktop stores those in the OS keychain and authenticates REST with
``Authorization: Bearer <access_token>`` (via the existing ``token_auth``
seam) and mints ws-tickets the same way no cookies anywhere.
Password providers ride the same broker with step 2 swapped: there is no
upstream IDP, so ``/auth/native/authorize`` sends the system browser to the
interactive ``/login`` form (broker_state in the PKCE cookie) and a successful
``/auth/password-login`` plays the role of the upstream callback it calls
:func:`complete_pending` and bounces the browser to the loopback redirect.
Steps 45 are identical. The point of brokering a password login at all is
that the system browser can autofill from the OS password manager (macOS
Passwords, etc.), which no embedded desktop webview can.
Security properties this module guarantees:
* **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client
that presented the matching ``code_challenge``. An attacker who intercepts
the loopback ``gw_code`` (e.g. a hostile process racing the redirect) cannot
exchange it without ``cv_d``, which never leaves the desktop.
* **Single use.** ``redeem_code`` pops the entry; a replay finds nothing.
* **Short TTLs.** A pending authorization lives ``_PENDING_TTL`` seconds (the
interactive login window); a minted code lives ``_CODE_TTL`` seconds (the
loopback round trip is sub-second). Expired entries are refused and GC'd.
* **Opaque, high-entropy handles.** ``broker_state`` and ``gw_code`` are
256-bit ``secrets.token_urlsafe`` values; comparison is constant-time.
* **No secret logging.** The module stores tokens transiently in memory only
between callback and redemption; nothing here writes them to disk (the
audit log strips token fields).
In-memory and process-local: the dashboard is a single process, so no
distributed coordination is needed (mirrors ``ws_tickets``). A functional API
(not a class) keeps ``time.time`` patchable in tests.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Dict, Optional
from hermes_cli.dashboard_auth.base import Session
# TTL for a pending authorization (step 2→3): the whole interactive login,
# including the user typing Portal credentials / approving in the browser.
_PENDING_TTL_SECONDS = 600 # 10 minutes — mirrors the PKCE cookie lifetime.
# TTL for a minted gateway code (step 3→4): only the loopback redirect + the
# desktop's immediate token POST, which is sub-second in practice.
_CODE_TTL_SECONDS = 120 # 2 minutes — generous for a slow local hop.
# Cap the number of concurrent pending/issued entries so a misbehaving or
# malicious client cannot grow the store unbounded. Well above any legitimate
# concurrent-login count for a single desktop user.
_MAX_ENTRIES = 256
# Per-IP cap on concurrent PENDING authorizations. /auth/native/authorize is a
# public (pre-auth) route, so without this a single unauthenticated spammer
# could fill the global store (600s TTL each) and lock out legitimate native
# logins for the pending window. A real desktop runs at most a couple of
# concurrent sign-ins from one address; 8 is generous.
_MAX_PENDING_PER_IP = 8
_lock = threading.Lock()
@dataclass
class _Pending:
"""An in-flight native authorization awaiting the upstream callback.
Created when the desktop hits ``/auth/native/authorize`` and consumed when
the upstream ``/auth/callback`` completes and mints the gateway code.
"""
code_challenge: str # the DESKTOP's S256 challenge (cc_d), base64url no-pad
redirect_uri: str # the desktop's loopback redirect (127.0.0.1:<port>/...)
client_state: str # the desktop's own ``state`` (echoed back on redirect)
client_ip: str # requester IP at authorize time (per-IP pending cap)
expires_at: int
@dataclass
class _IssuedCode:
"""A minted one-time gateway authorization code bound to a Session."""
code_challenge: str # cc_d — verified against cv_d at redemption
session: Session
expires_at: int
# broker_state -> _Pending
_pending: Dict[str, _Pending] = {}
# gw_code -> _IssuedCode
_issued: Dict[str, _IssuedCode] = {}
class NativeFlowError(Exception):
"""Base for native-flow failures (bad/expired/replayed handle, PKCE fail)."""
class PendingNotFound(NativeFlowError):
"""The broker_state is unknown or expired (login window lapsed)."""
class CodeInvalid(NativeFlowError):
"""The gateway code is unknown, expired, already redeemed, or PKCE-mismatched."""
def _b64url_no_pad(raw: bytes) -> str:
"""Base64url without ``=`` padding (RFC 7636 §4)."""
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _s256(verifier: str) -> str:
"""RFC 7636 S256 transform: base64url(sha256(ascii(verifier)))."""
return _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest())
def _gc_locked(now: int) -> None:
"""Drop expired pending + issued entries. Caller holds ``_lock``."""
expired_p = [k for k, v in _pending.items() if v.expires_at < now]
for k in expired_p:
_pending.pop(k, None)
expired_c = [k for k, v in _issued.items() if v.expires_at < now]
for k in expired_c:
_issued.pop(k, None)
def _capacity_ok_locked() -> bool:
return (len(_pending) + len(_issued)) < _MAX_ENTRIES
def register_pending(
*,
code_challenge: str,
redirect_uri: str,
client_state: str,
client_ip: str = "",
now: Optional[int] = None,
) -> str:
"""Stash a pending native authorization; return an opaque ``broker_state``.
Called by ``/auth/native/authorize``. ``code_challenge`` is the DESKTOP's
S256 challenge (``cc_d``) we never see the verifier until redemption.
``redirect_uri`` is the desktop's loopback callback and ``client_state`` is
the desktop's own CSRF ``state`` (echoed verbatim on the final redirect).
``client_ip`` is the requester's address, used only for the per-IP pending
cap below.
The returned ``broker_state`` is what the gateway threads through its OWN
upstream PKCE round trip (inside the ``hermes_session_pkce`` cookie), so the
callback can find this entry again via :func:`complete_pending`.
Raises ``NativeFlowError`` if the store is at capacity or the caller's IP
already holds ``_MAX_PENDING_PER_IP`` live pending entries (fail closed
this is a public pre-auth route, so one spammer must not be able to fill
the global store and deny sign-in to everyone else).
"""
now = int(time.time()) if now is None else now
broker_state = secrets.token_urlsafe(32)
with _lock:
_gc_locked(now)
if not _capacity_ok_locked():
raise NativeFlowError("native-flow authorization store at capacity")
if client_ip and (
sum(1 for v in _pending.values() if v.client_ip == client_ip)
>= _MAX_PENDING_PER_IP
):
raise NativeFlowError(
"too many pending native authorizations from this address"
)
_pending[broker_state] = _Pending(
code_challenge=code_challenge,
redirect_uri=redirect_uri,
client_state=client_state,
client_ip=client_ip,
expires_at=now + _PENDING_TTL_SECONDS,
)
return broker_state
def get_pending(broker_state: str, *, now: Optional[int] = None) -> _Pending:
"""Return the pending authorization for ``broker_state`` without consuming it.
Read-only peek used by the callback to learn the desktop's ``redirect_uri``
and ``client_state`` for the final 302. Raises :class:`PendingNotFound` if
unknown or expired (the entry is GC'd on expiry).
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
entry = _pending.get(broker_state)
if entry is None:
raise PendingNotFound("unknown or expired native authorization")
return entry
def complete_pending(
broker_state: str,
*,
session: Session,
now: Optional[int] = None,
) -> str:
"""Consume a pending authorization and mint a one-time gateway code.
Called by ``/auth/callback`` once the upstream :class:`Session` is verified.
Pops the pending entry (single use), binds a fresh ``gw_code`` to the
desktop's ``code_challenge`` + the verified ``session``, and returns the
``gw_code`` for the loopback redirect.
Raises :class:`PendingNotFound` if the broker_state is unknown/expired.
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
pending = _pending.pop(broker_state, None)
if pending is None:
raise PendingNotFound("unknown or expired native authorization")
if not _capacity_ok_locked():
raise NativeFlowError("native-flow code store at capacity")
gw_code = secrets.token_urlsafe(32)
_issued[gw_code] = _IssuedCode(
code_challenge=pending.code_challenge,
session=session,
expires_at=now + _CODE_TTL_SECONDS,
)
return gw_code
def redeem_code(
*,
code: str,
code_verifier: str,
now: Optional[int] = None,
) -> Session:
"""Verify PKCE + consume a gateway code; return the bound :class:`Session`.
Called by ``/auth/native/token``. Enforces:
* the code exists and is unexpired (else :class:`CodeInvalid`);
* ``S256(code_verifier) == code_challenge`` in constant time (RFC 7636);
* single use the entry is popped BEFORE the PKCE check so a wrong
verifier cannot be retried against the same code.
On any failure the code is already consumed (no oracle, no replay).
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
issued = _issued.pop(code, None)
# Pop happened under the lock; every return path below has already
# consumed the code, so a replay (valid or not) finds nothing.
if issued is None:
raise CodeInvalid("unknown, expired, or already-redeemed code")
if issued.expires_at < now:
raise CodeInvalid("code expired")
expected = issued.code_challenge
actual = _s256(code_verifier)
if not hmac.compare_digest(expected, actual):
raise CodeInvalid("PKCE verification failed")
return issued.session
def _reset_for_tests() -> None:
"""Test-only: drop all pending + issued state."""
with _lock:
_pending.clear()
_issued.clear()
+232
View File
@@ -0,0 +1,232 @@
"""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__)
# Home Assistant Supervisor ingress prefixes are already 63 chars before
# deployments add their own sub-path. Keep a bounded header budget, but leave
# room for mainstream reverse-proxy path mounts.
_MAX_PREFIX_LENGTH = 256
# 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()
_warned_malformed_prefixes: 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 _warn_if_malformed_prefix(raw: Optional[str], reason: str) -> None:
"""Warn once when a non-empty X-Forwarded-Prefix value is rejected."""
cleaned = raw.strip() if raw else ""
if not cleaned:
return
key = (cleaned, reason)
if key in _warned_malformed_prefixes:
return
_warned_malformed_prefixes.add(key)
_log.warning(
"X-Forwarded-Prefix header %r was ignored because %s. "
"Dashboard URLs will be generated without a reverse-proxy path prefix.",
cleaned,
reason,
)
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)
):
_warn_if_malformed_prefix(
raw,
"it contains a disallowed character or path sequence",
)
return ""
if len(p) > _MAX_PREFIX_LENGTH:
_warn_if_malformed_prefix(
raw,
f"it is longer than {_MAX_PREFIX_LENGTH} characters",
)
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
+60
View File
@@ -0,0 +1,60 @@
"""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({
# Minimal process liveness probe for desktop/backend boot handshakes. It
# intentionally avoids gateway config, platform discovery, MCP setup, and
# host-local detail so readiness checks cannot spend their budget inside
# cold plugin imports.
"/api/health",
# 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",
# Chronos managed-cron fire webhook (NAS -> agent). NOT cookie-gated: it
# carries its own short-lived NAS-minted JWT (purpose=cron_fire), which the
# handler verifies as the real auth. Must bypass the dashboard auth gate so
# the NAS relay's bearer-only callback reaches the verifier instead of a
# 401 no_cookie. The JWT — not this allowlist — is the security boundary.
"/api/cron/fire",
})
+167
View File
@@ -0,0 +1,167 @@
"""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_constants import hermes_home_key
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
assert_protocol_compliance,
)
_log = logging.getLogger(__name__)
_lock = threading.Lock()
_providers: dict[str, DashboardAuthProvider] = {}
_scoped_providers: dict[str, dict[str, DashboardAuthProvider]] = {}
def _merged(scope: Optional[str] = None) -> dict[str, DashboardAuthProvider]:
providers = dict(_providers)
providers.update(_scoped_providers.get(scope or hermes_home_key(), {}))
return providers
def register_provider(
provider: DashboardAuthProvider,
*,
scope: Optional[str] = None,
) -> 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:
target = _providers if scope is None else _scoped_providers.setdefault(scope, {})
effective = target if scope is None else _merged(scope)
if provider.name in effective:
raise ValueError(
f"dashboard-auth provider already registered: {provider.name!r}"
)
target[provider.name] = provider
_log.info(
"dashboard-auth: registered provider %r (%s)",
provider.name, provider.display_name,
)
def get_provider(
name: str,
*,
scope: Optional[str] = None,
) -> Optional[DashboardAuthProvider]:
"""Return the registered provider for ``name``, or None if unknown."""
with _lock:
return _merged(scope).get(name)
def snapshot_registration(
name: str,
*,
scope: Optional[str] = None,
) -> Optional[DashboardAuthProvider]:
with _lock:
target = _providers if scope is None else _scoped_providers.get(scope, {})
return target.get(name)
def restore_registration(
name: str,
current: DashboardAuthProvider,
previous: Optional[DashboardAuthProvider],
*,
scope: Optional[str] = None,
) -> bool:
"""Restore a host-owned provider registration if it is still current."""
with _lock:
target = _providers if scope is None else _scoped_providers.setdefault(scope, {})
if target.get(name) is not current:
return False
if previous is None:
target.pop(name, None)
else:
target[name] = previous
if scope is not None and not target:
_scoped_providers.pop(scope, None)
return True
def list_providers(*, scope: Optional[str] = None) -> List[DashboardAuthProvider]:
"""All registered providers, in registration order."""
with _lock:
return list(_merged(scope).values())
def list_token_providers() -> List[DashboardAuthProvider]:
"""Registered providers that support non-interactive token auth.
The subset of ``list_providers()`` whose ``supports_token`` flag is True,
in registration order. The ``token_auth`` middleware seam consults these
(and only these) when a token-authable route is hit, so OAuth/password-only
providers are never asked to ``verify_token``. Returns an empty list when
no token provider is registered a token-authable route then fails
closed (401), never open.
"""
return [p for p in list_providers() if getattr(p, "supports_token", False)]
def list_session_providers() -> List[DashboardAuthProvider]:
"""Registered providers with supports_session True (interactive cookie
sessions). The login page, /auth/login, and the gate's verify/refresh loops
consult only these. Mirror of list_token_providers.
"""
return [p for p in list_providers() if getattr(p, "supports_session", True)]
def register_global_provider(provider: DashboardAuthProvider) -> None:
"""Register a host-owned provider in the process-global slot (upsert).
The dashboard auth registry is process-global and shared across every
profile the dashboard serves from one process, so its providers must
outlive any single per-home plugin manager. Unlike ``register_provider``
this always targets the global ``_providers`` map (never a per-home
overlay) and *replaces* any same-name entry instead of raising, so a
forced plugin re-discovery (e.g. after a password change) rotates the
provider in place. Pairs with ``unregister_global_provider`` for teardown
of the exact object still current (#91701).
"""
assert_protocol_compliance(type(provider))
with _lock:
_providers[provider.name] = provider
_log.info(
"dashboard-auth: registered global provider %r (%s)",
provider.name, provider.display_name,
)
def unregister_global_provider(
name: str,
provider: DashboardAuthProvider,
) -> bool:
"""Remove a global provider registration if ``provider`` is still current.
Identity-conditional so a stale handle (whose provider was already
replaced by a later ``register_global_provider``) never clears the live
registration.
"""
with _lock:
if _providers.get(name) is provider:
_providers.pop(name, None)
return True
return False
def clear_providers() -> None:
"""Test-only: drop all registrations."""
with _lock:
_providers.clear()
_scoped_providers.clear()
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
"""Route-agnostic non-interactive (bearer-token) auth seam for the dashboard.
This is the generic API-token capability (decisions.md Q-C): a reusable seam
that ANY service-to-service / machine-credential provider plugs into, NOT a
drain-specific hook. The drain bearer-secret plugin is merely the first
consumer.
How it fits the existing auth framework:
* The interactive gate (``gated_auth_middleware``) authenticates a human
via a session cookie on every non-public route. A service caller has no
cookie it presents a bearer token in the ``Authorization`` header on a
single request. That is what this seam verifies.
* A route opts in by registering its exact path via
:func:`register_token_route`. Only registered paths are token-authable;
everything else is untouched, so this can never accidentally widen the
auth surface of an existing route.
* :func:`token_auth_middleware` runs OUTERMOST (installed last in
``web_server.py``). For a token route it fully owns the auth decision:
authenticate via the stacked token providers, attach the verified
:class:`~hermes_cli.dashboard_auth.base.TokenPrincipal` to
``request.state.token_principal`` + set ``request.state.token_authenticated``,
and pass through; otherwise reject (401 unauthenticated, or 503 when a
provider's backing store was unreachable). The downstream cookie/session
gates honour ``token_authenticated`` and skip enforcement, so a
token-authed service request is never bounced to ``/login``.
* Fails closed: a token route with no registered token provider, no token,
or an unrecognised token gets 401 never an open pass-through.
Provider stacking mirrors ``verify_session``: each ``supports_token`` provider
is consulted in registration order until one returns a principal. A provider
that doesn't recognise the token returns ``None`` and the seam moves on; a
provider whose backing store is unreachable raises ``ProviderError``, which the
seam remembers and surfaces as 503 only if NO provider accepts the token.
"""
from __future__ import annotations
import logging
import threading
from typing import Awaitable, Callable, Optional, Tuple
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from hermes_cli.dashboard_auth import list_token_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError, TokenPrincipal
_log = logging.getLogger(__name__)
# Exact paths that accept non-interactive bearer-token auth. A route registers
# itself here at import/startup; the seam only acts on registered paths.
_token_routes: set[str] = set()
_lock = threading.Lock()
def register_token_route(path: str) -> None:
"""Mark ``path`` (exact match) as token-authable.
Idempotent. Call at module import / app setup so the seam knows which
routes to guard. Registering a route does NOT make it public it makes
it authenticate by token instead of by session cookie.
"""
with _lock:
_token_routes.add(path)
def is_token_route(path: str) -> bool:
"""True if ``path`` was registered as token-authable (exact match)."""
with _lock:
return path in _token_routes
def clear_token_routes() -> None:
"""Test-only: drop all registered token routes."""
with _lock:
_token_routes.clear()
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 extract_bearer_token(request: Request) -> str:
"""Return the bearer token from the ``Authorization`` header, or "".
Accepts ``<scheme> <token>`` where scheme is "bearer" (case-insensitive).
Returns an empty string for a missing/malformed header or a non-bearer
scheme the caller treats "" as "no token presented".
"""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def authenticate_token(
request: Request,
) -> Tuple[Optional[TokenPrincipal], Optional[str]]:
"""Try every token provider against the request's bearer token.
Returns ``(principal, unreachable_provider_name)``:
* ``(TokenPrincipal, None)`` a provider recognised and accepted the token.
* ``(None, None)`` no token, or no provider recognised it (reject 401).
* ``(None, name)`` no provider accepted it AND at least one provider's
backing store was unreachable (the caller surfaces 503, not 401, so a
transient outage doesn't read as "bad credentials").
Never raises: a provider ``ProviderError`` is caught and remembered.
"""
token = extract_bearer_token(request)
if not token:
return None, None
unreachable: Optional[str] = None
for provider in list_token_providers():
try:
principal = provider.verify_token(token=token)
except ProviderError as e:
_log.warning(
"dashboard-auth: token provider %r unreachable during verify: %s",
provider.name, e,
)
if unreachable is None:
unreachable = provider.name
continue
except Exception as e: # noqa: BLE001 — a buggy provider must not 500 the gate
_log.warning(
"dashboard-auth: token provider %r raised during verify: %s",
provider.name, e,
)
continue
if principal is not None:
return principal, None
return None, unreachable
async def token_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Outermost auth seam for token-authable routes.
No-op pass-through for any path not registered via
:func:`register_token_route`. For a registered path, token auth is the
only accepted scheme:
* valid token attach principal + ``token_authenticated`` flag, pass through.
* unreachable 503 (provider backing store down; not "bad credentials").
* otherwise 401 unauthenticated.
Runs before the cookie/session gates (installed last in ``web_server.py``).
The cookie gates honour ``request.state.token_authenticated`` and skip
enforcement, so a token-authed request is never redirected to ``/login``.
"""
path = request.url.path
if not is_token_route(path):
return await call_next(request)
principal, unreachable = authenticate_token(request)
if principal is not None:
request.state.token_principal = principal
request.state.token_authenticated = True
return await call_next(request)
if unreachable:
audit_log(
AuditEvent.TOKEN_AUTH_FAILURE,
provider=unreachable,
reason="provider_unreachable",
path=path,
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {unreachable!r} unreachable"},
status_code=503,
)
audit_log(
AuditEvent.TOKEN_AUTH_FAILURE,
reason="no_provider_recognises_token",
path=path,
ip=_client_ip(request),
)
return JSONResponse(
{"error": "unauthenticated", "detail": "Unauthorized"},
status_code=401,
)
+161
View 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
File diff suppressed because it is too large Load Diff
+427
View 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 add 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 add 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 "",
)
+68
View File
@@ -0,0 +1,68 @@
{
"schema_version": 1,
"generated_at": "2026-08-12T00:00:00Z",
"plugins": [
{
"name": "hermes-media-studio",
"description": "Media Studio — generative media workspace plugin for Hermes Desktop (fal + Krea, durable job queue, library).",
"author": "NousResearch",
"tags": ["media", "image-gen", "video-gen", "dashboard", "desktop"],
"repo": "NousResearch/hermes-media-studio",
"ref": "e8d59971d2b7901405b39dac7b03bdd616272d0d",
"homepage": "https://github.com/NousResearch/hermes-media-studio",
"capabilities": ["tools", "dashboard"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "hermes-telegram-business",
"description": "Observe-with-approval Telegram Business Mode (secretary bot) plugin — every drafted reply requires owner approval before it reaches the customer.",
"author": "NousResearch",
"tags": ["telegram", "gateway", "approvals", "messaging"],
"repo": "NousResearch/hermes-telegram-business",
"ref": "e905f3bc5eeaa5a9dab9bc5155601b3ebec75757",
"homepage": "https://github.com/NousResearch/hermes-telegram-business",
"capabilities": ["platform"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "plugin-llm-example",
"description": "Reference plugin showing host-owned structured LLM access via ctx.llm.complete_structured(). Registers a /receipt-extract slash command.",
"author": "NousResearch",
"tags": ["example", "llm", "reference", "slash-command"],
"repo": "NousResearch/hermes-example-plugins",
"subdir": "plugin-llm-example",
"ref": "38fe0fb53eff98d477f807432e965429e665ca33",
"homepage": "https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example",
"capabilities": ["commands", "llm"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "plugin-llm-async-example",
"description": "Reference plugin demonstrating async host-owned LLM access from plugin code — the asyncio counterpart to plugin-llm-example.",
"author": "NousResearch",
"tags": ["example", "llm", "async", "reference"],
"repo": "NousResearch/hermes-example-plugins",
"subdir": "plugin-llm-async-example",
"ref": "38fe0fb53eff98d477f807432e965429e665ca33",
"homepage": "https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example",
"capabilities": ["commands", "llm"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "hermes-plugin-chrome-profiles",
"description": "Switch Hermes browser tools between Chrome profiles via CDP.",
"author": "anpicasso",
"tags": ["browser", "chrome", "cdp", "tools"],
"repo": "anpicasso/hermes-plugin-chrome-profiles",
"ref": "5b9c3257b464c0f926d4355149a8aed9c8f307b4",
"homepage": "https://github.com/anpicasso/hermes-plugin-chrome-profiles",
"capabilities": ["tools"],
"api_version": 1,
"added_at": "2026-08-12"
}
]
}
+1072
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
"""Default SOUL.md template seeded into HERMES_HOME on first run."""
# Kept identical to agent/prompt_builder.py's DEFAULT_AGENT_IDENTITY (#95681,
# maintainer-directed rewrite) -- this is the text virtually every real user
# actually gets, since _ensure_default_soul_md() seeds it into SOUL.md on
# first run. DEFAULT_AGENT_IDENTITY only serves sessions with no SOUL.md at
# all (e.g. skip_context_files), which is not the common case. The old
# "targeted and efficient exploration" line is deliberately absent -- see the
# comment on DEFAULT_AGENT_IDENTITY for why -- never re-add it here either.
DEFAULT_SOUL_MD = (
"You are Hermes Agent, built by Nous Research. Be direct: match the "
"length of your reply to the weight of the ask — a one-line question "
"gets a one-line answer, and finished work gets a short report of what "
"changed, what's verified, and what's left, never a replay of the "
"process. No filler (\"Great question,\" \"I'd be happy to\"), no "
"restating the request back, no re-summarizing what you already said, "
"no narrating tool calls the user can see. Plain claims over "
"adjectives; when unsure, say so plainly. Agree because it's right, "
"not because the user said it. Depth is earned — give it when the "
"user asks for detail, teaches, or the stakes demand it, not by "
"default."
)
# Legacy SOUL.md boilerplate that older installers (install.sh / install.ps1 /
# docker/SOUL.md) seeded before they were switched to write DEFAULT_SOUL_MD.
# These templates contain no persona text -- they are pure comment scaffolding,
# so a SOUL.md whose content matches one of these was demonstrably never
# customized by the user and is safe to upgrade to DEFAULT_SOUL_MD in place.
#
# Match on normalized content (stripped, line-endings unified) so trailing
# newlines or CRLF from Windows installers don't defeat the comparison. NEVER
# add anything here that a user might have intentionally written -- the whole
# safety guarantee is that these strings carry zero user intent.
_LEGACY_TEMPLATE_SOULS = (
(
"# Hermes Agent Persona\n"
"\n"
"<!--\n"
"This file defines the agent's personality and tone.\n"
"The agent will embody whatever you write here.\n"
"Edit this to customize how Hermes communicates with you.\n"
"\n"
"Examples:\n"
' - "You are a warm, playful assistant who uses kaomoji occasionally."\n'
' - "You are a concise technical expert. No fluff, just facts."\n'
' - "You speak like a friendly coworker who happens to know everything."\n'
"\n"
"This file is loaded fresh each message -- no restart needed.\n"
"Delete the contents (or this file) to use the default personality.\n"
"-->"
),
# docker/SOUL.md and the install.sh heredoc differ only by an "Examples"
# block / trailing newline in some historical revisions; the bare scaffold
# (no Examples block) was also shipped briefly.
(
"# Hermes Agent Persona\n"
"\n"
"<!--\n"
"This file defines the agent's personality and tone.\n"
"The agent will embody whatever you write here.\n"
"Edit this to customize how Hermes communicates with you.\n"
"\n"
"This file is loaded fresh each message -- no restart needed.\n"
"Delete the contents (or this file) to use the default personality.\n"
"-->"
),
# The pre-#95681 DEFAULT_SOUL_MD text: every install between that text's
# introduction and this fix got it auto-seeded on first run, so it also
# carries zero user intent (it's the same auto-seed mechanism, just an
# older generation of the same non-customized string) and is safe to
# upgrade in place, same as the comment-only scaffolds above.
(
"You are Hermes Agent, an intelligent AI assistant created by Nous "
"Research. You are helpful, knowledgeable, and direct. You assist "
"users with a wide range of tasks including answering questions, "
"writing and editing code, analyzing information, creative work, "
"and executing actions via your tools. You communicate clearly, "
"admit uncertainty when appropriate, and prioritize being "
"genuinely useful over being verbose unless otherwise directed "
"below. Be targeted and efficient in your exploration and "
"investigations."
),
# ASCII-dashed variant of the current DEFAULT_SOUL_MD, as seeded by
# scripts/install.ps1 (which must stay pure ASCII -- see
# tests/test_install_ps1_ascii_only.py -- so it writes "--" where the
# canonical text has an em-dash). Still pure auto-seed, zero user intent;
# upgrading it in place converges Windows installs onto the canonical
# em-dash text on first run.
DEFAULT_SOUL_MD.replace("\u2014", "--"),
)
def _normalize_soul(text: str) -> str:
"""Normalize SOUL.md content for legacy-template comparison."""
# Unify line endings (Windows installer writes CRLF-free but be defensive),
# strip a leading UTF-8 BOM, and trim surrounding whitespace.
return text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").strip()
def is_legacy_template_soul(text: str) -> bool:
"""True if ``text`` is a non-customized, auto-seeded SOUL.md.
Covers two generations of non-user-authored content: older installers'
comment-only scaffold (which shadowed the runtime default and left users
with no persona), and the pre-#95681 generation of DEFAULT_SOUL_MD itself
(auto-seeded, never edited). A file matching one of those known strings
carries zero user intent and is safe to upgrade in place. Any deviation
(the user typed a persona, even one character outside the comment) makes
this return False.
"""
normalized = _normalize_soul(text)
return any(normalized == _normalize_soul(t) for t in _LEGACY_TEMPLATE_SOULS)
+185
View File
@@ -0,0 +1,185 @@
"""Lazy dependency bootstrapper for non-Python runtime deps.
Detection and prompting live here in Python not in install.sh because:
1. shutil.which() works on every platform; install.sh needs bash.
2. Detection is instant; spawning bash for a "is node installed?" check is waste.
3. Python controls the UX (rich prompts, non-interactive fallback, TTY detection).
install.sh is still the *installation* backend because it has 1900 lines of
battle-tested OS detection and package-manager logic (apt/brew/pacman/dnf/
zypper/Termux/). Reimplementing that in Python would be huge duplication.
Deps that degrade gracefully (ripgrep grep fallback, ffmpeg skip conversion)
don't need ensure_dependency wired in — only hard-fail sites do (TUI needs node,
browser tool needs agent-browser).
"""
from __future__ import annotations
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_constants import agent_browser_runnable, find_node_executable
from tools.environments.local import hermes_subprocess_env
_IS_WINDOWS = platform.system() == "Windows"
_DEP_CHECKS = {
# find_node_executable() rather than a bare which(): $HERMES_HOME/node is
# not on PATH, so which() would report Node missing on an install that has
# a managed one and trigger a redundant re-install.
"node": lambda: find_node_executable("node") is not None,
"browser": lambda: (
agent_browser_runnable(shutil.which("agent-browser"))
or _has_system_browser()
or _has_hermes_agent_browser()
or _has_npx_agent_browser()
),
"ripgrep": lambda: shutil.which("rg") is not None,
"ffmpeg": lambda: shutil.which("ffmpeg") is not None,
}
_DEP_DESCRIPTIONS = {
"node": "Node.js (required for browser tools and TUI)",
"browser": "Browser engine (Chromium, for web browsing tools)",
"ripgrep": "ripgrep (fast file search)",
"ffmpeg": "ffmpeg (TTS voice messages)",
}
def _has_system_browser() -> bool:
if _IS_WINDOWS:
names = ("chrome", "msedge", "chromium")
else:
names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome")
for name in names:
if shutil.which(name):
return True
return False
def _has_npx_agent_browser() -> bool:
"""agent-browser resolves lazily via npx on the default install (#43564),
invisible to the PATH/managed-dir probes above. Mirror
tools.browser_tool.check_browser_requirements's Termux carve-out so this
check can't diverge from what browser tools actually find."""
try:
from tools.browser_tool import (
_find_agent_browser,
_is_npx_agent_browser_sentinel,
_requires_real_termux_browser_install,
)
browser_cmd = _find_agent_browser(validate=False)
except Exception:
return False
if not _is_npx_agent_browser_sentinel(browser_cmd):
return False
return not _requires_real_termux_browser_install(browser_cmd)
def _has_hermes_agent_browser() -> bool:
from hermes_constants import get_hermes_home
home = get_hermes_home()
if _IS_WINDOWS:
# npm -g --prefix puts .cmd shims directly in the prefix dir on Windows
return (home / "node" / "agent-browser.cmd").is_file()
# install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix
# Also check legacy node_modules/.bin/ path for git-clone installs.
return (
(home / "node" / "bin" / "agent-browser").is_file()
or (home / "node_modules" / ".bin" / "agent-browser").is_file()
)
def _find_install_script(
package_dir: Path | None = None,
repo_root: Path | None = None,
) -> tuple[Path | None, str | None]:
"""Locate the install script — bundled in wheel or in git checkout.
On Windows, prefers install.ps1; on POSIX, prefers install.sh.
Returns a (path, shell) tuple, or (None, None) if neither is found.
"""
if package_dir is None:
package_dir = Path(__file__).parent
if repo_root is None:
repo_root = package_dir.parent
if _IS_WINDOWS:
preferred = ("install.ps1", "powershell")
fallback = ("install.sh", "bash")
else:
preferred = ("install.sh", "bash")
fallback = ("install.ps1", "powershell")
for script_name, shell in (preferred, fallback):
bundled = package_dir / "scripts" / script_name
if bundled.is_file():
return bundled, shell
repo = repo_root / "scripts" / script_name
if repo.is_file():
return repo, shell
return None, None
def ensure_dependency(
dep: str,
interactive: bool = True,
) -> bool:
"""Ensure a non-Python dependency is available. Returns True if available."""
check = _DEP_CHECKS.get(dep)
if check is None:
# Unknown dep — don't silently forward to install script.
return False
if check():
return True
script, shell = _find_install_script()
if script is None:
if interactive:
desc = _DEP_DESCRIPTIONS.get(dep, dep)
print(f" {desc} is not installed and no install script was found.")
print(f" Install {dep} manually and try again.")
return False
if interactive and sys.stdin.isatty():
desc = _DEP_DESCRIPTIONS.get(dep, dep)
try:
reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
if reply not in ("", "y", "yes"):
return False
if shell == "powershell":
from hermes_constants import get_hermes_home
ps_bin = shutil.which("powershell") or shutil.which("pwsh")
if not ps_bin:
if interactive:
print(" PowerShell not found. Install PowerShell or run install.ps1 manually.")
return False
cmd = [
ps_bin,
"-ExecutionPolicy", "Bypass",
"-File", str(script),
"-Ensure", dep,
"-HermesHome", str(get_hermes_home()),
]
else:
cmd = ["bash", str(script), "--ensure", dep]
run_env = hermes_subprocess_env(inherit_credentials=False)
run_env["IS_INTERACTIVE"] = "false"
result = subprocess.run(
cmd,
env=run_env,
)
if result.returncode != 0:
return False
if check:
return check()
return True
+138
View File
@@ -0,0 +1,138 @@
"""Client for uploading ``hermes debug share`` bundles to Nous-internal S3.
This is the opt-in (``--nous``) destination for ``hermes debug share``.
Unlike the public paste.rs path, bundles uploaded here go to a Nous-owned
S3 bucket via a short-lived signed URL minted by the Nous account service
(NAS). The bucket auto-expires objects after 14 days, and the contents are
only viewable by Nous staff (and allowlisted Discord mods) through a
Google-OAuth-gated viewer.
Flow:
1. POST {NAS_BASE}/api/diagnostics/upload-url {uploadUrl, viewUrl, id, ...}
(the request body carries ``sizeBytes``; NAS signs it into the presigned
URL's ``ContentLength``, so the PUT must send exactly that many bytes)
2. PUT <uploadUrl> (the gzipped bundle, Content-Type application/gzip)
NAS is stateless the object's existence in S3 is the only state, so there is
no confirm/callback step.
Uses stdlib ``urllib`` only, matching ``debug.py`` style no third-party deps.
"""
import json
import os
import urllib.request
# Base URL of the Nous account service that mints the signed upload URL.
# Overridable via env so the feature can be pointed at staging / a local dev
# NAS instance during testing.
NAS_BASE = os.environ.get(
"HERMES_DIAGNOSTICS_BASE_URL", "https://portal.nousresearch.com"
)
# Network timeout for each request (seconds). The upload itself can be larger
# (a gzipped log bundle), so the PUT gets a more generous window.
_REQUEST_TIMEOUT = 30
_UPLOAD_TIMEOUT = 120
_USER_AGENT = "hermes-agent/debug-share"
def request_upload_url(
content_type: str = "application/gzip",
size_bytes: int | None = None,
) -> dict:
"""Ask NAS to mint a presigned PUT URL for a diagnostics bundle.
POSTs a small JSON body to ``{NAS_BASE}/api/diagnostics/upload-url`` and
returns the parsed JSON response, expected to contain at least
``uploadUrl``, ``viewUrl`` and ``id`` (plus optional ``expiresAt`` /
``uploadExpiresInSeconds``).
Raises on non-2xx responses or unparseable JSON.
"""
payload: dict = {"contentType": content_type}
if size_bytes is not None:
payload["sizeBytes"] = int(size_bytes)
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{NAS_BASE}/api/diagnostics/upload-url",
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
},
)
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
status = getattr(resp, "status", None)
if status is None:
status = resp.getcode()
if not (200 <= status < 300):
raise RuntimeError(
f"diagnostics upload-url request failed: HTTP {status}"
)
body = resp.read().decode("utf-8")
try:
result = json.loads(body)
except (ValueError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"diagnostics upload-url returned non-JSON response: {body[:200]}"
) from exc
if not isinstance(result, dict) or not result.get("uploadUrl"):
raise RuntimeError(
"diagnostics upload-url response missing 'uploadUrl': "
f"{body[:200]}"
)
return result
def put_bundle(
upload_url: str,
data: bytes,
content_type: str = "application/gzip",
) -> None:
"""PUT the gzipped *data* bundle to a presigned *upload_url*.
Sets the ``Content-Type`` header (must match what NAS pinned when signing
the URL, otherwise S3 rejects the signature). Raises on non-2xx.
"""
req = urllib.request.Request(
upload_url,
data=data,
method="PUT",
headers={
"Content-Type": content_type,
"User-Agent": _USER_AGENT,
},
)
with urllib.request.urlopen(req, timeout=_UPLOAD_TIMEOUT) as resp:
status = getattr(resp, "status", None)
if status is None:
status = resp.getcode()
if not (200 <= status < 300):
raise RuntimeError(f"diagnostics bundle PUT failed: HTTP {status}")
def share_to_nous(report_bundle: bytes) -> dict:
"""Orchestrate the full Nous-S3 upload of a gzipped *report_bundle*.
Two steps: mint a presigned PUT URL (sending the exact ``sizeBytes`` NAS
signs into the URL's ``ContentLength``), then PUT the bundle. NAS is
stateless the object's existence in S3 is the only state, so there is no
confirm/callback step. Returns the dict from :func:`request_upload_url`
(which carries ``viewUrl`` / ``id`` / expiry metadata) so the caller can
print the viewer link. Raises on any failure of either step.
"""
size_bytes = len(report_bundle)
info = request_upload_url(
content_type="application/gzip", size_bytes=size_bytes
)
put_bundle(info["uploadUrl"], report_bundle, content_type="application/gzip")
return info
+291
View File
@@ -0,0 +1,291 @@
"""
DingTalk Device Flow authorization.
Implements the same 3-step registration flow as dingtalk-openclaw-connector:
1. POST /app/registration/init get nonce
2. POST /app/registration/begin get device_code + verification_uri_complete
3. POST /app/registration/poll poll until SUCCESS get client_id + client_secret
The verification_uri_complete is rendered as a QR code in the terminal so the
user can scan it with DingTalk to authorize, yielding AppKey + AppSecret
automatically.
"""
from __future__ import annotations
import os
import sys
import time
import logging
from typing import Optional, Tuple
import requests
logger = logging.getLogger(__name__)
# ── Configuration ──────────────────────────────────────────────────────────
REGISTRATION_BASE_URL = os.environ.get(
"DINGTALK_REGISTRATION_BASE_URL", "https://oapi.dingtalk.com"
).rstrip("/")
REGISTRATION_SOURCE = os.environ.get("DINGTALK_REGISTRATION_SOURCE", "openClaw")
# ── API helpers ────────────────────────────────────────────────────────────
class RegistrationError(Exception):
"""Raised when a DingTalk registration API call fails."""
def _api_post(path: str, payload: dict) -> dict:
"""POST to the registration API and return the parsed JSON body."""
url = f"{REGISTRATION_BASE_URL}{path}"
try:
resp = requests.post(url, json=payload, timeout=15)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as exc:
raise RegistrationError(f"Network error calling {url}: {exc}") from exc
errcode = data.get("errcode", -1)
if errcode != 0:
errmsg = data.get("errmsg", "unknown error")
raise RegistrationError(f"API error [{path}]: {errmsg} (errcode={errcode})")
return data
# ── Core flow ──────────────────────────────────────────────────────────────
def begin_registration() -> dict:
"""Start a device-flow registration.
Returns a dict with keys:
device_code, verification_uri_complete, expires_in, interval
"""
# Step 1: init → nonce
init_data = _api_post("/app/registration/init", {"source": REGISTRATION_SOURCE})
nonce = str(init_data.get("nonce", "")).strip()
if not nonce:
raise RegistrationError("init response missing nonce")
# Step 2: begin → device_code, verification_uri_complete
begin_data = _api_post("/app/registration/begin", {"nonce": nonce})
device_code = str(begin_data.get("device_code", "")).strip()
verification_uri_complete = str(begin_data.get("verification_uri_complete", "")).strip()
if not device_code:
raise RegistrationError("begin response missing device_code")
if not verification_uri_complete:
raise RegistrationError("begin response missing verification_uri_complete")
return {
"device_code": device_code,
"verification_uri_complete": verification_uri_complete,
"expires_in": int(begin_data.get("expires_in", 7200)),
"interval": max(int(begin_data.get("interval", 3)), 2),
}
def poll_registration(device_code: str) -> dict:
"""Poll the registration status once.
Returns a dict with keys: status, client_id?, client_secret?, fail_reason?
"""
data = _api_post("/app/registration/poll", {"device_code": device_code})
status_raw = str(data.get("status", "")).strip().upper()
if status_raw not in {"WAITING", "SUCCESS", "FAIL", "EXPIRED"}:
status_raw = "UNKNOWN"
return {
"status": status_raw,
"client_id": str(data.get("client_id", "")).strip() or None,
"client_secret": str(data.get("client_secret", "")).strip() or None,
"fail_reason": str(data.get("fail_reason", "")).strip() or None,
}
def wait_for_registration_success(
device_code: str,
interval: int = 3,
expires_in: int = 7200,
on_waiting: Optional[callable] = None,
) -> Tuple[str, str]:
"""Block until the registration succeeds or times out.
Returns (client_id, client_secret).
"""
deadline = time.monotonic() + expires_in
retry_window = 120 # 2 minutes for transient errors
retry_start = 0.0
while time.monotonic() < deadline:
time.sleep(interval)
try:
result = poll_registration(device_code)
except RegistrationError:
if retry_start == 0:
retry_start = time.monotonic()
if time.monotonic() - retry_start < retry_window:
continue
raise
status = result["status"]
if status == "WAITING":
retry_start = 0
if on_waiting:
on_waiting()
continue
if status == "SUCCESS":
cid = result["client_id"]
csecret = result["client_secret"]
if not cid or not csecret:
raise RegistrationError("authorization succeeded but credentials are missing")
return cid, csecret
# FAIL / EXPIRED / UNKNOWN
if retry_start == 0:
retry_start = time.monotonic()
if time.monotonic() - retry_start < retry_window:
continue
reason = result.get("fail_reason") or status
raise RegistrationError(f"authorization failed: {reason}")
raise RegistrationError("authorization timed out, please retry")
# ── QR code rendering ─────────────────────────────────────────────────────
def _ensure_qrcode_installed() -> bool:
"""Try to import qrcode; if missing, auto-install it via pip/uv."""
try:
import qrcode # noqa: F401
return True
except ImportError:
pass
import subprocess
from hermes_cli.tools_config import _pip_install
try:
result = _pip_install(["-q", "qrcode"], timeout=120)
if result.returncode == 0:
import qrcode # noqa: F401,F811
return True
except (subprocess.SubprocessError, ImportError, OSError):
pass
return False
def render_qr_to_terminal(url: str) -> bool:
"""Render *url* as a compact QR code in the terminal.
Returns True if the QR code was printed, False if the library is missing.
"""
try:
import qrcode
except ImportError:
return False
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=1,
)
qr.add_data(url)
qr.make(fit=True)
# Use half-block characters for compact rendering (2 rows per character)
matrix = qr.get_matrix()
rows = len(matrix)
lines: list[str] = []
TOP_HALF = "\u2580" # ▀
BOTTOM_HALF = "\u2584" # ▄
FULL_BLOCK = "\u2588" # █
EMPTY = " "
for r in range(0, rows, 2):
line_chars: list[str] = []
for c in range(len(matrix[r])):
top = matrix[r][c]
bottom = matrix[r + 1][c] if r + 1 < rows else False
if top and bottom:
line_chars.append(FULL_BLOCK)
elif top:
line_chars.append(TOP_HALF)
elif bottom:
line_chars.append(BOTTOM_HALF)
else:
line_chars.append(EMPTY)
lines.append(" " + "".join(line_chars))
print("\n".join(lines))
return True
# ── High-level entry point for the setup wizard ───────────────────────────
def dingtalk_qr_auth() -> Optional[Tuple[str, str]]:
"""Run the interactive QR-code device-flow authorization.
Returns (client_id, client_secret) on success, or None if the user
cancelled or the flow failed.
"""
from hermes_cli.setup import print_info, print_success, print_warning, print_error
print()
print_info(" Initializing DingTalk device authorization...")
print_info(" Note: the scan page is branded 'OpenClaw' — DingTalk's")
print_info(" ecosystem onboarding bridge. Safe to use.")
try:
reg = begin_registration()
except RegistrationError as exc:
print_error(f" Authorization init failed: {exc}")
return None
url = reg["verification_uri_complete"]
# Ensure qrcode library is available (auto-install if missing)
if not _ensure_qrcode_installed():
print_warning(" qrcode library install failed, will show link only.")
print()
print_info(" Please scan the QR code below with DingTalk to authorize:")
print()
if not render_qr_to_terminal(url):
print_warning(" QR code render failed, please open the link below to authorize:")
print()
print_info(f" Or open this link manually: {url}")
print()
print_info(" Waiting for QR scan authorization... (timeout: 2 hours)")
dot_count = 0
def _on_waiting():
nonlocal dot_count
dot_count += 1
if dot_count % 10 == 0:
sys.stdout.write(".")
sys.stdout.flush()
try:
client_id, client_secret = wait_for_registration_success(
device_code=reg["device_code"],
interval=reg["interval"],
expires_in=reg["expires_in"],
on_waiting=_on_waiting,
)
except RegistrationError as exc:
print()
print_error(f" Authorization failed: {exc}")
return None
print()
print_success(" QR scan authorization successful!")
print_success(f" Client ID: {client_id}")
print_success(f" Client Secret: {client_secret[:8]}{'*' * (len(client_secret) - 8)}")
return client_id, client_secret
+3450
View File
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
"""``hermes doctor --live`` — opt-in bounded real-call tool-backend probes.
Design invariants:
- **Opt-in only.** These probes make real (cheap, metadata/read-only) network
calls and may spend a trivial amount of quota. They run ONLY when the user
passes ``hermes doctor --live``.
- **Bounded.** One probe per configured backend, sequential, each with a
~10s timeout (configurable via ``doctor.live_probe_timeout`` in
config.yaml).
- **Read-only.** Metadata GETs only no generation, no scrapes that spend
credits, no state mutation anywhere.
- **Failure-isolated.** A probe crashing must never crash the doctor run;
every probe is wrapped in a catch-all.
- **Configured-only.** Backends without credentials / config are skipped with
a note, never failed.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Callable, List, Optional
from hermes_cli.doctor import (
_section,
check_fail,
check_info,
check_ok,
check_warn,
)
DEFAULT_PROBE_TIMEOUT = 10.0
# Metadata-only endpoints. None of these spend generation credits.
FIRECRAWL_HEALTH_URL = "https://api.firecrawl.dev/v2/team/credit-usage"
FAL_MODELS_URL = "https://fal.ai/api/models?page=1"
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
GROQ_MODELS_URL = "https://api.groq.com/openai/v1/models"
ELEVENLABS_VOICES_URL = "https://api.elevenlabs.io/v1/voices"
# TTS/STT providers that never touch the network (nothing to probe).
_LOCAL_AUDIO_PROVIDERS = {"", "local", "edge", "neutts", "kittentts", "piper"}
@dataclass
class ProbeResult:
"""Outcome of one backend probe."""
name: str
status: str # "pass" | "warn" | "fail" | "skip"
detail: str = ""
# ---------------------------------------------------------------------------
# Small seams (monkeypatchable in tests, and single points of control).
# ---------------------------------------------------------------------------
def _load_config() -> dict:
try:
from hermes_cli.config import load_config
return load_config() or {}
except Exception:
return {}
def _http_get(url: str, headers: Optional[dict] = None,
timeout: Optional[float] = None):
"""Single HTTP GET seam for all metadata probes."""
import httpx
return httpx.get(url, headers=headers or {}, timeout=timeout)
def _browser_available() -> bool:
"""Is the local browser automation backend (agent-browser) installed?"""
import shutil
if shutil.which("agent-browser"):
return True
try:
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT
if (PROJECT_ROOT / "node_modules" / "agent-browser").exists():
return True
for candidate in (HERMES_HOME / "node" / "bin",
HERMES_HOME / "node",
HERMES_HOME / "node_modules" / ".bin"):
if shutil.which("agent-browser", path=str(candidate)):
return True
except Exception:
pass
# agent-browser resolves lazily via npx on the default install (#43564),
# invisible to the PATH/node_modules probes above. Mirror the rung
# hermes_cli.doctor uses so this probe can't diverge from it, including
# the Termux carve-out (bare npx is too fragile to advertise as ready
# there — see check_browser_requirements).
try:
from tools.browser_tool import (
_find_agent_browser,
_is_npx_agent_browser_sentinel,
_requires_real_termux_browser_install,
)
browser_cmd = _find_agent_browser(validate=False)
except Exception:
return False
if not _is_npx_agent_browser_sentinel(browser_cmd):
return False
return not _requires_real_termux_browser_install(browser_cmd)
def _launch_browser_probe(timeout: float) -> tuple:
"""Launch a browser, open about:blank, close. Returns (ok, detail).
Uses Playwright directly (what agent-browser drives underneath) so the
probe owns the full lifecycle and always cleans up.
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
return (False, "playwright not installed")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True,
timeout=timeout * 1000)
try:
page = browser.new_page()
page.goto("about:blank", timeout=timeout * 1000)
finally:
browser.close()
return (True, "launched + about:blank + closed")
def _probe_mcp_server(name: str, config: dict, timeout: float):
"""initialize + tools/list against one configured MCP server.
Reuses the exact machinery behind ``hermes mcp test``.
"""
from hermes_cli.mcp_config import _probe_single_server
return _probe_single_server(name, config, connect_timeout=timeout)
# ---------------------------------------------------------------------------
# Per-backend probes. Each returns a ProbeResult and never raises upward
# beyond what run_live_checks' catch-all handles.
# ---------------------------------------------------------------------------
def _classify_http(name: str, resp, key_hint: str) -> ProbeResult:
code = getattr(resp, "status_code", None)
if code is not None and 200 <= code < 300:
return ProbeResult(name, "pass", f"(HTTP {code})")
if code in (401, 403):
return ProbeResult(name, "fail",
f"(HTTP {code} — check {key_hint})")
return ProbeResult(name, "fail", f"(HTTP {code})")
def _probe_firecrawl(timeout: float) -> ProbeResult:
key = os.getenv("FIRECRAWL_API_KEY", "").strip()
if not key:
return ProbeResult("Firecrawl", "skip", "(not configured)")
resp = _http_get(FIRECRAWL_HEALTH_URL,
headers={"Authorization": f"Bearer {key}"},
timeout=timeout)
return _classify_http("Firecrawl", resp, "FIRECRAWL_API_KEY")
def _probe_fal(timeout: float) -> ProbeResult:
key = os.getenv("FAL_KEY", "").strip()
if not key:
return ProbeResult("FAL", "skip", "(not configured)")
# Metadata GET only — never a generation call.
resp = _http_get(FAL_MODELS_URL,
headers={"Authorization": f"Key {key}"},
timeout=timeout)
return _classify_http("FAL", resp, "FAL_KEY")
def _probe_browser(timeout: float) -> ProbeResult:
if not _browser_available():
return ProbeResult("Browser", "skip", "(not configured)")
ok, detail = _launch_browser_probe(timeout)
return ProbeResult("Browser", "pass" if ok else "fail", f"({detail})")
def _audio_provider_probe(kind: str, provider: str,
timeout: float) -> ProbeResult:
"""Shared TTS/STT metadata probe (voices/models list GET only)."""
name = kind.upper()
provider = (provider or "").strip().lower()
if provider in _LOCAL_AUDIO_PROVIDERS:
return ProbeResult(name, "skip",
f"(provider '{provider or 'local'}' — no remote "
"backend to probe)")
probes = {
"openai": (OPENAI_MODELS_URL, "OPENAI_API_KEY", "Bearer"),
"groq": (GROQ_MODELS_URL, "GROQ_API_KEY", "Bearer"),
"elevenlabs": (ELEVENLABS_VOICES_URL, "ELEVENLABS_API_KEY", "xi"),
}
entry = probes.get(provider)
if entry is None:
return ProbeResult(name, "skip",
f"(provider '{provider}' — no live probe "
"implemented)")
url, env_var, scheme = entry
key = os.getenv(env_var, "").strip()
if not key:
return ProbeResult(name, "warn",
f"(provider '{provider}' configured but "
f"{env_var} is not set)")
if scheme == "xi":
headers = {"xi-api-key": key}
else:
headers = {"Authorization": f"Bearer {key}"}
resp = _http_get(url, headers=headers, timeout=timeout)
result = _classify_http(name, resp, env_var)
result.detail = f"({provider}) {result.detail}"
return result
def _probe_tts(config: dict, timeout: float) -> ProbeResult:
provider = ((config.get("tts") or {}).get("provider")) or ""
return _audio_provider_probe("tts", provider, timeout)
def _probe_stt(config: dict, timeout: float) -> ProbeResult:
provider = ((config.get("stt") or {}).get("provider")) or ""
return _audio_provider_probe("stt", provider, timeout)
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
def _report(result: ProbeResult, issues: List[str]) -> None:
if result.status == "pass":
check_ok(result.name, result.detail)
elif result.status == "warn":
check_warn(result.name, result.detail)
elif result.status == "fail":
check_fail(result.name, result.detail)
issues.append(f"Live probe failed: {result.name} {result.detail}")
else: # skip
check_info(f"{result.name} {result.detail} — skipped")
def _run_one(name: str, fn: Callable[[], ProbeResult],
issues: List[str]) -> ProbeResult:
"""Run one probe with a catch-all so a crash never kills doctor."""
try:
result = fn()
except TimeoutError as exc:
result = ProbeResult(name, "fail", f"(timed out: {exc})")
except Exception as exc:
msg = str(exc) or exc.__class__.__name__
if "time" in msg.lower():
result = ProbeResult(name, "fail", f"(timed out: {msg})")
else:
result = ProbeResult(name, "fail", f"({msg})")
_report(result, issues)
return result
def run_live_checks(issues: List[str]) -> List[ProbeResult]:
"""Run one bounded, read-only probe per configured tool backend.
Sequential by design (bounded, predictable output ordering). Appends a
remediation line to ``issues`` for each failed probe. Skipped backends
never fail and never append issues.
"""
config = _load_config()
try:
timeout = float(
(config.get("doctor") or {}).get("live_probe_timeout",
DEFAULT_PROBE_TIMEOUT))
except (TypeError, ValueError):
timeout = DEFAULT_PROBE_TIMEOUT
timeout = max(1.0, timeout)
_section("Live Backend Probes (opt-in, real calls)")
results: List[ProbeResult] = []
results.append(_run_one(
"Firecrawl", lambda: _probe_firecrawl(timeout), issues))
results.append(_run_one(
"FAL", lambda: _probe_fal(timeout), issues))
results.append(_run_one(
"Browser", lambda: _probe_browser(timeout), issues))
servers = config.get("mcp_servers") or {}
if isinstance(servers, dict) and servers:
for name in sorted(servers):
entry = servers[name]
label = f"MCP: {name}"
def _probe(n=name, e=entry) -> ProbeResult:
if not isinstance(e, dict):
return ProbeResult(f"MCP: {n}", "skip",
"(malformed config entry)")
tools = _probe_mcp_server(n, e, timeout)
return ProbeResult(f"MCP: {n}", "pass",
f"({len(tools)} tool(s))")
results.append(_run_one(label, _probe, issues))
else:
results.append(ProbeResult("MCP", "skip", "(no servers configured)"))
_report(results[-1], issues)
results.append(_run_one(
"TTS", lambda: _probe_tts(config, timeout), issues))
results.append(_run_one(
"STT", lambda: _probe_stt(config, timeout), issues))
return results
def maybe_run_live_checks(args, issues: List[str]):
"""Entry point called from ``run_doctor`` after the static checks.
No-ops (returns None) unless the user explicitly passed ``--live``.
A crash anywhere in the live subsystem must never break doctor.
"""
if not getattr(args, "live", False):
return None
try:
return run_live_checks(issues)
except Exception as exc: # catch-all: doctor must survive
check_warn("Live backend probes crashed", f"({exc})")
return None
+453
View File
@@ -0,0 +1,453 @@
"""
Dump command for hermes CLI.
Outputs a compact, plain-text summary of the user's Hermes setup
that can be copy-pasted into Discord/GitHub/Telegram for support context.
No ANSI colors, no checkmarks just data.
"""
import json
import os
import platform
import subprocess
import sys
from pathlib import Path
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
from agent.skill_utils import is_excluded_skill_path
def _dotenv_key_names() -> set[str]:
"""Return the set of env-var names assigned a non-empty value in ~/.hermes/.env.
The managed backends (launchd / systemd / the desktop-spawned ``serve``
process) load credentials from this file NOT from an interactive shell's
exports. ``hermes debug share`` runs in a terminal, so ``os.getenv`` reflects
the shell's environment, which can include exported keys the managed backend
never sees. Comparing against this set lets the dump flag that mismatch (the
exact trap behind #48504-style "no web_search" reports: key exported in the
shell, absent from .env, invisible to the launchd backend).
"""
try:
env_path = get_env_path()
text = env_path.read_text(encoding="utf-8", errors="ignore")
except (OSError, UnicodeError):
return set()
names: set[str] = set()
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.lower().startswith("export "):
line = line[len("export "):].lstrip()
name, _, value = line.partition("=")
name = name.strip()
# A bare `KEY=` (empty value) is effectively unset for the backend.
if name and value.strip().strip("'\""):
names.add(name)
return names
def _get_git_commit(project_root: Path) -> str:
"""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"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
cwd=str(project_root),
)
if result.returncode == 0:
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)"
def _get_git_commit_date(project_root: Path) -> str:
"""Return the date the HEAD commit was authored (YYYY-MM-DD), or ''.
Resolves live via ``git log`` on source installs. The published Docker
image excludes ``.git``, so this returns '' there the dump line simply
drops the date suffix in that case (the baked SHA still identifies the
build).
"""
try:
result = subprocess.run(
["git", "log", "-1", "--format=%cd", "--date=short", "HEAD"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
cwd=str(project_root),
)
if result.returncode == 0:
value = result.stdout.strip()
if value:
return value
except Exception:
pass
return ""
def _redact(value: str) -> str:
"""Redact all but first 4 and last 4 chars.
Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for
an empty value (matches the historical behavior of this helper
``hermes dump`` formats empty values as blank, not as ``"(not set)"``).
"""
from agent.redact import mask_secret
return mask_secret(value)
def _gateway_status() -> str:
"""Return a short gateway status string."""
try:
from hermes_cli.gateway import get_gateway_runtime_snapshot
snapshot = get_gateway_runtime_snapshot()
if snapshot.running:
mode = snapshot.manager
if snapshot.has_process_service_mismatch:
mode = "manual"
return f"running ({mode}, pid {snapshot.gateway_pids[0]})"
if snapshot.service_installed and not snapshot.service_running:
return f"stopped ({snapshot.manager})"
return f"stopped ({snapshot.manager})"
except Exception:
return "unknown" if sys.platform.startswith(("linux", "darwin")) else "N/A"
def _count_skills(hermes_home: Path) -> int:
"""Count installed skills."""
skills_dir = hermes_home / "skills"
if not skills_dir.is_dir():
return 0
count = 0
for item in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(item):
continue
count += 1
return count
def _count_mcp_servers(config: dict) -> int:
"""Count configured MCP servers."""
mcp = config.get("mcp", {})
servers = mcp.get("servers", {})
return len(servers)
def _cron_summary(hermes_home: Path) -> str:
"""Return cron jobs summary."""
jobs_file = hermes_home / "cron" / "jobs.json"
if not jobs_file.exists():
return "0"
try:
# utf-8-sig: same dialect as cron/jobs.load_jobs — Windows editors
# may leave a UTF-8 BOM that plain utf-8 json.load rejects.
with open(jobs_file, encoding="utf-8-sig") as f:
data = json.load(f)
jobs = data.get("jobs", [])
active = sum(1 for j in jobs if j.get("enabled", True))
return f"{active} active / {len(jobs)} total"
except Exception:
return "(error reading)"
def _configured_platforms() -> list[str]:
"""Return list of configured messaging platform names."""
checks = {
"telegram": "TELEGRAM_BOT_TOKEN",
"discord": "DISCORD_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
"whatsapp": "WHATSAPP_ENABLED",
"signal": "SIGNAL_HTTP_URL",
"email": "EMAIL_ADDRESS",
"sms": "TWILIO_ACCOUNT_SID",
"matrix": "MATRIX_HOMESERVER_URL",
"mattermost": "MATTERMOST_URL",
"homeassistant": "HASS_TOKEN",
"dingtalk": "DINGTALK_CLIENT_ID",
"feishu": "FEISHU_APP_ID",
"wecom": "WECOM_BOT_ID",
"wecom_callback": "WECOM_CALLBACK_CORP_ID",
"weixin": "WEIXIN_ACCOUNT_ID",
"qqbot": "QQ_APP_ID",
}
return [name for name, env in checks.items() if os.getenv(env)]
def _memory_provider(config: dict) -> str:
"""Return the active memory provider name."""
mem = config.get("memory", {})
provider = mem.get("provider", "")
return provider if provider else "built-in"
def _get_model_and_provider(config: dict) -> tuple[str, str]:
"""Extract model and provider from config."""
model_cfg = config.get("model", "")
if isinstance(model_cfg, dict):
model = model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "(not set)"
provider = model_cfg.get("provider") or "(auto)"
elif isinstance(model_cfg, str):
model = model_cfg or "(not set)"
provider = "(auto)"
else:
model = "(not set)"
provider = "(auto)"
return model, provider
def _config_overrides(config: dict) -> dict[str, str]:
"""Find non-default config values worth reporting.
Returns a flat dict of dotpath -> value for interesting overrides.
"""
from hermes_cli.config import DEFAULT_CONFIG
overrides = {}
# Sections with interesting user-facing overrides
interesting_paths = [
("agent", "max_turns"),
("agent", "gateway_timeout"),
("agent", "session_stall_timeout"),
("agent", "sanitizer_heal_escalation_threshold"),
("agent", "tool_use_enforcement"),
("agent", "execution_guidance"),
("terminal", "backend"),
("terminal", "docker_image"),
("terminal", "persistent_shell"),
("browser", "allow_private_urls"),
("compression", "enabled"),
("compression", "threshold"),
("compression", "in_place"),
("display", "streaming"),
("display", "skin"),
("display", "show_reasoning"),
("privacy", "redact_pii"),
("tts", "provider"),
]
for section, key in interesting_paths:
default_section = DEFAULT_CONFIG.get(section, {})
user_section = config.get(section, {})
if not isinstance(default_section, dict) or not isinstance(user_section, dict):
continue
default_val = default_section.get(key)
user_val = user_section.get(key)
if user_val is not None and user_val != default_val:
overrides[f"{section}.{key}"] = str(user_val)
# Toolsets (if different from default)
default_toolsets = DEFAULT_CONFIG.get("toolsets", [])
user_toolsets = config.get("toolsets", [])
if user_toolsets != default_toolsets:
overrides["toolsets"] = str(user_toolsets)
# Fallback providers
fallbacks = config.get("fallback_providers", [])
if fallbacks:
overrides["fallback_providers"] = str(fallbacks)
return overrides
def run_dump(args):
"""Output a compact, copy-pasteable setup summary."""
show_keys = getattr(args, "show_keys", False)
# Load env from .env file so key checks work
env_path = get_env_path()
load_hermes_dotenv(
hermes_home=env_path.parent,
project_env=get_project_root() / ".env",
)
project_root = get_project_root()
hermes_home = get_hermes_home()
try:
from hermes_cli import __version__
except ImportError:
__version__ = "(unknown)"
commit = _get_git_commit(project_root)
commit_date = _get_git_commit_date(project_root)
try:
config = load_config()
except Exception:
config = {}
model, provider = _get_model_and_provider(config)
# Profile
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name() or "(default)"
except Exception:
profile = "(default)"
# Terminal backend — report the EFFECTIVE backend, not just config.yaml.
# ``terminal.backend`` in config.yaml is bridged to the TERMINAL_ENV env var,
# but a TERMINAL_ENV set directly in .env / the shell overrides config and is
# what terminal_tool actually uses (tools/terminal_tool.py reads TERMINAL_ENV).
# Reporting only the config value hides that override and sends users chasing
# the wrong cause when the agent runs in a docker/podman sandbox even though
# config says "local" (and vice-versa). run_dump() has already loaded .env,
# so os.environ reflects the real override here.
terminal_cfg = config.get("terminal", {})
config_backend = terminal_cfg.get("backend", "local")
env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
if env_backend and env_backend != str(config_backend).strip().lower():
backend = (
f"{env_backend} (TERMINAL_ENV overrides config.yaml "
f"terminal.backend={config_backend})"
)
else:
backend = config_backend
# OpenAI SDK version
try:
import openai
openai_ver = openai.__version__
except ImportError:
openai_ver = "not installed"
# OS info
os_info = f"{platform.system()} {platform.release()} {platform.machine()}"
lines = []
lines.append("--- hermes dump ---")
# Identify the build by commit + the date that commit was made, resolved
# live via git. __release_date__ (the package release date) is
# intentionally NOT shown here — it reads like a wall-clock timestamp and
# confuses support triage. The commit date is the real "as-of" date.
ver_str = f"{__version__}"
ver_str += f" [{commit}]"
if commit_date:
ver_str += f" ({commit_date})"
lines.append(f"version: {ver_str}")
lines.append(f"os: {os_info}")
lines.append(f"python: {sys.version.split()[0]}")
lines.append(f"openai_sdk: {openai_ver}")
lines.append(f"profile: {profile}")
lines.append(f"hermes_home: {display_hermes_home()}")
lines.append(f"model: {model}")
lines.append(f"provider: {provider}")
lines.append(f"terminal: {backend}")
# API keys
lines.append("")
lines.append("api_keys:")
api_keys = [
("OPENROUTER_API_KEY", "openrouter"),
("OPENAI_API_KEY", "openai"),
("ANTHROPIC_API_KEY", "anthropic"),
("ANTHROPIC_TOKEN", "anthropic_token"),
("NOUS_API_KEY", "nous"),
("GOOGLE_API_KEY", "google/gemini"),
("GEMINI_API_KEY", "gemini"),
("GLM_API_KEY", "glm/zai"),
("ZAI_API_KEY", "zai"),
("KIMI_API_KEY", "kimi"),
("MINIMAX_API_KEY", "minimax"),
("DEEPSEEK_API_KEY", "deepseek"),
("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"),
("COMMANDCODE_API_KEY", "commandcode"),
("KILOCODE_API_KEY", "kilocode"),
("FIRECRAWL_API_KEY", "firecrawl"),
("TAVILY_API_KEY", "tavily"),
("KEENABLE_API_KEY", "keenable"),
("BROWSERBASE_API_KEY", "browserbase"),
("FAL_KEY", "fal"),
("ELEVENLABS_API_KEY", "elevenlabs"),
("GITHUB_TOKEN", "github"),
]
dotenv_keys = _dotenv_key_names()
for env_var, label in api_keys:
val = os.getenv(env_var, "")
if show_keys and val:
display = _redact(val)
else:
display = "set" if val else "not set"
# Set in this (shell) process but absent from ~/.hermes/.env: a managed
# backend (launchd/systemd/desktop `serve`) loads .env, not the login
# shell, so it likely can't see this key — even though the dump reads
# "set". Flag it so support doesn't chase a phantom "key is configured"
# (the actual cause of gated tools like web_search going missing).
if val and env_var not in dotenv_keys:
display += " (shell only — not in .env; managed/desktop backend may not see it)"
# 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
lines.append("")
lines.append("features:")
toolsets = config.get("toolsets", ["hermes-cli"])
lines.append(f" toolsets: {', '.join(toolsets) if toolsets else '(default)'}")
lines.append(f" mcp_servers: {_count_mcp_servers(config)}")
lines.append(f" memory_provider: {_memory_provider(config)}")
lines.append(f" gateway: {_gateway_status()}")
platforms = _configured_platforms()
lines.append(f" platforms: {', '.join(platforms) if platforms else 'none'}")
lines.append(f" cron_jobs: {_cron_summary(hermes_home)}")
lines.append(f" skills: {_count_skills(hermes_home)}")
# Config overrides (non-default values)
overrides = _config_overrides(config)
if overrides:
lines.append("")
lines.append("config_overrides:")
for key, val in overrides.items():
lines.append(f" {key}: {val}")
lines.append("--- end dump ---")
output = "\n".join(lines)
print(output)
+839
View File
@@ -0,0 +1,839 @@
"""Helpers for loading Hermes .env files consistently across entrypoints."""
from __future__ import annotations
import codecs
import io
import os
import sys
import threading
from pathlib import Path
from dotenv import load_dotenv
from utils import atomic_replace, fast_safe_load
# Env var name suffixes that indicate credential values. These are the
# only env vars whose values we sanitize on load — we must not silently
# alter arbitrary user env vars, but credentials are known to require
# pure ASCII (they become HTTP header values).
_CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY")
# Names we've already warned about during this process, so repeated
# load_hermes_dotenv() calls (user env + project env, gateway hot-reload,
# tests) don't spam the same warning multiple times.
_WARNED_KEYS: set[str] = set()
# Paths we've already emitted a UTF-32 refuse-to-mangle warning for.
# load_hermes_dotenv can call _sanitize_env_file_if_needed multiple times
# for the same file (user env + project env + hot-reload); once per path
# is enough.
_WARNED_UTF32_PATHS: 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] = {}
# Applied values are immutable per-home snapshots. ``os.environ`` is shared
# across profiles and may be overwritten by a later home's source apply.
_SECRET_SOURCE_VALUES_BY_HOME: dict[str, 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()
_SECRET_SOURCE_CACHE_LOCK = threading.RLock()
# Routed profile homes whose dotenv load was skipped under multiplex, so the
# skip is logged once per home rather than on every lazy import mid-turn.
_SCOPED_SKIP_LOGGED: set[str] = set()
def _known_hermes_env_keys() -> set[str]:
"""Return the combined set of known Hermes env-var keys.
Includes both ``OPTIONAL_ENV_VARS`` (setup-flow vars with metadata) and
``_EXTRA_ENV_KEYS`` (provider/platform keys managed outside the setup
wizard). Lazy-imported to avoid circular-dependency during early-bootstrap
``load_hermes_dotenv()`` calls.
"""
from hermes_cli.config import _EXTRA_ENV_KEYS
from hermes_cli.config_defaults import OPTIONAL_ENV_VARS
return set(OPTIONAL_ENV_VARS.keys()) | set(_EXTRA_ENV_KEYS)
# Behavioral routing keys a parent Hermes process injects into child env and
# that silently redirect a profile onto the wrong provider path (ACP auth
# method, copilot-ACP endpoints). These — and ONLY these — are scrubbed from
# os.environ at startup when absent from the profile's .env. Credential keys
# (API keys/tokens) are excluded: shell exports are a legitimate,
# documented way to supply them, and read-time secret-scope checks
# (agent/secret_scope.py) own cross-profile credential isolation.
_PROFILE_MANAGED_ENV_KEYS: frozenset[str] = frozenset({
"HERMES_ACP_AUTH_METHOD",
"HERMES_ACP_AUTO_APPROVE",
"HERMES_COPILOT_ACP_COMMAND",
"HERMES_COPILOT_ACP_ARGS",
"COPILOT_CLI_PATH",
"COPILOT_ACP_BASE_URL",
})
def _env_keys_defined_in_dotenv(path: Path) -> set[str]:
"""Return KEY names assigned in a dotenv file (including empty ``KEY=``).
Uses a fast line scanner rather than full dotenv parsing so it works
during early bootstrap without importing python-dotenv. Ignores comment
and blank lines. Non-ASCII encoding errors fall back to ``latin-1``,
matching ``_load_dotenv_with_fallback``.
"""
keys: set[str] = set()
try:
text = path.read_text(encoding="utf-8", errors="replace")
except Exception:
try:
text = path.read_text(encoding="latin-1", errors="replace")
except Exception:
return keys
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:]
key = line.split("=", 1)[0].strip()
if key:
keys.add(key)
return keys
def _clear_known_keys_missing_from_dotenv(path: Path) -> None:
"""Remove inherited profile-managed Hermes keys absent from ``.env``.
After the profile's ``.env`` has been loaded with ``override=True``,
scan the file for which profile-managed keys it explicitly defines and
delete any such key that exists in ``os.environ`` but is *not* present
in the file.
Scope is deliberately NARROW: only ``_PROFILE_MANAGED_ENV_KEYS``
behavioral routing keys (ACP auth method, copilot-ACP endpoints) that a
parent Hermes process injects and that silently change *which provider
path* a profile uses. Provider API keys (OPENAI_API_KEY, ) are
intentionally excluded: users legitimately export those in their shell
(``export OPENAI_API_KEY=`` is a documented flow see
``tests/hermes_cli/test_dump_env_visibility.py``), and a startup scrub
cannot distinguish a shell export from parent-process leakage. Clearing
the full known-key set would delete user-exported credentials on every
``hermes`` invocation.
Cross-profile *credential* isolation is handled at read time by
``agent.secret_scope.get_secret`` (scope authoritative under
multiplexing), not by mutating ``os.environ`` here.
Does **not** run when the ``.env`` file does not exist (bare-profile
case, which follows ``#66930`` / ``#67027`` semantics).
"""
if not path.exists():
return
defined = _env_keys_defined_in_dotenv(path)
for key in _PROFILE_MANAGED_ENV_KEYS:
if key not in defined and key in os.environ:
del os.environ[key]
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 get_secret_source_values(
hermes_home: str | os.PathLike,
) -> dict[str, str]:
"""Return the external-secret value snapshot for ``hermes_home``."""
home_key = str(Path(hermes_home).resolve())
return dict(_SECRET_SOURCE_VALUES_BY_HOME.get(home_key, {}))
def hydrate_profile_secret_sources(
hermes_home: str | os.PathLike,
) -> dict[str, str]:
"""Resolve one profile's configured sources without mutating ``os.environ``.
Multiplex gateways can route a first turn to a secondary profile that has
never run the process-global dotenv startup path. Resolve that profile's
sources against a private mapping seeded from its own ``.env`` and record
the usual per-home snapshot for ``build_profile_secret_scope()``.
Fail-open and once-per-home semantics intentionally mirror
``_apply_external_secret_sources``. The returned mapping contains only
values actually contributed by external sources, never the profile's
plaintext ``.env`` entries.
"""
with _SECRET_SOURCE_CACHE_LOCK:
return _hydrate_profile_secret_sources(Path(hermes_home))
def _hydrate_profile_secret_sources(home: Path) -> dict[str, str]:
"""Locked implementation for :func:`hydrate_profile_secret_sources`."""
home_key = str(home.resolve())
if home_key in _APPLIED_HOMES:
return get_secret_source_values(home)
try:
cfg = _load_secrets_config(home)
except Exception: # noqa: BLE001 — external sources must not block routing
return {}
if not cfg:
return {}
try:
from agent.secret_scope import _is_global_env, load_env_file
from agent.secret_sources.registry import apply_all
local_env = {
name: value
for name, value in os.environ.items()
if _is_global_env(name)
}
local_env.update(load_env_file(home / ".env"))
# Mirror load_hermes_dotenv()'s .op.env bootstrap: the 1Password
# service-account token lives in <home>/.op.env (gitignored), not
# .env. Without seeding it here a cold profile configured for the
# supported .op.env flow fails 1Password hydration (sweeper review
# on #74549). .env values win — never override an existing key.
op_env = home / ".op.env"
if op_env.exists():
for _name, _value in load_env_file(op_env).items():
local_env.setdefault(_name, _value)
local_env["HERMES_HOME"] = str(home)
report = apply_all(cfg, home, environ=local_env)
except Exception: # noqa: BLE001 — preserve fail-open startup behavior
return {}
if not report.sources:
return {}
_APPLIED_HOMES.add(home_key)
values: dict[str, str] = {}
for name, applied in report.provenance.items():
value = local_env.get(name)
if value is None:
continue
_SECRET_SOURCES[name] = applied.source
values[name] = value
if values:
_SECRET_SOURCE_VALUES_BY_HOME[home_key] = values
return dict(values)
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()
_SECRET_SOURCES.clear()
_SECRET_SOURCE_VALUES_BY_HOME.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)"
# Ask the registry for the source's human label (e.g. "1Password").
# Fall back to the raw source name for labels the registry doesn't
# know (stale provenance from an uninstalled plugin, tests).
try:
from agent.secret_sources.registry import get_source
registered = get_source(source)
if registered is not None and registered.label:
return f" (from {registered.label})"
except Exception: # noqa: BLE001 — label lookup must never raise
pass
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."""
seen: list[str] = []
for ch in value:
if ord(ch) > 127:
label = f"U+{ord(ch):04X}"
if ch.isprintable():
label += f" ({ch!r})"
if label not in seen:
seen.append(label)
if len(seen) >= limit:
break
return ", ".join(seen)
def _sanitize_loaded_credentials() -> None:
"""Strip non-ASCII characters from credential env vars in os.environ.
Called after dotenv loads so the rest of the codebase never sees
non-ASCII API keys. Only touches env vars whose names end with
known credential suffixes (``_API_KEY``, ``_TOKEN``, etc.).
Emits a one-line warning to stderr when characters are stripped.
Silent stripping would mask copy-paste corruption (Unicode lookalike
glyphs from PDFs / rich-text editors, ZWSP from web pages) as opaque
provider-side "invalid API key" errors (see #6843).
"""
for key, value in list(os.environ.items()):
if not any(key.endswith(suffix) for suffix in _CREDENTIAL_SUFFIXES):
continue
try:
value.encode("ascii")
continue
except UnicodeEncodeError:
pass
cleaned = value.encode("ascii", errors="ignore").decode("ascii")
os.environ[key] = cleaned
if key in _WARNED_KEYS:
continue
_WARNED_KEYS.add(key)
stripped = len(value) - len(cleaned)
detail = _format_offending_chars(value) or "non-printable"
print(
f" Warning: {key} contained {stripped} non-ASCII character"
f"{'s' if stripped != 1 else ''} ({detail}) — stripped so the "
f"key can be sent as an HTTP header.",
file=sys.stderr,
)
print(
" This usually means the key was copy-pasted from a PDF, "
"rich-text editor, or web page that substituted lookalike\n"
" Unicode glyphs for ASCII letters. If authentication fails "
"(e.g. \"API key not valid\"), re-copy the key from the\n"
" provider's dashboard and run `hermes setup` (or edit the "
".env file in a plain-text editor).",
file=sys.stderr,
)
def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
try:
# utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1
# Set-Content -Encoding UTF8 / Notepad) and is a no-op for BOM-less
# UTF-8. Plain "utf-8" would keep U+FEFF on the first key name and
# silently drop it from os.environ under its canonical name.
load_dotenv(dotenv_path=path, override=override, encoding="utf-8-sig")
except UnicodeDecodeError:
# utf-8-sig can't strip a BOM once we fall back to latin-1 decode.
raw = path.read_bytes()
if raw.startswith(codecs.BOM_UTF8):
raw = raw[len(codecs.BOM_UTF8) :]
load_dotenv(stream=io.StringIO(raw.decode("latin-1")), override=override)
# Strip non-ASCII characters from credential env vars that were just
# loaded. API keys must be pure ASCII since they're sent as HTTP
# header values (httpx encodes headers as ASCII). Non-ASCII chars
# typically come from copy-pasting keys from PDFs or rich-text editors
# that substitute Unicode lookalike glyphs (e.g. ʋ U+028B for v).
_sanitize_loaded_credentials()
def _sanitize_env_file_if_needed(path: Path) -> None:
"""Pre-sanitize a .env file before python-dotenv reads it.
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.
Encoding: sniffs a leading BOM *before* any text decode. UTF-16
(Notepad "Unicode") is decoded correctly and rewritten as clean
UTF-8. UTF-32 is refused (left untouched) so we never fall through
to the errors=replace corruption path. Order of BOM checks matters:
UTF-32-LE's BOM starts with UTF-16-LE's FF FE.
``hermes_cli.config._sanitize_env_lines`` normalizes line endings while
treating content after the first ``=`` as opaque for boundary discovery.
"""
if not path.exists():
return
try:
from hermes_cli.config import _sanitize_env_lines
except ImportError:
return # early bootstrap — config module not available yet
try:
raw = path.read_bytes()
except Exception:
return
# Sniff leading BOM bytes BEFORE decoding. ORDER MATTERS:
# codecs.BOM_UTF32_LE is FF FE 00 00, which startswith
# codecs.BOM_UTF16_LE (FF FE). Checking UTF-16 first would
# misdetect UTF-32-LE as UTF-16-LE and mangle the file.
force_utf8_rewrite = False
if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE):
# Lazy import keeps the module import block identical to #65124's
# codecs/io additions so the two PRs auto-merge either order.
path_key = str(path.resolve())
if path_key not in _WARNED_UTF32_PATHS:
_WARNED_UTF32_PATHS.add(path_key)
import logging
logging.getLogger(__name__).warning(
"Skipping .env sanitize for %s: UTF-32 BOM detected; "
"leaving file untouched to avoid corruption",
path,
)
return
if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE):
# "utf-16" uses the BOM to select endianness and strips it.
# TextIOWrapper + newline=None matches open()'s universal-newlines
# line splitting (\\n/\\r\\n/\\r only — not splitlines()'s extra
# Unicode boundaries like U+2028), so sanitize sees the same lines
# as the UTF-8 path.
try:
with io.TextIOWrapper(
io.BytesIO(raw), encoding="utf-16", newline=None
) as f:
original = f.readlines()
except UnicodeDecodeError:
return
# Source is UTF-16 on disk; always rewrite as clean UTF-8 so
# the subsequent utf-8 dotenv load sees a canonical file.
force_utf8_rewrite = True
else:
# Default path: utf-8-sig (strips UTF-8 BOM if present) with
# errors=replace so embedded NULs can be stripped below.
try:
with open(path, encoding="utf-8-sig", errors="replace") as f:
original = f.readlines()
except Exception:
return
# Defense-in-depth: errors=replace turns undecodable leading
# bytes into U+FFFD. Persisting that glues replacement chars
# onto the first key name and rewrites the file permanently
# (the UTF-16-with-BOM corruption path before BOM sniffing).
# Leave the file untouched rather than write the mangling.
if original and original[0].startswith("\ufffd"):
return
try:
# Strip null bytes before _sanitize_env_lines so they never
# reach python-dotenv (which passes them to os.environ and
# crashes with ValueError). Also intentionally repairs
# BOM-less UTF-16 (NUL-padded ASCII) into clean UTF-8.
stripped = [line.replace("\x00", "") for line in original]
sanitized = _sanitize_env_lines(stripped)
if sanitized != original or force_utf8_rewrite:
import tempfile
fd, tmp = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp", prefix=".env_"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.writelines(sanitized)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception:
pass # best-effort — don't block gateway startup
def load_hermes_dotenv(
*,
hermes_home: str | os.PathLike | None = None,
project_env: str | os.PathLike | None = None,
load_external_secrets: bool = True,
) -> list[Path]:
"""Load Hermes environment files with user config taking precedence.
Behavior:
- `~/.hermes/.env` overrides stale shell-exported values when present.
- project `.env` acts as a dev fallback and only fills missing values when
the user env exists.
- if no user env exists, the project `.env` also overrides stale shell vars.
- callers that only maintain the installation can set
``load_external_secrets=False`` to avoid loading optional secret-manager
dependencies into the process that replaces that same environment.
- routed multiplex profile loads hydrate external sources into the
profile's private secret snapshot without mutating the shared process
environment; unscoped startup loads retain the normal behavior above.
"""
home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))
# A multiplex gateway hosts every profile in one process. While a routed
# profile-home override is active, copying that profile's .env into
# os.environ would expose its credentials to sibling turns and every
# subsequently spawned child. An unscoped startup load remains process
# configuration and must retain the normal loading path.
# External secret sources still need their normal refresh path, so resolve
# them against the existing profile-local mapping instead of simply
# returning before all hydration work.
from agent.secret_scope import is_multiplex_active
from hermes_constants import get_hermes_home_override
if is_multiplex_active() and get_hermes_home_override() is not None:
home_key = str(home_path.resolve())
if home_key not in _SCOPED_SKIP_LOGGED:
_SCOPED_SKIP_LOGGED.add(home_key)
import logging
logging.getLogger(__name__).debug(
"multiplex: skipping process-global dotenv load for routed "
"profile home %s (credentials resolve via the profile scope)",
home_path,
)
if load_external_secrets:
from hermes_cli import _early_recovery
if not _early_recovery._should_skip_external_secret_sources():
hydrate_profile_secret_sources(home_path)
return []
loaded: list[Path] = []
user_env = home_path / ".env"
project_env_path = Path(project_env) if project_env else None
# Normalize safe formatting and remove invalid NUL bytes before parsing.
if user_env.exists():
_sanitize_env_file_if_needed(user_env)
if project_env_path and project_env_path.exists():
_sanitize_env_file_if_needed(project_env_path)
if user_env.exists():
_load_dotenv_with_fallback(user_env, override=True)
loaded.append(user_env)
# Mirror reload_env() known-key cleanup so inherited Hermes keys
# absent from this profile's .env do not leak into the runtime.
_clear_known_keys_missing_from_dotenv(user_env)
# Load .op.env AFTER .env so that .env values win, but the bootstrap
# token (OP_SERVICE_ACCOUNT_TOKEN) becomes available for
# apply_onepassword_secrets() even in cron / subprocess environments
# that inherit no shell state (no systemd EnvironmentFile, no op run).
# .op.env is gitignored — the service-account token never enters the
# committed .env file.
# Users on systemd can alternatively use:
# EnvironmentFile=-/path/to/.hermes/.op.env
# in their gateway unit, which takes precedence (override=False below
# ensures .op.env never clobbers a token already in the environment).
op_env = home_path / ".op.env"
if op_env.exists() and not os.environ.get("OP_SERVICE_ACCOUNT_TOKEN"):
_load_dotenv_with_fallback(op_env, override=False)
if project_env_path and project_env_path.exists():
_load_dotenv_with_fallback(project_env_path, override=not loaded)
loaded.append(project_env_path)
# External secret sources are skipped in two updater situations:
# 1. ``load_external_secrets=False`` — the caller is an ``update``
# invocation that must not import optional secret-manager libraries
# (Bitwarden → cryptography → ``_rust.pyd``) into the process that
# replaces that same environment on Windows (#73381, #86735).
# 2. A fresh ``hermes update`` retry just completed a deferred dependency
# install before importing this module. Do not remap native
# secret-source dependencies in that same updater process or the
# self-lock preflight will recreate the marker and exit 2 again.
# Dotenv and managed env still load in both cases; only external source
# resolution is unnecessary for the updater.
from hermes_cli import _early_recovery
if load_external_secrets and not _early_recovery._should_skip_external_secret_sources():
_apply_external_secret_sources(home_path)
_apply_managed_env()
# config.yaml is the documented source of truth for terminal.* settings,
# but the dotenv loads above run with override=True — so a stale
# TERMINAL_ENV=docker left in ~/.hermes/.env (e.g. written by an older
# `hermes setup` before the user switched terminal.backend in config.yaml)
# silently wins again on every reload. Startup launchers bridge
# config→env once, but long-lived processes (gateway per-turn reload,
# cron standalone runs) call load_hermes_dotenv() repeatedly and used to
# flip the effective backend back to the stale .env value mid-session
# (#29186, #67323). Re-apply config.yaml's explicit terminal keys last so
# the documented config path always wins. Runs after _apply_managed_env()
# so the merged config (which already carries the managed overlay) is
# what lands in the env.
_reapply_terminal_config_bridge(home_path)
return loaded
def _reapply_terminal_config_bridge(home_path: Path) -> None:
"""Re-assert config.yaml's explicit ``terminal.*`` keys over reloaded .env.
Delegates to ``hermes_cli.config.apply_terminal_config_to_env`` the
single shared bridge (same one terminal_tool's fallback and the TUI/
dashboard launchers use) so key coverage, explicit-keys-only override
semantics, cwd placeholder handling, and the managed-scope overlay can't
drift from the other bridge sites. Only keys the user actually wrote in
config.yaml's ``terminal`` section override env values; a config.yaml
without a terminal section leaves .env/shell selections untouched.
Scoped to the process HERMES_HOME: the shared bridge reads the
process-global config, so re-applying it for a *different* profile's
``load_hermes_dotenv(hermes_home=...)`` call would bridge the wrong
profile's config. Fail-open — a config problem must never break dotenv
loading (the historical env-driven behavior still applies).
"""
try:
if Path(home_path).resolve() != _process_hermes_home().resolve():
return
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=None)
except Exception: # noqa: BLE001 — early bootstrap / malformed config
pass
def _apply_managed_env() -> None:
"""Apply the managed-scope .env last, with override, so it beats user/shell.
Managed scope is machine-global (independent of HERMES_HOME / profile). v1
enforcement is "applied last with override=True" at the end of startup load
``os.environ`` holds the managed value for every managed key, beating both the
user ``.env`` and any pre-existing shell export. This deliberately inverts the
usual env-over-config precedence for the pinned keys (see
``docs/design/managed-scope.md`` §4.1).
This does NOT prevent the agent from later mutating ``os.environ`` in-process
or ``export``-ing in a subprocess shell; that hard boundary is a documented
v2 item (design §8.1). v1 relies on filesystem permissions only.
Fail-open: a missing managed dir or .env is the common case and a no-op; any
error here is swallowed so managed scope can never block startup.
"""
try:
from hermes_cli import managed_scope
managed_dir = managed_scope.get_managed_dir()
except Exception: # noqa: BLE001 — managed scope must never block startup
return
if managed_dir is None:
return
managed_env = managed_dir / ".env"
if not managed_env.exists():
return
_sanitize_env_file_if_needed(managed_env)
_load_dotenv_with_fallback(managed_env, override=True)
def _apply_external_secret_sources(home_path: Path) -> None:
"""Pull secrets from every enabled external source into env.
Runs AFTER dotenv loads so .env values are visible (sources use them
to locate bootstrap tokens) 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.
The heavy lifting (source ordering, mapped-beats-bulk precedence,
first-claim-wins conflict handling, override semantics, provenance)
lives in ``agent.secret_sources.registry.apply_all``; this wrapper
owns the once-per-HERMES_HOME guard, the post-apply ASCII
sanitization sweep, the ``_SECRET_SOURCES`` provenance map that
UI surfaces read, and the startup status lines.
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 status lines would print 3-5x per CLI startup. Use
``reset_secret_source_cache()`` if you need to force a re-pull
(tests, long-running processes after a config change).
"""
home_key = str(Path(home_path).resolve())
if home_key in _APPLIED_HOMES:
return
try:
cfg = _load_secrets_config(home_path)
except Exception: # noqa: BLE001 — config errors must not block startup
# Deliberately NOT marked applied: a malformed config.yaml would
# otherwise permanently disable secret loading for this process
# even after the user fixes the file (#40597).
return
if not cfg:
# No secrets section (or everything disabled at parse level). Not
# marked applied either — the re-parse is a cheap fast_safe_load and
# leaving the home unmarked lets a process pick up a config change
# on its next load_hermes_dotenv() call instead of never.
return
# Defer the registry import until we know a secrets source is enabled —
# agent.secret_sources.bitwarden eagerly loads cryptography._rust.pyd,
# which causes the Windows updater to self-lock before its preflight
# (the updater itself maps the .pyd before the dependency sync runs).
# A config with no enabled sources costs one dict scan; a config with
# enabled sources pays the crypto load exactly once, on demand.
# NOTE: only keys that smell like a real secret source trigger the import —
# a generic dict entry must not force crypto load on every hermes launch.
# We whitelist by *shape* (source dict with enabled flag) rather than
# hardcoding names, so plugin/test sources pass through unknown keys.
any_enabled = any(
isinstance(v, dict) and v.get("enabled") is True
for v in cfg.values()
)
if not any_enabled:
return
try:
from agent.secret_sources.registry import apply_all
except ImportError:
return
try:
report = apply_all(cfg, home_path)
except Exception: # noqa: BLE001 — belt-and-braces; apply_all shouldn't raise
return
if not report.sources:
# Config parsed but no source is enabled: keep retrying cheaply
# (no fetch happens for disabled sources) so flipping a source on
# mid-process takes effect on the next call.
return
# A real fetch attempt happened (success OR error). Mark the home now
# so the 3-5 import-time load_hermes_dotenv() calls per startup don't
# re-fetch / re-print — error retries within one process are opt-in via
# reset_secret_source_cache(). Marking AFTER the attempt (not before,
# see #40597) is what lets the earlier failure paths stay retryable.
_APPLIED_HOMES.add(home_key)
if report.applied_any:
# Re-run the ASCII sanitization pass: vault values are
# user-supplied and might have the same copy-paste corruption as
# a manually edited .env (see #6843).
_sanitize_loaded_credentials()
# Remember where each var came from so setup / `hermes model`
# flows can label detected credentials with "(from Bitwarden)" /
# "(from 1Password)" — otherwise users see "credentials ✓" with
# no hint the value came from a vault rather than .env.
values: dict[str, str] = {}
for name, applied in report.provenance.items():
_SECRET_SOURCES[name] = applied.source
if name in os.environ:
values[name] = os.environ[name]
_SECRET_SOURCE_VALUES_BY_HOME[home_key] = values
for src in report.sources:
if src.applied:
print(
f" {src.label}: applied {len(src.applied)} "
f"secret{'s' if len(src.applied) != 1 else ''}",
file=sys.stderr,
)
if src.result.error:
print(f" {src.label}: {src.result.error}", file=sys.stderr)
hint = _remediation_hint(
src.name, src.result.error_kind, cfg, scope=home_key
)
if hint:
print(f" {src.label}: → {hint}", file=sys.stderr)
for warn in src.result.warnings:
print(f" {src.label}: {warn}", file=sys.stderr)
for conflict in report.conflicts:
print(f" Secret sources: {conflict}", file=sys.stderr)
def _remediation_hint(
source_name: str,
error_kind,
secrets_cfg: dict,
*,
scope: str | None = None,
) -> str:
"""Ask the failed source for its one-line fix-it hint.
Defensive wrapper: remediation() is a pure mapping and shouldn't
raise, but a plugin source could and startup must never break on
a status line.
"""
try:
from agent.secret_sources.registry import get_source
source = get_source(source_name, scope=scope)
if source is None:
return ""
src_cfg = secrets_cfg.get(source_name)
src_cfg = src_cfg if isinstance(src_cfg, dict) else {}
return str(source.remediation(error_kind, src_cfg) or "").strip()
except Exception: # noqa: BLE001 — hints must never block startup
return ""
def _load_secrets_config(home_path: Path) -> dict:
"""Read just the ``secrets:`` section out of config.yaml.
Imported lazily and isolated from the main config loader so a
malformed config can't take down dotenv loading entirely.
"""
config_path = home_path / "config.yaml"
if not config_path.exists():
return {}
# Prefer the shared (mtime, size)-keyed raw-config cache — this is the
# first config.yaml read in a normal `hermes` startup, so populating the
# shared cache here lets main.py's early bridge and hermes_logging reuse
# the same parse (one parse per process instead of 3-4). Falls back to a
# direct isolated parse if the shared reader is unavailable, preserving
# the "malformed config can't take down dotenv loading" property (the
# shared reader also swallows parse errors and returns {}).
if home_path == _process_hermes_home():
try:
from hermes_cli.config import read_raw_config
data = read_raw_config() or {}
return data.get("secrets") or {}
except Exception:
pass
try:
import yaml # type: ignore
except ImportError:
return {}
try:
with open(config_path, "r", encoding="utf-8") as f:
data = fast_safe_load(f) or {}
except Exception: # noqa: BLE001
return {}
return data.get("secrets") or {}
def _process_hermes_home() -> Path:
"""The HERMES_HOME the shared config cache is keyed to."""
try:
from hermes_constants import get_hermes_home
return get_hermes_home()
except Exception:
return Path.home() / ".hermes"
+377
View File
@@ -0,0 +1,377 @@
"""
hermes fallback manage the fallback provider chain.
Fallback providers are tried in order when the primary model fails with
rate-limit, overload, or connection errors. See:
https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers
Subcommands:
hermes fallback [list] Show the current fallback chain (default when no subcommand)
hermes fallback add Pick provider + model via the same picker as `hermes model`,
then append the selection to the chain
hermes fallback remove Pick an entry to delete from the chain
hermes fallback clear Remove all fallback entries
Storage: ``fallback_providers`` in ``~/.hermes/config.yaml`` (top-level, list of
``{provider, model, base_url?, api_mode?}`` dicts). The legacy single-dict
``fallback_model`` format is migrated to the new list format on first add.
"""
from __future__ import annotations
import copy
from typing import Any, Dict, List, Optional
from hermes_cli.fallback_config import get_fallback_chain
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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
``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.
"""
return get_fallback_chain(config)
def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None:
"""Persist the chain to ``fallback_providers`` and clear legacy key."""
config["fallback_providers"] = chain
# Drop the legacy single-dict key on write so there's only one source of truth.
if "fallback_model" in config:
config.pop("fallback_model", None)
def _format_entry(entry: Dict[str, Any]) -> str:
"""One-line human-readable rendering of a fallback entry."""
provider = entry.get("provider", "?")
model = entry.get("model", "?")
base = entry.get("base_url")
suffix = f" [{base}]" if base else ""
return f"{model} (via {provider}){suffix}"
def _extract_fallback_from_model_cfg(model_cfg: Any) -> Optional[Dict[str, Any]]:
"""Pull the ``{provider, model, base_url?, api_mode?}`` dict from a ``config["model"]`` snapshot."""
if not isinstance(model_cfg, dict):
return None
provider = (model_cfg.get("provider") or "").strip()
# The picker writes the selected model to ``model.default``.
model = (model_cfg.get("default") or model_cfg.get("model") or "").strip()
if not provider or not model:
return None
entry: Dict[str, Any] = {"provider": provider, "model": model}
base_url = (model_cfg.get("base_url") or "").strip()
if base_url:
entry["base_url"] = base_url
api_mode = (model_cfg.get("api_mode") or "").strip()
if api_mode:
entry["api_mode"] = api_mode
return entry
def _snapshot_auth_active_provider() -> Any:
"""Return the current ``active_provider`` in auth.json, or a sentinel if unavailable."""
try:
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
return store.get("active_provider")
except Exception:
return None
def _restore_auth_active_provider(value: Any) -> None:
"""Write back a previously snapshotted ``active_provider`` value."""
try:
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
with _auth_store_lock():
store = _load_auth_store()
store["active_provider"] = value
_save_auth_store(store)
except Exception:
# Best-effort — if auth.json can't be restored, the user's primary
# provider may have been deactivated by the picker. They can re-run
# `hermes model` to fix it. Don't fail the fallback add.
pass
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def cmd_fallback_list(args) -> None: # noqa: ARG001
"""Print the current fallback chain."""
from hermes_cli.config import load_config
config = load_config()
chain = _read_chain(config)
print()
if not chain:
print(" No fallback providers configured.")
print()
print(" Add one with: hermes fallback add")
print()
return
primary = _describe_primary(config)
if primary:
print(f" Primary: {primary}")
print()
print(f" Fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):")
for i, entry in enumerate(chain, 1):
print(f" {i}. {_format_entry(entry)}")
print()
print(" Tried in order when the primary fails (rate-limit, 5xx, connection errors).")
print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers")
print()
def _describe_primary(config: Dict[str, Any]) -> Optional[str]:
"""One-line description of the primary model for display purposes."""
model_cfg = config.get("model")
if isinstance(model_cfg, dict):
provider = (model_cfg.get("provider") or "?").strip() or "?"
model = (model_cfg.get("default") or model_cfg.get("model") or "?").strip() or "?"
return f"{model} (via {provider})"
if isinstance(model_cfg, str) and model_cfg.strip():
return model_cfg.strip()
return None
def cmd_fallback_add(args) -> None:
"""Launch the same picker as `hermes model`, then append the selection to the chain."""
from hermes_cli.main import _require_tty, select_provider_and_model
from hermes_cli.config import load_config, save_config
_require_tty("fallback add")
# Snapshot BEFORE the picker runs so we can distinguish "user actually
# picked something" from "user cancelled" by comparing before/after.
before_cfg = load_config()
model_before = copy.deepcopy(before_cfg.get("model"))
active_provider_before = _snapshot_auth_active_provider()
print()
print(" Adding a fallback provider. The picker below is the same one used by")
print(" `hermes model` — select the provider + model you want as a fallback.")
print()
try:
select_provider_and_model(args=args)
except SystemExit:
# Some provider flows exit on auth failure — restore state and re-raise.
_restore_model_cfg(model_before)
_restore_auth_active_provider(active_provider_before)
raise
# Read the post-picker state to see what the user selected.
after_cfg = load_config()
model_after = after_cfg.get("model")
new_entry = _extract_fallback_from_model_cfg(model_after)
if not new_entry:
# Picker didn't complete (user cancelled or flow bailed). Nothing to do.
_restore_model_cfg(model_before)
_restore_auth_active_provider(active_provider_before)
print()
print(" No fallback added.")
return
# Picker picked the same thing that's already the primary → nothing changed,
# and there's nothing useful to add as a fallback to itself. Identity
# semantics owned by agent.backend_identity (#54250/#57584/#62984): same
# provider+model on a DIFFERENT explicit base_url is a different backend
# (multi-endpoint pool) and is a legitimate fallback.
from agent.backend_identity import BackendIdentity, same_deployment
new_ident = BackendIdentity.build(
provider=new_entry.get("provider"),
model=new_entry.get("model"),
base_url=new_entry.get("base_url"),
)
primary_entry = _extract_fallback_from_model_cfg(model_before)
if primary_entry and same_deployment(
BackendIdentity.build(
provider=primary_entry.get("provider"),
model=primary_entry.get("model"),
base_url=primary_entry.get("base_url"),
),
new_ident,
):
_restore_model_cfg(model_before)
_restore_auth_active_provider(active_provider_before)
print()
print(f" Selected model matches the current primary ({_format_entry(new_entry)}).")
print(" A provider cannot be a fallback for itself — no change.")
return
# Reload the config with the primary restored, then append the new entry
# to ``fallback_providers``. We deliberately re-load (rather than mutating
# ``after_cfg``) because the picker may have touched other top-level keys
# (custom_providers, providers credentials) that we want to keep.
_restore_model_cfg(model_before)
_restore_auth_active_provider(active_provider_before)
final_cfg = load_config()
chain = _read_chain(final_cfg)
# Reject exact-duplicate fallback entries (same deployment; a different
# explicit base_url is a different endpoint and NOT a duplicate).
for existing in chain:
if same_deployment(
BackendIdentity.build(
provider=existing.get("provider"),
model=existing.get("model"),
base_url=existing.get("base_url"),
),
new_ident,
):
print()
print(f" {_format_entry(new_entry)} is already in the fallback chain — skipped.")
return
chain.append(new_entry)
_write_chain(final_cfg, chain)
save_config(final_cfg)
print()
print(f" Added fallback: {_format_entry(new_entry)}")
print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.")
print()
print(" Run `hermes fallback list` to view, or `hermes fallback remove` to delete.")
def _restore_model_cfg(model_before: Any) -> None:
"""Restore ``config["model"]`` to a previously-captured snapshot."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
if model_before is None:
cfg.pop("model", None)
else:
cfg["model"] = copy.deepcopy(model_before)
save_config(cfg)
def cmd_fallback_remove(args) -> None: # noqa: ARG001
"""Pick an entry from the chain and remove it."""
from hermes_cli.config import load_config, save_config
config = load_config()
chain = _read_chain(config)
if not chain:
print()
print(" No fallback providers configured — nothing to remove.")
print()
return
choices = [_format_entry(e) for e in chain]
choices.append("Cancel")
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice("Select a fallback to remove:", choices, 0)
except Exception:
idx = _numbered_pick("Select a fallback to remove:", choices)
if idx is None or idx < 0 or idx >= len(chain):
print()
print(" Cancelled — no change.")
return
removed = chain.pop(idx)
_write_chain(config, chain)
save_config(config)
print()
print(f" Removed fallback: {_format_entry(removed)}")
if chain:
print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.")
else:
print(" Fallback chain is now empty.")
print()
def cmd_fallback_clear(args) -> None: # noqa: ARG001
"""Remove all fallback entries (with confirmation)."""
from hermes_cli.config import load_config, save_config
config = load_config()
chain = _read_chain(config)
if not chain:
print()
print(" No fallback providers configured — nothing to clear.")
print()
return
print()
print(f" Current fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):")
for i, entry in enumerate(chain, 1):
print(f" {i}. {_format_entry(entry)}")
print()
try:
resp = input(" Clear all entries? [y/N]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
print(" Cancelled.")
return
if resp not in {"y", "yes"}:
print(" Cancelled — no change.")
return
_write_chain(config, [])
save_config(config)
print()
print(" Fallback chain cleared.")
print()
def _numbered_pick(question: str, choices: List[str]) -> Optional[int]:
"""Fallback numbered-list picker when curses is unavailable."""
print(question)
for i, c in enumerate(choices, 1):
print(f" {i}. {c}")
print()
while True:
try:
val = input(f"Choice [1-{len(choices)}]: ").strip()
if not val:
return None
idx = int(val) - 1
if 0 <= idx < len(choices):
return idx
print(f"Please enter 1-{len(choices)}")
except ValueError:
print("Please enter a number")
except (KeyboardInterrupt, EOFError):
print()
return None
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def cmd_fallback(args) -> None:
"""Top-level dispatcher for ``hermes fallback [subcommand]``."""
sub = getattr(args, "fallback_command", None)
if sub in {None, "", "list", "ls"}:
cmd_fallback_list(args)
elif sub == "add":
cmd_fallback_add(args)
elif sub in {"remove", "rm"}:
cmd_fallback_remove(args)
elif sub == "clear":
cmd_fallback_clear(args)
else:
print(f"Unknown fallback subcommand: {sub}")
print("Use one of: list, add, remove, clear")
raise SystemExit(2)
+101
View File
@@ -0,0 +1,101 @@
"""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 resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None:
"""API key for one fallback entry: inline ``api_key``, else ``key_env``.
Mirrors the custom-provider convention (``key_env`` names the env var
holding the key; ``api_key_env`` accepted as an alias). Returns None when
neither yields a non-empty value, letting ``resolve_runtime_provider``
fall through to the provider's standard credential resolution.
``key_env`` is resolved through ``agent.secret_scope.get_secret`` rather
than a raw ``os.getenv`` in a multiplexed gateway a bare env read would
ignore the active profile's scope and can return another profile's
credential. ``get_secret`` already implements the right fallback: it
reads ``os.environ`` when there's no active multiplexed scope (matching
prior single-profile behavior), and fails closed only when multiplexing
is active with no scope installed.
"""
if not isinstance(entry, dict):
return None
inline = str(entry.get("api_key") or "").strip()
if inline:
return inline
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
from agent.secret_scope import get_secret
return (get_secret(key_env) or "").strip() or None
return None
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
+166
View File
@@ -0,0 +1,166 @@
"""Focus view — a display-only reduced-output mode.
``/focus`` answers one question the existing ``/verbose`` cycle cannot:
*"just show me my prompt and the answer — and tell me what you hid."*
``/verbose off`` already silences per-tool progress lines (the
``tool_progress_mode == "off"`` gate in ``agent/tool_executor.py`` and the
scrollback gate in ``HermesCLI._on_tool_progress``). Focus view **composes
with** that machinery instead of duplicating it:
* turning focus ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the
mode the user had configured, so the *existing* suppression path does the
actual hiding;
* turning focus OFF restores that remembered mode verbatim;
* on top of that, focus view adds the two things ``/verbose off`` lacks
a per-turn count of what was hidden plus a recovery hint, and a persistent
``focus`` segment in the status bar so the reduced mode is never invisible.
Everything in this module is **display-only**. Nothing here reads or mutates
conversation history, the system prompt, tool schemas, or any request payload.
Flipping focus view must never change a single byte of what is sent to the
model that invariant is covered by
``tests/cli/test_focus_view.py::test_model_facing_messages_identical_with_focus_on_vs_off``.
"""
from __future__ import annotations
from typing import Optional
# Config key used by the sibling display toggles (/battery, /timestamps,
# /footer) — a plain boolean under ``display``.
FOCUS_CONFIG_KEY = "display.focus_view"
#: Tool-progress mode focus view snaps to. Deliberately the SAME value
#: ``/verbose off`` uses so both features share one suppression path.
FOCUS_TOOL_PROGRESS_MODE = "off"
#: Modes in which the CLI commits a per-tool scrollback line. Mirrors the gate
#: in ``HermesCLI._on_tool_progress``; kept here so the hidden-line counter and
#: the renderer can never drift apart.
TOOL_PROGRESS_VISIBLE_MODES = frozenset({"new", "all", "verbose"})
#: Valid tool-progress modes (``log`` is a gateway-only extra step).
TOOL_PROGRESS_MODES = ("off", "new", "all", "verbose")
#: Status-bar label. Short on purpose — the bar is width-constrained.
FOCUS_STATUSBAR_LABEL = "◉ focus"
_ON_WORDS = frozenset({"on", "enable", "enabled", "true", "yes", "1"})
_OFF_WORDS = frozenset({"off", "disable", "disabled", "false", "no", "0"})
_STATUS_WORDS = frozenset({"status", "show", "?"})
_TOGGLE_WORDS = frozenset({"", "toggle"})
FOCUS_USAGE = "Usage: /focus [on|off|status]"
def normalize_tool_progress_mode(mode: object, default: str = "all") -> str:
"""Coerce a raw config/attr value into a known tool-progress mode.
YAML 1.1 parses a bare ``off`` as ``False``, and older configs stored
``True``/``False`` booleans, so this mirrors ``cli.py``'s normalisation.
"""
if mode is False:
return "off"
if mode is True:
return "all"
text = str(mode or "").strip().lower()
if text in TOOL_PROGRESS_MODES:
return text
# ``log`` is a real gateway mode; treat any other unknown value as default.
if text == "log":
return "log"
return default
def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]:
"""Map a ``/focus`` argument onto an action, following the sibling toggles.
Returns ``(action, target)`` where ``action`` is one of ``"set"``,
``"status"`` or ``"usage"``. ``target`` is the requested enabled-state for
``"set"`` and ``None`` otherwise. Bare ``/focus`` toggles, matching
``/footer`` / ``/battery`` / ``/timestamps``.
"""
text = str(arg or "").strip().lower()
if text in _STATUS_WORDS:
return "status", None
if text in _ON_WORDS:
return "set", True
if text in _OFF_WORDS:
return "set", False
if text in _TOGGLE_WORDS:
return "set", not bool(current)
return "usage", None
def effective_tool_progress_mode(focus_enabled: bool, configured_mode: object) -> str:
"""Return the tool-progress mode that should actually be in force.
Focus view wins while it is on (it *is* "tool progress off" plus reporting).
When focus is off the user's configured mode is returned untouched — this is
what makes ``/focus off`` restore ``/verbose verbose`` rather than clobbering
it to ``all``.
"""
normalized = normalize_tool_progress_mode(configured_mode)
if focus_enabled:
return FOCUS_TOOL_PROGRESS_MODE
return normalized
def would_display_tool_line(
mode: object,
function_name: str,
last_tool_name: Optional[str] = None,
) -> bool:
"""Would the CLI have committed a scrollback line for this tool call?
Used to count *honestly*: if the user already had ``/verbose off``, focus
view is hiding nothing extra and must not claim otherwise. ``new`` mode
skips consecutive repeats of the same tool, so the counter skips them too.
"""
if not function_name:
return False
normalized = normalize_tool_progress_mode(mode)
if normalized not in TOOL_PROGRESS_VISIBLE_MODES:
return False
if normalized == "new" and function_name == last_tool_name:
return False
return True
def format_hidden_line(count: int) -> Optional[str]:
"""Dim post-turn recovery line, or ``None`` when nothing was hidden."""
try:
n = int(count)
except (TypeError, ValueError):
return None
if n <= 0:
return None
noun = "tool line" if n == 1 else "tool lines"
return f"{n} {noun} hidden · /focus off to show"
def focus_statusbar_segment(enabled: bool) -> str:
"""Status-bar segment text for focus view (empty when off)."""
return FOCUS_STATUSBAR_LABEL if enabled else ""
def format_focus_status(enabled: bool, configured_mode: object) -> str:
"""Human-readable ``/focus status`` body (no ANSI — callers colour it)."""
state = "ON" if enabled else "OFF"
if enabled:
restore = normalize_tool_progress_mode(configured_mode)
return (
f"Focus view: {state} — only your prompt and the final response.\n"
f" /focus off restores tool progress: {restore.upper()}"
)
mode = normalize_tool_progress_mode(configured_mode)
return f"Focus view: {state} — tool progress: {mode.upper()}"
def format_focus_toggle_message(enabled: bool, configured_mode: object) -> str:
"""Confirmation line printed when focus view is switched (no ANSI)."""
if enabled:
return "Focus view enabled — just your prompt and the final response"
mode = normalize_tool_progress_mode(configured_mode)
return f"Focus view disabled — tool progress: {mode.upper()}"
+489
View File
@@ -0,0 +1,489 @@
"""Import sessions from foreign coding agents (Claude Code, Codex CLI).
``hermes sessions import`` (and ``--resume @claude`` / ``--resume @codex``)
let a user pull a conversation they started in another agent CLI into
Hermes and continue it here.
Sources (read-only foreign files are never modified):
* **Claude Code** stores one JSONL file per session under
``~/.claude/projects/<encoded-cwd>/<uuid>.jsonl``. Each line is a JSON
object; ``type: "user"`` / ``type: "assistant"`` lines carry an
Anthropic-format ``message`` payload whose ``content`` is either a string
or a list of blocks (``text``, ``tool_use``, ``tool_result``, ...).
``type: "summary"`` lines carry a human title for the thread.
* **Codex CLI** stores rollout JSONL under
``~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl``. The first line is a
``session_meta`` record (cwd, session id); conversation turns are
``response_item`` records whose payload is ``{"type": "message",
"role": user|assistant|developer, "content": [{"type": "input_text"|
"output_text", "text": ...}]}`` plus ``custom_tool_call`` /
``function_call`` payloads for tool activity. (Schema verified against
real rollout files, Codex CLI 0.147.)
Conversion contract imported history must satisfy the provider
role-alternation invariant Hermes enforces everywhere else:
* only plain ``user`` / ``assistant`` text messages are produced (tool
calls become short bracketed summaries inside the assistant text; we
never fabricate ``tool_calls`` structures);
* consecutive same-role turns are merged rather than stubbed;
* system/developer payloads are never imported.
"""
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# User-message texts that are really injected context wrappers, not typed
# input. Matched against the stripped start of the text.
_WRAPPER_TAG_RE = re.compile(
r"^<(?:user_instructions|environment_context|recommended_plugins|"
r"skills_instructions|permissions[_-]instructions|turn_context|"
r"command-name|command-message|local-command-stdout|system-reminder)\b",
re.IGNORECASE,
)
_TITLE_MAX = 60
@dataclass
class ForeignSession:
"""A discoverable session in another tool's on-disk store."""
source: str # "claude" | "codex"
path: Path
mtime: float
cwd: Optional[str] = None
title_guess: Optional[str] = None
turn_count: int = 0
session_id: Optional[str] = None # the foreign tool's own id
@property
def label(self) -> str:
name = {"claude": "Claude Code", "codex": "Codex CLI"}.get(
self.source, self.source
)
title = (self.title_guess or "").strip() or self.path.stem
return f"[{name}] {title[:_TITLE_MAX]}"
def _read_json_lines(path: Path):
"""Yield parsed JSON objects, silently skipping unparseable lines."""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(obj, dict):
yield obj
except OSError:
return
def _flatten_blocks(content: Any, *, source: str) -> str:
"""Flatten a message ``content`` (string or block list) to plain text.
Tool activity becomes a short bracketed summary; unknown block types
are skipped rather than guessed at.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: List[str] = []
for block in content:
if not isinstance(block, dict):
if isinstance(block, str):
parts.append(block)
continue
btype = block.get("type")
if btype in ("text", "input_text", "output_text"):
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
elif btype == "tool_use": # Claude Code assistant block
name = block.get("name") or "tool"
parts.append(f"[ran tool: {name}]")
elif btype == "tool_result":
# Tool output echoed into a user message — not typed input.
continue
elif btype in ("thinking", "redacted_thinking", "reasoning"):
continue
elif btype == "image":
parts.append("[image]")
return "\n\n".join(p for p in (s.strip() for s in parts) if p)
def _is_wrapper_text(text: str) -> bool:
return bool(_WRAPPER_TAG_RE.match(text.lstrip()))
def _merge_turns(raw_turns: List[Tuple[str, str]]) -> List[Dict[str, str]]:
"""Merge consecutive same-role turns; guarantee strict alternation.
A leading assistant turn (session began before the log window) gets a
minimal user stub so the first message is always ``user``; this is the
only place a stub is ever inserted.
"""
merged: List[Dict[str, str]] = []
for role, text in raw_turns:
text = text.strip()
if not text:
continue
if merged and merged[-1]["role"] == role:
merged[-1]["content"] += "\n\n" + text
else:
merged.append({"role": role, "content": text})
if merged and merged[0]["role"] == "assistant":
merged.insert(
0,
{
"role": "user",
"content": "(imported conversation begins with an assistant reply)",
},
)
return merged
# ── Claude Code ──────────────────────────────────────────────────────────
def parse_claude_session(path: Path) -> Dict[str, Any]:
"""Parse one Claude Code session JSONL into normalized turns + meta."""
turns: List[Tuple[str, str]] = []
cwd: Optional[str] = None
summary: Optional[str] = None
session_id: Optional[str] = None
for obj in _read_json_lines(path):
otype = obj.get("type")
if otype == "summary":
s = obj.get("summary")
if isinstance(s, str) and s.strip():
summary = s.strip()
continue
if otype not in ("user", "assistant"):
continue
if obj.get("isSidechain") or obj.get("isMeta"):
continue
if cwd is None and isinstance(obj.get("cwd"), str):
cwd = obj["cwd"]
if session_id is None and isinstance(obj.get("sessionId"), str):
session_id = obj["sessionId"]
message = obj.get("message")
if not isinstance(message, dict):
continue
role = message.get("role")
if role not in ("user", "assistant"):
continue
text = _flatten_blocks(message.get("content"), source="claude")
if not text or (role == "user" and _is_wrapper_text(text)):
continue
turns.append((role, text))
return {
"turns": _merge_turns(turns),
"cwd": cwd,
"title_guess": summary or _first_user_line(turns),
"session_id": session_id,
}
def list_claude_sessions(root: Optional[Path] = None) -> List[ForeignSession]:
"""Discover Claude Code sessions under ``~/.claude/projects``."""
root = Path(root) if root else Path.home() / ".claude" / "projects"
results: List[ForeignSession] = []
if not root.is_dir():
return results
for jsonl in sorted(root.glob("*/*.jsonl")):
try:
mtime = jsonl.stat().st_mtime
except OSError:
continue
parsed = parse_claude_session(jsonl)
if not parsed["turns"]:
continue
results.append(
ForeignSession(
source="claude",
path=jsonl,
mtime=mtime,
cwd=parsed["cwd"],
title_guess=parsed["title_guess"],
turn_count=len(parsed["turns"]),
session_id=parsed["session_id"],
)
)
results.sort(key=lambda s: s.mtime, reverse=True)
return results
# ── Codex CLI ────────────────────────────────────────────────────────────
def parse_codex_session(path: Path) -> Dict[str, Any]:
"""Parse one Codex CLI rollout JSONL into normalized turns + meta."""
turns: List[Tuple[str, str]] = []
cwd: Optional[str] = None
session_id: Optional[str] = None
for obj in _read_json_lines(path):
otype = obj.get("type")
payload = obj.get("payload")
if not isinstance(payload, dict):
continue
if otype == "session_meta":
if isinstance(payload.get("cwd"), str):
cwd = payload["cwd"]
sid = payload.get("session_id") or payload.get("id")
if isinstance(sid, str):
session_id = sid
continue
if otype != "response_item":
continue
ptype = payload.get("type")
if ptype == "message":
role = payload.get("role")
if role not in ("user", "assistant"):
continue # developer/system payloads never imported
text = _flatten_blocks(payload.get("content"), source="codex")
if not text or (role == "user" and _is_wrapper_text(text)):
continue
turns.append((role, text))
elif ptype in ("custom_tool_call", "function_call", "local_shell_call"):
name = payload.get("name") or payload.get("tool") or "tool"
# Attach as assistant activity; merged into neighbors later.
turns.append(("assistant", f"[ran tool: {name}]"))
# tool outputs / reasoning / web_search etc. are skipped
return {
"turns": _merge_turns(turns),
"cwd": cwd,
"title_guess": _first_user_line(turns),
"session_id": session_id,
}
def list_codex_sessions(root: Optional[Path] = None) -> List[ForeignSession]:
"""Discover Codex CLI rollouts under ``~/.codex/sessions``."""
root = Path(root) if root else Path.home() / ".codex" / "sessions"
results: List[ForeignSession] = []
if not root.is_dir():
return results
for jsonl in sorted(root.rglob("rollout-*.jsonl")):
try:
mtime = jsonl.stat().st_mtime
except OSError:
continue
parsed = parse_codex_session(jsonl)
if not parsed["turns"]:
continue
results.append(
ForeignSession(
source="codex",
path=jsonl,
mtime=mtime,
cwd=parsed["cwd"],
title_guess=parsed["title_guess"],
turn_count=len(parsed["turns"]),
session_id=parsed["session_id"],
)
)
results.sort(key=lambda s: s.mtime, reverse=True)
return results
def _first_user_line(turns: List[Tuple[str, str]]) -> Optional[str]:
for role, text in turns:
if role == "user":
line = text.strip().splitlines()[0].strip()
if line:
return line[:_TITLE_MAX * 2]
return None
# ── Import ───────────────────────────────────────────────────────────────
_SOURCE_LABELS = {"claude": "Claude Code", "codex": "Codex CLI"}
_SOURCE_DB_NAMES = {"claude": "claude-code", "codex": "codex-cli"}
def import_foreign_session(source: str, path, db=None) -> str:
"""Import one foreign session into the Hermes SessionDB.
Returns the new Hermes session id. The foreign file is only read.
Raises ``ValueError`` on unknown source or a session with no usable
conversation turns.
"""
source = (source or "").strip().lower().lstrip("@")
if source not in _SOURCE_LABELS:
raise ValueError(f"Unknown foreign session source: {source!r}")
path = Path(path).expanduser()
if not path.is_file():
raise ValueError(f"Session file not found: {path}")
parsed = (
parse_claude_session(path)
if source == "claude"
else parse_codex_session(path)
)
turns = parsed["turns"]
if not turns:
raise ValueError(
f"No user/assistant conversation turns found in {path}"
)
label = _SOURCE_LABELS[source]
first_user = _first_user_line(
[(t["role"], t["content"]) for t in turns]
) or path.stem
if len(first_user) > _TITLE_MAX:
first_user = first_user[: _TITLE_MAX - 1] + ""
title = f"Imported from {label}: {first_user}"
owns_db = db is None
if owns_db:
from hermes_state import SessionDB
db = SessionDB()
try:
session_id = (
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
)
origin = {
"imported_from": {
"tool": _SOURCE_DB_NAMES[source],
"path": str(path),
"foreign_session_id": parsed.get("session_id"),
}
}
db.create_session(
session_id,
source=_SOURCE_DB_NAMES[source],
cwd=parsed.get("cwd"),
origin_json=json.dumps(origin),
)
for turn in turns:
db.append_message(session_id, turn["role"], turn["content"])
try:
db.set_session_title(session_id, title)
except Exception:
pass # title is cosmetic; the import itself succeeded
return session_id
finally:
if owns_db:
try:
db.close()
except Exception:
pass
# ── Picker / CLI helpers ─────────────────────────────────────────────────
def gather_foreign_sessions(
source: Optional[str] = None,
*,
claude_root: Optional[Path] = None,
codex_root: Optional[Path] = None,
limit: int = 25,
) -> List[ForeignSession]:
"""List foreign sessions across sources, newest first."""
sessions: List[ForeignSession] = []
if source in (None, "claude"):
sessions.extend(list_claude_sessions(claude_root))
if source in (None, "codex"):
sessions.extend(list_codex_sessions(codex_root))
sessions.sort(key=lambda s: s.mtime, reverse=True)
return sessions[:limit] if limit else sessions
def pick_foreign_session(
source: Optional[str] = None, *, limit: int = 25
) -> Optional[ForeignSession]:
"""Interactive numbered picker. Returns None when nothing was chosen."""
import os
import sys
sessions = gather_foreign_sessions(source, limit=limit)
if not sessions:
where = _SOURCE_LABELS.get(source or "", "Claude Code or Codex CLI")
print(f"No {where} sessions found on this machine.")
return None
print("Foreign sessions (newest first):")
for i, s in enumerate(sessions, 1):
when = datetime.fromtimestamp(s.mtime).strftime("%Y-%m-%d %H:%M")
ws = ""
if s.cwd:
ws = f" ({os.path.basename(s.cwd.rstrip('/')) or s.cwd})"
print(f" {i:>2}. {when} {s.label}{ws} [{s.turn_count} turns]")
if not sys.stdin.isatty():
print(
"Non-interactive terminal — pass the file path directly:\n"
" hermes sessions import --from claude|codex <path>"
)
return None
try:
raw = input(f"Import which session? [1-{len(sessions)}, empty to cancel] ")
except (EOFError, KeyboardInterrupt):
return None
raw = raw.strip()
if not raw:
return None
try:
idx = int(raw)
except ValueError:
print(f"Not a number: {raw}")
return None
if not 1 <= idx <= len(sessions):
print(f"Out of range: {idx}")
return None
return sessions[idx - 1]
def run_sessions_import(args, db=None) -> Optional[str]:
"""`hermes sessions import` entry point. Returns new session id or None."""
source = getattr(args, "from_source", None)
path = getattr(args, "path", None)
if path:
# Report a missing file distinctly instead of the misleading
# "cannot infer source" (SES-10).
if not Path(path).exists():
print(f"Error: file not found: {path}")
return None
if not source:
# Guess from the path shape.
p = str(path)
if "/.claude/" in p or p.endswith(".jsonl") and "claude" in p:
source = "claude"
if "/.codex/" in p or Path(p).name.startswith("rollout-"):
source = "codex"
if not source:
print("Cannot infer source from path; pass --from claude|codex.")
return None
chosen_path = Path(path)
else:
picked = pick_foreign_session(source)
if picked is None:
return None
source, chosen_path = picked.source, picked.path
try:
session_id = import_foreign_session(source, chosen_path, db=db)
except ValueError as e:
print(f"Error: {e}")
return None
label = _SOURCE_LABELS.get(source, source)
print(f"✓ Imported {label} session as {session_id}")
print(f" Continue it with: hermes --resume {session_id}")
return session_id
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
The connectorgateway channel is authenticated (the gateway may be
customer-managed and internet-exposed). This command is the gateway half of the
zero-touch enrollment in the connector repo's
``docs/connector-gateway-auth-design.md``:
1. Resolve a fresh Nous Portal access token from the existing login
(``~/.hermes/auth.json``) the same path ``hermes dashboard register``
uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)*
the caller owns; the connector derives the authoritative tenant from it via
``GET /api/oauth/account`` (never from anything the gateway asserts).
2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll``
with that token in the ``Authorization`` header, over TLS.
3. The connector verifies the enrollment token (signature + single-use +
tenant match), mints a per-gateway secret, get-or-creates the per-tenant
delivery key, and returns both ONCE.
4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into
``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade;
the per-tenant delivery key verifies signed inbound deliveries.
Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the
secret directly and stamps it into the container env, so this command refuses to
run under ``is_managed()`` (mirrors ``dashboard register``).
EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until
2 Class-1 platforms validate the contract.
"""
from __future__ import annotations
import json
import os
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
def _default_gateway_id() -> str:
"""A stable-ish default gateway instance id: ``<hostname>-<pid-free slug>``.
The gatewayId identifies this enrolled instance for kill-switch granularity
(the connector indexes its secret verify list by it). Default to the host
name so a human can recognize it; overridable via ``--gateway-id``.
"""
host = ""
try:
host = socket.gethostname().strip()
except Exception:
host = ""
return f"gw-{host or 'hermes'}"
def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
"""Resolve the connector base URL (no trailing slash) for enrollment.
Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env >
``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial
target; enrollment is an ``http(s)://`` POST to the same host, so we map the
scheme. Returns None when nothing is configured (the user must supply one).
"""
raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip()
if not raw:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
raw = str(cfg.get("relay_url", "") or "").strip()
except Exception:
raw = ""
if not raw:
return None
raw = raw.rstrip("/")
# The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
# Strip a trailing /relay path segment if the user pasted the dial URL.
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return raw
def _resolve_identity_token() -> str:
"""Resolve the caller-identity bearer token (generic-OIDC or Nous Portal).
Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and
the runtime self-provision path share ONE implementation (generic OAuth2
client-credentials when ``gateway.idp.token_url`` is set the air-gapped /
self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure.
"""
from gateway.relay import _resolve_relay_identity_token
return _resolve_relay_identity_token()
def _post_enroll(
*,
connector_base_url: str,
access_token: str,
enrollment_token: str,
gateway_id: str,
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/enroll`` and return the JSON body.
Raises RuntimeError with a user-facing message on any non-2xx / transport
failure. The connector returns ``{secret, deliveryKey, tenant, gatewayId}``
on success, ``{error}`` at 400/401/403.
"""
url = f"{connector_base_url.rstrip('/')}/relay/enroll"
data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).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:
detail = ""
try:
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
except Exception:
pass
if exc.code == 401:
raise RuntimeError(
"Connector rejected the caller identity (401). Your Nous Portal "
"token could not be verified — try `hermes auth add nous` and retry."
) from exc
if exc.code == 403:
raise RuntimeError(
detail
or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
) from exc
raise RuntimeError(
f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Could not reach the connector at {connector_base_url}: {exc.reason}"
) from exc
if not isinstance(payload, dict) or not payload.get("secret"):
raise RuntimeError("Connector returned an unexpected response (no secret).")
return payload
def cmd_gateway_enroll(args) -> None:
"""Enroll this gateway with a relay connector; persist the auth creds to .env."""
from hermes_cli.auth import AuthError
from hermes_cli.config import is_managed, save_env_value
# Managed installs get GATEWAY_RELAY_* stamped in by the orchestrator (NAS
# mints the secret directly per the design's managed shape). Self-enrolling
# from inside such a container is a mistake — and save_env_value refuses to
# write anyway.
if is_managed():
print(
"✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
" The relay gateway secret is provisioned by the hosting platform."
)
sys.exit(1)
enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
if not enrollment_token:
print(
"✗ No enrollment token. Pass --token <token> (or set "
"GATEWAY_RELAY_ENROLL_TOKEN).\n"
" The connector mints this single-use token when your tenant's route "
"is provisioned; it is delivered with your gateway config."
)
sys.exit(1)
connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
if not connector_base_url:
print(
"✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
"/ gateway.relay_url in config.yaml)."
)
sys.exit(1)
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
# 1. Resolve the caller-identity token (the tenant-proving identity). Generic
# OIDC client-credentials when an IdP token endpoint is configured (air-
# gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token.
try:
access_token = _resolve_identity_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 add 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 caller-identity token: {exc}")
sys.exit(1)
# 2-3. Redeem the enrollment token at the connector.
try:
result = _post_enroll(
connector_base_url=connector_base_url,
access_token=access_token,
enrollment_token=enrollment_token,
gateway_id=gateway_id,
)
except RuntimeError as exc:
print(f"✗ Enrollment failed: {exc}")
sys.exit(1)
secret = str(result.get("secret") or "")
delivery_key = str(result.get("deliveryKey") or "")
tenant = str(result.get("tenant") or "")
resolved_gateway_id = str(result.get("gatewayId") or gateway_id)
# 4. Persist the creds idempotently. The secret + delivery key are sensitive;
# save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs.
to_write = {
"GATEWAY_RELAY_ID": resolved_gateway_id,
"GATEWAY_RELAY_SECRET": secret,
"GATEWAY_RELAY_DELIVERY_KEY": delivery_key,
}
# Persist the connector URL too (as the ws(s):// dial target) when supplied
# explicitly, so the runtime can dial without re-specifying it.
explicit_url = (getattr(args, "connector_url", None) or "").strip()
if explicit_url:
to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/")
# Phase 5 §5.2: persist the wake URL so self_provision_relay forwards it to
# the connector (which pokes it to wake this gateway when buffered work
# arrives while it's idle). Optional — omitted ⇒ the connector can't wake it,
# but the gateway still drains on its next reconnect.
explicit_wake_url = (getattr(args, "wake_url", None) or "").strip()
if explicit_wake_url:
to_write["GATEWAY_RELAY_WAKE_URL"] = explicit_wake_url.rstrip("/")
for key, value in to_write.items():
if not value:
continue
try:
save_env_value(key, value)
except Exception as exc:
print(f"✗ Failed to write {key} to .env: {exc}")
sys.exit(1)
from hermes_cli.config import get_env_path
print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else ""))
print()
print(f" Wrote to {get_env_path()}:")
print(f" GATEWAY_RELAY_ID={resolved_gateway_id}")
print(" GATEWAY_RELAY_SECRET=<hidden>")
print(" GATEWAY_RELAY_DELIVERY_KEY=<hidden>")
if explicit_url:
print(f" GATEWAY_RELAY_URL={explicit_url.rstrip('/')}")
if explicit_wake_url:
print(f" GATEWAY_RELAY_WAKE_URL={explicit_wake_url.rstrip('/')}")
print()
# GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-global deployment
# stamps (agent/secret_scope.py): a multiplexed gateway resolves them from
# the PROCESS environment only, never from a secondary profile's .env
# (which is loaded into an isolated secret scope, not exported). The .env
# write above works for a single-profile gateway and for the profile the
# process is launched under (load_hermes_dotenv exports that .env), so
# warn rather than refuse — but don't let a secondary-profile enroll claim
# a config that will silently never activate. Emitted BEFORE the generic
# restart line so the two don't contradict each other.
warned_secondary = False
if explicit_url or explicit_wake_url:
warned_secondary = _warn_if_secondary_multiplex_profile()
if not warned_secondary:
print(
" The gateway now authenticates its relay WS upgrade with the per-gateway\n"
" secret and verifies signed inbound deliveries with the tenant delivery\n"
" key. Restart the gateway to pick up the new env."
)
def _warn_if_secondary_multiplex_profile() -> bool:
"""Warn when relay routing stamps were written to a secondary profile's
.env that a multiplexed gateway will never read them from. Returns True
when the warning fired (the caller suppresses the generic restart text).
The topology decision is owned by the DEFAULT root, not the active
profile home: ``multiplex_profiles`` normally lives in
``<default_root>/config.yaml`` (or the GATEWAY_MULTIPLEX_PROFILES env
override), and the secondary check is the resolved-path relationship to
``<default_root>/profiles/`` mirroring the multiplexer-conflict guard
in hermes_cli/gateway.py. Best-effort: any failure to determine the
topology stays silent (the credential write itself succeeded).
"""
try:
from hermes_constants import get_default_hermes_root
from hermes_cli.config import get_hermes_home
default_root = Path(get_default_hermes_root()).resolve()
home = Path(get_hermes_home()).resolve()
try:
home.relative_to(default_root / "profiles")
except ValueError:
return False # default profile or custom layout — not a secondary
# Multiplex flag precedence mirrors gateway.config: recognized env
# override wins, else the DEFAULT root's config.yaml (raw read — the
# active profile's load_gateway_config() is the wrong owner AND runs
# the full enablement pass, including the relay-exclusive sweep's own
# log output, which has no place in enroll output).
from gateway.config import _env_multiplex_profiles_override
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is False:
return False
if env_multiplex is not True:
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return False
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path) or {}
if not bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
):
return False
print(
" ⚠ This profile is a SECONDARY profile of a multiplexed gateway.\n"
" GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-level\n"
" deployment settings: the gateway reads them from the process\n"
" environment (or the default profile's .env), not from this\n"
" profile's .env. Set them in the environment the gateway process\n"
" is launched with, or enroll from the default profile. The\n"
" relay credentials written above are valid either way."
)
return True
except Exception:
return False
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
"""Stale git lock-file recovery for update/check paths.
A crashed or killed ``git fetch`` on a shallow clone can leave
``.git/shallow.lock`` behind. Every later fetch then fails with::
fatal: Unable to create '/path/.git/shallow.lock': File exists.
This wedges ``hermes update --check`` (hard failure) and silently degrades the
passive banner check in :mod:`hermes_cli.banner` (the fetch is swallowed, the
stale refs are compared, and the user can be told an update is available when
the checkout already contains the remote tip). Git does not self-heal these
lock files they persist until a human removes them.
This module provides two small, defensive helpers used by the update paths:
* :func:`clear_stale_git_locks` remove abandoned ``.git`` lock files (with
an age + git-process guard so a live fetch is never yanked).
* :func:`clear_stale_tmp_packs` remove aborted-fetch ``tmp_pack_*`` /
``tmp_idx_*`` debris from ``.git/objects/pack``. On flaky lines every
timed-out fetch leaves one behind; unchecked they accumulated to 6 GB /
hundreds of files over 9 days and eventually corrupted the pack directory
outright, permanently wedging the update check (#93732).
* :func:`is_ancestor_of_head` ask whether a remote tip is already contained
in HEAD. Used by the shallow-clone update check to avoid reporting a false
"update available" when local cherry-picks sit on top of the remote tip.
"""
from __future__ import annotations
import logging
import os
import subprocess
import time
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
# Lock files younger than this are presumed live (a fetch is in flight) and
# are never removed. git lock files are created and removed within a single
# fetch (seconds); anything older than 10 minutes is abandoned by any
# reasonable standard.
STALE_LOCK_MIN_AGE_SECONDS = 10 * 60
# Lock files we know how to self-heal. ``shallow.lock`` is the one observed in
# the wild (interrupted fetch on a shallow clone); the others are the same
# class of failure (interrupted git operation) and harmless to clear when
# stale. Index/HEAD locks from a live git process are protected by the
# process guard in :func:`clear_stale_git_locks`.
LOCK_NAMES = ("shallow.lock", "index.lock", "HEAD.lock", "MERGE_HEAD.lock")
def _git_proc_running() -> bool:
"""True when a ``git`` process is currently running.
The conservative answer on any platform we can't probe: if we can't tell,
treat a lock as possibly-live and don't remove it. This is the safety
check that stops us from yanking a lock a real fetch is holding.
"""
try:
if os.name == "nt":
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq git.exe", "/FO", "CSV"],
capture_output=True, text=True, timeout=10,
).stdout.lower()
return "git.exe" in out
out = subprocess.run(
["pgrep", "-x", "git"], capture_output=True, text=True, timeout=10,
)
return out.returncode == 0
except Exception:
logger.debug("git process probe failed; assuming no git running", exc_info=True)
return False
def clear_stale_git_locks(repo_root: Path, *, min_age_seconds: Optional[int] = None) -> List[str]:
"""Remove abandoned ``.git`` lock files under ``repo_root``.
A lock is removed only when BOTH conditions hold:
* it is older than :data:`STALE_LOCK_MIN_AGE_SECONDS` (default), and
* no ``git`` process is currently running.
Returns the list of removed lock file paths. Never raises: a lock we
cannot stat or unlink is skipped (a concurrently-held lock may have just
been created between our age check and the unlink the process guard
makes that window vanishingly small, and skipping is always safe).
"""
git_dir = Path(repo_root) / ".git"
if not git_dir.is_dir():
return []
if _git_proc_running():
logger.debug("git process running; skipping stale-lock sweep")
return []
cutoff = time.time() - (min_age_seconds if min_age_seconds is not None else STALE_LOCK_MIN_AGE_SECONDS)
removed: List[str] = []
for name in LOCK_NAMES:
lock_path = git_dir / name
try:
if lock_path.is_file() and lock_path.stat().st_mtime < cutoff:
lock_path.unlink()
removed.append(str(lock_path))
logger.info("Removed stale git lock %s", lock_path)
except OSError:
logger.debug("Could not clear %s (skipping)", lock_path, exc_info=True)
return removed
# Aborted-fetch pack debris younger than this is presumed live (a fetch may
# be writing it right now) and is never removed. A healthy fetch completes in
# minutes; the same 10-minute bar the lock sweep uses is comfortably safe.
STALE_TMP_PACK_MIN_AGE_SECONDS = STALE_LOCK_MIN_AGE_SECONDS
# Temp-file prefixes git writes into .git/objects/pack during a transfer and
# renames away on success. Anything left with these names after a fetch died
# is garbage by definition — git itself never reuses or cleans them.
_TMP_PACK_PREFIXES = ("tmp_pack_", "tmp_idx_", "tmp_rev_", "tmp_mtimes_")
def clear_stale_tmp_packs(
repo_root: Path, *, min_age_seconds: Optional[int] = None
) -> List[str]:
"""Remove aborted-fetch temp pack files under ``.git/objects/pack``.
Every ``git fetch`` that dies mid-transfer (timeout, HTTP 429, dropped
connection) leaves a ``tmp_pack_*`` (and sometimes ``tmp_idx_*``) file
behind, and git never cleans them up. On a flaky line the banner's
background update check produces several per day; observed in the wild
at hundreds of files / 6 GB after 9 days, after which the pack directory
corrupted outright and every fetch failed permanently (#93732).
Same safety contract as :func:`clear_stale_git_locks`: only files older
than the age floor, never while a git process is running, never raises.
Returns the removed paths.
"""
pack_dir = Path(repo_root) / ".git" / "objects" / "pack"
if not pack_dir.is_dir():
return []
if _git_proc_running():
logger.debug("git process running; skipping tmp-pack sweep")
return []
cutoff = time.time() - (
min_age_seconds if min_age_seconds is not None else STALE_TMP_PACK_MIN_AGE_SECONDS
)
removed: List[str] = []
try:
entries = list(pack_dir.iterdir())
except OSError:
return []
for entry in entries:
name = entry.name
if not name.startswith(_TMP_PACK_PREFIXES):
continue
try:
if entry.is_file() and entry.stat().st_mtime < cutoff:
size = entry.stat().st_size
entry.unlink()
removed.append(str(entry))
logger.info(
"Removed aborted-fetch pack debris %s (%d bytes)", entry, size
)
except OSError:
logger.debug("Could not clear %s (skipping)", entry, exc_info=True)
return removed
def is_ancestor_of_head(repo_root: Path, rev: str) -> bool:
"""True when ``rev`` is an ancestor of (or equal to) HEAD.
Wraps ``git merge-base --is-ancestor <rev> HEAD``. This is the correct
question for update checks: a local cherry-pick on top of the remote tip
makes HEAD *different* from ``origin/main`` but still *contains* it, so
the answer to "is there an update?" is no.
Returns False on any probe failure (missing rev, shallow boundary, git
error) callers treat that as "can't prove contained", which is the
conservative direction for an update check.
"""
try:
result = subprocess.run(
["git", "merge-base", "--is-ancestor", rev, "HEAD"],
cwd=str(repo_root),
capture_output=True, text=True, timeout=10,
)
return result.returncode == 0
except Exception:
logger.debug("merge-base --is-ancestor probe failed for %s", rev, exc_info=True)
return False
+2379
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
"""
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``.
from hermes_cli.linux_desktop_entry import desktop_entry_path
data = os.environ.get("XDG_DATA_HOME")
data_base = Path(data) if data else (home / ".local" / "share")
paths += [
# The launcher entry `hermes desktop` installs. Its icon is
# also copied into the hicolor tree (see
# linux_desktop_entry._install_icon_to_hicolor) — remove
# every size dir the installer could have written.
desktop_entry_path(),
# Some packaged builds emit this casing.
data_base / "applications" / "Hermes.desktop",
data_base / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png",
]
# Fixed-size hicolor dirs the installer may have written (resized
# panel sizes plus leftover native-size copies from older builds).
for size in ("24x24", "32x32", "48x48", "256x256", "512x512", "1024x1024"):
paths.append(data_base / "icons" / "hicolor" / size / "apps" / "hermes.png")
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"):
# The desktop entry was removed above (it is in
# ``packaged_gui_app_paths``), but the menu caches still list it.
# Reindex so Hermes disappears from the launcher.
try:
from hermes_cli.linux_desktop_entry import (
desktop_entry_path,
refresh_desktop_databases,
)
entry = desktop_entry_path()
if entry in removed:
for tool in refresh_desktop_databases(entry.parent):
log_success(f"Refreshed the application menu cache ({tool})")
except Exception as e:
log_warn(f"Could not refresh the application menu cache: {e}")
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
+335
View File
@@ -0,0 +1,335 @@
"""Session heartbeats — recurring re-entry prompts for the current session.
A heartbeat is one user-owned recurring instruction bound to a session
(`/heartbeat every 10m Check the deployment and report meaningful changes`).
When due AND the session is idle, the prompt is injected as a normal user
turn same mechanism as a /goal continuation, so message-role alternation
and prompt caching are untouched. If the agent is busy at the due moment,
the tick coalesces: it fires once when the session next goes idle, never
stacking a backlog.
This is deliberately session-scoped and in-process (CLI process or gateway
process must be running) the durable cross-process scheduling surface
remains ``hermes cron`` / the ``cronjob`` tool, which runs in isolated
sessions. A heartbeat is for "keep re-entering THIS conversation", the
cron system is for "run this job on a schedule". Distinct by design.
State is persisted in SessionDB ``state_meta`` keyed by
``heartbeat:<session_id>`` so ``/resume`` picks it up.
Invariants (mirrors goals.py):
- Injection is a plain user message. No system-prompt mutation, no toolset
swap prompt caching stays intact.
- A real user message always wins: heartbeats only fire into an idle
session with an empty input queue.
- Failures are contained: any DB/import error degrades to "no heartbeat",
never to a crashed input loop.
"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass, asdict
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Floor: a heartbeat that re-enters the session more often than once a
# minute is a busy-loop, not a heartbeat. (Prime-Agent uses a similar floor.)
MIN_INTERVAL_SECONDS = 60
# How often drivers poll for due heartbeats. Not user-facing.
POLL_SECONDS = 5.0
HEARTBEAT_PROMPT_TEMPLATE = (
"[Heartbeat — recurring instruction, fires every {interval}]\n"
"{prompt}\n\n"
"If there is nothing meaningful to do or report for this instruction "
"right now, reply briefly that nothing has changed and stop — do not "
"invent work."
)
_INTERVAL_RE = re.compile(
r"^\s*(?:every\s+)?(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)\s*$",
re.IGNORECASE,
)
_UNIT_SECONDS = {
"s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1,
"m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60,
"h": 3600, "hr": 3600, "hrs": 3600, "hour": 3600, "hours": 3600,
"d": 86400, "day": 86400, "days": 86400,
}
def parse_interval(text: str) -> Optional[int]:
"""Parse ``10m`` / ``every 2h`` / ``every 90 minutes`` into seconds.
Returns None when the text is not an interval. Values below
``MIN_INTERVAL_SECONDS`` are rejected (returns -1 so callers can
distinguish "not an interval" from "too small").
"""
if not text:
return None
m = _INTERVAL_RE.match(text)
if not m:
return None
value = float(m.group(1))
unit = m.group(2).lower()
seconds = int(value * _UNIT_SECONDS[unit])
if seconds < MIN_INTERVAL_SECONDS:
return -1
return seconds
def format_interval(seconds: int) -> str:
"""Human-readable interval (``600`` → ``10m``)."""
seconds = int(seconds)
if seconds % 86400 == 0:
return f"{seconds // 86400}d"
if seconds % 3600 == 0:
return f"{seconds // 3600}h"
if seconds % 60 == 0:
return f"{seconds // 60}m"
return f"{seconds}s"
@dataclass
class HeartbeatState:
"""Serializable per-session heartbeat."""
prompt: str
interval_seconds: int
status: str = "active" # active | paused | cleared
created_at: float = 0.0
last_fired_at: float = 0.0
fire_count: int = 0
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False)
@classmethod
def from_json(cls, raw: str) -> "HeartbeatState":
data = json.loads(raw)
return cls(
prompt=str(data.get("prompt") or ""),
interval_seconds=int(data.get("interval_seconds", 0) or 0),
status=str(data.get("status") or "active"),
created_at=float(data.get("created_at", 0.0) or 0.0),
last_fired_at=float(data.get("last_fired_at", 0.0) or 0.0),
fire_count=int(data.get("fire_count", 0) or 0),
)
def is_due(self, now: Optional[float] = None) -> bool:
if self.status != "active" or not self.prompt or self.interval_seconds <= 0:
return False
now = now if now is not None else time.time()
anchor = self.last_fired_at or self.created_at
return (now - anchor) >= self.interval_seconds
def render_prompt(self) -> str:
return HEARTBEAT_PROMPT_TEMPLATE.format(
interval=format_interval(self.interval_seconds),
prompt=self.prompt,
)
# ──────────────────────────────────────────────────────────────────────
# Persistence (SessionDB state_meta) — same pattern as goals.py
# ──────────────────────────────────────────────────────────────────────
def _meta_key(session_id: str) -> str:
return f"heartbeat:{session_id}"
def _get_session_db() -> Optional[Any]:
# Reuse the goals module's per-HERMES_HOME cached SessionDB so both
# features share one connection instead of thrashing the file.
try:
from hermes_cli.goals import _get_session_db as _goals_db
return _goals_db()
except Exception as exc: # pragma: no cover
logger.debug("HeartbeatManager: SessionDB bootstrap failed (%s)", exc)
return None
def load_heartbeat(session_id: str) -> Optional[HeartbeatState]:
if not session_id:
return None
db = _get_session_db()
if db is None:
return None
try:
raw = db.get_meta(_meta_key(session_id))
except Exception as exc:
logger.debug("HeartbeatManager: get_meta failed: %s", exc)
return None
if not raw:
return None
try:
state = HeartbeatState.from_json(raw)
except Exception as exc:
logger.warning("HeartbeatManager: could not parse stored heartbeat for %s: %s", session_id, exc)
return None
return None if state.status == "cleared" else state
def save_heartbeat(session_id: str, state: HeartbeatState) -> None:
if not session_id:
return
db = _get_session_db()
if db is None:
from hermes_cli.goals import _warn_dropped_write
_warn_dropped_write("HeartbeatManager", "heartbeat", session_id)
return
try:
db.set_meta(_meta_key(session_id), state.to_json())
except Exception as exc:
logger.debug("HeartbeatManager: set_meta failed: %s", exc)
# ──────────────────────────────────────────────────────────────────────
# Manager — the surface CLI + gateway talk to
# ──────────────────────────────────────────────────────────────────────
class HeartbeatManager:
"""Per-session heartbeat state + due-tick decisions.
Drivers (CLI thread / gateway task) call :meth:`due_prompt` on a poll
cadence while the session is idle; a non-None return is the user-role
message to inject. Firing is recorded immediately so a slow turn can't
double-fire.
"""
def __init__(self, session_id: str):
self.session_id = session_id
self._state: Optional[HeartbeatState] = load_heartbeat(session_id)
@property
def state(self) -> Optional[HeartbeatState]:
return self._state
def has_heartbeat(self) -> bool:
return self._state is not None and self._state.status in {"active", "paused"}
def is_active(self) -> bool:
return self._state is not None and self._state.status == "active"
def status_line(self) -> str:
s = self._state
if s is None:
return "No heartbeat. Set one with /heartbeat every <interval> <prompt>."
every = format_interval(s.interval_seconds)
fired = f", fired {s.fire_count}×" if s.fire_count else ""
if s.status == "active":
anchor = s.last_fired_at or s.created_at
next_in = max(0, int(anchor + s.interval_seconds - time.time()))
return f"♥ Heartbeat (every {every}, next in ~{next_in}s{fired}): {s.prompt}"
if s.status == "paused":
return f"⏸ Heartbeat (paused, every {every}{fired}): {s.prompt}"
return f"Heartbeat ({s.status}, every {every}{fired}): {s.prompt}"
# --- mutation -----------------------------------------------------
def set(self, prompt: str, interval_seconds: int) -> HeartbeatState:
prompt = (prompt or "").strip()
if not prompt:
raise ValueError("heartbeat prompt is empty")
interval_seconds = int(interval_seconds)
if interval_seconds < MIN_INTERVAL_SECONDS:
raise ValueError(f"interval must be at least {MIN_INTERVAL_SECONDS}s")
state = HeartbeatState(
prompt=prompt,
interval_seconds=interval_seconds,
status="active",
created_at=time.time(),
)
self._state = state
save_heartbeat(self.session_id, state)
return state
def pause(self) -> Optional[HeartbeatState]:
if not self._state:
return None
self._state.status = "paused"
save_heartbeat(self.session_id, self._state)
return self._state
def resume(self) -> Optional[HeartbeatState]:
if not self._state:
return None
self._state.status = "active"
# Re-anchor so resuming doesn't instantly fire a stale tick.
self._state.last_fired_at = time.time()
save_heartbeat(self.session_id, self._state)
return self._state
def clear(self) -> bool:
if self._state is None:
return False
self._state.status = "cleared"
save_heartbeat(self.session_id, self._state)
self._state = None
return True
# --- driver entry point --------------------------------------------
def due_prompt(self, now: Optional[float] = None) -> Optional[str]:
"""Return the injection prompt if the heartbeat is due, else None.
Records the fire immediately (before the turn runs) so overlapping
polls or a long turn can never double-fire the same tick. Missed
ticks coalesce into one the anchor resets to NOW, not to the
theoretical schedule.
"""
s = self._state
if s is None or not s.is_due(now):
return None
s.last_fired_at = now if now is not None else time.time()
s.fire_count += 1
save_heartbeat(self.session_id, s)
return s.render_prompt()
def migrate_heartbeat_to_session(old_session_id: str, new_session_id: str) -> bool:
"""Carry a heartbeat across a compression session rotation.
Same shape as ``goals.migrate_goal_to_session`` copy to the child,
archive the parent row, never raise.
"""
if not old_session_id or not new_session_id or old_session_id == new_session_id:
return False
try:
state = load_heartbeat(old_session_id)
if state is None:
return False
if load_heartbeat(new_session_id) is not None:
return False
save_heartbeat(new_session_id, state)
state.status = "cleared"
save_heartbeat(old_session_id, state)
return True
except Exception as exc: # pragma: no cover - defensive
logger.debug("HeartbeatManager: migration failed: %s", exc)
return False
__all__ = [
"HeartbeatState",
"HeartbeatManager",
"parse_interval",
"format_interval",
"load_heartbeat",
"save_heartbeat",
"migrate_heartbeat_to_session",
"HEARTBEAT_PROMPT_TEMPLATE",
"MIN_INTERVAL_SECONDS",
"POLL_SECONDS",
]
+441
View File
@@ -0,0 +1,441 @@
"""hermes hooks — inspect and manage shell-script hooks.
Usage::
hermes hooks list
hermes hooks test <event> [--for-tool X] [--payload-file F]
hermes hooks revoke <command>
hermes hooks doctor
Consent records live under ``~/.hermes/shell-hooks-allowlist.json`` and
hook definitions come from the ``hooks:`` block in ``~/.hermes/config.yaml``
(the same config read by the CLI / gateway at startup).
This module is a thin CLI shell over :mod:`agent.shell_hooks`; every
shared concern (payload serialisation, response parsing, allowlist
format) lives there.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List
def hooks_command(args) -> None:
"""Entry point for ``hermes hooks`` — dispatches to the requested action."""
sub = getattr(args, "hooks_action", None)
if not sub:
print("Usage: hermes hooks {list|test|revoke|doctor}")
print("Run 'hermes hooks --help' for details.")
return
if sub in {"list", "ls"}:
_cmd_list(args)
elif sub == "test":
_cmd_test(args)
elif sub in {"revoke", "remove", "rm"}:
_cmd_revoke(args)
elif sub == "doctor":
_cmd_doctor(args)
else:
print(f"Unknown hooks subcommand: {sub}")
# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------
def _cmd_list(_args) -> None:
from hermes_cli.config import load_config
from agent import outbound_webhooks, shell_hooks
cfg = load_config()
specs = shell_hooks.iter_configured_hooks(cfg)
outbound = outbound_webhooks.iter_configured_targets(cfg)
if not specs and not outbound:
print("No shell hooks or outbound webhooks configured in ~/.hermes/config.yaml.")
print("See `hermes hooks --help` or")
print(" website/docs/user-guide/features/hooks.md")
print("for the config schema and worked examples.")
return
if not specs:
print("No shell hooks configured in ~/.hermes/config.yaml.")
else:
by_event: Dict[str, List] = {}
for spec in specs:
by_event.setdefault(spec.event, []).append(spec)
allowlist = shell_hooks.load_allowlist()
approved = {
(e.get("event"), e.get("command"))
for e in allowlist.get("approvals", [])
if isinstance(e, dict)
}
print(f"Configured shell hooks ({len(specs)} total):\n")
for event in sorted(by_event.keys()):
print(f" [{event}]")
for spec in by_event[event]:
is_approved = (spec.event, spec.command) in approved
status = "✓ allowed" if is_approved else "✗ not allowlisted"
matcher_part = f" matcher={spec.matcher!r}" if spec.matcher else ""
print(
f" - {spec.command}{matcher_part} "
f"(timeout={spec.timeout}s, {status})"
)
if is_approved:
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
if entry and entry.get("approved_at"):
print(f" approved_at: {entry['approved_at']}")
mtime_now = shell_hooks.script_mtime_iso(spec.command)
mtime_at = entry.get("script_mtime_at_approval")
if mtime_now and mtime_at and mtime_now > mtime_at:
print(
f" ⚠ script modified since approval "
f"(was {mtime_at}, now {mtime_now}) — "
f"run `hermes hooks doctor` to re-validate"
)
print()
if outbound:
print(f"Configured outbound webhooks ({len(outbound)} total):\n")
for target in outbound:
signed = "signed" if target.secret else "UNSIGNED"
matcher_part = f" matcher={target.matcher!r}" if target.matcher else ""
print(f" - {target.label}")
print(f" url: {target.url}")
print(
f" events: {', '.join(target.events)}{matcher_part} "
f"(timeout={target.timeout}s, {signed})"
)
print()
# ---------------------------------------------------------------------------
# test
# ---------------------------------------------------------------------------
# Synthetic kwargs matching the real invoke_hook() call sites — these are
# passed verbatim to agent.shell_hooks.run_once(), which routes them through
# the same _serialize_payload() that production firings use. That way the
# stdin a script sees under `hermes hooks test` and `hermes hooks doctor`
# is identical in shape to what it will see at runtime.
_DEFAULT_PAYLOADS = {
"pre_tool_call": {
"tool_name": "terminal",
"args": {"command": "echo hello"},
"session_id": "test-session",
"task_id": "test-task",
"tool_call_id": "test-call",
},
"post_tool_call": {
"tool_name": "terminal",
"args": {"command": "echo hello"},
"session_id": "test-session",
"task_id": "test-task",
"tool_call_id": "test-call",
"result": '{"output": "hello"}',
"duration_ms": 42,
},
"pre_llm_call": {
"session_id": "test-session",
"user_message": "What is the weather?",
"conversation_history": [],
"is_first_turn": True,
"model": "gpt-4",
"platform": "cli",
},
"post_llm_call": {
"session_id": "test-session",
"model": "gpt-4",
"platform": "cli",
},
"pre_verify": {
"session_id": "test-session",
"platform": "cli",
"model": "gpt-4",
"coding": True,
"attempt": 0,
"final_response": "All done — the change is applied.",
"changed_paths": ["src/app.tsx"],
},
"on_session_start": {"session_id": "test-session"},
"on_session_end": {
"session_id": "test-session",
"task_id": "test-task",
"turn_id": "test-turn",
"completed": True,
"failed": False,
"interrupted": False,
"turn_exit_reason": "text_response(stop)",
"model": "gpt-4",
"platform": "cli",
},
"on_session_finalize": {"session_id": "test-session"},
"on_session_reset": {"session_id": "test-session"},
"pre_api_request": {
"session_id": "test-session",
"task_id": "test-task",
"platform": "cli",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"base_url": "https://api.anthropic.com",
"api_mode": "anthropic_messages",
"api_call_count": 1,
"message_count": 4,
"tool_count": 12,
"approx_input_tokens": 2048,
"request_char_count": 8192,
"max_tokens": 4096,
},
"post_api_request": {
"session_id": "test-session",
"task_id": "test-task",
"platform": "cli",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"base_url": "https://api.anthropic.com",
"api_mode": "anthropic_messages",
"api_call_count": 1,
"api_duration": 1.234,
"started_at": 1756000000.0,
"ended_at": 1756000001.234,
"first_chunk_at": 1756000000.512,
"finish_reason": "stop",
"message_count": 4,
"response_model": "claude-sonnet-4-6",
"usage": {"input_tokens": 2048, "output_tokens": 512},
"assistant_content_chars": 1200,
"assistant_tool_call_count": 0,
# Per-advisor metrics on a MoA turn, None otherwise. MoA returns only
# the aggregator's response, so without this an observer cannot see the
# fan-out or price it at each advisor's own model.
"moa_references": None,
},
"subagent_stop": {
"parent_session_id": "parent-sess",
"child_role": None,
"child_summary": "Synthetic summary for hooks test",
"child_status": "completed",
"tool_call_history": [
{
"tool_name": "write_file",
"tool_input": {
"argument_keys": ["content", "path"],
"targets": {"path": "/tmp/report.txt"},
},
"input_bytes": 128,
"output_bytes": 32,
"status": "ok",
}
],
"duration_ms": 1234,
},
}
def _cmd_test(args) -> None:
from hermes_cli.config import load_config
from hermes_cli.plugins import VALID_HOOKS
from agent import shell_hooks
event = args.event
if event not in VALID_HOOKS:
print(f"Unknown event: {event!r}")
print(f"Valid events: {', '.join(sorted(VALID_HOOKS))}")
return
# Synthetic kwargs in the same shape invoke_hook() would pass. Merged
# with --for-tool (overrides tool_name) and --payload-file (extra kwargs).
payload = dict(_DEFAULT_PAYLOADS.get(event, {"session_id": "test-session"}))
if getattr(args, "for_tool", None):
payload["tool_name"] = args.for_tool
if getattr(args, "payload_file", None):
try:
custom = json.loads(Path(args.payload_file).read_text(encoding="utf-8"))
if isinstance(custom, dict):
payload.update(custom)
else:
print(f"Warning: {args.payload_file} is not a JSON object; ignoring")
except Exception as exc:
print(f"Error reading payload file: {exc}")
return
specs = shell_hooks.iter_configured_hooks(load_config())
specs = [s for s in specs if s.event == event]
if getattr(args, "for_tool", None):
specs = [
s for s in specs
if s.event not in {"pre_tool_call", "post_tool_call"}
or s.matches_tool(args.for_tool)
]
if not specs:
print(f"No shell hooks configured for event: {event}")
if getattr(args, "for_tool", None):
print(f"(with matcher filter --for-tool={args.for_tool})")
return
print(f"Firing {len(specs)} hook(s) for event '{event}':\n")
for spec in specs:
print(f"{spec.command}")
result = shell_hooks.run_once(spec, payload)
_print_run_result(result)
print()
def _print_run_result(result: Dict[str, Any]) -> None:
if result.get("error"):
print(f" ✗ error: {result['error']}")
return
if result.get("timed_out"):
print(f" ✗ timed out after {result['elapsed_seconds']}s")
return
rc = result.get("returncode")
elapsed = result.get("elapsed_seconds", 0)
print(f" exit={rc} elapsed={elapsed}s")
stdout = (result.get("stdout") or "").strip()
stderr = (result.get("stderr") or "").strip()
if stdout:
print(f" stdout: {_truncate(stdout, 400)}")
if stderr:
print(f" stderr: {_truncate(stderr, 400)}")
parsed = result.get("parsed")
if parsed:
print(f" parsed (Hermes wire shape): {json.dumps(parsed)}")
else:
print(" parsed: <none — hook contributed nothing to the dispatcher>")
def _truncate(s: str, n: int) -> str:
return s if len(s) <= n else s[: n - 3] + "..."
# ---------------------------------------------------------------------------
# revoke
# ---------------------------------------------------------------------------
def _cmd_revoke(args) -> None:
from agent import shell_hooks
removed = shell_hooks.revoke(args.command)
if removed == 0:
print(f"No allowlist entry found for command: {args.command}")
return
print(f"Removed {removed} allowlist entry/entries for: {args.command}")
print(
"Note: currently running CLI / gateway processes keep their "
"already-registered callbacks until they restart."
)
# ---------------------------------------------------------------------------
# doctor
# ---------------------------------------------------------------------------
def _cmd_doctor(_args) -> None:
from hermes_cli.config import load_config
from agent import shell_hooks
specs = shell_hooks.iter_configured_hooks(load_config())
if not specs:
print("No shell hooks configured — nothing to check.")
return
print(f"Checking {len(specs)} configured shell hook(s)...\n")
problems = 0
for spec in specs:
print(f" [{spec.event}] {spec.command}")
problems += _doctor_one(spec, shell_hooks)
print()
if problems:
print(f"{problems} issue(s) found. Fix before relying on these hooks.")
else:
print("All shell hooks look healthy.")
def _doctor_one(spec, shell_hooks) -> int:
problems = 0
# 1. Script exists and is executable
if shell_hooks.script_is_executable(spec.command):
print(" ✓ script exists and is executable")
else:
problems += 1
print(" ✗ script missing or not executable "
"(chmod +x the file, or fix the path)")
# 2. Allowlist status
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
if entry:
print(f" ✓ allowlisted (approved {entry.get('approved_at', '?')})")
else:
problems += 1
print(" ✗ not allowlisted — hook will NOT fire at runtime "
"(run with --accept-hooks once, or confirm at the TTY prompt)")
# 3. Mtime drift
if entry and entry.get("script_mtime_at_approval"):
mtime_now = shell_hooks.script_mtime_iso(spec.command)
mtime_at = entry["script_mtime_at_approval"]
if mtime_now and mtime_at and mtime_now > mtime_at:
problems += 1
print(f" ⚠ script modified since approval "
f"(was {mtime_at}, now {mtime_now}) — review changes, "
f"then `hermes hooks revoke` + re-approve to refresh")
elif mtime_now and mtime_at and mtime_now == mtime_at:
print(" ✓ script unchanged since approval")
# 4. Produces valid JSON for a synthetic payload — only when the entry
# is already allowlisted. Otherwise `hermes hooks doctor` would execute
# every script listed in a freshly-pulled config before the user has
# reviewed them, which directly contradicts the documented workflow
# ("spot newly-added hooks *before they register*").
if not entry:
print(" skipped JSON smoke test — not allowlisted yet. "
"Approve the hook first (via TTY prompt or --accept-hooks), "
"then re-run `hermes hooks doctor`.")
elif shell_hooks.script_is_executable(spec.command):
payload = _DEFAULT_PAYLOADS.get(spec.event, {"extra": {}})
result = shell_hooks.run_once(spec, payload)
if result.get("timed_out"):
problems += 1
print(f" ✗ timed out after {result['elapsed_seconds']}s "
f"on synthetic payload (timeout={spec.timeout}s)")
elif result.get("error"):
problems += 1
print(f" ✗ execution error: {result['error']}")
else:
rc = result.get("returncode")
elapsed = result.get("elapsed_seconds", 0)
stdout = (result.get("stdout") or "").strip()
if stdout:
try:
json.loads(stdout)
print(f" ✓ produced valid JSON on synthetic payload "
f"(exit={rc}, {elapsed}s)")
except json.JSONDecodeError:
problems += 1
print(f" ✗ stdout was not valid JSON (exit={rc}, "
f"{elapsed}s): {_truncate(stdout, 120)}")
else:
print(f" ✓ ran clean with empty stdout "
f"(exit={rc}, {elapsed}s) — hook is observer-only")
return problems
+137
View File
@@ -0,0 +1,137 @@
"""Image-authored deployment provenance for immutable Hermes runtimes.
The published image bakes ``/etc/hermes/image-provenance.json`` outside both
``$HERMES_HOME`` and the mutable checkout. A bind-mounted checkout (including
``.git``) therefore cannot hide the build fact, and environment or config
values cannot forge it.
Absence preserves every pre-existing source/package install path. Presence
fails closed: an unreadable, non-regular, or malformed marker still means the
runtime is image-managed; it is an integrity defect, never permission to
mutate the image in place.
"""
from __future__ import annotations
import json
import stat
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Optional
IMAGE_PROVENANCE_PATH = Path("/etc/hermes/image-provenance.json")
IMAGE_PROVENANCE_SCHEMA = 1
@dataclass(frozen=True)
class ImageProvenance:
"""Validated provenance, or a fail-closed description of an invalid one."""
schema: int
deployment_kind: str
manager: str
image: Optional[str]
version: Optional[str]
revision: Optional[str]
marker_path: str
valid: bool = True
error: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _invalid(path: Path, reason: str) -> ImageProvenance:
return ImageProvenance(
schema=IMAGE_PROVENANCE_SCHEMA,
deployment_kind="image",
manager="unknown",
image=None,
version=None,
revision=None,
marker_path=str(path),
valid=False,
error=reason,
)
def read_image_provenance(
marker_path: Optional[Path] = None,
) -> Optional[ImageProvenance]:
"""Read the baked marker without consulting environment or config.
``None`` has one precise meaning: ``lstat`` proved that no marker exists.
Every other filesystem or validation failure returns an invalid
:class:`ImageProvenance`, so callers refuse image mutation closed. In
particular, ``lstat`` makes a dangling symlink visibly *present* and the
regular-file check rejects symlinks, directories, and device nodes.
``marker_path`` is a dependency-injection seam for tests and alternate
image builders. Normal callers always use the image-owned absolute path.
This function never raises.
"""
path = IMAGE_PROVENANCE_PATH
try:
path = Path(marker_path) if marker_path is not None else path
except BaseException as exc:
return _invalid(path, f"marker_presence_unreadable:{type(exc).__name__}")
try:
marker_stat = path.lstat()
except FileNotFoundError:
return None
except BaseException as exc:
# Permission errors and other lookup failures do not prove absence.
return _invalid(path, f"marker_presence_unreadable:{type(exc).__name__}")
if not stat.S_ISREG(marker_stat.st_mode):
return _invalid(path, "marker_not_regular_file")
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
# The file may disappear between lstat/read; it was nevertheless
# observed present, so the decision remains fail-closed.
return _invalid(path, f"marker_unreadable:{type(exc).__name__}")
if not isinstance(payload, dict):
return _invalid(path, "marker_not_object")
schema = payload.get("schema")
# ``bool`` is an ``int`` subclass in Python. Schema ``true`` must not be
# accepted as schema 1, hence the exact type check.
if type(schema) is not int or schema != IMAGE_PROVENANCE_SCHEMA:
return _invalid(path, "unsupported_marker_schema")
if payload.get("deployment_kind") != "image":
return _invalid(path, "invalid_deployment_kind")
manager = payload.get("manager")
if not isinstance(manager, str) or not manager.strip():
return _invalid(path, "missing_manager")
def _optional_string(name: str) -> Optional[str]:
value = payload.get(name)
if value is None:
return None
if not isinstance(value, str):
raise TypeError(name)
value = value.strip()
return value or None
try:
image = _optional_string("image")
version = _optional_string("version")
revision = _optional_string("revision")
except TypeError as exc:
return _invalid(path, f"invalid_{exc.args[0]}")
return ImageProvenance(
schema=IMAGE_PROVENANCE_SCHEMA,
deployment_kind="image",
manager=manager.strip(),
image=image,
version=version,
revision=revision,
marker_path=str(path),
)
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""``/init`` — build the prompt that generates or updates a project AGENTS.md.
Port of Codex ``/init`` (Claude Code has the same for CLAUDE.md). Hermes
already *loads* AGENTS.md / CLAUDE.md / .cursorrules as project context, but
had no command to bootstrap one. ``/init`` hands the live agent ONE
guidance-laden prompt instructing it to:
1. Inspect the project with its own read-only tools (``read_file`` /
``search_files`` on manifests, CI configs, lockfiles, existing docs) to
learn the layout, toolchain, and the exact build/test/lint commands.
2. Write a CONCISE ``AGENTS.md`` (target under 100 lines) with the sections
an agent actually needs overview, setup, commands, conventions,
pitfalls not an essay.
3. If an AGENTS.md already exists, UPDATE it: preserve the user's existing
content and merge in what's missing, never blow it away.
There is no engine and no model-tool footprint: the agent does the work with
its existing toolset, so this works identically on local, Docker, and remote
terminal backends. Every surface (CLI ``/init``, gateway ``/init``, TUI
``/init``) calls :func:`build_init_prompt` and feeds the result to the agent
as a normal user turn the same prompt-injection pattern as ``/learn`` and
``/blueprint``, which preserves prompt-cache invariants (no system-prompt or
history mutation).
"""
from __future__ import annotations
# The quality bar, embedded in every prompt so the generated file reads like a
# maintainer wrote it — concrete and command-exact, not generic advice.
_QUALITY_BAR = """\
Quality bar for the file you write (this is what separates a useful AGENTS.md
from noise):
- CONCISE: target under 100 lines. Agents load this file every session every
line costs context. No essays, no marketing prose, no filler.
- Commands must be EXACT invocations you verified from the repo (package.json
scripts, Makefile targets, pyproject/tox/CI config, existing docs). Write
`npm run test:unit` or `scripts/run_tests.sh tests/foo`, never "run the
tests". NEVER invent a command you didn't see evidence for.
- No generic advice. "Write tests for new code" and "follow best practices"
are banned if a line would be true of any repo, cut it.
- Conventions must be OBSERVED, not assumed: naming patterns, module layout,
error-handling style, commit-message format only what the code actually
shows.
- Include pitfalls that would genuinely trip up a newcomer or an agent
(required env vars, generated files not to hand-edit, slow test suites,
ports already in use), if you found any. Skip the section if you found none.
- Markdown structure: a short title + one-paragraph overview, then focused
sections (e.g. "Dev environment", "Build & test", "Conventions",
"Pitfalls"). Flat and scannable no deep nesting."""
def build_init_prompt(
cwd: str,
existing_file: str | None = None,
extra: str = "",
) -> str:
"""Build the agent prompt for a ``/init`` request.
Args:
cwd: the project directory the agent should scan and write
``AGENTS.md`` into (usually the session working directory).
existing_file: the current content of ``AGENTS.md`` if one already
exists, else ``None``. When present the prompt switches to
update-and-merge discipline instead of fresh generation.
extra: free-text the user gave after ``/init`` emphasis or notes to
honor while authoring (e.g. "focus on the test setup").
Returns:
A complete instruction the agent runs as a normal turn.
"""
extra = (extra or "").strip()
parts: list[str] = [
"[/init] The user wants you to "
+ (
"UPDATE the existing AGENTS.md project-instructions file"
if existing_file is not None
else "generate an AGENTS.md project-instructions file"
)
+ f" for the project at: {cwd}\n",
"AGENTS.md is the instruction file coding agents (Hermes included) "
"load as project context every session. It should teach an agent how "
"to work in THIS repo: what the project is, how to set up, the exact "
"build/test/lint commands, the conventions the code actually follows, "
"and the pitfalls that waste time.\n",
"Do this:\n"
"1. Inspect the project with your read-only tools (`read_file`, "
"`search_files`) — start with manifests and toolchain files "
"(package.json, pyproject.toml, Cargo.toml, go.mod, Makefile, "
"CI workflow configs, lockfiles), then the directory layout, existing "
"README/docs, and test/lint configuration. Learn the real commands, "
"don't guess them.\n"
"2. Write the file to "
f"{cwd.rstrip('/')}/AGENTS.md with `write_file`"
+ (
" — but this is an UPDATE, so follow the merge discipline below."
if existing_file is not None
else "."
)
+ "\n"
"3. Confirm to the user the exact path you wrote and summarize in one "
"or two lines what the file covers.\n",
]
if existing_file is not None:
parts.append(
"MERGE DISCIPLINE — an AGENTS.md already exists (its current "
"content is below). Do NOT overwrite or regenerate it from "
"scratch. Preserve the user's existing content — their wording, "
"their sections, their rules — and merge in only what is missing "
"or verifiably stale (e.g. a command that no longer exists in the "
"repo). When existing content conflicts with what you observed, "
"prefer minimal surgical edits over rewrites, and keep the "
"user's intent. The result must still meet the quality bar.\n\n"
"CURRENT AGENTS.md CONTENT:\n"
"<<<EXISTING_AGENTS_MD\n"
f"{existing_file}\n"
"EXISTING_AGENTS_MD\n"
)
parts.append(_QUALITY_BAR)
if extra:
parts.append(
"\nUSER NOTES — honor these while authoring (they override the "
f"defaults above where they conflict):\n{extra}"
)
return "\n".join(parts)
def build_init_prompt_for_cwd(cwd: str | None = None, extra: str = "") -> str:
"""Convenience wrapper used by the dispatch surfaces.
Resolves ``cwd`` (defaults to the process working directory), reads an
existing ``AGENTS.md`` there if present, and returns the full prompt.
"""
import os
resolved = os.path.abspath(cwd or os.getcwd())
existing: str | None = None
agents_path = os.path.join(resolved, "AGENTS.md")
try:
if os.path.isfile(agents_path):
with open(agents_path, encoding="utf-8", errors="replace") as fh:
existing = fh.read()
except OSError:
existing = None
return build_init_prompt(resolved, existing_file=existing, extra=extra)
+70
View File
@@ -0,0 +1,70 @@
"""Sanitize user prompt text leaked from terminal / paste control sequences."""
from __future__ import annotations
import re
_BRACKETED_PASTE_BOUNDARY_START = re.compile(r"(^|[\s\n>:\]\)])\[200~")
_BRACKETED_PASTE_BOUNDARY_END = re.compile(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])")
_BRACKETED_PASTE_DEGRADED_START = re.compile(r"(^|[\s\n>:\]\)])00~")
_BRACKETED_PASTE_DEGRADED_END = re.compile(r"01~(?=$|[\s\n<\[\(\):;.,!?])")
# Corruption signature from desktop bracketed-paste leaks (#62557).
_DESKTOP_PASTE_ARTIFACT = "~[[e"
def strip_leaked_bracketed_paste_wrappers(text: str) -> str:
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
Defensive normalization for cases where terminal/prompt_toolkit parsing
fails and bracketed-paste markers end up in the buffer as literal text.
Canonical wrappers are stripped unconditionally. Degraded visible forms like
``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries
so embedded literals such as ``literal[200~tag`` stay intact.
"""
if not text:
return text
text = (
text.replace("\x1b[200~", "")
.replace("\x1b[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "")
)
text = _BRACKETED_PASTE_BOUNDARY_START.sub(r"\1", text)
text = _BRACKETED_PASTE_BOUNDARY_END.sub("", text)
text = _BRACKETED_PASTE_DEGRADED_START.sub(r"\1", text)
text = _BRACKETED_PASTE_DEGRADED_END.sub("", text)
return text
def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str:
"""Drop a trailing run of the desktop ~[[e corruption signature (#62557)."""
if not text:
return text
marker = _DESKTOP_PASTE_ARTIFACT
index = len(text)
repeat_count = 0
while index >= len(marker) and text[index - len(marker) : index] == marker:
repeat_count += 1
index -= len(marker)
if repeat_count < min_repeats:
return text
start = index
if start >= 2 and text[start - 2 : start] == "[e":
start -= 2
elif start >= 1 and text[start - 1] == "[":
start -= 1
return text[:start]
def sanitize_user_prompt_text(text: str) -> str:
"""Normalize user-authored prompt text before persistence or model input."""
if not isinstance(text, str) or not text:
return text
cleaned = strip_leaked_bracketed_paste_wrappers(text)
return collapse_repeated_input_artifacts(cleaned)
+153
View File
@@ -0,0 +1,153 @@
"""Stable opaque identity shared by every profile in one Hermes install."""
from __future__ import annotations
import contextlib
import os
from pathlib import Path
import re
import tempfile
import threading
from typing import Optional
import uuid
from hermes_constants import get_default_hermes_root
_INSTALL_ID_FILENAME = "install_id"
_INSTALL_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_INSTALL_ID_CACHE: dict[str, Optional[str]] = {"root": None, "value": None}
_INSTALL_ID_LOCK = threading.Lock()
_INSTALL_ID_PUBLICATION_LOCK = threading.Lock()
@contextlib.contextmanager
def _install_id_file_lock(root: Path):
"""Serialize identity publication across processes on POSIX and Windows."""
lock_path = root / ".install_id.lock"
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
windows = os.name == "nt"
try:
if windows:
import msvcrt
if os.fstat(fd).st_size == 0:
os.write(fd, b"\0")
os.fsync(fd)
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
try:
if windows:
import msvcrt
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def _fsync_directory(path: Path) -> None:
"""Best-effort durability for the directory entry after replace."""
if os.name == "nt":
return
try:
fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(fd)
except OSError:
pass
finally:
os.close(fd)
def read_or_create_install_id(root: Path | None = None) -> Optional[str]:
"""Read or atomically mint the opaque id for the physical install.
``None`` means the id could neither be read nor persisted. Returning an
ephemeral id would violate the authority and connection-registry contract.
"""
root = get_default_hermes_root() if root is None else root
path = root / _INSTALL_ID_FILENAME
try:
existing = path.read_text(encoding="utf-8").strip().lower()
if _INSTALL_ID_RE.fullmatch(existing):
return existing
except FileNotFoundError:
pass
except (OSError, UnicodeDecodeError):
return None
try:
root.mkdir(parents=True, exist_ok=True)
except OSError:
return None
try:
# Windows byte-range locks can report a same-process lock conflict
# instead of waiting for another thread. Serialize threads here, then
# retain the file lock as the cross-process publication fence.
with _INSTALL_ID_PUBLICATION_LOCK, _install_id_file_lock(root):
try:
existing = path.read_text(encoding="utf-8").strip().lower()
if _INSTALL_ID_RE.fullmatch(existing):
return existing
except FileNotFoundError:
pass
except (OSError, UnicodeDecodeError):
return None
minted = uuid.uuid4().hex
fd, tmp_name = tempfile.mkstemp(dir=str(root), prefix=".install_id-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(minted + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, path)
_fsync_directory(root)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
committed = path.read_text(encoding="utf-8").strip().lower()
return committed if _INSTALL_ID_RE.fullmatch(committed) else None
except OSError:
return None
def get_install_id(
*,
cache: dict[str, Optional[str]] | None = None,
) -> Optional[str]:
"""Return the process-cached stable id for the active Hermes root."""
root = get_default_hermes_root()
root_key = str(root)
target_cache = _INSTALL_ID_CACHE if cache is None else cache
cached = target_cache.get("value")
if cached and target_cache.get("root") in (None, root_key):
return cached
with _INSTALL_ID_LOCK:
cached = target_cache.get("value")
if cached and target_cache.get("root") in (None, root_key):
return cached
value = read_or_create_install_id(root)
if value:
target_cache["root"] = root_key
target_cache["value"] = value
return value
__all__ = ["get_install_id", "read_or_create_install_id"]
File diff suppressed because it is too large Load Diff
+414
View File
@@ -0,0 +1,414 @@
"""``hermes journey`` — what Hermes has learned, on a timeline.
A terminal-native rendition of the desktop Star Map / Memory Graph: a horizontal
timeline bar chart of learned skills and memories over time (oldest at top,
newest at bottom) plus the playable constellation scrubber. Graph assembly,
layout, and the (ported-from-desktop) palette all live in
``agent.learning_graph`` / ``agent.learning_graph_render`` so the CLI, the TUI
``/journey`` overlay, and the desktop panel draw the same data.
"""
from __future__ import annotations
import argparse
import shutil
import sys
import time
from functools import lru_cache
from typing import Any, Optional
_TITLE_COLOR = "#E8C463"
_CHARTED_SIGNAL_MIN_CONTRAST = 4.5
def _build_payload() -> dict[str, Any]:
from agent.learning_graph import build_learning_graph
return build_learning_graph()
@lru_cache(maxsize=1)
def _primary_hex() -> str:
"""The active skin's primary color (mirrors the TUI theme primary)."""
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
return skin.get_color("ui_primary", "") or skin.get_color("banner_title", "#FFD700")
except Exception:
return "#FFD700"
@lru_cache(maxsize=1)
def _palette() -> dict[str, str]:
from agent.learning_graph_render import derive_palette
return derive_palette(_primary_hex(), dark=True)
def _fade(base: Optional[str], alpha: float) -> Optional[str]:
from agent.learning_graph_render import hex_to_rgb, mix_rgb, rgb_to_hex
if not base:
return None
if alpha >= 0.999:
return base
return rgb_to_hex(mix_rgb(hex_to_rgb(_palette()["bg"]), hex_to_rgb(base), alpha))
def _resolve(style: str, alpha: float) -> Optional[str]:
"""Fade the style's base ink toward the background by ``alpha`` (rgba-over-bg)."""
return _fade(_palette().get(style), alpha)
def _relative_luminance(rgb: tuple[int, int, int]) -> float:
def channel(value: int) -> float:
normalized = value / 255
return (
normalized / 12.92
if normalized <= 0.03928
else ((normalized + 0.055) / 1.055) ** 2.4
)
red, green, blue = (channel(value) for value in rgb)
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
def _contrast_ratio(
foreground: tuple[int, int, int], background: tuple[int, int, int]
) -> float:
foreground_luminance = _relative_luminance(foreground)
background_luminance = _relative_luminance(background)
high, low = sorted((foreground_luminance, background_luminance), reverse=True)
return (high + 0.05) / (low + 0.05)
def _ensure_contrast(
color: Optional[str], background: str, minimum: float
) -> Optional[str]:
"""Lift a foreground toward the readable pole until it clears ``minimum``."""
if not color:
return None
from agent.learning_graph_render import hex_to_rgb, mix_rgb, rgb_to_hex
foreground_rgb = hex_to_rgb(color)
background_rgb = hex_to_rgb(background)
if _contrast_ratio(foreground_rgb, background_rgb) >= minimum:
return color
pole = (0, 0, 0) if _relative_luminance(background_rgb) > 0.5 else (255, 255, 255)
for step in range(1, 21):
candidate = mix_rgb(foreground_rgb, pole, step * 0.05)
if _contrast_ratio(candidate, background_rgb) >= minimum:
return rgb_to_hex(candidate)
return rgb_to_hex(pole)
def _resolve_charted_signal(style: str, alpha: float) -> Optional[str]:
"""Keep age tinting without allowing explanatory labels to disappear."""
return _ensure_contrast(
_resolve(style, alpha), _palette()["bg"], _CHARTED_SIGNAL_MIN_CONTRAST
)
def _row_to_text(row: list, color: bool):
from rich.text import Text
text = Text()
for run in row:
chunk = run[0]
style = run[1]
alpha = run[2] if len(run) > 2 else 1.0
override = run[3] if len(run) > 3 else None
if not color:
text.append(chunk)
elif override:
text.append(chunk, style=_fade(override, alpha))
else:
text.append(chunk, style=_resolve(style, alpha))
return text
def _term_size(width: Optional[int], height: Optional[int]) -> tuple[int, int]:
size = shutil.get_terminal_size((90, 30))
return max(40, width or size.columns), max(10, height or size.lines)
def _frame_renderable(payload, *, cols, rows, reveal, color):
from rich.console import Group
from rich.text import Text
from agent import learning_graph_render as render
legend = render.build_legend(payload)
categories = render.category_legend(payload)
summary = render.build_summary(payload)
axis = render.axis_labels(payload)
# Lines are pad_left(2), so content must fit in cols-2.
inner = max(24, cols - 2)
# Reserve rows for title/legend/blank/axis/footer/labels + summary; field gets rest.
field_rows = max(6, rows - 10 - len(summary))
frame = render.render_graph(payload, cols=inner, rows=field_rows, reveal=reveal)
count = len(payload.get("nodes", []))
parts: list[Any] = []
title = Text()
title.append("✦ Journey ", style=f"bold {_TITLE_COLOR}" if color else None)
title.append("· learned skills & memories over time", style="grey62" if color else None)
parts.append(title)
legend_line = Text(" ")
for i, item in enumerate(legend):
if i:
legend_line.append(" ")
legend_line.append(item["glyph"] + " ", style=_resolve(item["style"], 1.0) if color else None)
legend_line.append(item["label"], style="grey62" if color else None)
parts.append(legend_line)
if categories:
cat_line = Text(" ")
for i, item in enumerate(categories):
if i:
cat_line.append(" ")
cat_line.append(item["glyph"] + " ", style=_fade(item.get("color"), 1.0) if color else None)
cat_line.append(item["label"], style="grey54" if color else None)
parts.append(cat_line)
parts.append(Text(""))
for grow in frame["grid"]:
line = _row_to_text(grow, color)
line.pad_left(2)
parts.append(line)
# Date axis under the field (oldest → now), with the playhead date centered.
axis_line = Text(" ")
axis_line.append(axis["start"], style="grey54" if color else None)
gap = max(1, inner - len(axis["start"]) - len(axis["end"]))
axis_line.append(" " * gap)
axis_line.append(axis["end"], style="grey54" if color else None)
parts.append(axis_line)
pct = int(round(reveal * 100))
foot = Text(" ")
foot.append("", style="grey54" if color else None)
foot.append(frame["date"] or "", style=_TITLE_COLOR if color else None)
foot.append(f" {frame['visible']}/{count} revealed · {pct}%", style="grey54" if color else None)
parts.append(foot)
labels = frame.get("labels", [])
if labels:
parts.append(Text(""))
heading = Text(" charted signals", style="grey62" if color else None)
parts.append(heading)
def label_row(item) -> Text:
row = Text(" ")
row.append(f"{item['key']} ", style="grey70" if color else None)
signal_style = (
_resolve_charted_signal(item["style"], float(item.get("alpha", 1.0)))
if color
else None
)
row.append(f"{item['glyph']} ", style=signal_style)
row.append(str(item["label"]), style=signal_style)
meta = str(item["meta"])
row.append(f" {meta if len(meta) <= 32 else meta[:29] + ''}", style="grey54" if color else None)
return row
for item in labels[:6]:
row = label_row(item)
parts.append(row)
for line_text in summary:
parts.append(Text(" " + line_text, style="grey62" if color else None))
return Group(*parts)
def _console(*, color: bool, width: Optional[int] = None, force: bool = False):
"""A Rich console. ``force`` emits truecolor ANSI even into a captured
stream the interactive CLI grabs that output and re-renders it through
prompt_toolkit (raw escapes to a real terminal would otherwise be
swallowed). Mirrors the ``ChatConsole`` idiom in ``cli.py``."""
from rich.console import Console
extra = {"force_terminal": True, "color_system": "truecolor"} if force else {}
return Console(no_color=not color, width=width, **extra)
def _cmd_show(args: argparse.Namespace) -> int:
from rich.console import Console
if getattr(args, "json", False):
import json
Console(no_color=bool(getattr(args, "no_color", False))).print_json(json.dumps(_build_payload()))
return 0
payload = _build_payload()
color = not bool(getattr(args, "no_color", False))
cols, rows = _term_size(getattr(args, "width", None), getattr(args, "height", None))
console = _console(color=color, width=cols, force=bool(getattr(args, "force_color", False)))
if not payload.get("nodes"):
console.print(
"[grey62]No learning yet — use Hermes a while and your learned skills and "
"memories will start mapping out here.[/grey62]"
)
return 0
if getattr(args, "play", False):
return _play(console, payload, cols=cols, rows=rows, color=color, fps=getattr(args, "fps", 12))
reveal = _clamp(float(getattr(args, "reveal", 1.0) or 1.0), 0.0, 1.0)
console.print(_frame_renderable(payload, cols=cols, rows=rows, reveal=reveal, color=color))
return 0
def _play(console, payload, *, cols, rows, color, fps: int) -> int:
from rich.live import Live
frames = 42
delay = 1.0 / max(1, min(60, fps))
try:
with Live(console=console, refresh_per_second=max(1, fps), screen=False) as live:
for i in range(frames):
reveal = i / (frames - 1)
live.update(_frame_renderable(payload, cols=cols, rows=rows, reveal=reveal, color=color))
time.sleep(delay)
live.update(_frame_renderable(payload, cols=cols, rows=rows, reveal=1.0, color=color))
except KeyboardInterrupt:
console.print("[grey54]interrupted[/grey54]")
return 130
return 0
def _clamp(v: float, lo: float, hi: float) -> float:
return lo if v < lo else hi if v > hi else v
# ── list / delete / edit ─────────────────────────────────────────────────────
def _cmd_list(args: argparse.Namespace) -> int:
from agent.learning_graph_render import format_date
console = _console(color=not bool(getattr(args, "no_color", False)), force=bool(getattr(args, "force_color", False)))
nodes = sorted(_build_payload().get("nodes", []), key=lambda n: n.get("timestamp") or 0)
if not nodes:
console.print("[grey62]No learning yet.[/grey62]")
return 0
for node in nodes:
glyph = "" if node.get("kind") == "memory" else ""
date = format_date(node.get("timestamp"))
console.print(f"[grey54]{node['id']}[/grey54] {glyph} {node.get('label', '')} [grey54]{date}[/grey54]")
return 0
def _cmd_delete(args: argparse.Namespace) -> int:
from agent.learning_mutations import delete_node, node_detail
detail = node_detail(args.node)
if not detail.get("ok"):
print(f" {detail.get('message', 'not found')}")
return 1
if not getattr(args, "yes", False):
try:
if input(f" Delete {detail['label']!r}? [y/N] ").strip().lower() not in ("y", "yes"):
print(" aborted")
return 1
except (EOFError, KeyboardInterrupt):
print("\n aborted")
return 1
res = delete_node(args.node)
print(f" {res['message']}")
return 0 if res.get("ok") else 1
def _cmd_edit(args: argparse.Namespace) -> int:
from agent.learning_mutations import edit_node, node_detail
detail = node_detail(args.node)
if not detail.get("ok"):
print(f" {detail.get('message', 'not found')}")
return 1
suffix = ".md" if detail["kind"] == "skill" else ".txt"
edited = _open_in_editor(detail["content"], suffix=suffix)
if edited is None or edited.strip() == detail["content"].strip():
print(" no changes")
return 0
res = edit_node(args.node, edited)
print(f" {res['message']}")
return 0 if res.get("ok") else 1
def _open_in_editor(initial: str, *, suffix: str) -> Optional[str]:
import os
import subprocess
import tempfile
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as fh:
fh.write(initial)
path = fh.name
try:
subprocess.call([*editor.split(), path])
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError as exc:
print(f" editor failed: {exc}")
return None
finally:
try:
os.unlink(path)
except OSError:
pass
def register_cli(parent: argparse.ArgumentParser) -> None:
parent.add_argument(
"--reveal",
type=float,
default=1.0,
metavar="0..1",
help="Render the timeline built up to this point (0=oldest, 1=now).",
)
parent.add_argument("--play", action="store_true", help="Animate the build-up over time (Ctrl-C to stop).")
parent.add_argument("--fps", type=int, default=12, help="Animation frames per second for --play (default 12).")
parent.add_argument("--width", type=int, default=None, help="Override render width in columns.")
parent.add_argument("--height", type=int, default=None, help="Override render height in rows.")
parent.add_argument("--no-color", action="store_true", help="Disable color output.")
# Force ANSI even when stdout is captured — the interactive CLI re-renders it.
parent.add_argument("--force-color", action="store_true", help=argparse.SUPPRESS)
parent.add_argument("--json", action="store_true", help="Print the raw graph payload as JSON and exit.")
parent.set_defaults(func=_cmd_show)
sub = parent.add_subparsers(dest="journey_action")
p_list = sub.add_parser("list", help="List node ids (for delete/edit).")
p_list.add_argument("--no-color", action="store_true")
p_list.add_argument("--force-color", action="store_true", help=argparse.SUPPRESS)
p_list.set_defaults(func=_cmd_list)
p_del = sub.add_parser("delete", help="Delete a learned skill (archived) or memory by node id.")
p_del.add_argument("node", help="Node id (skill name or memory:<source>:<index>; see `journey list`).")
p_del.add_argument("-y", "--yes", action="store_true", help="Skip the confirmation prompt.")
p_del.set_defaults(func=_cmd_delete)
p_edit = sub.add_parser("edit", help="Edit a learned skill or memory by node id in $EDITOR.")
p_edit.add_argument("node", help="Node id (skill name or memory:<source>:<index>; see `journey list`).")
p_edit.set_defaults(func=_cmd_edit)
def cmd_journey(args: argparse.Namespace) -> int:
return _cmd_show(args)
if __name__ == "__main__":
_p = argparse.ArgumentParser(prog="hermes journey")
register_cli(_p)
_a = _p.parse_args()
sys.exit(_a.func(_a))
+3567
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
"""Kanban decomposer — fan a triage task out into a graph of child tasks.
Invoked by ``hermes kanban decompose [task_id | --all]`` and the
auto-decompose path in the gateway dispatcher loop. Reads the user's
profile roster (with descriptions) and asks the auxiliary LLM to
return a task graph in JSON. Then atomically creates the children,
links them under the root, and flips the root ``triage -> todo``.
The root task stays alive and becomes the parent of every leaf child,
so when the whole graph completes the root wakes back up its
assignee (the orchestrator profile) gets a chance to judge completion
and add more tasks if the work isn't done yet.
Design notes
------------
* Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux
client import inside the function, lenient response parse, never
raises on expected failure modes.
* 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 decomposer can
match on name as a fallback, but the user has an obvious incentive
to describe them.
* ``fanout=false`` collapses to the same effect as ``kanban specify``:
we tighten the body and flip ``triage -> todo`` as a single task,
no children created. This makes ``decompose`` a strict superset of
``specify`` from the user's perspective.
* If the LLM picks an assignee that doesn't exist as a profile, we
rewrite it to the configured ``default_assignee`` (or the default
profile if unset). A child task NEVER ends up with ``assignee=None``.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Optional
from hermes_cli import kanban_db as kb
from hermes_cli import profiles as profiles_mod
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """You are the Kanban decomposer for the Hermes Agent board.
A user dropped a rough idea into the Triage column. Your job is to break it
into a small graph of concrete child tasks and route each one to the best-
matching profile from the available roster.
You will be given:
- The original task title and body
- The list of available profiles (each with name + description)
- The fallback "default_assignee" used when no profile fits
Output a single JSON object with this exact shape:
{
"fanout": true,
"rationale": "<one sentence on why this decomposition>",
"tasks": [
{
"title": "<concrete task title, imperative voice, <= 80 chars>",
"body": "<detailed spec for the worker on this child task>",
"assignee": "<profile name from the roster, or null for default>",
"parents": [<int>, ...]
},
...
]
}
Rules:
- "parents" is a list of INDICES (0-based) into this same "tasks" list,
expressing actual data dependencies. Tasks with no parents run in
PARALLEL. Tasks with parents wait until every parent completes.
- Prefer parallelism. If two tasks can be done independently, give
them no parents so the dispatcher fans them out at once.
- Use 2-6 tasks for normal work. Don't create 20 tiny tasks. Don't
cram everything into 1 task.
- Pick assignees from the roster by matching the task to the profile's
DESCRIPTION (not just the name). When nothing matches well, use null
and the system will route to the default_assignee.
- Each child task body is what a fresh worker will read with no other
context be specific about goal, approach, and acceptance criteria.
When the task is genuinely a single unit of work (no useful decomposition),
return:
{
"fanout": false,
"rationale": "<one sentence>",
"title": "<tightened title>",
"body": "<concrete spec for a single worker>",
"assignee": "<profile name from the roster, or null for default>"
}
In that case the task stays as one work item, just with a tightened spec and
a concrete assignee. If no profile fits, use null and the system will route to
the default_assignee.
No preamble, no closing remarks, no code fences. Output only the JSON object.
"""
_USER_TEMPLATE = """Task id: {task_id}
Title: {title}
Body:
{body}
Available profiles (assignees you may pick from):
{roster}
Default assignee (used when no profile fits a task): {default_assignee}
"""
_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
@dataclass
class DecomposeOutcome:
"""Result of decomposing a single triage task."""
task_id: str
ok: bool
reason: str = ""
fanout: bool = False
child_ids: list[str] | None = None
new_title: Optional[str] = None
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 1] + ""
def _extract_json_blob(raw: str) -> Optional[dict]:
if not raw:
return None
stripped = _FENCE_RE.sub("", raw.strip())
first = stripped.find("{")
last = stripped.rfind("}")
if first == -1 or last == -1 or last <= first:
return None
candidate = stripped[first : last + 1]
try:
val = json.loads(candidate)
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(val, dict):
return None
return val
def _profile_author() -> str:
"""Mirror of ``hermes_cli.kanban._profile_author``."""
return (
os.environ.get("HERMES_PROFILE")
or os.environ.get("USER")
or "decomposer"
)
def _load_config() -> dict:
try:
from hermes_cli.config import load_config
return load_config() or {}
except Exception:
return {}
def _resolve_orchestrator_profile(cfg: dict) -> str:
"""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.
"""
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
explicit = (kanban_cfg.get("orchestrator_profile") or "").strip()
if explicit:
try:
if profiles_mod.profile_exists(explicit):
return explicit
except Exception:
pass
# Fall back to the active default profile.
try:
return profiles_mod.get_active_profile_name() or "default"
except Exception:
return "default"
def _resolve_default_assignee(cfg: dict) -> str:
"""Resolve which profile catches child tasks the orchestrator can't route."""
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
explicit = (kanban_cfg.get("default_assignee") or "").strip()
if explicit:
try:
if profiles_mod.profile_exists(explicit):
return explicit
except Exception:
pass
try:
return profiles_mod.get_active_profile_name() or "default"
except Exception:
return "default"
def _build_roster() -> tuple[list[dict], set[str]]:
"""Return (roster_for_prompt, valid_assignee_names).
Each roster entry is ``{name, description, has_description}``. The
valid-set is used after the LLM responds to rewrite invalid
assignees to the default fallback.
"""
roster: list[dict] = []
valid: set[str] = set()
try:
all_profiles = profiles_mod.list_profiles()
except Exception as exc:
logger.warning("decompose: failed to list profiles: %s", exc)
return roster, valid
for p in all_profiles:
desc = (p.description or "").strip()
roster.append({
"name": p.name,
"description": desc or f"(no description; profile named {p.name!r})",
"has_description": bool(desc),
})
valid.add(p.name)
return roster, valid
def _format_roster(roster: list[dict]) -> str:
if not roster:
return " (no profiles installed — decomposer cannot route work)"
lines = []
for entry in roster:
tag = "" if entry["has_description"] else " ⚠ undescribed"
lines.append(f" - {entry['name']}{tag}: {entry['description']}")
return "\n".join(lines)
def _normalize_assignee_choice(
assignee: object,
*,
default_assignee: str,
valid_names: set[str],
) -> str:
"""Return a valid assignee, falling back to ``default_assignee``.
Fan-out children and the single-task fallback should share the same
routing guarantee: promoted work must not be left unassigned.
"""
if not isinstance(assignee, str) or not assignee.strip():
return default_assignee
chosen = assignee.strip()
if chosen not in valid_names:
return default_assignee
return chosen
def decompose_task(
task_id: str,
*,
author: Optional[str] = None,
timeout: Optional[int] = None,
) -> DecomposeOutcome:
"""Decompose a triage task into a graph of child tasks.
Returns an outcome describing what happened. Never raises for
expected failure modes (task not in triage, no aux client
configured, API error, malformed response, decomposer returned
fanout=true with empty task list) those surface via ``ok=False``.
"""
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")
if task.status != "triage":
return DecomposeOutcome(
task_id, False, f"task is not in triage (status={task.status!r})"
)
cfg = _load_config()
orchestrator = _resolve_orchestrator_profile(cfg)
default_assignee = _resolve_default_assignee(cfg)
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
auto_promote = bool(kanban_cfg.get("auto_promote_children", True))
roster, valid_names = _build_roster()
try:
from agent.auxiliary_client import call_llm # type: ignore
except Exception as exc:
logger.debug("decompose: auxiliary client import failed: %s", exc)
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
user_msg = _USER_TEMPLATE.format(
task_id=task.id,
title=_truncate(task.title or "", 400),
body=_truncate(task.body or "(no body)", 4000),
roster=_format_roster(roster),
default_assignee=default_assignee,
)
try:
# Route through call_llm so auxiliary.kanban_decomposer.* config
# (provider/model/base_url, extra_body, reasoning_effort, retries)
# all apply — the previous direct client.chat.completions.create()
# path dropped auxiliary.<task>.extra_body entirely (#35566).
resp = call_llm(
task="kanban_decomposer",
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.3,
max_tokens=4000,
timeout=timeout or 180,
)
except Exception as exc:
logger.info(
"decompose: API call failed for %s (%s)", task_id, exc,
)
return DecomposeOutcome(task_id, False, f"LLM error: {type(exc).__name__}")
try:
raw = resp.choices[0].message.content or ""
except Exception:
raw = ""
parsed = _extract_json_blob(raw)
if parsed is None:
return DecomposeOutcome(task_id, False, "LLM returned malformed JSON")
fanout = bool(parsed.get("fanout"))
audit_author = author or _profile_author()
if not fanout:
# Fall back to single-task spec promotion (same effect as specify).
new_title = parsed.get("title")
new_body = parsed.get("body")
title_val = new_title.strip() if isinstance(new_title, str) and new_title.strip() else None
body_val = new_body if isinstance(new_body, str) and new_body.strip() else None
assignee_val = None
if not task.assignee:
assignee_val = _normalize_assignee_choice(
parsed.get("assignee"),
default_assignee=default_assignee,
valid_names=valid_names,
)
if title_val is None and body_val is None:
return DecomposeOutcome(
task_id, False, "decomposer returned fanout=false with no title/body",
)
with kb.connect_closing() as conn:
ok = kb.specify_triage_task(
conn,
task_id,
title=title_val,
body=body_val,
assignee=assignee_val,
author=audit_author,
)
if not ok:
return DecomposeOutcome(
task_id, False, "task moved out of triage before promotion",
)
return DecomposeOutcome(
task_id, True, "single task (no fanout)",
fanout=False, new_title=title_val,
)
raw_tasks = parsed.get("tasks") or []
if not isinstance(raw_tasks, list) or not raw_tasks:
return DecomposeOutcome(
task_id, False, "decomposer returned fanout=true with empty tasks list",
)
# Rewrite invalid assignees to the default fallback. Never leave a
# task with assignee=None — the user explicitly does not want that.
children: list[dict] = []
for idx, entry in enumerate(raw_tasks):
if not isinstance(entry, dict):
return DecomposeOutcome(
task_id, False, f"tasks[{idx}] is not an object",
)
title = entry.get("title")
if not isinstance(title, str) or not title.strip():
return DecomposeOutcome(
task_id, False, f"tasks[{idx}].title is missing or empty",
)
body = entry.get("body")
if not isinstance(body, str):
body = ""
assignee = entry.get("assignee")
chosen = _normalize_assignee_choice(
assignee,
default_assignee=default_assignee,
valid_names=valid_names,
)
if (
isinstance(assignee, str)
and assignee.strip()
and assignee.strip() not in valid_names
):
logger.info(
"decompose: task %s child %d picked unknown assignee %r"
"routing to default_assignee %r",
task_id, idx, assignee, default_assignee,
)
parents = entry.get("parents") or []
if not isinstance(parents, list):
parents = []
# Clean parent indices: drop non-int and out-of-range.
clean_parents = [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx]
children.append({
"title": title.strip()[:200],
"body": body.strip(),
"assignee": chosen,
"parents": clean_parents,
})
try:
with kb.connect_closing() as conn:
child_ids = kb.decompose_triage_task(
conn,
task_id,
root_assignee=orchestrator,
children=children,
author=audit_author,
auto_promote=auto_promote,
)
except ValueError as exc:
return DecomposeOutcome(task_id, False, f"DB rejected graph: {exc}")
except Exception as exc:
logger.exception("decompose: DB error on task %s", task_id)
return DecomposeOutcome(task_id, False, f"DB error: {type(exc).__name__}")
if child_ids is None:
return DecomposeOutcome(
task_id, False, "task moved out of triage before decomposition",
)
return DecomposeOutcome(
task_id, True, f"decomposed into {len(child_ids)} children",
fanout=True, child_ids=child_ids,
)
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
"""Return task ids currently in the triage column."""
with kb.connect_closing() as conn:
rows = kb.list_tasks(
conn,
status="triage",
tenant=tenant,
limit=1000,
)
return [row.id for row in rows]
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
"""Kanban triage specifier — flesh out a one-liner into a real spec.
Used by ``hermes kanban specify [task_id | --all]``. Takes a task that
lives in the Triage column (a rough idea, typically only a title), calls
the auxiliary LLM to produce:
* A tightened title (optional only replaces if the model proposes a
materially different one)
* A concrete body: goal, proposed approach, acceptance criteria
and then flips the task ``triage -> todo`` via
``kanban_db.specify_triage_task``. The dispatcher promotes it to
``ready`` on its next tick (or immediately if there are no open parents).
Design notes
------------
* This module intentionally mirrors ``hermes_cli/goals.py`` same aux
client pattern, same "empty config => skip, don't crash" tolerance.
Keeps the surface area tiny and the failure modes predictable.
* The prompt is a short system + user pair. We ask for JSON with
``{title, body}``; if parsing fails, we fall back to treating the
whole response as the body and leave the title untouched. No
retry loop one shot, keep cost bounded.
* Structured output / JSON mode is not requested explicitly so the
specifier works on providers that don't implement it. The parse
is lenient (tolerates markdown code fences around the JSON).
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Optional
from hermes_cli import kanban_db as kb
from utils import env_int
HERMES_KANBAN_SPECIFY_MAX_TOKENS = max(
1500,
env_int("HERMES_KANBAN_SPECIFY_MAX_TOKENS", 6000),
)
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """You are the Kanban triage specifier for the Hermes Agent board.
A user dropped a rough idea into the Triage column. Your job is to turn it
into a concrete, actionable task spec that an autonomous worker can pick up
and execute without further clarification.
Output a single JSON object with exactly two keys:
{
"title": "<tightened task title, <= 80 chars, imperative voice>",
"body": "<multi-line spec, see structure below>"
}
The body MUST include these sections, each prefixed with a bold markdown
heading, in this order:
**Goal** one sentence, user-facing outcome.
**Approach** 2-5 bullets on how a worker should tackle it.
**Acceptance criteria** checklist of concrete, verifiable conditions.
**Out of scope** short list of things NOT to touch (omit if nothing
obvious; never invent scope creep).
Rules:
- Keep the tightened title close in meaning to the original idea do
NOT invent a different project.
- If the original idea is already detailed, preserve its substance and
just reformat into the sections above.
- Never add invented requirements the user didn't hint at.
- No preamble, no closing remarks, no code fences around the JSON.
- Output only the JSON object and nothing else.
"""
_USER_TEMPLATE = """Task id: {task_id}
Current title: {title}
Current body:
{body}
"""
@dataclass
class SpecifyOutcome:
"""Result of specifying a single triage task."""
task_id: str
ok: bool
reason: str = ""
new_title: Optional[str] = None
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 1] + ""
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
def _extract_json_blob(raw: str) -> Optional[dict]:
"""Lenient JSON extraction — tolerates fenced code blocks and
leading/trailing whitespace. Returns None if nothing parses."""
if not raw:
return None
stripped = _FENCE_RE.sub("", raw.strip())
# Greedy: find the first `{` and last `}` and try that slice.
first = stripped.find("{")
last = stripped.rfind("}")
if first == -1 or last == -1 or last <= first:
return None
candidate = stripped[first : last + 1]
try:
val = json.loads(candidate)
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(val, dict):
return None
return val
def _profile_author() -> str:
"""Mirror of ``hermes_cli.kanban._profile_author``. Kept local to
avoid a circular import when kanban.py imports this module."""
return (
os.environ.get("HERMES_PROFILE")
or os.environ.get("USER")
or "specifier"
)
def specify_task(
task_id: str,
*,
author: Optional[str] = None,
timeout: Optional[int] = None,
) -> SpecifyOutcome:
"""Specify a single triage task and promote it to ``todo``.
Returns an outcome describing what happened. Never raises for expected
failure modes (task not in triage, no aux client configured, API
error, malformed response) those surface via ``ok=False`` so the
``--all`` sweep can continue past individual failures.
"""
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")
if task.status != "triage":
return SpecifyOutcome(
task_id, False, f"task is not in triage (status={task.status!r})"
)
try:
from agent.auxiliary_client import call_llm
except Exception as exc: # pragma: no cover — import smoke test
logger.debug("specify: auxiliary client import failed: %s", exc)
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
user_msg = _USER_TEMPLATE.format(
task_id=task.id,
title=_truncate(task.title or "", 400),
body=_truncate(task.body or "(no body)", 4000),
)
try:
# Route through call_llm so auxiliary.triage_specifier.* config
# (provider/model/base_url, extra_body, reasoning_effort, retries)
# all apply — the direct-create path dropped extra_body (#35566).
resp = call_llm(
task="triage_specifier",
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.3,
max_tokens=HERMES_KANBAN_SPECIFY_MAX_TOKENS,
timeout=timeout or 120,
)
except Exception as exc:
logger.info(
"specify: API call failed for %s (%s) — skipping",
task_id, exc,
)
return SpecifyOutcome(
task_id, False, f"LLM error: {type(exc).__name__}"
)
try:
raw = (resp.choices[0].message.content or "").strip()
except Exception:
raw = ""
parsed = _extract_json_blob(raw)
new_title: Optional[str]
new_body: Optional[str]
if parsed is None:
# Fall back: treat the whole reply as the body, leave title as-is.
# Worst case the user edits afterward — still better than stranding
# the task in triage on a malformed LLM reply.
stripped_raw = raw.strip()
if not stripped_raw:
return SpecifyOutcome(
task_id, False, "LLM returned an empty response"
)
new_title = None
new_body = stripped_raw
else:
title_val = parsed.get("title")
body_val = parsed.get("body")
new_title = (
title_val.strip()
if isinstance(title_val, str) and title_val.strip()
else None
)
new_body = (
body_val if isinstance(body_val, str) and body_val.strip() else None
)
if new_body is None and new_title is None:
return SpecifyOutcome(
task_id, False, "LLM response missing title and body"
)
with kb.connect_closing() as conn:
ok = kb.specify_triage_task(
conn,
task_id,
title=new_title,
body=new_body,
author=author or _profile_author(),
)
if not ok:
# Race: someone else promoted / archived the task between our
# read above and the write. Report, don't crash.
return SpecifyOutcome(
task_id, False, "task moved out of triage before promotion"
)
return SpecifyOutcome(task_id, True, "specified", new_title=new_title)
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
"""Return task ids currently in the triage column.
``tenant`` narrows the sweep; ``None`` returns every triage task.
"""
with kb.connect_closing() as conn:
tasks = kb.list_tasks(
conn,
status="triage",
tenant=tenant,
include_archived=False,
)
return [t.id for t in tasks]
+390
View File
@@ -0,0 +1,390 @@
"""Kanban Swarm v1: thin swarm topology helpers on top of Kanban.
This module intentionally does not introduce a second scheduler. It writes a
small task graph into the existing Kanban kernel:
planning root (completed immediately)
parallel specialist workers (ready)
verifier (todo until all workers done)
synthesizer (todo until verifier done)
The shared blackboard is also deliberately low-tech: structured JSON comments on
the root task. That keeps all state in existing task_comments/task_events rows,
so the dashboard, notifier, slash command, and dispatcher keep working without a
new service.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import json
import sqlite3
from typing import Any, Iterable, Optional
from hermes_cli import kanban_db as kb
BLACKBOARD_PREFIX = "[swarm:blackboard] "
@dataclass(frozen=True)
class SwarmWorkerSpec:
"""A single parallel worker card in a swarm."""
profile: str
title: str
body: str
skills: list[str] = field(default_factory=list)
priority: int = 0
max_runtime_seconds: Optional[int] = None
@dataclass(frozen=True)
class SwarmCreated:
"""IDs produced by :func:`create_swarm`."""
root_id: str
worker_ids: list[str]
verifier_id: str
synthesizer_id: str
def as_dict(self) -> dict[str, Any]:
return {
"root_id": self.root_id,
"worker_ids": list(self.worker_ids),
"verifier_id": self.verifier_id,
"synthesizer_id": self.synthesizer_id,
}
def _require_text(value: str, field_name: str) -> str:
text = (value or "").strip()
if not text:
raise ValueError(f"{field_name} is required")
return text
def _swarm_context(root_id: str, goal: str) -> str:
return (
"\n\n## Swarm protocol\n"
f"- Swarm root / shared blackboard: `{root_id}`.\n"
"- Read sibling/parent handoffs from Kanban context before working.\n"
"- Put machine-readable facts in completion metadata.\n"
"- Put cross-worker notes on the root task using structured comments.\n"
f"- Goal: {goal.strip()}\n"
)
def _activate_root_inline(
conn: sqlite3.Connection,
root_id: str,
*,
summary: str,
metadata: dict[str, Any],
) -> bool:
"""Inline blocked→done CAS flip + event insert for the swarm root.
Runs INSIDE create_swarm's outer write_txn, so it must not call
``kb.complete_task`` that helper opens its own transaction and fires
post-commit side effects (workspace cleanup, failure-counter clear,
``recompute_ready``) that would execute while the outer transaction can
still roll back. Instead we do the minimal durable writes here and let
the caller run ``recompute_ready`` after the outer commit.
"""
import time as _time
now = int(_time.time())
cur = conn.execute(
"""
UPDATE tasks
SET status = 'done',
completed_at = ?,
claim_lock = NULL,
claim_expires= NULL,
worker_pid = NULL
WHERE id = ?
AND status = 'blocked'
""",
(now, root_id),
)
if cur.rowcount != 1:
return False
run_id = kb._synthesize_ended_run(
conn,
root_id,
outcome="completed",
summary=summary,
metadata=metadata,
)
kb._append_event(
conn,
root_id,
"completed",
{"result_len": 0, "summary": summary[:400] or None},
run_id=run_id,
)
return True
def create_swarm(
conn: sqlite3.Connection,
*,
goal: str,
workers: Iterable[SwarmWorkerSpec],
verifier_assignee: str,
synthesizer_assignee: str,
root_title: Optional[str] = None,
verifier_title: str = "Verify swarm outputs",
synthesizer_title: str = "Synthesize swarm outputs",
tenant: Optional[str] = None,
created_by: str = "swarm-orchestrator",
workspace_kind: str = "scratch",
workspace_path: Optional[str] = None,
priority: int = 0,
idempotency_key: Optional[str] = None,
) -> SwarmCreated:
"""Atomically create a durable, immediately dispatchable Kanban swarm."""
activation_summary = (
"Swarm topology planned; root remains the shared blackboard."
)
activated = False
with kb.write_txn(conn):
created = _create_swarm_uncommitted(
conn,
goal=goal,
workers=workers,
verifier_assignee=verifier_assignee,
synthesizer_assignee=synthesizer_assignee,
root_title=root_title,
verifier_title=verifier_title,
synthesizer_title=synthesizer_title,
tenant=tenant,
created_by=created_by,
workspace_kind=workspace_kind,
workspace_path=workspace_path,
priority=priority,
idempotency_key=idempotency_key,
)
root = kb.get_task(conn, created.root_id)
if root is not None and root.status == "blocked":
if not _activate_root_inline(
conn,
created.root_id,
summary=activation_summary,
metadata={
"kind": "kanban_swarm_v1",
"goal": goal.strip(),
"worker_count": len(created.worker_ids),
},
):
raise RuntimeError("could not activate the completed swarm topology")
activated = True
if activated:
# Outside the outer transaction: promote the root's children now
# that its 'done' flip is durable (recompute_ready opens its own
# txn and must never run under an open write_txn).
kb.recompute_ready(conn)
root = kb.get_task(conn, created.root_id)
run = kb.latest_run(conn, created.root_id)
kb._fire_kanban_lifecycle_hook(
"kanban_task_completed",
created.root_id,
board=kb.get_current_board(),
assignee=root.assignee if root else None,
run_id=run.id if run else None,
summary=activation_summary,
)
return created
def _create_swarm_uncommitted(
conn: sqlite3.Connection,
*,
goal: str,
workers: Iterable[SwarmWorkerSpec],
verifier_assignee: str,
synthesizer_assignee: str,
root_title: Optional[str] = None,
verifier_title: str = "Verify swarm outputs",
synthesizer_title: str = "Synthesize swarm outputs",
tenant: Optional[str] = None,
created_by: str = "swarm-orchestrator",
workspace_kind: str = "scratch",
workspace_path: Optional[str] = None,
priority: int = 0,
idempotency_key: Optional[str] = None,
) -> SwarmCreated:
"""Create a durable Kanban swarm graph.
The returned graph is immediately dispatchable: the planning root is marked
``done`` with topology metadata, parallel workers are ``ready``, the verifier
waits for every worker, and the synthesizer waits for the verifier.
"""
goal = _require_text(goal, "goal")
verifier_assignee = _require_text(verifier_assignee, "verifier_assignee")
synthesizer_assignee = _require_text(synthesizer_assignee, "synthesizer_assignee")
worker_specs = list(workers)
if not worker_specs:
raise ValueError("at least one worker is required")
for i, spec in enumerate(worker_specs, start=1):
_require_text(spec.profile, f"workers[{i}].profile")
_require_text(spec.title, f"workers[{i}].title")
root = kb.create_task(
conn,
title=root_title or f"Swarm: {goal.splitlines()[0][:80]}",
body=(
"Kanban Swarm v1 planning/root card. This card is completed "
"immediately so parallel workers can start while it remains the "
"shared blackboard and audit anchor.\n\n"
f"Goal:\n{goal}"
),
assignee=created_by,
created_by=created_by,
tenant=tenant,
priority=priority,
idempotency_key=idempotency_key,
initial_status="blocked",
workspace_kind=workspace_kind,
workspace_path=workspace_path,
)
# If idempotency returned an existing non-archived root, do not duplicate the
# swarm graph. Recover the topology from the root's latest blackboard, if it
# was created by this helper previously.
existing = latest_blackboard(conn, root).get("topology")
if isinstance(existing, dict):
worker_ids = [str(x) for x in existing.get("worker_ids", []) if x]
verifier_id = existing.get("verifier_id")
synthesizer_id = existing.get("synthesizer_id")
if worker_ids and verifier_id and synthesizer_id:
return SwarmCreated(
root_id=root,
worker_ids=worker_ids,
verifier_id=str(verifier_id),
synthesizer_id=str(synthesizer_id),
)
context_suffix = _swarm_context(root, goal)
worker_ids: list[str] = []
for spec in worker_specs:
worker_id = kb.create_task(
conn,
title=spec.title,
body=(spec.body or "") + context_suffix,
assignee=spec.profile,
created_by=created_by,
parents=[root],
tenant=tenant,
priority=spec.priority or priority,
workspace_kind=workspace_kind,
workspace_path=workspace_path,
skills=spec.skills or None,
max_runtime_seconds=spec.max_runtime_seconds,
)
worker_ids.append(worker_id)
verifier_body = (
"Review every worker handoff and blackboard update. Gate the swarm: "
"complete only with metadata {\"gate\": \"pass\"} when evidence is "
"sufficient; otherwise block with exact missing work."
+ context_suffix
)
verifier = kb.create_task(
conn,
title=verifier_title,
body=verifier_body,
assignee=verifier_assignee,
created_by=created_by,
parents=worker_ids,
tenant=tenant,
priority=priority,
workspace_kind=workspace_kind,
workspace_path=workspace_path,
skills=["requesting-code-review"],
)
synthesizer_body = (
"Synthesize the verified worker outputs into the final deliverable. "
"Do not start until the verifier has passed the gate."
+ context_suffix
)
synthesizer = kb.create_task(
conn,
title=synthesizer_title,
body=synthesizer_body,
assignee=synthesizer_assignee,
created_by=created_by,
parents=[verifier],
tenant=tenant,
priority=priority,
workspace_kind=workspace_kind,
workspace_path=workspace_path,
skills=["humanizer"],
)
created = SwarmCreated(root, worker_ids, verifier, synthesizer)
post_blackboard_update(
conn,
root,
author=created_by,
key="topology",
value=created.as_dict() | {"goal": goal},
)
return created
def post_blackboard_update(
conn: sqlite3.Connection,
root_id: str,
*,
author: str,
key: str,
value: Any,
) -> int:
"""Append one structured update to the swarm root blackboard."""
_require_text(root_id, "root_id")
author = _require_text(author, "author")
key = _require_text(key, "key")
payload = json.dumps({"key": key, "value": value}, ensure_ascii=False, sort_keys=True)
return kb.add_comment(conn, root_id, author=author, body=BLACKBOARD_PREFIX + payload)
def latest_blackboard(conn: sqlite3.Connection, root_id: str) -> dict[str, Any]:
"""Merge structured blackboard comments on a root card.
Later comments replace earlier values for the same key. ``_authors`` records
the author of the winning value for traceability.
"""
merged: dict[str, Any] = {}
authors: dict[str, str] = {}
for comment in kb.list_comments(conn, root_id):
body = comment.body or ""
if not body.startswith(BLACKBOARD_PREFIX):
continue
try:
payload = json.loads(body[len(BLACKBOARD_PREFIX):])
except json.JSONDecodeError:
continue
key = payload.get("key")
if not isinstance(key, str) or not key:
continue
merged[key] = payload.get("value")
authors[key] = comment.author
if authors:
merged["_authors"] = authors
return merged
def parse_worker_arg(raw: str) -> SwarmWorkerSpec:
"""Parse CLI ``--worker profile:title[:skill,skill]`` values."""
parts = [p.strip() for p in raw.split(":", 2)]
if len(parts) < 2:
raise ValueError("worker must be profile:title or profile:title:skill,skill")
skills: list[str] = []
if len(parts) == 3 and parts[2]:
skills = [s.strip() for s in parts[2].split(",") if s.strip()]
return SwarmWorkerSpec(profile=parts[0], title=parts[1], body=parts[1], skills=skills)
+478
View File
@@ -0,0 +1,478 @@
"""Kanban board export / import — move a whole board between machines.
Backs ``hermes kanban export|import``, the matching ``/boards/{slug}/export``
and ``/boards/import`` REST endpoints, and the desktop board switcher's
Export/Import items.
Archive layout (``<slug>.tar.gz``, one top-level directory named for the
source board's slug)::
<slug>/
manifest.json format + version + provenance + row counts
board.json display metadata, machine-local fields stripped
kanban.db consistent snapshot of the board database
attachments/<task>/ attachment blobs (unless --no-attachments)
logs/<task>.log worker logs (only with --include-logs)
Two things make this more than a ``tar czf`` of the board directory.
**The database is live.** Kanban runs in WAL mode and a dispatcher may be
mid-write, so copying ``kanban.db`` off the filesystem yields a torn
snapshot that is missing whatever still sits in the ``-wal`` file. Export
goes through SQLite's online-backup API instead, which produces a
consistent single-file image of a database that is being written to.
**Rows carry machine-local state.** Claims, PIDs, heartbeats, absolute
workspace and attachment paths, gateway chat subscriptions, and session
ids are all meaningful only on the machine that wrote them. Shipping them
verbatim is how an imported board arrives holding claims owned by a
process on somebody else's laptop, or starts pushing task events into a
stranger's Telegram thread. Everything machine-local is scrubbed on the
export side (so the archive itself never carries it) and defensively
re-scrubbed on import; see :func:`_scrub_local_state` and
:func:`_relocate_imported_rows`.
Imports always land as a **new** board the slug auto-suffixes on
collision so an import can never mutate a board that is already there.
That also means an imported board is never ``default``, which is what
lets the import side ignore the default board's split on-disk layout
(``<root>/kanban.db`` beside ``<root>/kanban/attachments/``) and put
everything inside one ``boards/<slug>/`` directory.
"""
from __future__ import annotations
import contextlib
import json
import shutil
import sqlite3
import tempfile
import time
from pathlib import Path
from typing import Any, Optional
from hermes_cli import kanban_db as kb
from hermes_cli.archive_safe import (
archive_root_dirs,
copy_regular_files,
make_targz,
safe_extract_targz,
)
ARCHIVE_FORMAT = "hermes-kanban-board"
ARCHIVE_FORMAT_VERSION = 1
# Statuses from which the dispatcher can still act on a task. A task whose
# workspace cannot be rebuilt on this machine is parked in ``triage`` only
# if it is in one of these — terminal and already-parked tasks are left
# alone rather than having their history rewritten.
_DISPATCHABLE_STATUSES = ("ready", "running", "todo", "scheduled")
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
def _snapshot_db(source: Path, target: Path) -> None:
"""Write a consistent copy of ``source`` to ``target``.
Uses SQLite's online-backup API rather than a file copy: in WAL mode
a just-committed page can still live in the ``-wal`` sidecar, so
copying only ``kanban.db`` loses recent writes and can produce a
torn image if the dispatcher commits mid-copy.
"""
src = sqlite3.connect(str(source))
try:
dst = sqlite3.connect(str(target))
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
def _scrub_local_state(conn: sqlite3.Connection) -> None:
"""Strip machine-local runtime state. Caller owns the transaction.
Runs on the export side so the archive itself never carries another
machine's claims, PIDs, or — the one that actually matters for a
board shared with someone else the gateway chat ids subscribed to
its task events. Repeated on import because an archive is untrusted
input.
"""
conn.execute("DELETE FROM kanban_notify_subs")
conn.execute(
"""
UPDATE tasks
SET claim_lock = NULL,
claim_expires = NULL,
worker_pid = NULL,
current_run_id = NULL,
last_heartbeat_at = NULL,
session_id = NULL,
project_id = NULL,
consecutive_failures = 0,
last_failure_error = NULL
"""
)
# A task caught mid-run is not running anywhere the importer can see.
# Send it back to the queue rather than shipping a phantom claim.
conn.execute("UPDATE tasks SET status = 'ready' WHERE status = 'running'")
conn.execute(
"""
UPDATE task_runs
SET status = 'released',
outcome = COALESCE(outcome, 'reclaimed'),
ended_at = COALESCE(ended_at, ?),
last_heartbeat_at = NULL
WHERE status = 'running'
""",
(int(time.time()),),
)
conn.execute("UPDATE task_runs SET claim_lock = NULL, worker_pid = NULL")
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
def _count_rows(conn: sqlite3.Connection) -> dict[str, int]:
tables = (
"tasks", "task_links", "task_comments",
"task_events", "task_runs", "task_attachments",
)
return {
t: int(conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0])
for t in tables
}
def export_board(
board: Optional[str],
output_path: str,
*,
include_attachments: bool = True,
include_logs: bool = False,
) -> dict[str, Any]:
"""Export ``board`` to a ``tar.gz`` archive. Returns a summary dict.
``output_path`` may be given with or without the ``.tar.gz`` suffix.
Workspaces are never included: they are git worktrees and scratch
trees that are large, machine-local, and rebuilt on demand.
"""
slug = kb._normalize_board_slug(board) or kb.get_current_board()
if not kb.board_exists(slug):
raise ValueError(f"board {slug!r} does not exist")
db_path = kb.kanban_db_path(slug)
if not db_path.exists():
raise FileNotFoundError(f"board {slug!r} has no database at {db_path}")
output = Path(output_path).expanduser()
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
Path(base).parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmpdir:
staged = Path(tmpdir) / slug
staged.mkdir(parents=True)
_snapshot_db(db_path, staged / "kanban.db")
# The snapshot is a private file with no other writers, so plain
# commit/close is enough — no need for the board DB's WAL dance.
with contextlib.closing(sqlite3.connect(str(staged / "kanban.db"))) as snapshot:
_scrub_local_state(snapshot)
snapshot.commit()
counts = _count_rows(snapshot)
meta = kb.read_board_metadata(slug)
# Both name a location on the exporting machine; the importer
# resolves its own.
meta.pop("db_path", None)
meta["default_workdir"] = None
meta["project_id"] = None
_write_json(staged / "board.json", meta)
attachments = 0
if include_attachments:
attachments = copy_regular_files(
kb.attachments_root(slug), staged / "attachments"
)
logs = 0
if include_logs:
logs = copy_regular_files(
kb.worker_logs_dir(slug), staged / "logs"
)
try:
from hermes_cli import __version__ as hermes_version
except Exception:
hermes_version = ""
manifest = {
"format": ARCHIVE_FORMAT,
"format_version": ARCHIVE_FORMAT_VERSION,
"board": slug,
"board_name": meta.get("name") or slug,
"exported_at": int(time.time()),
"hermes_version": str(hermes_version),
"includes": {
"attachments": bool(include_attachments),
"logs": bool(include_logs),
},
"counts": {**counts, "attachment_files": attachments, "log_files": logs},
}
_write_json(staged / "manifest.json", manifest)
archive = make_targz(base, tmpdir, slug)
return {
"board": slug,
"archive": archive,
"size": Path(archive).stat().st_size,
"counts": manifest["counts"],
}
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
def _available_slug(preferred: str) -> str:
"""Return ``preferred``, or the first free ``<preferred>-N`` variant.
``default`` always reports as existing, so an archive exported from a
default board naturally lands as ``default-2`` instead of colliding
with the importer's own default board.
"""
if not kb.board_exists(preferred):
return preferred
# Leave headroom for the suffix inside the 64-char slug limit.
stem = preferred[:58].rstrip("-_") or "board"
n = 2
while True:
candidate = f"{stem}-{n}"
if not kb.board_exists(candidate):
return candidate
n += 1
def _read_manifest(root: Path) -> dict[str, Any]:
path = root / "manifest.json"
if not path.exists():
raise ValueError(
"archive is not a Hermes kanban board export (no manifest.json)"
)
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"archive manifest is not valid JSON: {exc}") from exc
if not isinstance(manifest, dict) or manifest.get("format") != ARCHIVE_FORMAT:
raise ValueError(
"archive is not a Hermes kanban board export "
f"(format={manifest.get('format') if isinstance(manifest, dict) else None!r})"
)
version = manifest.get("format_version")
if not isinstance(version, int) or version > ARCHIVE_FORMAT_VERSION:
raise ValueError(
f"archive format version {version!r} is newer than this Hermes "
f"understands (max {ARCHIVE_FORMAT_VERSION}) — update Hermes and retry"
)
return manifest
def _read_board_metadata(path: Path) -> dict[str, Any]:
"""Read an archive's ``board.json``, tolerating a missing/broken file."""
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return raw if isinstance(raw, dict) else {}
def _relocate_imported_rows(
conn: sqlite3.Connection, slug: str
) -> tuple[dict[str, int], list[str]]:
"""Re-anchor an imported board's rows to this machine.
Returns ``(stats, warnings)``. Three things move:
* Attachment rows are repointed at this board's attachments tree.
Rows whose blob did not travel (an export made with
``--no-attachments``) are dropped, because a row pointing at a file
that does not exist breaks download in every UI that lists it.
* Workspace paths are cleared. ``scratch`` tasks regenerate one under
this board on the next claim, so they are simply reset. ``dir`` and
``worktree`` tasks cannot be resolved without a path that means
something here, so any that are still dispatchable are parked in
``triage`` otherwise the dispatcher claims them, fails to build a
workspace, and burns them straight into the failure breaker.
* Runtime state is scrubbed again. Export already did this, but an
archive is an untrusted input and the cost is one UPDATE.
"""
warnings: list[str] = []
now = int(time.time())
attachments_dir = kb.attachments_root(slug)
with kb.write_txn(conn):
_scrub_local_state(conn)
dropped = 0
rehomed = 0
for row in conn.execute(
"SELECT id, task_id, stored_path FROM task_attachments"
).fetchall():
landed = attachments_dir / row["task_id"] / Path(row["stored_path"]).name
if landed.is_file():
conn.execute(
"UPDATE task_attachments SET stored_path = ? WHERE id = ?",
(str(landed), row["id"]),
)
rehomed += 1
else:
conn.execute(
"DELETE FROM task_attachments WHERE id = ?", (row["id"],)
)
dropped += 1
if dropped:
warnings.append(
f"{dropped} attachment record(s) dropped — the files were not "
f"in the archive"
)
parked = [
r["id"]
for r in conn.execute(
"SELECT id FROM tasks WHERE workspace_kind IN ('dir', 'worktree') "
f"AND status IN ({', '.join('?' * len(_DISPATCHABLE_STATUSES))})",
_DISPATCHABLE_STATUSES,
).fetchall()
]
conn.execute("UPDATE tasks SET workspace_path = NULL, branch_name = NULL")
if parked:
conn.execute(
f"UPDATE tasks SET status = 'triage' "
f"WHERE id IN ({', '.join('?' * len(parked))})",
parked,
)
warnings.append(
f"{len(parked)} task(s) moved to triage — their workspace was a "
f"directory or git worktree on the exporting machine and needs "
f"to be pointed somewhere on this one"
)
for row in conn.execute("SELECT id FROM tasks").fetchall():
conn.execute(
"INSERT INTO task_events (task_id, run_id, kind, payload, created_at) "
"VALUES (?, NULL, 'imported', ?, ?)",
(
row["id"],
json.dumps(
{
"board": slug,
"parked": row["id"] in parked,
},
ensure_ascii=False,
),
now,
),
)
return {"attachments": rehomed, "parked": len(parked)}, warnings
def import_board(
archive_path: str,
slug: Optional[str] = None,
*,
activate: bool = False,
) -> dict[str, Any]:
"""Import a board archive as a new board. Returns a summary dict.
``slug`` overrides the name from the archive. Either way the final
slug auto-suffixes if it is taken, so an import never merges into or
overwrites an existing board.
"""
archive = Path(archive_path).expanduser()
if not archive.exists():
raise FileNotFoundError(f"archive not found: {archive}")
roots = archive_root_dirs(archive)
if len(roots) != 1:
raise ValueError(
"a kanban board archive must contain exactly one top-level directory"
)
archive_root = roots.pop()
with tempfile.TemporaryDirectory() as tmpdir:
staging = Path(tmpdir)
safe_extract_targz(archive, staging)
extracted = staging / archive_root
manifest = _read_manifest(extracted)
staged_db = extracted / "kanban.db"
if not staged_db.is_file():
raise ValueError("archive is missing kanban.db")
requested = kb._normalize_board_slug(
slug or manifest.get("board") or archive_root
)
if not requested:
raise ValueError(
"cannot determine a board name from the archive — pass one "
"explicitly with --as <slug>"
)
target = _available_slug(requested)
staged_meta = _read_board_metadata(extracted / "board.json")
board_root = kb.board_dir(target)
board_root.mkdir(parents=True, exist_ok=True)
shutil.move(str(staged_db), str(board_root / "kanban.db"))
for tree in ("attachments", "logs"):
src = extracted / tree
if src.is_dir():
shutil.move(str(src), str(board_root / tree))
# Rewritten rather than moved across: the archive's copy names a slug
# and a workdir that belong to the exporting machine.
name = str(staged_meta.get("name") or manifest.get("board_name") or target)
kb.write_board_metadata(
target,
name=name,
description=str(staged_meta.get("description") or ""),
icon=str(staged_meta.get("icon") or ""),
color=str(staged_meta.get("color") or ""),
archived=False,
)
# Bring the imported schema up to this install's version before the
# relocation pass writes to it.
kb.init_db(board=target)
with kb.connect_closing(board=target) as conn:
stats, warnings = _relocate_imported_rows(conn, target)
counts = _count_rows(conn)
if activate:
kb.set_current_board(target)
return {
"board": target,
"requested_board": requested,
"renamed": target != requested,
"name": name,
"path": str(kb.board_dir(target)),
"db_path": str(kb.kanban_db_path(target)),
"source": {
"board": manifest.get("board"),
"exported_at": manifest.get("exported_at"),
"hermes_version": manifest.get("hermes_version"),
},
"counts": counts,
"attachments_restored": stats["attachments"],
"tasks_parked": stats["parked"],
"warnings": warnings,
"activated": bool(activate),
}

Some files were not shown because too many files have changed in this diff Show More