Compare commits

..

6 Commits

Author SHA1 Message Date
alt-glitch
2ff73853ee fix(installer): heal off-PATH node on update/migration + harden node discovery
Follow-up to the FHS root-install node-PATH fix, addressing the high-risk
gaps a reviewer flagged: fresh-install passing does not mean an existing
broken install gets healed.

Migration repair (the #1 trap):
- node-bootstrap.sh ensure_node() and install.sh check_node() both
  early-returned when a bundled node already existed at HERMES_HOME/node/bin,
  only fixing the current shell PATH and never re-creating the /usr/local/bin
  symlinks. A previously-broken root box therefore stayed broken after
  `hermes update` / re-install.
- Both paths now call a shared link_bundled_node / _nb_link_bundled_node that
  idempotently re-creates the symlinks in the canonical command-link dir AND
  prunes stale links left in the other candidate dirs, so a migrated root
  install no longer keeps shadowing copies in ~/.local/bin (the #34536
  nvm-shadow class).

Parity (messy-middle edge case):
- _nb_get_link_dir() now mirrors resolve_install_layout()'s legacy-install
  carve-out: a root user with HERMES_HOME/hermes-agent/.git keeps ~/.local/bin,
  so the bootstrap path can no longer link node to a different dir than the
  installer placed the hermes command.

Canonical helper (kills the duplicated layout-logic root cause):
- hermes_constants now owns command_link_dir, command_link_display_dir,
  command_link_candidate_dirs, bundled_node_bin_dir, find_node_executable.
  doctor.py, profiles.py, uninstall.py, backup.py, main.py all consume it.

Doctor now catches this class of regression:
- new _resolve_node_for_doctor reports "Node.js installed but not on PATH"
  instead of a false "not found", verifies the /usr/local/bin symlink on
  root FHS, self-heals PATH for the rest of the run, and the npm-audit block
  no longer silently vanishes when npm is off-PATH.
- doctor command-link detection uses the canonical helper, so it no longer
  looks in ~/.local/bin on root FHS or creates a wrong duplicate symlink
  with --fix.

Profile-alias wrappers now land in the layout-aware dir (was hardcoded
~/.local/bin, off-PATH for root FHS); remove_wrapper_script and uninstall
scan all candidate dirs.

Defensive bundled-node fallback (find_node_executable) added to the dashboard
web-UI build, WhatsApp bridge, and LSP installer so an off-PATH bundled node
does not silently disable those features.

Tests: 9 new hermes_constants helper tests + 4 profiles wrapper-dir tests.
Verified on a throwaway VM: fresh-root install (node on PATH, dashboard
serves HTTP 200, tsc present) and the migration scenario (broken old layout
re-installed -> node restored to /usr/local/bin, stale ~/.local/bin pruned).
2026-06-04 15:19:31 +05:30
alt-glitch
6495027f60 fix(installer): symlink bundled node/npm into command bin dir for FHS root installs
Root installs on Linux (FHS layout, #15608) put the `hermes` command in
`/usr/local/bin` (on PATH) but symlinked the bundled node/npm/npx into
`~/.local/bin`, which isn't on PATH for a stock root shell. `node`/`npm`
were 'command not found' and `hermes dashboard` failed with 'npm is not
available' because its build-on-demand fallback couldn't find npm.

Fix: `install_node()` now symlinks into `get_command_link_dir()` — the same
helper the `hermes` command link already uses — so node/npm/npx land
wherever the command does (`/usr/local/bin` on FHS root, `~/.local/bin`
otherwise, `$PREFIX/bin` on Termux). Non-root and Termux installs are
unchanged.

Also fixes:
- `scripts/lib/node-bootstrap.sh`: adds `_nb_get_link_dir()` mirroring
  the same root/Termux/user logic for the standalone bootstrap path
  (used by `hermes update`, TUI node bootstrap, etc.)
- `hermes_cli/uninstall.py`: `remove_node_symlinks()` now checks all
  candidate directories (`~/.local/bin`, `/usr/local/bin`, `$PREFIX/bin`)
  so root FHS uninstalls don't leave orphan symlinks

Regression from #15608, which created the FHS path for the command but
left `install_node` pointed at the legacy user-local dir.
2026-06-04 13:34:42 +05:30
Ben Barclay
fe74a1acda fix(dashboard_auth): allow any http:// host in redirect_uri fast-fail (#38827)
The Nous dashboard OAuth login rejected any http:// redirect_uri whose
host was not localhost/127.0.0.1, surfacing "redirect_uri may only use
http:// for localhost/127.0.0.1" on the login screen. This broke
self-hosted dashboards reached over plain HTTP — LAN IPs, internal
hostnames, and reverse proxies that terminate TLS upstream.

The Portal-side check (agent-redirect-uri.ts) is authoritative on which
redirect_uris are permitted; this client-side _validate_redirect_uri is
only a fast-fail for obvious operator error and should not second-guess
valid http:// deployments.

Fix: drop the localhost-only branch on the http scheme. Validation now
enforces only that the scheme is http(s) and the path ends with
/auth/callback. Updated the docstring to explain the relaxed contract,
and replaced test_rejects_http_with_non_localhost (which pinned the old
behavior) with test_allows_http_with_arbitrary_host covering a Fly
hostname, a LAN IP, and an internal hostname.
2026-06-04 00:51:44 -07:00
Teknium
6717914e0a fix(dashboard): explain WHY a chat WS connection was refused (#38743)
* Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml

When config.yaml fails to parse, load_config() silently falls back to
DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs
the setup wizard or hermes config set (both rewrite config.yaml), their
broken-but-recoverable overrides are lost for good.

Adapts the policy-file recovery from gemini-cli#21541: on the first parse
warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak
(best-effort, symlink-guarded, size-deduped) and tell the user where it
landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a
clean state — hermes never silently mutates user config, and leaving it means
a hand-fixed file is re-read on the next load.

Tests: 3 new cases (backup created + content preserved + original untouched;
same-size backup dedup; symlink not copied). E2E verified with isolated
HERMES_HOME and a real tab-indented broken config.

* fix(dashboard): explain WHY a chat WS connection was refused

The embedded-chat PTY WebSocket (/api/pty) collapsed every rejection
into a bare close code: 4401 for any auth failure, 4403 for three
unrelated failures (host mismatch, origin mismatch, peer-IP). Neither
the server log nor the browser said which gate fired or why, so a
"chat won't connect" report was undiagnosable without a repro.

Server (web_server.py):
- _ws_auth_reason / _ws_host_origin_reason / _ws_client_reason return a
  short machine-parseable reason; old bool wrappers kept for callers/tests.
- pty_ws splits the overloaded 4403 into 4401 (auth), 4403 (host/origin),
  4408 (peer not allowed), 4404 (chat disabled), and sends the reason on
  the close frame (clamped to the 123-byte RFC6455 limit).
- Each path logs one line: 'pty auth rejected reason=.. mode=.. cred=.. peer=..'
  / 'pty refused: <reason> ..'. Accepted path logs 'pty accepted peer=..
  mode=.. cred=..' so an audit shows HOW a peer authed, not just that it did.

tui_gateway/ws.py:
- 'ws send/write failed' now logs error_type=<ExcName> so an exception
  whose str() is empty (closed-transport sends) no longer logs 'error='.

web/src/pages/ChatPage.tsx:
- console.warn the real close code + server reason on every close.
- Map 4404/4408 to specific banners; 4401/4403 banners echo the server
  reason; [session ended] prints the close code.

E2E verified all five reject paths + accepted path produce matching
close code, wire reason, and server log line.
2026-06-04 00:36:03 -07:00
Ben
c2ca3f01ab fix(dashboard): honor --portal-url / HERMES_DASHBOARD_PORTAL_URL override in register
The register command resolved the portal base URL purely from the stored
login, ignoring any override. That meant `HERMES_DASHBOARD_PORTAL_URL` (and
the absence of any flag) gave no way to point registration at a staging or
preview portal — the request always hit the login's portal, returning 404
against a branch that wasn't deployed there.

- _resolve_portal_base_url now takes an optional override (precedence:
  override > stored login portal > prod default).
- New --portal-url flag; falls back to HERMES_DASHBOARD_PORTAL_URL env.
- Documents that the access token must be valid at the overridden portal
  (it's minted by whoever you logged into).
- 3 new tests for override precedence.

Verified live against the PR #324 Vercel preview: CLI -> preview endpoint ->
real agent:{id} client_id written to .env.
2026-06-04 00:17:57 -07:00
Ben
bb291b6bbc feat(dashboard): hermes dashboard register for self-hosted OAuth client
Adds a CLI command that registers this install as a self-hosted dashboard
with the user's Nous Portal account, automating the manual browser flow on
/local-dashboards.

- New hermes_cli/dashboard_register.py: resolves a fresh Nous access token
  from auth.json (fast-fails with a `hermes setup` hint when not logged in),
  POSTs to {portal}/api/oauth/self-hosted-client, and writes
  HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env idempotently.
- Docker-style adjective_noun auto-naming; --name and --redirect-uri overrides.
- Persists HERMES_DASHBOARD_PORTAL_URL only when non-default and unset (so a
  Vercel preview / staging portal sticks, prod default stays implicit).
- Refuses in managed/hosted installs (the orchestrator stamps the client_id).
- Post-register hint explains the OAuth gate only engages on a non-loopback bind.
- Nested 'register' subparser leaves bare `hermes dashboard` unchanged.
- 9 unit tests (name gen, fast-fails, POST shape, env writes, redirect URI,
  portal-URL persistence, 401/403 mapping); dashboard lifecycle tests still green.

Depends on NousResearch/nous-account-service#324 (the portal endpoint).
2026-06-04 00:17:57 -07:00
21 changed files with 1414 additions and 340 deletions

View File

@@ -245,6 +245,14 @@ 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

View File

@@ -1,179 +0,0 @@
# 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.

View File

@@ -197,10 +197,15 @@ def check_whatsapp_requirements() -> bool:
WhatsApp requires a Node.js bridge for most implementations.
"""
# 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")
# 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")
if not _node:
return False
try:
@@ -592,8 +597,16 @@ 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.
_npm_bin = shutil.which("npm") or "npm"
# 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"
try:
# Read timeout from environment variable, default to 300 seconds (5 minutes)
# to accommodate slower systems like Unraid NAS

View File

@@ -448,9 +448,10 @@ def run_import(args) -> None:
if skipped:
print(f" Profile aliases skipped: {', '.join(skipped)}")
if not _is_wrapper_dir_in_path():
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
_wd = _get_wrapper_dir()
print(f"\n Note: {_wd} is not in your PATH.")
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
print(' export PATH="$HOME/.local/bin:$PATH"')
print(f' export PATH="{_wd}:$PATH"')
except ImportError:
# hermes_cli.profiles might not be available (fresh install)
if any(profiles_dir.iterdir()):

View File

@@ -0,0 +1,300 @@
"""``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,
)

View File

@@ -13,6 +13,11 @@ 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()
@@ -198,6 +203,75 @@ 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)
@@ -1185,15 +1259,11 @@ def run_doctor(args):
_venv_bin = _candidate
break
# 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"
# 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()
_cmd_link = _cmd_link_dir / "hermes"
if _venv_bin is None:
@@ -1244,7 +1314,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",
"(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
f'(add it to your shell config: export PATH="{_cmd_link_dir}:$PATH")'
)
manual_issues.append(f"Add {_cmd_link_display} to your PATH")
else:
@@ -1373,8 +1443,10 @@ def run_doctor(args):
)
# Node.js + agent-browser (for browser automation tools)
if _safe_which("node"):
check_ok("Node.js")
# 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:
# Check if agent-browser is installed
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
agent_browser_ok = False
@@ -1451,8 +1523,15 @@ def run_doctor(args):
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")
# npm audit for all Node.js packages
# 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_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)"),

