mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 01:50:49 +08:00
Compare commits
6 Commits
bb/coding-
...
fix/node-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff73853ee | ||
|
|
6495027f60 | ||
|
|
fe74a1acda | ||
|
|
6717914e0a | ||
|
|
c2ca3f01ab | ||
|
|
bb291b6bbc |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()):
|
||||
|
||||
300
hermes_cli/dashboard_register.py
Normal file
300
hermes_cli/dashboard_register.py
Normal 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,
|
||||
)
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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', "
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
217
tests/hermes_cli/test_dashboard_register.py
Normal file
217
tests/hermes_cli/test_dashboard_register.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user