Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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).",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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-...")
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 v2→v3 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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
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
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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__)}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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 · 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>'
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 4–5 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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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 "",
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()}"
|
||||
@@ -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
@@ -0,0 +1,350 @@
|
||||
"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
|
||||
|
||||
The connector⇄gateway 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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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))
|
||||
File diff suppressed because it is too large
Load Diff
+12191
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user