View File

@@ -6817,6 +6817,7 @@ 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.
@@ -6851,6 +6852,7 @@ 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.
@@ -6915,6 +6917,7 @@ 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``.
@@ -6936,6 +6939,7 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
if ci_result.returncode == 0:
return ci_result
@@ -6950,6 +6954,7 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
@@ -6981,12 +6986,44 @@ 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:
@@ -7008,6 +7045,7 @@ 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(
@@ -7023,13 +7061,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)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
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)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
if r2.returncode != 0:
# _run_with_idle_timeout merges stderr into stdout; older callers
@@ -11384,11 +11422,12 @@ def cmd_profile(args):
if wrapper_path:
print(f"Wrapper created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
print(f"\n{_get_wrapper_dir()} is not in your PATH.")
_wd = _get_wrapper_dir()
print(f"\n{_wd} is not in your PATH.")
print(
f" Add to your shell config (~/.bashrc or ~/.zshrc):"
)
print(f' export PATH="$HOME/.local/bin:$PATH"')
print(f' export PATH="{_wd}:$PATH"')
# Profile dir for display
try:
@@ -11978,6 +12017,13 @@ 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
@@ -15288,6 +15334,50 @@ 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
#

View File

@@ -240,8 +240,25 @@ def _get_active_profile_path() -> Path:
def _get_wrapper_dir() -> Path:
"""Return the directory for wrapper scripts."""
return Path.home() / ".local" / "bin"
"""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()
# ---------------------------------------------------------------------------
@@ -399,27 +416,34 @@ 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."""
wrapper_dir = _get_wrapper_dir()
"""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``.
"""
canon = normalize_profile_name(name)
is_windows = sys.platform == "win32"
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
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")
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
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
# ---------------------------------------------------------------------------

View File

@@ -9,6 +9,7 @@ Provides options for:
import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -117,45 +118,58 @@ 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 drops in ~/.local/bin.
"""Remove the node/npm/npx symlinks the installer placed on PATH.
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
creates::
symlinks node/npm/npx into the same directory as the ``hermes`` command:
~/.local/bin/node -> $HERMES_HOME/node/bin/node
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
- ``$PREFIX/bin/`` on Termux
- ``~/.local/bin/`` otherwise (the common non-root case)
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.
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.
"""
node_dir = (hermes_home / "node").resolve()
removed = []
for name in ("node", "npm", "npx"):
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
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
# 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
@@ -458,14 +472,28 @@ 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 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}")
# 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}")
# 3. Wipe the profile's HERMES_HOME directory.
try:

