Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson d459868776 docs(analysis): map IDE coding-agent harness techniques onto Hermes
Study of the five harness subsystems that make in-editor coding agents
outperform the raw model (same models underneath), with a grounded map of
each onto the Hermes codebase: indexed semantic retrieval, retrieval-as-
accuracy-driver, decoupled apply model, ambient context, per-task routing.

Findings reference real files (tools/fuzzy_match.py, agent/auxiliary_client.py,
agent/prompt_builder.py, hermes_state.py). Identifies semantic codebase
retrieval as the one structural gap; apply-reliability and model-routing as
existing strengths.
2026-06-04 01:54:49 -05:00
21 changed files with 340 additions and 1414 deletions
-8
View File
@@ -245,14 +245,6 @@ def _install_npm(
needs ``typescript`` next to it; intelephense ships standalone).
"""
npm = shutil.which("npm")
if npm is None:
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
# (e.g. root FHS install whose symlink is missing, #38889).
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
if npm is None:
logger.info("[install] cannot install %s: npm not on PATH", pkg)
return None
+179
View File
@@ -0,0 +1,179 @@
# Why IDE-Embedded Coding Agents Feel Better — and Where Hermes Stands
A study of the *harness* techniques that make in-editor coding agents punch above
the raw model, and an honest map of which ones Hermes already implements, which it
approximates, and which it lacks.
## TL;DR
The leading IDE-embedded coding products are **not better models** — they call the
same frontier models (Claude, GPT) that power terminal agents like this one. Their
edge comes entirely from the **harness**: how context is retrieved and assembled,
how mechanical edits are applied reliably, and how cheap specialized models are
routed in for sub-tasks. The model is a commodity; the meal it's fed is not.
This document breaks the advantage into five concrete subsystems, each backed by
published engineering from the vendors, and maps each onto the Hermes codebase.
| # | Technique | What it buys | Hermes status |
|---|-----------|--------------|---------------|
| 1 | Indexed semantic retrieval (Merkle delta-sync + content-addressed embedding cache) | Knows the repo in ms; feeds the *right* snippets | ❌ **Gap** (grep/FTS5 only, no vector index) |
| 2 | Retrieval as the accuracy driver | +~12.5% answer accuracy (vendor eval) | ⚠️ **Approximated** (lexical search, not semantic) |
| 3 | Decoupled "apply" model + line-number-free search/replace | Frontier model only *reasons*; mechanical patching never botches the file | ✅ **Have an analog** (`tools/fuzzy_match.py`) |
| 4 | Ambient IDE context (cursor pos, selection, live diagnostics) | More intent-signal per token | ⚠️ **Partial** (context files + LSP, no live cursor/selection) |
| 5 | Per-task model routing (tiny model for autocomplete, frontier for reasoning) | Right tool per job | ✅ **Have** (`agent/auxiliary_client.py`) |
---
## 1. Indexed semantic retrieval — the actual moat
The headline trick is *not* the system prompt. It's a vector index over the repo
that is kept fresh cheaply:
- **Merkle tree for change detection.** Every file is SHA-256 hashed; folder hashes
derive from children; the root summarizes the repo. An edit changes only that
file's hash plus the path to the root, so the indexer walks **only the branches
that differ** instead of rescanning. (This is git's own content-addressing trick
repurposed for indexing.)
- **Syntax-aware chunking.** Changed files are split on function/class boundaries,
not arbitrary token windows, then embedded.
- **Content-addressed embedding cache.** Embeddings are keyed by the hash of the
chunk content. Re-indexing unchanged code is a cache hit → zero embedding cost.
Embedding is the expensive step, so this is the whole ballgame for speed.
- **Cross-clone index reuse.** Vendors observe that clones of one repo are ~92%
identical across an org; a "simhash" lets a new clone reuse a teammate's index,
collapsing time-to-first-query from hours (99th pct) to seconds. Access is gated
cryptographically: you can only compute a Merkle node's hash if you actually hold
the file, so results you can't *prove* you possess are dropped.
**Hermes status: this is the real gap.** Core Hermes has no vector index, no
embedding store, no Merkle delta-sync. Codebase awareness is achieved at task time
via lexical tools (`search_files` → ripgrep) and session recall via SQLite FTS5
(`hermes_state.py`, `tools/session_search_tool.py`). The only embedding-flavored
retrieval lives in an optional plugin (`plugins/memory/holographic/`), and even
that is FTS5-backed, not dense-vector.
This is a *defensible* design choice for a terminal-first agent — ripgrep over a
known working tree is fast, dependency-free, and always current — but it means
Hermes "discovers" a codebase cold each task rather than walking in pre-indexed.
## 2. Retrieval is the accuracy driver (empirically)
Vendor evals attribute **~+12.5% answer accuracy** and higher edit-retention to
semantic search alone — same model, better-retrieved context. This is the
empirical proof of the thesis: *a worse model with better context beats a better
model with worse context.*
**Hermes status: approximated, lexically.** Hermes gets the *shape* of this through
aggressive context assembly — `agent/prompt_builder.py` and `agent/system_prompt.py`
inject project context files (AGENTS.md / CLAUDE.md / .cursorrules), and
`agent/subdirectory_hints.py` surfaces local structure. What's missing is *ranked
semantic* retrieval: Hermes finds text by pattern, not by meaning. For
"where is the thing that does X" questions, lexical search is strictly weaker than
embeddings.
## 3. Decoupled apply model — the most underrated trick
Leading products split edits into two stages:
1. **Plan** — the frontier model emits a *terse* edit, often with
`// ... existing code ...` placeholders.
2. **Apply** — a separate, cheap, often self-hosted model turns that sketch into the
final file.
Why bother? Frontier models are *lazy and inaccurate* at large rewrites: they drop
code, emit `...`, "helpfully" reformat unrelated lines, miscount line numbers, and
can trap the agent in retry loops. Three published findings drive the design:
- **Whole-file rewrites beat diffs** for the model, because diffs force fewer output
tokens (less room to "think"), are out-of-distribution (models saw far more whole
files in training), and line numbers are tokenizer poison (a number is one token,
forcing a one-shot commit, and models can't count lines).
- So edits use **search/replace blocks with no line numbers**, with redundant
context lines so the parser tolerates model slips.
- **Speculative decoding** makes apply fast (~1000 tok/s) because the unchanged file
*is* the draft — and because that can't be built into hosted Anthropic/OpenAI
models, vendors train and self-host their own apply model.
**Hermes status: it has a deterministic analog, and it's good.**
`tools/fuzzy_match.py` implements an **8-strategy search/replace matcher** (exact →
line-trimmed → whitespace-normalized → indentation-flexible → escape-normalized →
trimmed-boundary → block-anchor → context-aware-similarity) that is *precisely* the
"tolerate model slips in line-number-free search/replace" idea — just solved with
`difflib.SequenceMatcher` instead of a trained model. `tools/patch_parser.py` and
`tools/file_tools.py` wire it into the `patch` tool. Hermes also already adopts the
correct *interface*: the model emits `old_string`/`new_string`, never line numbers.
Where the vendors go further: a *trained* apply model can reconstruct intent from a
sketch (resolve `// ... existing ...` placeholders against the real file), whereas a
fuzzy matcher can only locate-and-substitute text the model actually wrote. Hermes
trades that capability for zero latency, zero cost, and full determinism — a sound
trade for a local agent, but worth naming.
## 4. Ambient context — the editor's free advantage
Because the product *is* the editor, it injects for free: the open file, **cursor
position**, current selection, **live LSP/linter diagnostics**, and recent diffs.
Terminal agents must spend tool calls reconstructing all of this. More intent-signal
per token → better output from the same model.
**Hermes status: partial.** Hermes injects project context files and has LSP
plumbing (`agent/lsp/`), and the ACP adapter (`acp_adapter/`) gives editors a way to
feed edits/approvals back. What it lacks is the *passive* signal: it doesn't know
where your cursor is or what you've selected, because in a terminal there is no
cursor to read. The ACP integration narrows this gap when Hermes runs inside an
editor, but the default terminal surface is signal-poorer by construction.
## 5. Per-task model routing
Autocomplete uses a tiny fast model; chat uses a frontier model; apply uses the
custom fast model; the agent loop uses a frontier model plus tools. Nothing is
forced through one monolith.
**Hermes status: have it.** `agent/auxiliary_client.py` provides a routed auxiliary
model used for cheaper sub-tasks — title generation (`agent/title_generator.py`),
vision routing (`tools/computer_use/vision_routing.py`), background review
(`agent/background_review.py`), and conversation compression
(`agent/context_compressor.py`, `trajectory_compressor.py`). The pattern — reserve
the expensive model for reasoning, route mechanical sub-tasks to a cheap one — is
already core to Hermes.
---
## Synthesis: where Hermes wins, ties, and trails
**Ties or wins:**
- **Apply reliability** — the 8-strategy fuzzy matcher is a genuinely strong,
zero-cost analog to a trained apply model, and uses the same line-number-free
search/replace interface the research converged on.
- **Model routing** — auxiliary-client routing already reserves the frontier model
for reasoning.
- **Context-file injection & session memory** — robust, and FTS5 session search is a
real recall capability terminal-first.
**Trails:**
- **Semantic codebase retrieval** is the one structural gap. Hermes is lexical
(ripgrep + FTS5) where the leaders are dense-vector with a cheaply-maintained
index. This is the highest-leverage area if Hermes ever wants to close the
"feels like it already knows my repo" gap.
- **Ambient passive context** (cursor/selection/live diagnostics) is inherently
weaker outside an editor; the ACP path is the right place to invest if that
matters.
**The single most transferable insight:** the research independently concluded that
it is *better to rewrite via fuzzy-tolerant, line-number-free search/replace than to
trust the smart model to emit a precise diff* — and Hermes already lands on the same
answer in `tools/fuzzy_match.py`. That convergence is a good sign the harness
fundamentals here are sound; the missing piece is retrieval, not editing.
## Suggested follow-ups (not implemented here — analysis only)
1. **Optional semantic index plugin.** A content-addressed embedding cache keyed by
file hash, behind the existing plugin interface, would give ranked semantic
retrieval without bloating the core terminal path. Merkle delta-sync keeps it
cheap to refresh.
2. **Apply-from-sketch mode.** Let the model emit `// ... existing ...` placeholders
and resolve them against the real file before handing to the fuzzy matcher —
captures most of a trained apply model's benefit deterministically.
3. **Richer ambient context over ACP.** Pipe editor cursor/selection/diagnostics
into prompt assembly when running embedded, closing the passive-signal gap.
+6 -19
View File
@@ -197,15 +197,10 @@ def check_whatsapp_requirements() -> bool:
WhatsApp requires a Node.js bridge for most implementations.
"""
# Check for Node.js. Resolve with bundled-fallback awareness (PATH first,
# then <HERMES_HOME>/node/bin) so a bundled-but-off-PATH install (e.g. a
# root FHS install whose symlink is missing, #38889) doesn't make the
# WhatsApp bridge silently unavailable.
try:
from hermes_constants import find_node_executable
_node = find_node_executable("node")
except Exception:
_node = shutil.which("node")
# Check for Node.js. Resolve via shutil.which so we respect PATHEXT
# (node.exe vs node) and get a meaningful "not installed" signal
# instead of spawning a cmd flash on Windows.
_node = shutil.which("node")
if not _node:
return False
try:
@@ -597,16 +592,8 @@ class WhatsAppAdapter(BasePlatformAdapter):
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
# Resolve npm path so Windows can execute the .cmd shim.
# shutil.which honours PATHEXT; on POSIX it returns the
# plain executable path. Fall back to the bundled npm at
# <HERMES_HOME>/node/bin when off-PATH (#38889).
_npm_bin = shutil.which("npm")
if not _npm_bin:
try:
from hermes_constants import find_node_executable
_npm_bin = find_node_executable("npm")
except Exception:
_npm_bin = None
_npm_bin = _npm_bin or "npm"
# plain executable path.
_npm_bin = shutil.which("npm") or "npm"
try:
# Read timeout from environment variable, default to 300 seconds (5 minutes)
# to accommodate slower systems like Unraid NAS
+2 -3
View File
@@ -448,10 +448,9 @@ def run_import(args) -> None:
if skipped:
print(f" Profile aliases skipped: {', '.join(skipped)}")
if not _is_wrapper_dir_in_path():
_wd = _get_wrapper_dir()
print(f"\n Note: {_wd} is not in your PATH.")
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
print(f' export PATH="{_wd}:$PATH"')
print(' export PATH="$HOME/.local/bin:$PATH"')
except ImportError:
# hermes_cli.profiles might not be available (fresh install)
if any(profiles_dir.iterdir()):
-300
View File
@@ -1,300 +0,0 @@
"""``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: str,
custom_redirect_uri: Optional[str],
timeout: float = 15.0,
) -> dict:
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
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] = {"name": name}
if custom_redirect_uri:
body["custom_redirect_uri"] = custom_redirect_uri
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
# The endpoint returns structured JSON errors ({error, error_description}).
detail = ""
try:
err_body = json.loads(exc.read().decode())
detail = (
err_body.get("error_description")
or err_body.get("error")
or ""
)
except Exception:
pass
if exc.code == 401:
raise RuntimeError(
"Nous Portal rejected the access token (401). "
"Try `hermes auth login nous` to re-authenticate."
) from exc
if exc.code == 403:
raise RuntimeError(
detail
or "Your account is not permitted to register a self-hosted dashboard."
) from exc
raise RuntimeError(
f"Portal returned HTTP {exc.code}"
+ (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
) from exc
if not isinstance(payload, dict) or not payload.get("client_id"):
raise RuntimeError("Portal returned an unexpected response (no client_id).")
return payload
def _print_post_register_hint(
*,
client_id: str,
portal_base_url: str,
custom_redirect_uri: Optional[str],
wrote_portal_url: bool,
) -> None:
"""Print the success summary + the gate-engagement caveat."""
from hermes_cli.config import get_env_path
env_path = get_env_path()
print()
print(f" Wrote to {env_path}:")
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
if wrote_portal_url:
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
print()
print(
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
" without auth, which is fine for your own machine."
)
print()
if custom_redirect_uri:
# Derive the host the user registered so the example matches it.
try:
from urllib.parse import urlparse
host = urlparse(custom_redirect_uri).hostname or "your-host"
except Exception:
host = "your-host"
print(" To require Nous login on your registered host, run the dashboard")
print(f" bound publicly (it must be reachable at https://{host}) and log in")
print(" at its /login page.")
else:
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
print(" hermes dashboard --host 0.0.0.0")
print(" …then log in at the dashboard's /login page.")
print()
print(
" If the dashboard is already running, restart it to pick up the new env."
)
print(
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
)
def cmd_dashboard_register(args) -> None:
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
from hermes_cli.auth import AuthError, resolve_nous_access_token
from hermes_cli.config import get_env_value, is_managed, save_env_value
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
# via buildContainerEnvVars). Registering from inside such a container is a
# mistake — and save_env_value refuses to write anyway.
if is_managed():
print(
"✗ `hermes dashboard register` is not available in a managed/hosted "
"install.\n"
" The dashboard OAuth client is provisioned by the hosting platform."
)
sys.exit(1)
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
# with a setup hint when the user isn't logged in.
try:
access_token = resolve_nous_access_token()
except AuthError as exc:
if getattr(exc, "relogin_required", False):
print("✗ You're not logged into Nous Portal.")
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
else:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
except Exception as exc:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
# Portal override: explicit --portal-url flag wins, else the
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
portal_override = getattr(args, "portal_url", None) or os.environ.get(
"HERMES_DASHBOARD_PORTAL_URL"
)
portal_base_url = _resolve_portal_base_url(portal_override)
name = getattr(args, "name", None) or _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,
)
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)
print(f'✓ Registered dashboard "{registered_name}"')
# 3. Write env vars idempotently. Always set the client_id. Only set the
# portal URL when it isn't already configured (env or config) AND differs
# from the production default, so we don't clutter .env for the common case
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
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)
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 not existing_portal and portal_base_url.rstrip("/") != default_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
# 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,
)
+13 -92
View File
@@ -13,11 +13,6 @@ from pathlib import Path
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
from hermes_constants import (
command_link_dir as _command_link_dir,
command_link_display_dir as _command_link_display_dir,
bundled_node_bin_dir as _bundled_node_bin_dir,
)
PROJECT_ROOT = get_project_root()
HERMES_HOME = get_hermes_home()
@@ -203,75 +198,6 @@ def _section(title: str) -> None:
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
def _resolve_node_for_doctor(issues: list) -> str | None:
"""Resolve Node.js with bundled-fallback awareness and diagnose off-PATH.
Returns the resolved ``node`` binary path if node is usable from *some*
known location, else ``None``. Emits the appropriate check_ok/check_warn/
check_info lines and appends a fix to ``issues`` when node is installed but
unreachable via PATH (the PR #38889 class of regression: bundled node lives
at ``<HERMES_HOME>/node/bin`` but its PATH symlink is missing or off-PATH).
Discovery mirrors tools/browser_tool._browser_candidate_path_dirs and
hermes_cli/main._ensure_tui_node so doctor's verdict matches what actually
runs. As a side effect, when a bundled node is found off-PATH it is
prepended to ``os.environ["PATH"]`` for the remainder of this doctor run so
downstream npm/agent-browser checks don't cascade into false negatives.
"""
on_path = _safe_which("node")
if on_path:
check_ok("Node.js", f"({on_path})")
return on_path
# Not on PATH — is it installed at the bundled location?
bundled = _bundled_node_bin_dir() / "node"
if bundled.exists() and os.access(bundled, os.X_OK):
bin_dir = bundled.parent
check_warn(
"Node.js installed but not on PATH",
f"(found {bundled}, but `node` is not resolvable via PATH)",
)
# Root FHS installs are supposed to symlink node into /usr/local/bin.
# Verify that canonical symlink so doctor catches the exact PR #38889
# breakage rather than only the generic PATH miss.
try:
is_root = hasattr(os, "geteuid") and os.geteuid() == 0
except OSError:
is_root = False
if is_root and sys.platform == "linux":
fhs_link = Path("/usr/local/bin/node")
if not fhs_link.exists():
check_info(
"Root FHS install: node should be linked into /usr/local/bin."
)
check_info(f"Fix: ln -sf {bundled} /usr/local/bin/node "
f"(and the same for npm, npx)")
issues.append(
"Bundled Node.js is off-PATH on a root FHS install — run: "
f"ln -sf {bundled} /usr/local/bin/node "
"(repeat for npm, npx), or re-run the installer"
)
elif fhs_link.resolve() != bundled.resolve():
check_warn(
"/usr/local/bin/node points to the wrong target",
f"(→ {fhs_link.resolve()}, expected {bundled})",
)
issues.append(
f"Fix stale node symlink: ln -sf {bundled} /usr/local/bin/node"
)
else:
check_info(f"Bundled Node.js exists at {bin_dir} but isn't on PATH.")
check_info(f'Fix: export PATH="{bin_dir}:$PATH" (add to your shell rc)')
issues.append(f"Node.js is installed but off-PATH — add {bin_dir} to PATH")
# Make the rest of the doctor run see this node so npm/agent-browser
# checks succeed instead of reporting more false negatives.
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
return str(bundled)
return None
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
"""Emit a check_fail and append the corresponding fix instruction."""
check_fail(text, detail)
@@ -1259,11 +1185,15 @@ def run_doctor(args):
_venv_bin = _candidate
break
# Determine the expected command link directory (canonical helper —
# single source of truth shared with scripts/install.sh, so root FHS
# installs correctly resolve to /usr/local/bin instead of ~/.local/bin).
_cmd_link_dir = _command_link_dir()
_cmd_link_display = _command_link_display_dir()
# Determine the expected command link directory (mirrors install.sh logic)
_prefix = os.environ.get("PREFIX", "")
_is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
if _is_termux_env and _prefix:
_cmd_link_dir = Path(_prefix) / "bin"
_cmd_link_display = "$PREFIX/bin"
else:
_cmd_link_dir = Path.home() / ".local" / "bin"
_cmd_link_display = "~/.local/bin"
_cmd_link = _cmd_link_dir / "hermes"
if _venv_bin is None:
@@ -1314,7 +1244,7 @@ def run_doctor(args):
if str(_cmd_link_dir) not in _path_dirs:
check_warn(
f"{_cmd_link_display} is not on your PATH",
f'(add it to your shell config: export PATH="{_cmd_link_dir}:$PATH")'
"(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
)
manual_issues.append(f"Add {_cmd_link_display} to your PATH")
else:
@@ -1443,10 +1373,8 @@ def run_doctor(args):
)
# Node.js + agent-browser (for browser automation tools)
# Resolve with bundled-fallback awareness so an off-PATH bundled install
# is diagnosed as "installed but not on PATH" instead of "not found".
_node_resolved = _resolve_node_for_doctor(issues)
if _node_resolved:
if _safe_which("node"):
check_ok("Node.js")
# Check if agent-browser is installed
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
agent_browser_ok = False
@@ -1523,15 +1451,8 @@ def run_doctor(args):
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")
# npm audit for all Node.js packages. Use bundled-fallback resolution so a
# bundled-but-off-PATH npm is still found (the _resolve_node_for_doctor call
# above already prepended the bundled bin dir to PATH for this run, so plain
# which usually works now; the explicit fallback is belt-and-suspenders).
# npm audit for all Node.js packages
_npm_bin = _safe_which("npm")
if not _npm_bin:
_bundled_npm = _bundled_node_bin_dir() / "npm"
if _bundled_npm.exists() and os.access(_bundled_npm, os.X_OK):
_npm_bin = str(_bundled_npm)
if _npm_bin:
npm_dirs = [
(PROJECT_ROOT, "Browser tools (agent-browser)"),
+4 -94
View File
@@ -6817,7 +6817,6 @@ def _run_with_idle_timeout(
*,
idle_timeout_seconds: int = 180,
indent: str = " ",
env: dict | None = None,
) -> subprocess.CompletedProcess:
"""Run a subprocess that streams output, with an idle-output timeout.
@@ -6852,7 +6851,6 @@ def _run_with_idle_timeout(
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
)
except OSError as exc:
# E.g. npm not on PATH between the which() check and now.
@@ -6917,7 +6915,6 @@ def _run_npm_install_deterministic(
*,
extra_args: tuple[str, ...] = (),
capture_output: bool = True,
env: dict | None = None,
) -> subprocess.CompletedProcess:
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
@@ -6939,7 +6936,6 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
if ci_result.returncode == 0:
return ci_result
@@ -6954,7 +6950,6 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
@@ -6986,44 +6981,12 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
# Resolve npm with bundled-fallback awareness: on a root FHS install whose
# PATH symlink is missing, or any context with a stripped PATH (systemd
# service, RHEL non-login shell), shutil.which("npm") returns None even
# though the bundled npm exists at <HERMES_HOME>/node/bin/npm. See #38889.
npm = shutil.which("npm")
if not npm:
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
if not npm:
if fatal:
_say("Web UI frontend not built and npm is not available.")
_say("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
# Ensure the bundled node/bin dir is on PATH for the build subprocesses so
# the `npm run build` step (which shells out to tsc / vite from
# node_modules/.bin, and those re-invoke `node`) can find node even when the
# caller's PATH doesn't include it.
_build_env = None
try:
from hermes_constants import bundled_node_bin_dir
_node_bin = bundled_node_bin_dir()
if _node_bin.is_dir():
_build_env = os.environ.copy()
_existing = _build_env.get("PATH", "")
if str(_node_bin) not in _existing.split(os.pathsep):
_build_env["PATH"] = str(_node_bin) + os.pathsep + _existing
# Also fold in the resolved npm's own dir (covers system node installs).
_npm_dir = str(Path(npm).resolve().parent)
if _build_env is None:
_build_env = os.environ.copy()
if _npm_dir not in _build_env.get("PATH", "").split(os.pathsep):
_build_env["PATH"] = _npm_dir + os.pathsep + _build_env.get("PATH", "")
except Exception:
_build_env = None
_say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None:
@@ -7045,7 +7008,6 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
env=_build_env,
)
if r1.returncode != 0:
_say(
@@ -7061,13 +7023,13 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# users react by rebooting, which leaves the editable install in a
# half-state. Streaming + idle-kill makes failures observable AND
# recoverable (the stale-dist fallback below handles the kill path).
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
if r2.returncode != 0:
# Retry once after a short delay — covers boot-time races on Windows
# (antivirus scanning Node.js binaries, npm cache not ready, transient
# I/O when launched via Scheduled Task at logon). See issue #23817.
_time.sleep(3)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
if r2.returncode != 0:
# _run_with_idle_timeout merges stderr into stdout; older callers
@@ -11422,12 +11384,11 @@ def cmd_profile(args):
if wrapper_path:
print(f"Wrapper created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
_wd = _get_wrapper_dir()
print(f"\n⚠ {_wd} is not in your PATH.")
print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.")
print(
f" Add to your shell config (~/.bashrc or ~/.zshrc):"
)
print(f' export PATH="{_wd}:$PATH"')
print(f' export PATH="$HOME/.local/bin:$PATH"')
# Profile dir for display
try:
@@ -12017,13 +11978,6 @@ def cmd_dashboard(args):
)
def cmd_dashboard_register(args):
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
from hermes_cli.dashboard_register import cmd_dashboard_register as _impl
_impl(args)
def cmd_completion(args, parser=None):
"""Print shell completion script."""
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
@@ -15334,50 +15288,6 @@ Examples:
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# `hermes dashboard register` — register a self-hosted dashboard OAuth
# client with Nous Portal and write the client_id into ~/.hermes/.env.
# Nested subparser so bare `hermes dashboard` keeps launching the server
# (set_defaults(func=cmd_dashboard) above remains the default).
dashboard_subparsers = dashboard_parser.add_subparsers(
dest="dashboard_subcommand"
)
dashboard_register_parser = dashboard_subparsers.add_parser(
"register",
help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)",
description=(
"Register this install as a self-hosted dashboard with your Nous "
"Portal account. Creates an OAuth client, writes "
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints "
"how to engage the login gate. Requires being logged in (hermes setup)."
),
)
dashboard_register_parser.add_argument(
"--name",
default=None,
help="Human-readable label for the dashboard (default: an auto-generated name)",
)
dashboard_register_parser.add_argument(
"--redirect-uri",
dest="redirect_uri",
default=None,
help=(
"Optional public HTTPS OAuth redirect URI for the dashboard, e.g. "
"https://hermes.example.com/auth/callback. Omit for localhost-only use."
),
)
dashboard_register_parser.add_argument(
"--portal-url",
dest="portal_url",
default=None,
help=(
"Override the Nous Portal base URL for registration (default: the "
"portal you logged into). The access token must be valid at this "
"portal. Also settable via HERMES_DASHBOARD_PORTAL_URL. Mainly for "
"testing against a staging/preview portal."
),
)
dashboard_register_parser.set_defaults(func=cmd_dashboard_register)
# =========================================================================
# desktop (a.k.a. gui) command
#
+19 -43
View File
@@ -240,25 +240,8 @@ def _get_active_profile_path() -> Path:
def _get_wrapper_dir() -> Path:
"""Return the directory for profile-alias wrapper scripts.
Uses the canonical command-link directory so aliases land wherever the
``hermes`` command itself lives and is therefore on PATH: ``/usr/local/bin``
for root FHS installs, ``$PREFIX/bin`` on Termux, ``~/.local/bin`` otherwise
(including Windows). Previously hardcoded ``~/.local/bin``, which left
aliases off-PATH on root FHS installs (PR #38889).
"""
from hermes_constants import command_link_dir
return command_link_dir()
def _wrapper_candidate_dirs() -> list[Path]:
"""All dirs a profile alias may live in, for cleanup that must find links
regardless of which layout created them."""
from hermes_constants import command_link_candidate_dirs
return command_link_candidate_dirs()
"""Return the directory for wrapper scripts."""
return Path.home() / ".local" / "bin"
# ---------------------------------------------------------------------------
@@ -416,34 +399,27 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed.
Scans all candidate command-link directories (``~/.local/bin``,
``/usr/local/bin``, ``$PREFIX/bin``) so aliases are removable regardless of
which layout created them — e.g. an alias written to ``/usr/local/bin`` on a
root FHS install, or a legacy one left in ``~/.local/bin``.
"""
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_dir = _get_wrapper_dir()
canon = normalize_profile_name(name)
is_windows = sys.platform == "win32"
removed = False
for wrapper_dir in _wrapper_candidate_dirs():
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
for wrapper_path in candidates:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
removed = True
except Exception:
pass
return removed
for wrapper_path in candidates:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
return True
except Exception:
pass
return False
# ---------------------------------------------------------------------------
+35 -63
View File
@@ -9,7 +9,6 @@ Provides options for:
import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -118,58 +117,45 @@ def remove_wrapper_script():
return removed
def _node_symlink_candidate_dirs() -> "list[Path]":
"""Directories where the installer may have placed node/npm/npx symlinks.
Delegates to the canonical helper in hermes_constants so the layout logic
lives in exactly one place (shared with profiles, doctor, backup).
"""
from hermes_constants import command_link_candidate_dirs
return command_link_candidate_dirs()
def remove_node_symlinks(hermes_home: Path) -> list:
"""Remove the node/npm/npx symlinks the installer placed on PATH.
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
symlinks node/npm/npx into the same directory as the ``hermes`` command:
creates::
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
- ``$PREFIX/bin/`` on Termux
- ``~/.local/bin/`` otherwise (the common non-root case)
~/.local/bin/node -> $HERMES_HOME/node/bin/node
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
We check all candidate directories so that uninstall works regardless of
how the install was done (e.g. a root FHS install that placed links in
``/usr/local/bin``, or an older install that used ``~/.local/bin`` before
the FHS fix). Only symlinks that resolve into this Hermes home's ``node``
directory are removed — links the user has repointed elsewhere (nvm, fnm,
etc.) are left untouched.
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
manager such as nvm. Symmetrically remove them on uninstall, but *only*
when the link still resolves into this Hermes home's ``node`` directory.
A link the user has since repointed at nvm (or anything else outside
Hermes) is left untouched so we never break unrelated tooling.
"""
node_dir = (hermes_home / "node").resolve()
removed = []
for name in ("node", "npm", "npx"):
for bin_dir in _node_symlink_candidate_dirs():
link = bin_dir / name
try:
# Only act on symlinks — never delete a real binary the user put here.
if not link.is_symlink():
continue
link = Path.home() / ".local" / "bin" / name
try:
# Only act on symlinks — never delete a real binary the user put here.
if not link.is_symlink():
continue
# Resolve the link target and confirm it points into our node dir.
# os.readlink + manual join handles broken (dangling) links too;
# Path.resolve() on a dangling link still returns the target path.
target = Path(os.readlink(link))
if not target.is_absolute():
target = (link.parent / target)
target = target.resolve()
# Resolve the link target and confirm it points into our node dir.
# os.readlink + manual join handles broken (dangling) links too;
# Path.resolve() on a dangling link still returns the target path.
target = Path(os.readlink(link))
if not target.is_absolute():
target = (link.parent / target)
target = target.resolve()
if target == node_dir or node_dir in target.parents:
link.unlink()
removed.append(link)
except Exception as e:
log_warn(f"Could not remove {link}: {e}")
if target == node_dir or node_dir in target.parents:
link.unlink()
removed.append(link)
except Exception as e:
log_warn(f"Could not remove {link}: {e}")
return removed
@@ -472,28 +458,14 @@ def _uninstall_profile(profile) -> None:
except Exception as e:
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
# 2. Remove the wrapper alias script wherever it landed. Use the
# profiles helper which scans all candidate command-link dirs
# (~/.local/bin, /usr/local/bin, $PREFIX/bin) so root FHS aliases are
# removed too — then fall back to the recorded alias_path for safety.
removed_alias = False
try:
from hermes_cli.profiles import remove_wrapper_script
removed_alias = remove_wrapper_script(name)
if removed_alias:
log_success(f" Removed profile alias '{name}'")
except Exception as e:
log_warn(f" Could not scan for profile alias '{name}': {e}")
if not removed_alias:
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 3. Wipe the profile's HERMES_HOME directory.
try:
+54 -138
View File
@@ -7014,28 +7014,6 @@ _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
"""Return a rejection reason for the client IP, or None when allowed.
Reasons are short machine-parseable tokens logged on the rejection path
so a "WS keeps closing" report can be diagnosed from agent.log without a
repro. ``None`` means the peer IP passed this gate.
See :func:`_ws_client_is_allowed` for the full policy rationale.
"""
if getattr(app.state, "auth_required", False):
return None
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return None
client_host = ws.client.host if ws.client else ""
if not client_host:
return None
if client_host in _LOOPBACK_HOSTS:
return None
return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}"
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""Check if the WebSocket client IP is acceptable.
@@ -7076,40 +7054,6 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
return client_host in _LOOPBACK_HOSTS
def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
"""Return a Host/Origin rejection reason, or None when allowed.
Mirrors :func:`_ws_host_origin_is_allowed` but yields a short
machine-parseable token (``host_mismatch …`` / ``origin_mismatch …``)
on rejection so the close path can log *why* the upgrade was refused.
"""
bound_host = getattr(app.state, "bound_host", None)
if not bound_host:
return None
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
return f"host_mismatch host={host_header or '?'} bound={bound_host}"
origin = ws.headers.get("origin", "")
if not origin:
return None
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
# Non-web origin (packaged Electron: file://, null, app://). The
# upstream credential check is the real auth boundary; trust it.
# See _ws_host_origin_is_allowed for the full rationale.
return None
if not parsed.netloc:
return f"origin_mismatch origin={origin} bound={bound_host}"
if not _is_accepted_host(parsed.netloc, bound_host):
return f"origin_mismatch origin={origin} bound={bound_host}"
return None
def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
"""Apply the dashboard Host/Origin guard to WebSocket upgrades.
@@ -7119,12 +7063,45 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
header on WebSocket handshakes; when present, require it to target the
same bound dashboard host.
"""
return _ws_host_origin_reason(ws) is None
bound_host = getattr(app.state, "bound_host", None)
if not bound_host:
return True
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
return False
def _ws_request_reason(ws: "WebSocket") -> Optional[str]:
"""First Host/Origin or peer-IP rejection reason, or None when allowed."""
return _ws_host_origin_reason(ws) or _ws_client_reason(ws)
origin = ws.headers.get("origin", "")
if not origin:
return True
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
# Packaged Electron loads the desktop renderer over a non-web origin
# such as file://, null, or a custom app:// scheme. This helper is
# called only AFTER _ws_auth_ok has already accepted the WS credential,
# which is the real auth boundary in every mode:
# * loopback bind → legacy dashboard session token
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
# A non-web origin can only be produced by a native client (the desktop
# shell); a DNS-rebinding attack always arrives from an http(s) origin
# and is still match-checked against the bound host below. So once the
# credential check upstream has passed, the Origin guard adds nothing
# for a non-web origin — trust it in every mode.
#
# (Earlier revisions restricted this to loopback, then to non-gated
# binds; both excluded the packaged desktop talking to a remote
# OAuth-gated gateway, whose file:// renderer origin then got rejected
# at the WS upgrade even with a valid ticket. The ticket is the gate,
# not the origin.)
return True
if not parsed.netloc:
return False
return _is_accepted_host(parsed.netloc, bound_host)
def _ws_request_is_allowed(ws: "WebSocket") -> bool:
@@ -7132,25 +7109,8 @@ def _ws_request_is_allowed(ws: "WebSocket") -> bool:
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
def _ws_auth_mode() -> str:
"""Short label for the active WS auth mode — logged on every connection."""
if getattr(app.state, "auth_required", False):
return "gated"
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return "insecure"
return "loopback"
def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
"""Validate WS-upgrade auth; return ``(reason, credential)``.
``reason`` is None when the credential is accepted, else a short
machine-parseable token explaining the rejection (``no_credential``,
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
``credential`` names which credential type was presented (``ticket``,
``internal``, ``token``, or ``none``) so the accepted path can log *how*
a peer authed, not just that it did.
def _ws_auth_ok(ws: "WebSocket") -> bool:
"""Validate WS-upgrade auth in either loopback or gated mode.
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
@@ -7171,8 +7131,9 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
(the SPA bundle isn't carrying the token any longer, and a leaked
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
Audit-logs the rejection so operators can debug "WS keeps closing"
issues from the log.
Returns True if the WS should be accepted; callers close with the
appropriate WS code (4401) on False. Audit-logs the rejection so
operators can debug "WS keeps closing" issues from the log.
"""
auth_required = bool(getattr(app.state, "auth_required", False))
if auth_required:
@@ -7192,7 +7153,7 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
if internal:
try:
consume_internal_credential(internal)
return None, "internal"
return True
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
@@ -7200,15 +7161,15 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return "internal_invalid", "internal"
return False
ticket = ws.query_params.get("ticket", "")
if not ticket:
return "no_credential", "none"
return False
try:
consume_ticket(ticket)
return None, "ticket"
return True
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
@@ -7216,19 +7177,10 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return "ticket_invalid", "ticket"
return False
token = ws.query_params.get("token", "")
if not token:
return "no_credential", "none"
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
return None, "token"
return "token_mismatch", "token"
def _ws_auth_ok(ws: "WebSocket") -> bool:
"""True when the WS-upgrade credential is accepted. See _ws_auth_reason."""
return _ws_auth_reason(ws)[0] is None
return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
@@ -7380,58 +7332,22 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
return channel if _VALID_CHANNEL_RE.match(channel) else None
def _ws_close_reason(text: str) -> str:
"""Clamp a WS close reason to the protocol's 123-byte UTF-8 limit.
RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a
longer string is passed. Our reasons embed an attacker-controlled origin,
so truncate defensively rather than crash the close handler.
"""
encoded = text.encode("utf-8", "replace")
if len(encoded) <= 123:
return text
return encoded[:120].decode("utf-8", "ignore") + "..."
@app.websocket("/api/pty")
async def pty_ws(ws: WebSocket) -> None:
peer = ws.client.host if ws.client else "?"
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
_log.info("pty refused: embedded chat disabled peer=%s", peer)
await ws.close(code=4404, reason="embedded chat disabled")
await ws.close(code=4403)
return
# --- auth + host/origin/peer check (before accept so we can close
# cleanly AND tell the client WHY via the close code + reason).
# Each gate maps to a distinct close code so the log and the
# browser banner agree on the cause:
# 4401 bad credential 4403 host/origin mismatch
# 4408 peer not allowed 4404 chat disabled
auth_reason, cred = _ws_auth_reason(ws)
mode = _ws_auth_mode()
if auth_reason is not None:
_log.warning(
"pty auth rejected reason=%s mode=%s cred=%s peer=%s",
auth_reason, mode, cred, peer,
)
await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
# --- auth + loopback check (before accept so we can close cleanly) ---
if not _ws_auth_ok(ws):
await ws.close(code=4401)
return
host_origin_reason = _ws_host_origin_reason(ws)
if host_origin_reason is not None:
_log.warning("pty refused: %s peer=%s", host_origin_reason, peer)
await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
return
client_reason = _ws_client_reason(ws)
if client_reason is not None:
_log.warning("pty refused: %s", client_reason)
await ws.close(code=4408, reason=_ws_close_reason(client_reason))
if not _ws_request_is_allowed(ws):
await ws.close(code=4403)
return
await ws.accept()
_log.info("pty accepted peer=%s mode=%s cred=%s", peer, mode, cred)
# On native Windows, the POSIX PTY bridge can't be imported. Tell the
# client and close cleanly rather than pretending the feature works.
-139
View File
@@ -414,145 +414,6 @@ def get_env_path() -> Path:
return get_hermes_home() / ".env"
# ─── Command-Link & Bundled-Node Locations ───────────────────────────────────
#
# Canonical, single source of truth for *where the installer places executables
# so they land on PATH*. This MUST stay in lockstep with the bash helper
# ``get_command_link_dir()`` in ``scripts/install.sh`` and ``_nb_get_link_dir()``
# in ``scripts/lib/node-bootstrap.sh``. Historically this logic was duplicated
# (and went stale) in doctor.py, profiles.py, uninstall.py and backup.py, which
# caused root-FHS installs to look for / write the ``hermes`` command and node
# symlinks in ``~/.local/bin`` even though they actually live in
# ``/usr/local/bin``. See PR #38889.
def _is_root_fhs_layout() -> bool:
"""Return True when this is a root install using the Linux FHS layout.
Mirrors ``resolve_install_layout()`` in ``scripts/install.sh``: root (uid 0)
on Linux uses ``/usr/local/lib/hermes-agent`` for code and ``/usr/local/bin``
for the command link. We detect it the same way the installer's own runtime
guard does (``_ensure_fhs_path_guard`` in main.py): Linux + uid 0 + a command
link present at ``/usr/local/bin/hermes`` OR code at
``/usr/local/lib/hermes-agent``. Falling back to the uid check alone keeps
this correct *during* an install before the symlink exists.
"""
if sys.platform != "linux":
return False
try:
if not hasattr(os, "geteuid") or os.geteuid() != 0:
return False
except OSError:
return False
# Confirm it's actually the FHS layout (not a root user who installed into
# ~/.local/bin anyway). A legacy git install at <HERMES_HOME>/hermes-agent
# means resolve_install_layout() kept the ~/.local/bin layout — mirror that.
if (get_hermes_home() / "hermes-agent" / ".git").exists():
return False
if Path("/usr/local/bin/hermes").exists():
return True
if Path("/usr/local/lib/hermes-agent").exists():
return True
# No markers yet (e.g. mid-install): for a Linux root user the installer
# defaults to the FHS layout, so assume FHS.
return True
def command_link_dir() -> Path:
"""Return the directory where the ``hermes`` command (and bundled node/npm/
npx symlinks, profile-alias wrappers) are placed so they land on PATH.
Resolution mirrors ``get_command_link_dir()`` in ``scripts/install.sh``:
* Termux → ``$PREFIX/bin``
* root FHS install on Linux → ``/usr/local/bin``
* everything else (the common non-root case, and Windows) → ``~/.local/bin``
"""
if is_termux():
prefix = os.environ.get("PREFIX", "").strip()
if prefix:
return Path(prefix) / "bin"
if _is_root_fhs_layout():
return Path("/usr/local/bin")
return Path.home() / ".local" / "bin"
def command_link_display_dir() -> str:
"""User-friendly display string for :func:`command_link_dir`.
Uses ``~/.local/bin`` shorthand and ``$PREFIX/bin`` for Termux, matching
``get_command_link_display_dir()`` in ``scripts/install.sh``.
"""
if is_termux() and os.environ.get("PREFIX", "").strip():
return "$PREFIX/bin"
if _is_root_fhs_layout():
return "/usr/local/bin"
return "~/.local/bin"
def command_link_candidate_dirs() -> list[Path]:
"""All directories the installer may have placed command links in.
Used by uninstall and other cleanup paths that must find links regardless
of which layout created them (e.g. an old ``~/.local/bin`` install upgraded
to FHS, or vice-versa). Always includes ``~/.local/bin`` plus the
layout-specific dirs, de-duplicated and order-preserving.
"""
dirs: list[Path] = [Path.home() / ".local" / "bin"]
if sys.platform == "linux":
dirs.append(Path("/usr/local/bin"))
prefix = os.environ.get("PREFIX", "").strip()
if prefix and "com.termux" in prefix:
dirs.append(Path(prefix) / "bin")
# De-dupe while preserving order.
seen: set[str] = set()
out: list[Path] = []
for d in dirs:
key = str(d)
if key not in seen:
seen.add(key)
out.append(d)
return out
def bundled_node_bin_dir() -> Path:
"""Return the bundled Node.js ``bin`` directory: ``<HERMES_HOME>/node/bin``.
This is where ``install_node()`` / ``node-bootstrap.sh`` extract the
Hermes-managed Node runtime. Profile-aware via :func:`get_hermes_home`.
Discovery code that gates a feature on ``node``/``npm``/``npx`` should fall
back to this directory when ``shutil.which`` returns nothing, so a misplaced
or missing PATH symlink doesn't make an installed runtime invisible.
"""
return get_hermes_home() / "node" / "bin"
def find_node_executable(name: str = "node") -> str | None:
"""Resolve a Node executable (node/npm/npx) with bundled fallback.
Returns an absolute path string if found on PATH or in the bundled
``<HERMES_HOME>/node/bin`` directory, else ``None``. Prefer this over a
bare ``shutil.which(name)`` anywhere a feature depends on Node, so an
off-PATH bundled install still works.
"""
import shutil
on_path = shutil.which(name)
if on_path:
return on_path
candidate = bundled_node_bin_dir() / name
if sys.platform == "win32" and not candidate.suffix:
# Windows ships node.exe / npm.cmd; try common suffixes.
for suffix in (".exe", ".cmd", ".bat", ""):
c = candidate.with_suffix(suffix) if suffix else candidate
if c.exists() and os.access(c, os.X_OK):
return str(c)
return None
if candidate.exists() and os.access(candidate, os.X_OK):
return str(candidate)
return None
# ─── Network Preferences ─────────────────────────────────────────────────────
+9 -5
View File
@@ -383,17 +383,21 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
"""Surface obviously-broken redirect_uris before bouncing to Portal.
The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
this is a fast-fail for the common operator-error case. We allow any
``http://`` host (not just localhost) so self-hosted dashboards reached
over plain HTTP — LAN IPs, internal hostnames, reverse proxies that
terminate TLS upstream — are not rejected here; Portal makes the final
call on which redirect_uris are permitted.
this is a fast-fail for the common operator-error case.
"""
parsed = urllib.parse.urlparse(redirect_uri)
if parsed.scheme not in ("https", "http"):
raise ProviderError(
f"redirect_uri must be http(s), got {redirect_uri!r}"
)
if parsed.scheme == "http" and parsed.hostname not in (
"localhost",
"127.0.0.1",
):
raise ProviderError(
"redirect_uri may only use http:// for localhost/127.0.0.1, "
f"got {redirect_uri!r}"
)
if not parsed.path or not parsed.path.endswith("/auth/callback"):
raise ProviderError(
"redirect_uri path must end with '/auth/callback', "
+5 -35
View File
@@ -731,12 +731,6 @@ check_node() {
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
if [ -x "$HERMES_HOME/node/bin/node" ] && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
export PATH="$HERMES_HOME/node/bin:$PATH"
# Migration repair (#38889): a previously-broken install may have its
# node symlinks only in ~/.local/bin (off-PATH on root FHS) or missing.
# Re-link into the canonical dir + prune stale copies so re-running the
# installer (or `hermes update`) heals the box instead of leaving it
# broken.
link_bundled_node
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
HAS_NODE=true
return 0
@@ -752,31 +746,6 @@ check_node() {
install_node
}
# Idempotently (re)create node/npm/npx PATH symlinks in the command-link dir
# and prune stale ones in the other candidate dirs. Shared by install_node
# (fresh install) and check_node (migration repair of an existing broken box,
# #38889). Pruning only removes symlinks that resolve into THIS Hermes home's
# node dir — never a real binary or a user's nvm/fnm link.
link_bundled_node() {
local node_link_dir stale_dir name target
node_link_dir="$(get_command_link_dir)"
mkdir -p "$node_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
[ "$stale_dir" = "$node_link_dir" ] && continue
for name in node npm npx; do
[ -L "$stale_dir/$name" ] || continue
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
case "$target" in
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" ;;
esac
done
done
}
install_node() {
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Node.js via pkg..."
@@ -867,15 +836,16 @@ install_node() {
return 0
fi
# Place into ~/.hermes/node/ and symlink binaries into the same bin dir
# the hermes command uses (get_command_link_dir): /usr/local/bin for root
# FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
# Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/
rm -rf "$HERMES_HOME/node"
mkdir -p "$HERMES_HOME"
mv "$extracted_dir" "$HERMES_HOME/node"
rm -rf "$tmp_dir"
link_bundled_node
mkdir -p "$HOME/.local/bin"
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
export PATH="$HERMES_HOME/node/bin:$PATH"
+4 -65
View File
@@ -44,63 +44,6 @@ _nb_is_termux() {
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
}
# Where to symlink node/npm/npx so they land on PATH.
# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
# Termux → $PREFIX/bin, otherwise ~/.local/bin.
#
# Parity note (#38889): install.sh keys off $ROOT_FHS_LAYOUT, which
# resolve_install_layout() sets to FALSE for a root user who has a LEGACY
# install at $HERMES_HOME/hermes-agent/.git (those keep ~/.local/bin). We
# mirror that here so the bootstrap path (hermes update) can't diverge from the
# installer and link node into a different dir than the hermes command.
_nb_get_link_dir() {
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
echo "$PREFIX/bin"
return
fi
if [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
# Root on Linux: FHS layout UNLESS a legacy git install exists, matching
# resolve_install_layout() in install.sh.
if [ -d "${HERMES_HOME:-$HOME/.hermes}/hermes-agent/.git" ]; then
echo "$HOME/.local/bin"
else
echo "/usr/local/bin"
fi
return
fi
echo "$HOME/.local/bin"
}
# Idempotently (re)create the node/npm/npx PATH symlinks in the canonical link
# dir, and prune stale ones left in OTHER candidate dirs by an older/broken
# install (the #38889 migration case: a root box upgraded from the old layout
# has links only in ~/.local/bin, off-PATH). Safe to call repeatedly.
#
# Pruning rule mirrors hermes_cli/uninstall.remove_node_symlinks: only remove a
# symlink that still resolves into THIS Hermes home's node dir — never touch a
# real binary or a link the user repointed at nvm/fnm.
_nb_link_bundled_node() {
local link_dir stale_dir name target
link_dir="$(_nb_get_link_dir)"
mkdir -p "$link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$link_dir/npx"
# Prune stale links in the other candidate dirs (so a migrated root install
# doesn't keep shadowing copies in ~/.local/bin — #34536 nvm-shadow class).
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
[ "$stale_dir" = "$link_dir" ] && continue
for name in node npm npx; do
[ -L "$stale_dir/$name" ] || continue
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
case "$target" in
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" ;;
esac
done
done
}
_nb_node_major() {
local v
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
@@ -244,8 +187,10 @@ _nb_install_bundled_node() {
mv "$extracted" "$HERMES_HOME/node"
rm -rf "$tmp"
# Create PATH symlinks in the canonical link dir (and prune stale ones).
_nb_link_bundled_node
mkdir -p "$HOME/.local/bin"
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
export PATH="$HERMES_HOME/node/bin:$PATH"
_nb_have_modern_node || return 1
@@ -269,12 +214,6 @@ ensure_node() {
if [ -x "$HERMES_HOME/node/bin/node" ]; then
export PATH="$HERMES_HOME/node/bin:$PATH"
if _nb_have_modern_node; then
# Migration repair (#38889): an existing install may have its node
# symlinks only in ~/.local/bin (off-PATH on root FHS) or missing
# entirely. Re-create them in the canonical link dir and prune
# stale copies, so `hermes update` heals a previously-broken box
# instead of silently leaving it broken.
_nb_link_bundled_node
_nb_ok "Node $(node --version) found (Hermes-managed)"
HERMES_NODE_AVAILABLE=true
return 0
-217
View File
@@ -1,217 +0,0 @@
"""Tests for ``hermes dashboard register``.
Covers the CLI half of self-hosted dashboard registration:
- Docker-style auto-name generation
- not-logged-in fast-fail (AuthError with relogin_required)
- managed-install refusal
- the happy path: POST shape, env-var writes, custom redirect URI
- portal-URL write logic (only when non-default and not already set)
- portal HTTP error mapping (401/403)
The portal HTTP call and the Nous token resolution are both mocked — this
file proves the CLI wiring + env-write behaviour. The live end-to-end token
round-trip against the Vercel preview build is a separate manual step.
"""
from __future__ import annotations
import argparse
import json
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
import hermes_cli.dashboard_register as dr
def _ns(**kw):
defaults = dict(name=None, redirect_uri=None)
defaults.update(kw)
return argparse.Namespace(**defaults)
class TestNameGenerator:
def test_shape_is_adjective_underscore_noun(self):
for _ in range(50):
name = dr._generate_dashboard_name()
assert "_" in name
adj, _, noun = name.partition("_")
assert adj in dr._NAME_ADJECTIVES
assert noun in dr._NAME_NOUNS
class TestFastFails:
def test_not_logged_in_exits_1_with_setup_hint(self, capsys):
from hermes_cli.auth import AuthError
err = AuthError("not logged in", provider="nous", relogin_required=True)
with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register):
with patch(
"hermes_cli.auth.resolve_nous_access_token", side_effect=err
), patch("hermes_cli.config.is_managed", return_value=False):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
assert exc.value.code == 1
out = capsys.readouterr().out
assert "not logged into Nous Portal" in out
assert "hermes setup" in out
def test_managed_install_refuses(self, capsys):
with patch("hermes_cli.config.is_managed", return_value=True):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
assert exc.value.code == 1
out = capsys.readouterr().out
assert "not available in a managed" in out
def _fake_http_ok(payload: dict):
"""Return a context-manager urlopen stub yielding `payload` as JSON."""
cm = MagicMock()
cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
return cm
class TestHappyPath:
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
response=None, captured=None):
response = response or {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
}
def fake_urlopen(req, timeout=None):
if captured is not None:
captured["url"] = req.full_url
captured["headers"] = dict(req.header_items())
captured["body"] = json.loads(req.data.decode())
return _fake_http_ok(response)
saved = {}
def fake_save(key, value):
saved[key] = value
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value=portal
), patch(
"hermes_cli.config.get_env_value", return_value=None
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", side_effect=fake_urlopen
):
dr.cmd_dashboard_register(args)
return saved
def test_writes_client_id_and_posts_generated_name(self, capsys):
captured: dict = {}
saved = self._run(args=_ns(), captured=captured)
# POST shape
assert captured["url"].endswith("/api/oauth/self-hosted-client")
assert captured["headers"]["Authorization"] == "Bearer tok_abc"
assert "name" in captured["body"] and captured["body"]["name"]
assert "custom_redirect_uri" not in captured["body"]
# env write: client_id present, portal URL NOT written (default portal)
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
out = capsys.readouterr().out
assert "Registered dashboard" in out
assert "non-loopback bind" in out # the gate-engagement hint
def test_explicit_name_is_sent(self, capsys):
captured: dict = {}
self._run(args=_ns(name="my_box"), captured=captured)
assert captured["body"]["name"] == "my_box"
def test_custom_redirect_uri_is_forwarded(self, capsys):
captured: dict = {}
self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
captured=captured,
)
assert (
captured["body"]["custom_redirect_uri"]
== "https://hermes.example.com/auth/callback"
)
def test_non_default_portal_is_persisted(self, capsys):
saved = self._run(
args=_ns(),
portal="https://nous-account-service-git-feat-x.vercel.app",
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"]
== "https://nous-account-service-git-feat-x.vercel.app"
)
class TestPortalResolution:
def test_override_arg_wins(self):
assert (
dr._resolve_portal_base_url("https://preview.example.com/")
== "https://preview.example.com"
)
def test_falls_back_to_stored_login_portal(self):
with patch(
"hermes_cli.auth.get_provider_auth_state",
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
):
assert (
dr._resolve_portal_base_url(None)
== "https://portal.staging-nousresearch.com"
)
def test_blank_override_ignored(self):
with patch(
"hermes_cli.auth.get_provider_auth_state",
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
):
assert (
dr._resolve_portal_base_url(" ")
== "https://portal.staging-nousresearch.com"
)
class TestPortalErrors:
def _run_http_error(self, code, body):
err = urllib.error.HTTPError(
url="https://portal.nousresearch.com/api/oauth/self-hosted-client",
code=code,
msg="err",
hdrs=None,
fp=BytesIO(json.dumps(body).encode()),
)
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
), patch.object(dr.urllib.request, "urlopen", side_effect=err):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
return exc.value.code
def test_401_maps_to_reauth_message(self, capsys):
code = self._run_http_error(401, {"error": "invalid_token"})
assert code == 1
assert "re-authenticate" in capsys.readouterr().out
def test_403_surfaces_server_detail(self, capsys):
code = self._run_http_error(
403, {"error": "access_denied", "error_description": "Not permitted here."}
)
assert code == 1
assert "Not permitted here." in capsys.readouterr().out
-45
View File
@@ -1288,48 +1288,3 @@ class TestEdgeCases:
delete_profile("coder", yes=True)
assert get_active_profile() == "default"
class TestWrapperDirLayoutAware:
"""Profile-alias wrapper dir follows the canonical command-link layout (#38889)."""
def test_wrapper_dir_root_fhs(self, monkeypatch):
import hermes_cli.profiles as profiles
import hermes_constants
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
assert profiles._get_wrapper_dir() == Path("/usr/local/bin")
def test_wrapper_dir_nonroot(self, tmp_path, monkeypatch):
import hermes_cli.profiles as profiles
import hermes_constants
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
assert profiles._get_wrapper_dir() == tmp_path / ".local" / "bin"
def test_remove_wrapper_scans_all_dirs(self, tmp_path, monkeypatch):
"""An alias in /usr/local/bin is removed even though _get_wrapper_dir
would (in test) point at ~/.local/bin — remove must scan candidates."""
import hermes_cli.profiles as profiles
fake_usr_local = tmp_path / "usr_local_bin"
fake_usr_local.mkdir()
alias = fake_usr_local / "myprof"
alias.write_text('#!/usr/bin/env bash\nexec hermes -p myprof "$@"\n')
alias.chmod(0o755)
monkeypatch.setattr(
profiles, "_wrapper_candidate_dirs", lambda: [fake_usr_local]
)
assert profiles.remove_wrapper_script("myprof") is True
assert not alias.exists()
def test_remove_wrapper_leaves_foreign_files(self, tmp_path, monkeypatch):
"""A file that isn't our wrapper (no 'hermes -p') is left untouched."""
import hermes_cli.profiles as profiles
d = tmp_path / "bin"
d.mkdir()
foreign = d / "myprof"
foreign.write_text("#!/bin/sh\necho not ours\n")
monkeypatch.setattr(profiles, "_wrapper_candidate_dirs", lambda: [d])
assert profiles.remove_wrapper_script("myprof") is False
assert foreign.exists()
@@ -130,37 +130,3 @@ def test_only_some_links_present(fake_home):
assert (local_bin / "node").exists()
assert not (local_bin / "npm").is_symlink()
assert not (local_bin / "npx").is_symlink()
def test_removes_fhs_symlinks_in_usr_local_bin(fake_home, tmp_path, monkeypatch):
"""Root FHS installs place node symlinks in /usr/local/bin.
We monkeypatch _node_symlink_candidate_dirs to return a temp dir standing
in for /usr/local/bin so the test doesn't need real root privileges.
"""
hermes_home = fake_home / ".hermes"
node_bin = _make_hermes_node(hermes_home)
# Fake /usr/local/bin as a temp dir with our symlinks.
fhs_bin = tmp_path / "usr_local_bin"
fhs_bin.mkdir()
for name in ("node", "npm", "npx"):
(fhs_bin / name).symlink_to(node_bin / name)
# Ensure ~/.local/bin has NO symlinks (simulate pure FHS install).
local_bin = fake_home / ".local" / "bin"
for name in ("node", "npm", "npx"):
p = local_bin / name
if p.exists() or p.is_symlink():
p.unlink()
# Return only our fake FHS dir as a candidate.
monkeypatch.setattr(
uninstall, "_node_symlink_candidate_dirs", lambda: [fhs_bin]
)
removed = uninstall.remove_node_symlinks(hermes_home)
assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
for name in ("node", "npm", "npx"):
assert not (fhs_bin / name).is_symlink()
@@ -494,15 +494,11 @@ class TestStartLogin:
with pytest.raises(ProviderError, match="http"):
provider.start_login(redirect_uri="ftp://x/auth/callback")
def test_allows_http_with_arbitrary_host(self, provider):
# http:// is permitted for any host now, not just localhost — the
# Portal-side check is authoritative on which redirect_uris are
# accepted; this client-side fast-fail must not reject self-hosted
# dashboards reached over plain HTTP (LAN IPs, internal hostnames,
# TLS-terminating reverse proxies). Should not raise.
provider.start_login(redirect_uri="http://hermes.fly.dev/auth/callback")
provider.start_login(redirect_uri="http://192.168.1.50:8080/auth/callback")
provider.start_login(redirect_uri="http://my-internal-host/auth/callback")
def test_rejects_http_with_non_localhost(self, provider):
with pytest.raises(ProviderError, match="localhost"):
provider.start_login(
redirect_uri="http://hermes.fly.dev/auth/callback"
)
def test_allows_http_localhost(self, provider):
# Should not raise.
-63
View File
@@ -298,66 +298,3 @@ class TestSecureParentDir:
assert len(called_with) == 1
assert called_with[0] == (str(real_dir), 0o700)
class TestCommandLinkDir:
"""Tests for the canonical command-link / bundled-node helpers (#38889)."""
def test_nonroot_returns_local_bin(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("PREFIX", raising=False)
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
assert hermes_constants.command_link_dir() == tmp_path / ".local" / "bin"
assert hermes_constants.command_link_display_dir() == "~/.local/bin"
def test_root_fhs_returns_usr_local_bin(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
assert hermes_constants.command_link_dir() == Path("/usr/local/bin")
assert hermes_constants.command_link_display_dir() == "/usr/local/bin"
def test_termux_returns_prefix_bin(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_termux", lambda: True)
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
assert hermes_constants.command_link_dir() == Path(
"/data/data/com.termux/files/usr/bin"
)
assert hermes_constants.command_link_display_dir() == "$PREFIX/bin"
def test_candidate_dirs_includes_both_on_linux(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.delenv("PREFIX", raising=False)
dirs = hermes_constants.command_link_candidate_dirs()
assert tmp_path / ".local" / "bin" in dirs
assert Path("/usr/local/bin") in dirs
def test_candidate_dirs_deduped(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.delenv("PREFIX", raising=False)
dirs = hermes_constants.command_link_candidate_dirs()
assert len(dirs) == len({str(d) for d in dirs})
def test_bundled_node_bin_dir(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert hermes_constants.bundled_node_bin_dir() == tmp_path / "node" / "bin"
def test_find_node_prefers_path(self, monkeypatch):
monkeypatch.setattr("shutil.which", lambda n: "/usr/bin/" + n)
assert hermes_constants.find_node_executable("node") == "/usr/bin/node"
def test_find_node_falls_back_to_bundled(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("shutil.which", lambda n: None)
node_bin = tmp_path / "node" / "bin"
node_bin.mkdir(parents=True)
(node_bin / "npm").write_text("#!/bin/sh\n")
(node_bin / "npm").chmod(0o755)
assert hermes_constants.find_node_executable("npm") == str(node_bin / "npm")
def test_find_node_returns_none_when_absent(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("shutil.which", lambda n: None)
assert hermes_constants.find_node_executable("node") is None
+2 -8
View File
@@ -100,10 +100,7 @@ class WSTransport:
return not self._closed
except Exception as exc:
self._closed = True
_log.warning(
"ws write failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
_log.warning("ws write failed peer=%s error=%s", self._peer, exc)
return False
async def write_async(self, obj: dict) -> bool:
@@ -118,10 +115,7 @@ class WSTransport:
await self._ws.send_text(line)
except Exception as exc:
self._closed = True
_log.warning(
"ws send failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
_log.warning("ws send failed peer=%s error=%s", self._peer, exc)
def close(self) -> None:
self._closed = True
+3 -34
View File
@@ -603,50 +603,19 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
if (unmounting) {
return;
}
// Surface the real cause to the browser console on every close so a
// "chat won't connect" report can be diagnosed without server access.
// The server sends a machine-parseable reason on every rejection (see
// pty_ws in web_server.py); echo it verbatim alongside the close code.
const why = ev.reason ? ` reason=${ev.reason}` : "";
console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);
if (ev.code === 4401) {
setBanner(
ev.reason
? `Auth failed (${ev.reason}). Reload to refresh the session.`
: "Auth failed. Reload the page to refresh the session token.",
);
setBanner("Auth failed. Reload the page to refresh the session token.");
return;
}
if (ev.code === 4403) {
// Host/Origin mismatch (DNS-rebinding guard).
setBanner(
ev.reason
? `Refused: ${ev.reason}.`
: "Refused: request host/origin doesn't match the dashboard.",
);
return;
}
if (ev.code === 4404) {
setBanner(
"Embedded chat is disabled on this server (start it with --tui).",
);
return;
}
if (ev.code === 4408) {
setBanner(
ev.reason
? `Refused: ${ev.reason}.`
: "Refused: your client isn't permitted (server bound to localhost only).",
);
setBanner("Chat is only reachable from localhost.");
return;
}
if (ev.code === 1011) {
// Server already wrote an ANSI error frame.
return;
}
term.write(
`\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`,
);
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
};
// Keystrokes → PTY.