View File

@@ -7014,6 +7014,28 @@ _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.
@@ -7054,6 +7076,40 @@ 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.
@@ -7063,45 +7119,12 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
header on WebSocket handshakes; when present, require it to target the
same bound dashboard host.
"""
bound_host = getattr(app.state, "bound_host", None)
if not bound_host:
return True
return _ws_host_origin_reason(ws) is None
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
return False
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_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)
def _ws_request_is_allowed(ws: "WebSocket") -> bool:
@@ -7109,8 +7132,25 @@ def _ws_request_is_allowed(ws: "WebSocket") -> bool:
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
def _ws_auth_ok(ws: "WebSocket") -> bool:
"""Validate WS-upgrade auth in either loopback or gated mode.
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.
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
@@ -7131,9 +7171,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
(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).
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.
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:
@@ -7153,7 +7192,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
if internal:
try:
consume_internal_credential(internal)
return True
return None, "internal"
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
@@ -7161,15 +7200,15 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return False
return "internal_invalid", "internal"
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
return "no_credential", "none"
try:
consume_ticket(ticket)
return True
return None, "ticket"
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
@@ -7177,10 +7216,19 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return False
return "ticket_invalid", "ticket"
token = ws.query_params.get("token", "")
return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
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
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
@@ -7332,22 +7380,58 @@ 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:
await ws.close(code=4403)
_log.info("pty refused: embedded chat disabled peer=%s", peer)
await ws.close(code=4404, reason="embedded chat disabled")
return
# --- auth + loopback check (before accept so we can close cleanly) ---
if not _ws_auth_ok(ws):
await ws.close(code=4401)
# --- 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}"))
return
if not _ws_request_is_allowed(ws):
await ws.close(code=4403)
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))
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.

View File

@@ -414,6 +414,145 @@ 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 ─────────────────────────────────────────────────────

View File

@@ -383,21 +383,17 @@ 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.
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.
"""
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', "

View File

@@ -731,6 +731,12 @@ 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
@@ -746,6 +752,31 @@ 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..."
@@ -836,16 +867,15 @@ install_node() {
return 0
fi
# Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/
# 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.
rm -rf "$HERMES_HOME/node"
mkdir -p "$HERMES_HOME"
mv "$extracted_dir" "$HERMES_HOME/node"
rm -rf "$tmp_dir"
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"
link_bundled_node
export PATH="$HERMES_HOME/node/bin:$PATH"

View File

@@ -44,6 +44,63 @@ _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)
@@ -187,10 +244,8 @@ _nb_install_bundled_node() {
mv "$extracted" "$HERMES_HOME/node"
rm -rf "$tmp"
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"
# Create PATH symlinks in the canonical link dir (and prune stale ones).
_nb_link_bundled_node
export PATH="$HERMES_HOME/node/bin:$PATH"
_nb_have_modern_node || return 1
@@ -214,6 +269,12 @@ 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

View File

@@ -0,0 +1,217 @@
"""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

View File

@@ -1288,3 +1288,48 @@ 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()

View File

@@ -130,3 +130,37 @@ 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()

View File

@@ -494,11 +494,15 @@ class TestStartLogin:
with pytest.raises(ProviderError, match="http"):
provider.start_login(redirect_uri="ftp://x/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_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_allows_http_localhost(self, provider):
# Should not raise.

View File

@@ -298,3 +298,66 @@ 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

View File

@@ -100,7 +100,10 @@ class WSTransport:
return not self._closed
except Exception as exc:
self._closed = True
_log.warning("ws write failed peer=%s error=%s", self._peer, exc)
_log.warning(
"ws write failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
return False
async def write_async(self, obj: dict) -> bool:
@@ -115,7 +118,10 @@ class WSTransport:
await self._ws.send_text(line)
except Exception as exc:
self._closed = True
_log.warning("ws send failed peer=%s error=%s", self._peer, exc)
_log.warning(
"ws send failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
def close(self) -> None:
self._closed = True

View File

@@ -603,19 +603,50 @@ 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("Auth failed. Reload the page to refresh the session token.");
setBanner(
ev.reason
? `Auth failed (${ev.reason}). Reload to refresh the session.`
: "Auth failed. Reload the page to refresh the session token.",
);
return;
}
if (ev.code === 4403) {
setBanner("Chat is only reachable from localhost.");
// 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).",
);
return;
}
if (ev.code === 1011) {
// Server already wrote an ANSI error frame.
return;
}
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
term.write(
`\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`,
);
};
// Keystrokes → PTY.