Compare commits

...
Author SHA1 Message Date
alt-glitch 200fc3c794 test(installer): factor node-bootstrap test layout into one helper
/simplify quality pass: the 5-segment Termux link-dir path was re-derived
in _run_nb_link and three test bodies; centralize it in a single
_layout(tmp_path) NamedTuple helper so the paths can't drift. Test-only,
no behavior change.
2026-06-04 19:42:07 +05:30
alt-glitch 4361159cbc fix(installer): close review gaps in node-on-PATH FHS heal
Follow-up hardening for the off-PATH node heal whose core landed via
PR #38889 (which squash-merged only the fresh-install link-dir fix). A
review of the full change surfaced the following, fixed here:

- install.sh: ensure_mode/postinstall_mode now call resolve_install_layout
  before check_node, so a root FHS box reached via `install.sh --ensure
  node` (dep_ensure / acp_adapter / TUI fallback) links node into
  /usr/local/bin instead of the off-PATH ~/.local/bin — the original
  #38889 regression still bit on those two paths.
- install.sh / node-bootstrap.sh: the best-effort stale-link prune now
  uses `rm -f ... 2>/dev/null || true` and the link helpers end with
  `return 0`, so a non-removable shadow link (read-only parent dir, uid
  mismatch) can no longer abort the whole installer under `set -e`.
- node-bootstrap.sh _nb_get_link_dir / hermes_constants _is_root_fhs_layout:
  handle the explicit --dir/$HERMES_INSTALL_DIR root install (which keeps
  ~/.local/bin) by placing node where the `hermes` command actually
  landed, instead of re-deriving a layout that diverges from the installer.
- whatsapp.py: launch the bridge with the bundled-fallback node binary and
  put the bundled node bin dir on the bridge PATH, so a bundled-but-off-PATH
  install doesn't FileNotFoundError at bridge launch (the check + npm install
  already used the fallback; the spawn didn't).
- doctor.py: diagnose a dangling /usr/local/bin/node symlink as a stale
  target (lexists/is_symlink) rather than misreporting it as missing.
- tests: add tests/test_node_bootstrap_link_prune.py covering the migration
  relink + stale-prune, prune safety (real files and user nvm/fnm links are
  preserved), idempotency, and the set -e prune-abort guard.
- docstring cleanups for the layout-aware wrapper-dir helpers.
2026-06-04 15:52:44 +05:30
alt-glitch 85b03a0c91 fix(installer): heal off-PATH node on update/migration + harden node discovery
Follow-up to the FHS root-install node-PATH fix, addressing the high-risk
gaps a reviewer flagged: fresh-install passing does not mean an existing
broken install gets healed.

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

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

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

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

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

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

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

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

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

Regression from #15608, which created the FHS path for the command but
left `install_node` pointed at the legacy user-local dir.
2026-06-04 02:31:49 -07:00
Teknium b1b0f4b668 fix(desktop): surface command approval even when its tool is in a collapsed group (#38829)
The desktop command-approval ApprovalBar renders inline inside ToolEntry,
which lives inside ToolGroupSlot. When 2+ tools group, the group body is
hidden until expanded, so an approval raised by a pending terminal/
execute_code call was buried behind "Tool actions · N steps" and required
manual expansion to act on (sudo/secret were unaffected — they use modal
overlays).

ToolGroupSlot now subscribes to $approvalRequest and force-opens its body
while an approval targeting one of its pending approval-eligible tools is in
flight, so the inline controls surface with nothing expanded. The group
reverts to the user's stored collapse state once the approval resolves.
2026-06-04 02:29:46 -07:00
Teknium 0175be3aa7 chore(desktop): silence Vite chunk-size warning for intentional single bundle (#38888)
The desktop renderer is bundled as one chunk on purpose (codeSplitting:
false) because Shiki's many dynamic chunks make electron-builder OOM
scanning thousands of files. That makes the ~22 MB bundle expected, but
Vite still nags with 'Some chunks are larger than 500 kB' on every build.

Raise chunkSizeWarningLimit to 25000 kB so the cosmetic warning stays
quiet while still firing as a regression alarm if the bundle grows well
past today's size. Config-only; codeSplitting:false is untouched.
2026-06-04 02:28:57 -07:00
Teknium 928f1ac0e1 fix(desktop): re-mint OAuth WS ticket on gateway reconnect (#38886)
attemptReconnect() connected with the stale cached conn.wsUrl. OAuth WS
tickets are single-use with a ~30s TTL, so the first sign-in (which goes
through boot() and re-mints via resolveGatewayWsUrl) succeeds, but every
reconnect (sleep/wake, network online, window refocus, socket drop, app
restart) reused a dead ticket and failed the WS upgrade with an opaque
"Could not connect to Hermes gateway" — even though backend resolution
(cookie + REST) reported ready.

attemptReconnect now mints a fresh ticket before connecting, mirroring
use-gateway-request.ts, and surfaces the reauth "sign in again" message
once on OAuth expiry instead of silently looping backoff against a dead
ticket. Local/token gateways are unaffected (re-mint is a no-op).
2026-06-04 02:28:43 -07:00
Teknium 4ed63170e4 fix(update): don't fail desktop rebuild / skills sync on mid-rebuild venv (#38885)
When 'hermes update' rebuilds the project venv (rmtree + uv venv on the
first managed-uv migration), the desktop-rebuild and profile-skills-sync
steps that follow both spawn sys.executable. Firing while the venv is
mid-rewrite makes the child interpreter abort with the bare stderr line
'No pyvenv.cfg file', surfacing as a spurious 'Desktop build failed' /
'default: sync failed' on an update that actually succeeded.

Add _wait_for_interpreter_venv_ready(): resolve the venv hosting
sys.executable and poll briefly for pyvenv.cfg to (re)appear before each
of those subprocess steps. No-op when the interpreter isn't venv-hosted.
The desktop rebuild also retries once after re-waiting, and keeps
streaming its output live (no capture). Best-effort throughout — callers
proceed regardless, so a genuinely broken venv still surfaces the real
error.
2026-06-04 02:20:11 -07:00
Teknium bd12b3c232 feat(desktop): username/password login for remote gateways (#38851)
Surface the username/password dashboard-auth provider in Hermes Desktop's
remote-gateway connect flow. A password gateway gates the same way an OAuth
one does (auth_required + session cookie + ws-ticket), so the desktop already
drives it through the existing sign-in window; the only gaps were that the
probe dropped supports_password and the UI always said "OAuth".

- main.cjs: capture supports_password from /api/auth/providers in the probe.
- global.d.ts: add optional supportsPassword to DesktopAuthProvider.
- gateway-settings.tsx: derive isPasswordProvider; render a plain "Sign in"
  button + "username and password" copy instead of an OAuth provider label
  when every advertised provider is password-based. Login still flows through
  the gateway's /login credential form (POST /auth/password-login).
2026-06-04 01:33:23 -07:00
Teknium fe709a4210 fix(test): expect 4404 close code for disabled embedded chat (#38841)
PR #38743 split the dashboard PTY WebSocket refusal codes (4404 = chat
disabled, 4403 = host/origin mismatch — see web_server.py refusal site
comment) but left test_rejects_when_embedded_chat_disabled asserting the
old 4403, so it has expected 4403 while the server sends 4404. Main CI has
been red on test (2)/(4) shards since that commit. Update the assertion to
4404 to match the disabled-chat path.
2026-06-04 01:13:03 -07:00
BenandKenmege 385a508e43 fix(desktop): don't fall back to a dead WS ticket on OAuth re-mint failure
The reconnect and boot paths resolved the WS URL with
`(await getGatewayWsUrl().catch(() => null)) || conn.wsUrl`. For OAuth
gateways the cached conn.wsUrl carries a single-use, ~30s-TTL ticket; the
desktop connection is memoized for the process lifetime, so on reconnect
that ticket is both expired and already consumed. A failed fresh mint
therefore fell back to a guaranteed-dead ticket and surfaced as an opaque
"connection closed", masking the gateway's actionable "session expired,
sign in again" message.

Extract resolveGatewayWsUrl() (with unit tests): in OAuth mode a mint
failure throws a tagged GatewayReauthRequiredError instead of falling back;
token/local modes keep the long-lived-token fallback. Thread that error
through the reconnect path so requestGateway surfaces the reauth message
rather than the generic transport error that triggered the retry.

Co-authored-by: Kenmege <205099287+Kenmege@users.noreply.github.com>
2026-06-04 01:11:34 -07:00
Ben bf590c81d0 fix(desktop): hide gateway auth control until probe resolves the scheme
The remote-gateway settings rendered the session-token box for every gateway
during the idle/probing window before the first /api/status probe lands,
because authMode defaults to 'token'. Gate both the OAuth sign-in button and
the token box behind an authResolved flag so neither renders until the probe
resolves the scheme (or a previously-saved remote config is being re-shown,
so re-opening settings doesn't flicker).

The gateway-side WS Origin fix that lets the packaged desktop (file:// origin)
connect to an OAuth-gated remote gateway landed separately in #37870; this
branch is now purely the desktop client + this UI fix.
2026-06-04 01:11:34 -07:00
Ben 9d07927a23 desktop: OAuth-aware remote gateway connection
The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.

Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.

OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
  Electron session partition (persist:hermes-remote-oauth); main-process
  REST routes through electron net bound to that partition so the cookie
  attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
  resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
  POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
  getGatewayWsUrl() re-mints before every (re)connect since tickets are
  single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
  (Nous Portal contract v1 issues no refresh token).

Local and token modes are unchanged.

Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
2026-06-04 01:11:34 -07:00
9cbc37e25b feat(desktop): dedicated Providers settings + polished Accounts/API-keys UX (#38551)
* feat(desktop): dedicated Providers settings with Accounts/API-keys subnav

Rework provider configuration in the desktop app into its own Providers
page that mirrors the first-run onboarding picker, instead of burying
provider keys in the generic Tools & Keys list.

- Add a Providers settings page (providers-settings.tsx) reusing the
  onboarding picker cards/ApiKeyForm so the two surfaces stay identical
- Add a sidebar subnav (Accounts vs API keys) backed by a deep-linkable
  `pview` URL param; nested OverlayNavItem variant for a lighter active
  state so children don't compete with the parent item
- Scope provider search to the active sub-view in its native card format
  (no more accordion fallback); collapse the API-key grid to the top
  providers behind a "Show all" toggle to cut scrolling
- Launch real in-app OAuth from settings via startManualProviderOAuth;
  fix the misleading red "reason" banner that showed during an active
  connect (neutral style, hidden during a flow, omitted for direct
  per-provider launches)
- Expand PROVIDER_GROUPS and add longest-prefix matching so providers
  like xAI/Ollama group correctly instead of landing under "Other"
- Drop redundant messaging API keys from Tools & Keys (channel_managed)

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(desktop): Cursor-style provider key list with inline inputs

Replace the card-grid API-key form on the Providers page with a
per-provider list (mirrors Cursor's API keys section):

- One row per vendor with its primary key input inline; rows with extra
  vars (base URL, region, alt tokens) expand to reveal those on focus
- Set keys show their redacted value as the placeholder; Save appears on
  edit, Remove on a set key
- Hide redundant alias key fields (e.g. ANTHROPIC_TOKEN vs
  ANTHROPIC_API_KEY) unless already set, and label set aliases by env var
  name so they're unambiguous
- Smaller mono input text + compact height

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(desktop): flatten providers settings UI chrome

Tighten the providers settings surface to match the newer desktop style:
remove extra card rails/borders in API-key rows, reduce visual noise in the
providers subnav, replace bespoke link-like controls with shared text-button
variants, and improve key input readability.

* feat(desktop): rework providers settings UI

- Flatten the shared OAuth picker rows (accounts + onboarding): drop the
  rounded-2xl/border cards for flat hover-bg rows; Nous hero keeps a subtle
  tint plus an animated blue→purple arc border.
- Key fields collapse to a single input: a set key reads read-only (redacted)
  and edits in place on focus/click — no Replace/Cancel chrome. Save on type,
  Esc cancels (without closing the overlay), "Remove or esc to cancel" hint.
- Non-key overrides render boxless, content-sized (field-sizing) and
  right-anchored; advanced fields align under the primary key column.
- Add `xs` control size; size fields via padding (no fixed heights).
- Cards expand on key-input focus; chevron shows on hover/expanded; expanded
  state uses a ring + softer bg tier so hover ≠ focus.
- Relocate "Get a key" to the bottom-right of the expanded panel; drop the
  redundant provider description.
- Cmd+K: add Providers (accounts) and Provider API keys deep-links.

* fix(desktop): flatten provider fields, drop input shadows, fix Cmd+K provider rank

- KeyField: collapse to one stacked label-above-input form field (drop the
  bespoke `naked`/inline/column branches); empty advanced overrides fade until
  hover/focus/set
- styles: kill the resting + focus drop shadow on shared input chrome so form
  inputs sit flat (composer keeps its own shadow)
- Cmd+K: drop stray `providers` keyword from Skills & Tools so the Providers
  settings entry ranks first for "provider"

* fix(desktop): nous portal arc blue → orange

* fix(desktop): rank appearance above settings in Cmd+K

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-04 03:03:42 -05:00
Ben b36a30db20 docs(dashboard-auth): document the username/password provider
Add a 'Username/password provider (no OAuth IDP)' section to the web
dashboard guide (config.yaml + env surfaces, the explicit-secret caveat,
the rate-limit/generic-401 properties, and a 'write your own password
provider' pointer to the supports_password extension point), and list the
HERMES_DASHBOARD_BASIC_AUTH_* env vars in the environment-variables
reference.
2026-06-04 01:02:25 -07:00
Ben 3a25912c14 test(dashboard-auth): cover password login route, provider, and plugin
- test_dashboard_auth_password_login.py: drives /auth/password-login
    end-to-end through the REAL gated_auth_middleware (login -> session
    cookie -> authenticated /api/auth/me -> transparent refresh via the RT
    cookie), plus protocol-extension checks, the generic-401/404 oracle
    properties, the rate limiter, and login-page rendering (form+script
    when supports_password, script-free otherwise, both for mixed
    providers). Reuses the existing StubAuthProvider harness convention.
  - test_basic_provider.py: scrypt hash/verify, login mint, kind-claim
    enforcement (access != refresh), cross-secret rejection, and the
    register() config/env precedence + skip reasons.

Mutation-tested: dropping the kind-claim check in verify_session makes
test_access_token_not_accepted_as_refresh fail, confirming the test isn't
theater.
2026-06-04 01:02:25 -07:00
Ben acb0e2bacb feat(dashboard-auth): add BasicAuthProvider username/password plugin
A bundled, zero-infrastructure 'just put a password on my dashboard'
provider that uses the supports_password extension point. No external IDP,
no database: sessions are stateless HMAC-signed tokens the provider mints
and verifies itself, and passwords are hashed with stdlib scrypt (no
third-party dependency — deliberately avoids bcrypt to keep the dep
surface unchanged).

  - plugins/dashboard_auth/basic: BasicAuthProvider (scrypt verify with a
    constant-time dummy-hash path for unknown users so the endpoint is not
    a username-timing oracle; access/refresh tokens carry a 'kind' claim
    that verify/refresh enforce; cross-secret tokens are rejected). The
    register() entry point mirrors the Nous plugin's config/env precedence
    (env wins; empty treated as unset) and LAST_SKIP_REASON channel.
  - config.py: document the canonical dashboard.basic_auth.* surface
    (username / password_hash / password / secret / session_ttl_seconds).

Activates only when username + (password or password_hash) are set, so
OAuth users and loopback/--insecure operators are unaffected. Without an
explicit secret a random per-process key is generated (logged): fine for a
single process, but sessions then don't survive restart or span workers.
2026-06-04 01:02:25 -07:00
Ben ed9e8ba097 feat(dashboard-auth): add pluggable password (non-redirect) login
The dashboard auth gate was OAuth-only: a DashboardAuthProvider could
authenticate only via a redirect to an IDP (start_login -> /auth/callback
-> complete_login). There was no first-class path for username/password
auth, so self-hosters who just want a password on their dashboard had no
clean option short of an external OAuth IDP.

Extend the provider framework with a parallel, non-redirect front door
that converges on the same Session + cookie + refresh machinery:

  - base.py: add the optional supports_password flag and
    complete_password_login(username, password) -> Session (default
    raises NotImplementedError so an OAuth-only provider that forgets the
    flag fails loudly). Add InvalidCredentialsError. OAuth providers are
    unaffected (flag defaults False; the method is never called).
  - routes.py: add POST /auth/password-login, mirroring the cookie-minting
    tail of /auth/callback but skipping PKCE/state/code. Returns JSON
    {ok, next} (the form POSTs via fetch). Generic 401 for both unknown
    user and wrong password (no enumeration oracle); 404 hides whether a
    provider exists or supports passwords; per-IP sliding-window rate
    limit (10/min -> 429). /api/auth/providers now reports
    supports_password so the login page can branch.
  - middleware.py: allowlist /auth/password-login (a bootstrap route).
    verify/refresh/revoke/ws-tickets/logout need zero changes — a password
    session is just a Session with provider-minted opaque tokens.
  - login_page.py: render a credential form (instead of a redirect button)
    for supports_password providers, wired by a small inline script that
    POSTs to /auth/password-login and navigates on success. OAuth-only
    pages stay script-free.
2026-06-04 01:02:25 -07:00
Ben Barclay fe74a1acda fix(dashboard_auth): allow any http:// host in redirect_uri fast-fail (#38827)
The Nous dashboard OAuth login rejected any http:// redirect_uri whose
host was not localhost/127.0.0.1, surfacing "redirect_uri may only use
http:// for localhost/127.0.0.1" on the login screen. This broke
self-hosted dashboards reached over plain HTTP — LAN IPs, internal
hostnames, and reverse proxies that terminate TLS upstream.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Depends on NousResearch/nous-account-service#324 (the portal endpoint).
2026-06-04 00:17:57 -07:00
67 changed files with 6507 additions and 895 deletions
+8
View File
@@ -245,6 +245,14 @@ def _install_npm(
needs ``typescript`` next to it; intelephense ships standalone).
"""
npm = shutil.which("npm")
if npm is None:
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
# (e.g. root FHS install whose symlink is missing, #38889).
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
if npm is None:
logger.info("[install] cannot install %s: npm not on PATH", pkg)
return None
+118
View File
@@ -0,0 +1,118 @@
/**
* connection-config.cjs
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
* `X-Hermes-Session-Token` header; WS uses `?token=`.
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
* by an HttpOnly session cookie; WS upgrades require a single-use
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
if (!value) {
throw new Error('Remote gateway URL is required.')
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return parsed.toString().replace(/\/+$/, '')
}
function buildGatewayWsUrl(baseUrl, token) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
}
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
}
/**
* Classify a gateway's auth mode from its public /api/status body.
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
* Returns 'oauth' | 'token'.
*/
function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}
/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
return 'token'
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
module.exports = {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
}
@@ -0,0 +1,180 @@
/**
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
* classification from /api/status, the coerce-time auth-mode resolution rules,
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
} = require('./connection-config.cjs')
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
})
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
})
test('normalizeRemoteBaseUrl rejects empty input', () => {
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
})
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
})
test('normalizeRemoteBaseUrl rejects garbage', () => {
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
})
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(
buildGatewayWsUrl('https://gw.example.com', 'tok123'),
'wss://gw.example.com/api/ws?token=tok123'
)
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(
buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'),
'ws://127.0.0.1:9119/api/ws?token=abc'
)
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(
buildGatewayWsUrl('https://host/hermes', 't'),
'wss://host/hermes/api/ws?token=t'
)
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(
buildGatewayWsUrl('https://host', 'a/b c+d'),
'wss://host/api/ws?token=a%2Fb%20c%2Bd'
)
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
assert.ok(!url.includes('token='))
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(
buildGatewayWsUrlWithTicket('https://host', 'a+b/c'),
'wss://host/api/ws?ticket=a%2Bb%2Fc'
)
})
// --- authModeFromStatus ---
test('authModeFromStatus returns oauth when auth_required is true', () => {
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
})
test('authModeFromStatus returns token when auth_required is false/missing', () => {
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
assert.equal(authModeFromStatus({}), 'token')
assert.equal(authModeFromStatus(null), 'token')
assert.equal(authModeFromStatus(undefined), 'token')
})
// --- resolveAuthMode ---
test('resolveAuthMode: explicit input wins over existing', () => {
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
})
test('resolveAuthMode: falls back to existing when input absent', () => {
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
})
test('resolveAuthMode: defaults to token when nothing is set', () => {
assert.equal(resolveAuthMode(undefined, undefined), 'token')
assert.equal(resolveAuthMode(null, null), 'token')
})
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
})
// --- cookiesHaveSession ---
test('cookiesHaveSession detects the bare access-token cookie', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
test('cookiesHaveSession handles non-arrays', () => {
assert.equal(cookiesHaveSession(null), false)
assert.equal(cookiesHaveSession(undefined), false)
assert.equal(cookiesHaveSession([]), false)
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, [
'__Host-hermes_session_at',
'__Secure-hermes_session_at',
'hermes_session_at'
])
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
assert.equal(tokenPreview(''), null)
assert.equal(tokenPreview(null), null)
})
test('tokenPreview returns set for short tokens', () => {
assert.equal(tokenPreview('12345678'), 'set')
})
test('tokenPreview returns a masked suffix for long tokens', () => {
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
})
+528 -208
View File
@@ -27,6 +27,15 @@ const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
} = require('./connection-config.cjs')
const {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
@@ -466,10 +475,6 @@ let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
// existing-install adopt branch (3b) so repair re-drives the installer instead
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
let forceBootstrapRepair = false
let connectionConfigCache = null
let connectionConfigCacheMtime = null
const hermesLog = []
@@ -1571,12 +1576,8 @@ function readJson(filePath) {
// Marker schema (version 1):
// {
// schemaVersion: 1,
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
// // may be null for adopted installs
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
// pinnedBranch: "<branch name>" | null,
// adopted: <bool>, // true when we adopted a pre-existing
// // install rather than bootstrapping it;
// // treated as authoritative even sans commit
// completedAt: "<ISO 8601>",
// desktopVersion: "<app.getVersion()>" // for forensics
// }
@@ -1584,25 +1585,11 @@ function readBootstrapMarker() {
return readJson(BOOTSTRAP_COMPLETE_MARKER)
}
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
// ever having written the bootstrap marker -- so we must be able to recognise
// "already installed" off the filesystem alone, not just the marker.
function isActiveRuntimeUsable() {
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function isBootstrapComplete() {
const marker = readBootstrapMarker()
if (!marker || typeof marker !== 'object') return false
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
// Adopted markers (an existing install we detected and took ownership of,
// possibly without a resolvable commit) are still authoritative -- they
// attest a runnable install we deliberately decided to forward to.
if (marker.adopted !== true) return false
}
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
// We DELIBERATELY do NOT verify that the checkout is currently at the
// pinned commit -- users update via the in-app update path or `hermes
// update`, which moves HEAD legitimately. The marker just attests "we
@@ -1610,22 +1597,7 @@ function isBootstrapComplete() {
// a runnable venv: an interrupted or split-home install can leave the marker
// + checkout without a venv, and trusting that spawns a dead backend
// ("gateway offline") instead of re-running bootstrap to repair it.
return isActiveRuntimeUsable()
}
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
// provenance a freshly-bootstrapped one would. null when git is unavailable or
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
function readActiveHeadCommit() {
try {
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
} catch {
return null
}
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function writeBootstrapMarker(payload) {
@@ -1634,7 +1606,6 @@ function writeBootstrapMarker(payload) {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null,
pinnedBranch: payload.pinnedBranch || null,
adopted: Boolean(payload.adopted),
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
@@ -1792,24 +1763,6 @@ function resolveHermesBackend(dashboardArgs) {
return createActiveBackend(dashboardArgs)
}
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
// written only by OUR bootstrap, so a runtime from `install.sh
// --include-desktop` (or a DMG launch over a prior CLI install) is
// runnable yet markerless -- without this we'd fall to step 6 and re-run
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
// canonical location (unlike a random `hermes` on PATH), so adopt it:
// stamp the marker once and forward straight to the app. Repair skips
// this so a broken-but-present venv still gets rebuilt.
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
try {
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
} catch (err) {
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
}
return createActiveBackend(dashboardArgs)
}
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
// a previous tool-only setup, or pip-installed system-wide. Use it but
// do NOT write a bootstrap marker; the user did this themselves and we
@@ -2000,9 +1953,6 @@ async function ensureRuntime(backend) {
}
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
// below SHOULD land on the fresh marker fast-path rather than skip it.
forceBootstrapRepair = false
// Re-resolve now that the install exists. The new resolution lands in
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
return ensureRuntime(resolveHermesBackend(backend.args))
@@ -2149,6 +2099,80 @@ function fetchJson(url, token, options = {}) {
})
}
function fetchPublicJson(url, options = {}) {
// Credential-free JSON GET/POST for public gateway endpoints
// (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends
// NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before
// any credentials exist, and any time we must not leak a token to an
// endpoint that doesn't need one.
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
let parsed
try {
parsed = new URL(url)
} catch (error) {
reject(new Error(`Invalid URL: ${error.message}`))
return
}
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const req = client.request(
parsed,
{
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(body ? { 'Content-Length': String(body.length) } : {})
}
},
res => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
if ((res.statusCode || 500) >= 400) {
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
return
}
if (!text) {
resolve(null)
return
}
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
const contentType = String(res.headers['content-type'] || '')
if (looksHtml || contentType.includes('text/html')) {
reject(
new Error(
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
'The endpoint is likely missing on the Hermes backend.'
)
)
return
}
try {
resolve(JSON.parse(text))
} catch {
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
}
})
}
)
req.on('error', reject)
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
})
if (body) req.write(body)
req.end()
})
}
function mimeTypeForPath(filePath) {
const ext = path.extname(filePath || '').toLowerCase()
@@ -2212,6 +2236,7 @@ const RENDER_TITLE_BLOCKED_RESOURCES = new Set([
])
let linkTitleSession = null
let oauthSession = null
let renderTitleInFlight = 0
const renderTitleQueue = []
@@ -3077,47 +3102,269 @@ function installMediaPermissions() {
})
}
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
// ---------------------------------------------------------------------------
// OAuth remote-gateway auth.
//
// Hosted Hermes gateways gate the dashboard behind an OAuth provider (e.g.
// Nous Research) instead of a static session token. The auth model is
// fundamentally different from the token path:
//
// * REST is authed by HttpOnly session cookies (``hermes_session_at``),
// established by a browser redirect round-trip (/login → IDP →
// /auth/callback sets cookies). We cannot read the HttpOnly cookie value
// in JS — instead we let an Electron BrowserWindow complete the round
// trip into a PERSISTENT session partition, and thereafter route our REST
// through Electron's ``net`` bound to that same partition so the cookie
// jar attaches the cookie automatically.
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
// path is unconditionally rejected by gated gateways.
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
// a ~15-min TTL. On 401 we must re-run the login round trip.
// ---------------------------------------------------------------------------
if (!value) {
throw new Error('Remote gateway URL is required.')
}
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
let parsed
try {
parsed = new URL(value)
} catch (error) {
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return parsed.toString().replace(/\/+$/, '')
function getOauthSession() {
if (oauthSession || !app.isReady()) return oauthSession
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
return oauthSession
}
function buildGatewayWsUrl(baseUrl, token) {
// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
if (!sess) return false
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
try {
// Query by URL so the cookie jar applies Domain/Path/Secure scoping for us.
const cookies = await sess.cookies.get({ url: baseUrl })
return cookiesHaveSession(cookies)
} catch {
// Fall back to a host match if the URL query path errors.
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHaveSession(cookies)
} catch {
return false
}
}
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return
try {
const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {})
await Promise.all(
cookies.map(c => {
const scheme = c.secure ? 'https' : 'http'
const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}`
return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined)
})
)
} catch {
// Best effort — a stale cookie self-expires anyway.
}
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
// Open the gateway's /login page in a visible window using the OAuth session
// partition, and resolve once the access-token cookie appears (login done) or
// reject if the user closes the window first. The window navigates through the
// IDP and back to /auth/callback, which sets the session cookies on the
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
function openOauthLoginWindow(baseUrl) {
return new Promise((resolve, reject) => {
if (!app.isReady()) {
reject(new Error('Desktop is not ready to start an OAuth login.'))
return
}
const sess = getOauthSession()
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
return
}
let settled = false
let win = null
let pollTimer = null
const finish = (err) => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
try {
if (win && !win.isDestroyed()) win.destroy()
} catch {
// window already torn down
}
if (err) reject(err)
else resolve({ baseUrl, ok: true })
}
const checkCookie = async () => {
if (settled) return
if (await hasOauthSessionCookie(baseUrl)) finish(null)
}
try {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes gateway',
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
session: sess,
webSecurity: true
}
})
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
// Re-check the cookie jar on every successful navigation (the callback
// redirect is the moment cookies get set) plus a low-frequency poll as a
// belt-and-braces fallback for IDPs that finish via in-page JS.
win.webContents.on('did-navigate', () => void checkCookie())
win.webContents.on('did-redirect-navigation', () => void checkCookie())
win.webContents.on('did-frame-navigate', () => void checkCookie())
pollTimer = setInterval(() => void checkCookie(), 750)
win.on('closed', () => {
if (!settled) finish(new Error('Login window closed before authentication completed.'))
})
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
// login, which is a valid authenticated page that sets the cookies. We
// only care that the cookie jar is populated.
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
win.loadURL(loginUrl).catch(error => {
finish(error instanceof Error ? error : new Error(String(error)))
})
})
}
// JSON request routed through the OAuth session partition so the HttpOnly
// session cookie is attached automatically by Electron's net stack. Used for
// authed REST against a gated gateway, including minting WS tickets.
function fetchJsonViaOauthSession(url, options = {}) {
return new Promise((resolve, reject) => {
const sess = getOauthSession()
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
return
}
let parsed
try {
parsed = new URL(url)
} catch (error) {
reject(new Error(`Invalid URL: ${error.message}`))
return
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const request = electronNet.request({
method: options.method || 'GET',
url,
session: sess,
useSessionCookies: true,
redirect: 'follow'
})
request.setHeader('Content-Type', 'application/json')
if (body) request.setHeader('Content-Length', String(body.length))
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
try {
request.abort()
} catch {
// already finished
}
reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
}, timeoutMs)
request.on('response', res => {
const chunks = []
res.on('data', chunk => chunks.push(Buffer.from(chunk)))
res.on('end', () => {
if (timedOut) return
clearTimeout(timer)
const text = Buffer.concat(chunks).toString('utf8')
const statusCode = res.statusCode || 500
if (statusCode >= 400) {
const err = new Error(`${statusCode}: ${text || ''}`)
err.statusCode = statusCode
reject(err)
return
}
if (!text) {
resolve(null)
return
}
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
const contentType = String((res.headers['content-type'] || res.headers['Content-Type'] || ''))
if (looksHtml || contentType.includes('text/html')) {
reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`))
return
}
try {
resolve(JSON.parse(text))
} catch {
reject(new Error(`Invalid JSON from ${url} (status ${statusCode}): ${text.slice(0, 200)}`))
}
})
})
request.on('error', error => {
if (timedOut) return
clearTimeout(timer)
reject(error)
})
if (body) request.write(body)
request.end()
})
}
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
// Throws (with statusCode 401) if the session cookie is missing/expired —
// callers treat that as "needs re-login".
async function mintGatewayWsTicket(baseUrl) {
const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
method: 'POST',
timeoutMs: 8_000
})
const ticket = body?.ticket
if (!ticket || typeof ticket !== 'string') {
throw new Error('Gateway did not return a WS ticket.')
}
return ticket
}
// Build a fresh WS URL for the *current* connection. Critical for reconnects:
// OAuth WS tickets are single-use with a ~30s TTL, so the ticket baked into
// the cached connection's wsUrl is stale on the second connect. The renderer
// calls this immediately before every gateway.connect() so each WS upgrade
// carries a freshly-minted ticket. For local/token connections this just
// reuses the static token (no minting needed).
async function freshGatewayWsUrl() {
const connection = await startHermes()
if (connection.authMode === 'oauth') {
const ticket = await mintGatewayWsTicket(connection.baseUrl)
return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket)
}
// Local/token: the cached wsUrl already carries the (long-lived) token.
return connection.wsUrl
}
function encryptDesktopSecret(value) {
@@ -3168,9 +3415,14 @@ function readDesktopConnectionConfig() {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
const remote = parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
// authMode lives on the remote sub-object: 'oauth' (cookie + ws-ticket)
// or 'token' (legacy static session token). Default to 'token' for
// backward compatibility with configs written before OAuth support.
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
remote
}
}
} catch {
@@ -3190,12 +3442,25 @@ function writeDesktopConnectionConfig(config) {
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
}
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
const remoteToken = decryptDesktopSecret(config.remote?.token)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
const remoteUrl = String(config.remote?.url || '')
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
} catch {
remoteOauthConnected = false
}
}
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
remoteUrl: String(config.remote?.url || ''),
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
@@ -3206,10 +3471,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
const persistToken = options.persistToken !== false
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
authMode,
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
@@ -3220,7 +3488,10 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
if (!decryptDesktopSecret(nextRemote.token)) {
// OAuth gateways authenticate via the session cookie established by the
// login window, NOT a static token — so no token is required here. The
// cookie presence is verified at connect time (resolveRemoteBackend).
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
throw new Error('Remote gateway session token is required.')
}
} else if (remoteUrl) {
@@ -3230,7 +3501,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
return { mode, remote: nextRemote }
}
function resolveRemoteBackend() {
async function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
@@ -3248,6 +3519,7 @@ function resolveRemoteBackend() {
baseUrl,
mode: 'remote',
source: 'env',
authMode: 'token',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
@@ -3259,6 +3531,47 @@ function resolveRemoteBackend() {
return null
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
// Verify the cookie is present, then mint a single-use WS ticket (the
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
// user needs to (re-)log in via Settings → Gateway.
if (!(await hasOauthSessionCookie(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
)
err.needsOauthLogin = true
throw err
}
let ticket
try {
ticket = await mintGatewayWsTicket(baseUrl)
} catch (error) {
const err = new Error(
'Your remote gateway session has expired. ' +
'Open Settings → Gateway and click "Sign in" again.'
)
err.needsOauthLogin = true
err.cause = error
throw err
}
return {
baseUrl,
mode: 'remote',
source: 'settings',
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
@@ -3268,31 +3581,104 @@ function resolveRemoteBackend() {
)
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
return {
baseUrl,
mode: 'remote',
source: 'settings',
authMode: 'token',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
async function probeRemoteAuthMode(rawUrl) {
// Determine how a remote gateway expects callers to authenticate, WITHOUT
// sending any credentials. ``/api/status`` is public on every Hermes
// gateway (it backs the portal liveness probe) and reports:
// auth_required: true → OAuth gate is engaged (cookie + ws-ticket auth)
// auth_required: false → loopback/--insecure: legacy session-token auth
// ``/api/auth/providers`` (also public, only meaningful when gated) gives
// the human-facing provider name(s) for the login button label.
//
// The settings UI calls this as the user types a URL so it can render an
// OAuth login button vs a session-token entry box. Network/parse failures
// surface as ``reachable: false`` rather than throwing, so a half-typed or
// unreachable URL degrades to "can't tell yet" instead of a hard error.
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
let status
try {
status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
} catch (error) {
return {
baseUrl,
reachable: false,
authMode: 'unknown',
providers: [],
version: null,
error: error instanceof Error ? error.message : String(error)
}
}
const authRequired = authModeFromStatus(status) === 'oauth'
let providers = []
if (authRequired) {
// Best-effort: a gated gateway exposes the registered providers so the
// button can read "Sign in with Nous Research" instead of a generic
// label, and so a username/password provider can be distinguished from
// an OAuth-redirect one (``supports_password``). A failure here doesn't
// change the auth mode, so swallow it.
try {
const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
if (Array.isArray(body?.providers)) {
providers = body.providers
.filter(p => p && typeof p === 'object')
.map(p => ({
name: String(p.name || ''),
displayName: String(p.display_name || p.name || ''),
supportsPassword: Boolean(p.supports_password)
}))
.filter(p => p.name)
}
} catch {
// Provider listing is optional metadata; the auth mode is already known.
}
}
return {
baseUrl,
reachable: true,
authMode: authRequired ? 'oauth' : 'token',
providers,
version: status?.version || null,
error: null
}
}
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const remote =
config.mode === 'remote'
? {
baseUrl: normalizeRemoteBaseUrl(config.remote.url),
token: decryptDesktopSecret(config.remote.token)
}
: resolveRemoteBackend() || (await startHermes())
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
// for local we fall back to the resolved/started backend.
let baseUrl
let token = null
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
if ((config.remote.authMode || 'token') !== 'oauth') {
token = decryptDesktopSecret(config.remote.token)
}
} else {
const remote = (await resolveRemoteBackend()) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
return {
ok: true,
baseUrl: remote.baseUrl,
baseUrl,
version: status?.version || null
}
}
@@ -3335,7 +3721,7 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = resolveRemoteBackend()
const remote = await resolveRemoteBackend()
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@@ -3350,6 +3736,7 @@ async function startHermes() {
baseUrl: remote.baseUrl,
mode: 'remote',
source: remote.source,
authMode: remote.authMode || 'token',
token: remote.token,
wsUrl: remote.wsUrl,
logs: hermesLog.slice(-80),
@@ -3454,6 +3841,7 @@ async function startHermes() {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
@@ -3605,13 +3993,13 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.handle('hermes:gateway:ws-url', async () => freshGatewayWsUrl())
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
bootstrapFailure = null
forceBootstrapRepair = false
connectionPromise = null
bootstrapState = {
active: false,
@@ -3639,9 +4027,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
// Force the next resolution past both the marker fast-path and the adopt
// branch so the installer actually re-runs (the whole point of repair).
forceBootstrapRepair = true
resetHermesConnection()
return { ok: true }
})
@@ -3661,6 +4046,21 @@ ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
// land in the OAuth partition. The caller (settings UI) typically saves the
// remote config with authMode='oauth' first, then calls this. We normalize
// the URL defensively so a login can be driven from a raw URL too.
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
await openOauthLoginWindow(baseUrl)
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
})
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
@@ -3691,7 +4091,19 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes()
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
const url = `${connection.baseUrl}${request.path}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
// the static session-token header.
if (connection.authMode === 'oauth') {
return fetchJsonViaOauthSession(url, {
method: request?.method,
body: request?.body,
timeoutMs
})
}
return fetchJson(url, connection.token, {
method: request?.method,
body: request?.body,
timeoutMs
@@ -4157,99 +4569,7 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ---------------------------------------------------------------------------
// macOS first-launch placement: move into /Applications and pin to the Dock
// ---------------------------------------------------------------------------
//
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
// no Dock tile, and "which icon do I click?" confusion. On first packaged
// launch we relocate into /Applications (Electron relaunches from there) and,
// once we're that canonical copy, pin to the Dock. Both macOS-only,
// packaged-only, best-effort, run at most once.
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
// is underway (caller must stop init). No-op in dev, off macOS, or already in
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
// it. `exists` -> stale copy; replace it so there's exactly one current app.
function maybeRelocateToApplications() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
try {
if (app.isInApplicationsFolder()) return false
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
return moved
} catch (err) {
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
return false
}
}
const DOCK_PINNED_MARKER = 'dock-pinned.json'
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
// Guarded by a userData marker + membership check so we never duplicate the tile.
function maybePinToDock() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
if (fileExists(marker)) return
let bundle
try {
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
bundle = runningAppBundle()
} catch {
return
}
if (!bundle) return
// The Dock stores tiles as file-reference URLs (type 15), e.g.
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
const done = (note = {}) => {
try {
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
} catch {
// best-effort; we re-check next launch (membership guard dedupes)
}
}
try {
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (apps.includes(url)) return done({ alreadyPresent: true })
} catch {
// persistent-apps may not exist yet; -array-add creates it
}
const tile =
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
'</dict></dict></dict>'
try {
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
// Flush the write through cfprefsd before restarting the Dock, otherwise the
// Dock reloads stale prefs and our tile is lost in the race.
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
done()
rememberLog(`[install] pinned to Dock: ${url}`)
} catch (err) {
rememberLog(`[install] Dock pin skipped: ${err.message}`)
}
}
app.whenReady().then(() => {
// macOS: relocate into /Applications before anything else so setup + state
// land in the final location; on success this relaunches, so bail here.
if (maybeRelocateToApplications()) return
maybePinToDock()
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
} else {
+4
View File
@@ -2,11 +2,15 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
+1 -1
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+38 -22
View File
@@ -36,7 +36,8 @@ import {
Settings,
Sun,
Users,
Wrench
Wrench,
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
@@ -98,8 +99,20 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
})
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
{
icon: Zap,
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
label: 'Providers',
tab: 'providers&pview=accounts'
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
label: 'Provider API keys',
tab: 'providers&pview=keys'
},
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' },
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
@@ -169,7 +182,7 @@ export function CommandPalette() {
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets', 'providers'],
keywords: ['tools', 'toolsets'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
@@ -207,25 +220,9 @@ export function CommandPalette() {
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
},
{
// Declared before Settings: cmdk keeps group order, so this keeps the
// theme/mode pickers on top for "theme"/"color" queries instead of
// buried under a fuzzy Settings match.
heading: 'Appearance',
items: [
{
@@ -243,6 +240,25 @@ export function CommandPalette() {
to: 'color-mode'
}
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
}
]
}, [go])
+1 -1
View File
@@ -316,7 +316,7 @@ export function DesktopController() {
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=keys`)
navigate(`${SETTINGS_ROUTE}?tab=providers`)
}, [navigate])
const modelMenuContent = useMemo(
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
applyDesktopBootProgress,
@@ -103,7 +104,15 @@ export function useGatewayBoot({
}
publish(conn)
await gateway.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
// dead on every reconnect after the initial boot — reusing it surfaces
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
// than connecting with a stale one). For local/token gateways the URL
// carries a long-lived token and the re-mint is a cheap no-op.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
@@ -113,8 +122,14 @@ export function useGatewayBoot({
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
} catch {
// Fall through to scheduleReconnect's backoff below.
} catch (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
// ticket that can never succeed. Transport failures fall through to the
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err)) {
notifyError(err, 'Gateway sign-in required')
}
} finally {
reconnecting = false
@@ -230,7 +245,13 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
await gateway.connect(conn.wsUrl)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
// failure, throws a reauth error rather than connecting with a dead
// ticket (which would surface as an opaque "connection closed").
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@@ -14,6 +15,10 @@ export function useGatewayRequest() {
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
// Holds the reauth error from the most recent failed reconnect so
// requestGateway can surface the gateway's "session expired, sign in again"
// message instead of the opaque "connection closed" that triggered the retry.
const reauthErrorRef = useRef<unknown>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
@@ -41,14 +46,26 @@ export function useGatewayRequest() {
return null
}
reauthErrorRef.current = null
try {
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
await existing.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is dead here;
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
// connecting with a stale ticket. Stash it so requestGateway can show
// the actionable "sign in again" message.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await existing.connect(wsUrl)
return existing
} catch {
} catch (error) {
if (isGatewayReauthRequired(error)) {
reauthErrorRef.current = error
}
connectionRef.current = null
setConnection(null)
@@ -81,6 +98,15 @@ export function useGatewayRequest() {
const recovered = await ensureGatewayOpen()
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session
// expired) over the generic transport error that triggered the retry.
const reauthError = reauthErrorRef.current
reauthErrorRef.current = null
if (reauthError) {
throw reauthError
}
throw error
}
@@ -24,6 +24,9 @@ interface OverlayNavItemProps {
active: boolean
icon: IconComponent
label: string
// Renders as an indented child of another nav item: smaller icon and a
// lighter active state so it never competes with the boxed parent item.
nested?: boolean
onClick: () => void
trailing?: ReactNode
}
@@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
nested
? active
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
: active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<Icon
className={cn(
'shrink-0',
nested ? 'size-3.5' : 'size-4',
active ? 'text-foreground/80' : 'text-muted-foreground/80'
)}
/>
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>
+185 -17
View File
@@ -15,9 +15,21 @@ import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
// Provider group definitions used to fold raw env-var names like
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
// description, and signup URL. Membership is determined by longest
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
// (``MINIMAX_``). New providers should be added here so they get their
// own card in Settings → Keys instead of being lumped into "Other".
interface ProviderPrefix {
prefix: string
name: string
/** Optional one-line tagline shown beneath the group name. */
description?: string
/** Optional canonical signup/console URL surfaced from the card header. */
docsUrl?: string
/** Lower numbers float to the top of the providers list. */
priority: number
}
@@ -25,24 +37,180 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-xs'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
{
prefix: 'NOUS_',
name: 'Nous Portal',
description: 'Hosted Hermes & Nous-trained models',
docsUrl: 'https://portal.nousresearch.com',
priority: 0
},
{
prefix: 'OPENROUTER_',
name: 'OpenRouter',
description: 'Aggregator for hundreds of frontier models',
docsUrl: 'https://openrouter.ai/keys',
priority: 1
},
{
prefix: 'ANTHROPIC_',
name: 'Anthropic',
description: 'Claude API access (Sonnet, Opus, Haiku)',
docsUrl: 'https://console.anthropic.com/settings/keys',
priority: 2
},
{
prefix: 'XAI_',
name: 'xAI',
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
docsUrl: 'https://console.x.ai/',
priority: 3
},
{
prefix: 'GOOGLE_',
name: 'Gemini',
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
docsUrl: 'https://aistudio.google.com/app/apikey',
priority: 4
},
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
{
prefix: 'DEEPSEEK_',
name: 'DeepSeek',
description: 'Direct DeepSeek API (V3.x, R1)',
docsUrl: 'https://platform.deepseek.com/api_keys',
priority: 5
},
{
prefix: 'DASHSCOPE_',
name: 'DashScope (Qwen)',
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
priority: 6
},
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
{
prefix: 'GLM_',
name: 'GLM / Z.AI',
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
docsUrl: 'https://z.ai/',
priority: 7
},
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
{
prefix: 'KIMI_',
name: 'Kimi / Moonshot',
description: 'Moonshot Kimi K2 / coding endpoints',
docsUrl: 'https://platform.moonshot.cn/',
priority: 8
},
{
prefix: 'KIMI_CN_',
name: 'Kimi (China)',
description: 'Moonshot China endpoint',
docsUrl: 'https://platform.moonshot.cn/',
priority: 9
},
{
prefix: 'MINIMAX_',
name: 'MiniMax',
description: 'MiniMax-M2 and Hailuo international endpoints',
docsUrl: 'https://www.minimax.io/',
priority: 10
},
{
prefix: 'MINIMAX_CN_',
name: 'MiniMax (China)',
description: 'MiniMax mainland China endpoint',
docsUrl: 'https://www.minimaxi.com/',
priority: 11
},
{
prefix: 'HF_',
name: 'Hugging Face',
description: 'Inference Providers — 20+ open models via router.huggingface.co',
docsUrl: 'https://huggingface.co/settings/tokens',
priority: 12
},
{
prefix: 'OPENCODE_ZEN_',
name: 'OpenCode Zen',
description: 'Pay-as-you-go access to curated coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 13
},
{
prefix: 'OPENCODE_GO_',
name: 'OpenCode Go',
description: '$10/month subscription for open coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 14
},
{
prefix: 'NVIDIA_',
name: 'NVIDIA NIM',
description: 'build.nvidia.com or your own local NIM endpoint',
docsUrl: 'https://build.nvidia.com/',
priority: 15
},
{
prefix: 'OLLAMA_',
name: 'Ollama Cloud',
description: 'Cloud-hosted open models from ollama.com',
docsUrl: 'https://ollama.com/settings',
priority: 16
},
{
prefix: 'LM_',
name: 'LM Studio',
description: 'Local LM Studio server (OpenAI-compatible)',
docsUrl: 'https://lmstudio.ai/docs/local-server',
priority: 17
},
{
prefix: 'STEPFUN_',
name: 'StepFun',
description: 'StepFun Step Plan coding models',
docsUrl: 'https://platform.stepfun.com/',
priority: 18
},
{
prefix: 'XIAOMI_',
name: 'Xiaomi MiMo',
description: 'MiMo-V2.5 and Xiaomi proprietary models',
docsUrl: 'https://platform.xiaomimimo.com',
priority: 19
},
{
prefix: 'ARCEEAI_',
name: 'Arcee AI',
description: 'Arcee-hosted small + medium models',
docsUrl: 'https://chat.arcee.ai/',
priority: 20
},
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
{
prefix: 'GMI_',
name: 'GMI Cloud',
description: 'GMI Cloud GPU + model serving',
docsUrl: 'https://www.gmicloud.ai/',
priority: 21
},
{
prefix: 'AZURE_FOUNDRY_',
name: 'Azure Foundry',
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
docsUrl: 'https://ai.azure.com/',
priority: 22
},
{
prefix: 'AWS_',
name: 'AWS Bedrock',
description: 'Authenticate via AWS profile + region',
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
priority: 23
}
]
export const BUILTIN_PERSONALITIES = [
@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
import { Pill } from './primitives'
import type { EnvRowProps } from './types'
// Shared filter used by every credential surface (Providers + Keys pages):
// category gate first, then a free-text match across key name + description.
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
}
export function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/20 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
Cancel
</Button>
</div>
)}
</div>
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{count && <Pill>{count}</Pill>}
</div>
)
}
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
// credential pages (Providers, Keys) share one source of truth and one set of
// mutation handlers instead of duplicating the plumbing.
export function useEnvCredentials(): UseEnvCredentials {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Best-effort cleanup of a retired localStorage flag (global "Show
// advanced" toggle) — everything in these views is configuration-level.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
// Direct save for a known value (no edit-state round-trip) — used by the
// onboarding-style key form, which owns its own input. Returns a result so
// the form can surface inline errors instead of only toasting.
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
const trimmed = value.trim()
if (!trimmed) {
return { message: 'Enter a value first.', ok: false }
}
setSaving(key)
try {
await setEnvVar(key, trimmed)
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
clearLocalState(key)
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
return { ok: true }
} catch (err) {
notifyError(err, `Failed to save ${key}`)
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
return {
saveValue,
vars,
rowProps: {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
}
}
interface CategoryHeadingProps {
count?: string
icon: IconComponent
title: string
}
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
}
interface UseEnvCredentials {
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
vars: Record<string, EnvVarInfo> | null
}
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -10,10 +11,14 @@ import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
mode: Mode
remoteAuthMode: AuthMode
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@@ -22,6 +27,8 @@ interface GatewaySettingsState {
const EMPTY_STATE: GatewaySettingsState = {
envOverride: false,
mode: 'local',
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
@@ -71,10 +78,18 @@ export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const probeSeq = useRef(0)
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@@ -104,15 +119,128 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}, [])
const canUseRemote = useMemo(
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
[remoteToken, state.remoteTokenSet, state.remoteUrl]
)
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const seq = ++probeSeq.current
setProbeStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [state.mode, trimmedUrl])
// Effective auth mode: a reachable probe wins; otherwise fall back to the
// saved config's mode so a re-open of settings doesn't flicker.
const authMode: AuthMode = useMemo(() => {
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
return probe.authMode
}
return state.remoteAuthMode
}, [probe, probeStatus, state.remoteAuthMode])
// Whether we actually KNOW how this gateway authenticates yet. Until we do,
// neither the OAuth button nor the session-token box should render —
// `authMode` defaults to 'token', so without this gate the token box flashes
// for every gateway (including OAuth ones) during the idle/probing window
// before the first probe lands. The scheme is known when either:
// * the live probe finished (probeStatus 'done'), or
// * we're idle but showing a previously-saved remote config (re-opening
// settings for a gateway already signed-in or with a saved token), so
// its control appears immediately with no flicker.
// While probing (or after a probe error), the scheme is unknown and we show
// the probe status row instead of a control.
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
const authResolved = useMemo(() => {
if (probeStatus === 'done') {
return true
}
return probeStatus === 'idle' && hasSavedRemote
}, [probeStatus, hasSavedRemote])
const providerLabel = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
if (providers.length === 1) {
return providers[0].displayName || providers[0].name
}
if (providers.length > 1) {
return providers.map(p => p.displayName || p.name).join(' / ')
}
return 'your identity provider'
}, [probe])
// A username/password gateway authenticates through a credential form on the
// gateway's /login page (POST /auth/password-login) rather than an OAuth
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
// the persistent partition — is identical, so the desktop drives it through
// the same sign-in window; only the button copy changes. We treat the
// gateway as password-style only when EVERY advertised provider supports
// password, so a mixed deployment keeps the generic OAuth copy.
const isPasswordProvider = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
if (!trimmedUrl) {
return false
}
if (authMode === 'oauth') {
return oauthConnected
}
return Boolean(remoteToken.trim()) || state.remoteTokenSet
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
const payload = () => ({
mode: state.mode,
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const save = async (apply: boolean) => {
@@ -120,7 +248,10 @@ export function GatewaySettings() {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before switching to remote.'
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before switching to remote.'
: 'Enter a remote URL and session token before switching to remote.'
})
return
@@ -147,12 +278,73 @@ export function GatewaySettings() {
}
}
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
// the URL the login window needs), then open the gateway login window and
// refresh the connection status from the saved config once it completes.
const signIn = async () => {
if (!trimmedUrl) {
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
return
}
setSigningIn(true)
try {
// Save (don't apply/restart) so the login window has a URL to use and the
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
setState(saved)
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
}
} catch (err) {
notifyError(err, 'Sign-in failed')
} finally {
setSigningIn(false)
}
}
const signOut = async () => {
setSigningIn(true)
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
notifyError(err, 'Sign-out failed')
} finally {
setSigningIn(false)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before testing.'
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before testing.'
: 'Enter a remote URL and session token before testing.'
})
return
@@ -164,8 +356,9 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
@@ -229,7 +422,7 @@ export function GatewaySettings() {
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes backend using its session token."
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
@@ -251,23 +444,75 @@ export function GatewaySettings() {
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
title="Remote URL"
/>
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
Checking how this gateway authenticates
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
Could not reach this gateway yet. Check the URL the auth method will appear once it responds.
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> Signed in
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
Sign out
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
</Button>
)
}
description={
oauthConnected
? isPasswordProvider
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
: isPasswordProvider
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
}
title="Authentication"
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
) : null}
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
+25 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { getNested, setNested } from './helpers'
import { getNested, providerGroup, setNested } from './helpers'
describe('settings helpers', () => {
it('reads and writes nested config paths', () => {
@@ -20,4 +20,28 @@ describe('settings helpers', () => {
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
describe('providerGroup', () => {
it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
})
it('prefers the longest matching prefix so CN/regional buckets win', () => {
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
// KIMI_CN_ likewise must beat KIMI_.
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
})
it('falls back to "Other" for un-grouped env vars', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
})
+23 -2
View File
@@ -19,9 +19,30 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
// "Other" used by the Keys settings view for un-grouped env vars.
export const providerGroup = (key: string) => {
let best: (typeof PROVIDER_GROUPS)[number] | undefined
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
for (const candidate of PROVIDER_GROUPS) {
if (!key.startsWith(candidate.prefix)) {
continue
}
if (!best || candidate.prefix.length > best.prefix.length) {
best = candidate
}
}
return best?.name ?? 'Other'
}
export const providerMeta = (name: string) =>
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
PROVIDER_GROUPS.find(g => g.name === name)
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
+38 -2
View File
@@ -3,7 +3,7 @@ import { useRef } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -18,11 +18,13 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
'keys',
'mcp',
@@ -32,6 +34,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const openProviderView = (view: ProviderView) => {
setActiveView('providers')
setProviderView(view)
}
const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -83,6 +93,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
)
})}
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label="Providers"
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
label="Accounts"
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label="API keys"
nested
onClick={() => openProviderView('keys')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
@@ -92,7 +126,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="API Keys"
label="Tools & Keys"
onClick={() => setActiveView('keys')}
/>
<OverlayNavItem
@@ -154,6 +188,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings />
) : activeView === 'mcp' ? (
+106 -369
View File
@@ -1,425 +1,162 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
import { Settings2, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
import { EnvVarRow, useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
// Providers live on their own page; messaging-platform credentials live on the
// dedicated Messaging page (and are hidden here via `channel_managed`). This
// view covers tool API keys plus server/setting env vars (API server, webhook,
// gateway), which fold into the Settings tab.
const KEY_TABS = [
{ icon: Wrench, id: 'tool', label: 'Tools' },
{ icon: Settings2, id: 'setting', label: 'Settings' }
] as const
type KeyCategoryId = (typeof KEY_TABS)[number]['id']
const CATEGORY_LABELS: Record<KeyCategoryId, string> = {
setting: 'Settings',
tool: 'Tools'
}
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="textStrong">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
// Backend categories that surface under each tab. Server/gateway vars carry the
// `messaging` category server-side but belong with general settings here, since
// the platform-credential half of `messaging` is owned by the Messaging page.
const TAB_CATEGORIES: Record<KeyCategoryId, readonly string[]> = {
setting: ['setting', 'messaging'],
tool: ['tool']
}
function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
function tabForCategory(category: string): KeyCategoryId | null {
for (const tab of KEY_TABS) {
if (TAB_CATEGORIES[tab.id].includes(category)) {
return tab.id
}
}
return (
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
Cancel
</Button>
</div>
)}
</div>
)
return null
}
function EnvProviderGroup({
group,
rowProps,
forceExpand = false
function CategoryTabs({
active,
counts,
onSelect
}: {
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
forceExpand?: boolean
active: KeyCategoryId
counts: Record<KeyCategoryId, number>
onSelect: (id: KeyCategoryId) => void
}) {
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
useEffect(() => {
if (forceExpand) {
setExpanded(true)
}
}, [forceExpand])
return (
<div className="overflow-hidden rounded-xl bg-background/60">
<button
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
onClick={() => setExpanded(e => !e)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<Zap className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{group.name === 'Other' ? 'Other providers' : group.name}
</span>
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
</span>
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
</button>
{expanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{group.entries.map(([key, info]) => (
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
</div>
))}
</div>
)}
<div className="mb-4 inline-flex w-full gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/30 p-1">
{KEY_TABS.map(tab => {
const isActive = active === tab.id
const count = counts[tab.id]
return (
<button
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[length:var(--conversation-text-font-size)] font-medium transition-colors',
isActive
? 'bg-(--ui-chat-surface-background) text-foreground shadow-sm'
: 'text-(--ui-text-secondary) hover:text-foreground'
)}
key={tab.id}
onClick={() => onSelect(tab.id)}
type="button"
>
<tab.icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.label}</span>
{count > 0 && (
<span
className={cn(
'rounded-full px-1.5 text-[0.6875rem] tabular-nums',
isActive ? 'bg-primary/12 text-primary' : 'bg-(--ui-bg-tertiary)/60 text-muted-foreground'
)}
>
{count}
</span>
)}
</button>
)
})}
</div>
)
}
export function KeysSettings() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
const { rowProps, vars } = useEnvCredentials()
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('tool')
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
// matching provider group, scroll the row in, and flash it.
const highlightKey = useDeepLinkHighlight({
elementId: key => `env-var-${key}`,
param: 'key',
ready: key => Boolean(vars?.[key])
})
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
// default-collapsed-unless-set keep the surface manageable.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
const groups = useMemo(() => {
if (!vars) {
return []
}
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
return KEY_TABS.map(t => t.id).flatMap(tab => {
const cats = TAB_CATEGORIES[tab]
const groups = new Map<string, [string, EnvVarInfo][]>()
for (const entry of entries) {
const name = providerGroup(entry[0])
groups.set(name, [...(groups.get(name) ?? []), entry])
}
return Array.from(groups, ([name, entries]) => ({
name,
priority: providerPriority(name),
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
hasAnySet: entries.some(([, info]) => info.is_set)
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}, [vars])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
setting: 'Settings'
}
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([, info]) => asText(info.category) === cat)
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }]
})
}, [vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
// Tab badge counts reflect how many keys are set per tab. Channel-managed
// credentials are owned by the Messaging page and excluded here.
const categoryCounts = useMemo<Record<KeyCategoryId, number>>(() => {
const counts: Record<KeyCategoryId, number> = { setting: 0, tool: 0 }
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
if (!vars) {
return counts
}
setSaving(key)
for (const info of Object.values(vars)) {
if (!info.is_set || info.channel_managed) {
continue
}
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
const tab = tabForCategory(asText(info.category))
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
if (tab) {
counts[tab] += 1
}
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
return counts
}, [vars])
if (!vars) {
return <LoadingState label="Loading API keys and credentials..." />
}
const rowProps = {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
const visible = groups.filter(g => g.category === activeCategory)
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading
icon={Zap}
meta={`${configuredCount} of ${providerGroups.length} configured`}
title="LLM providers"
/>
<div className="grid gap-2">
{providerGroups.map(group => (
<EnvProviderGroup
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
group={group}
key={group.name}
rowProps={rowProps}
/>
))}
</div>
</div>
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
{otherGroups.map(group => (
<div className="mb-6" key={group.category}>
<SectionHeading
icon={Settings2}
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
title={group.label}
/>
{visible.map(group => (
<section className="mb-6" key={group.category}>
<div className="grid gap-2">
{group.entries.map(([key, info]) => (
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow info={info} varKey={key} {...rowProps} />
</div>
{group.entries.map(([key, info]: [string, EnvVarInfo]) => (
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
</div>
</section>
))}
{visible.length === 0 && (
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Nothing configured in this category yet.
</div>
)}
</SettingsContent>
)
}
@@ -0,0 +1,489 @@
import { useStore } from '@nanostores/react'
import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
import {
FEATURED_ID,
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { listOAuthProviders } from '@/hermes'
import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
import type { EnvRowProps } from './types'
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
// duplicate key input. URL-ish vars get a URL hint; everything else stays optional.
const advancedPlaceholder = (key: string, info: EnvVarInfo): string =>
isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional'
// Group the env catalog by provider so the keys view can render one collapsible
// row per vendor: a primary key field inline, with any secondary / advanced vars
// (base URL overrides, alt tokens) revealed when the row is focused/expanded.
// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g.
// Nous Portal's lone base-URL override) and the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
for (const [key, info] of Object.entries(vars)) {
if (info.category !== 'provider') {
continue
}
const name = providerGroup(key)
if (name === 'Other') {
continue
}
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
}
const groups: ProviderKeyGroup[] = []
for (const [name, entries] of buckets) {
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
if (!primary) {
continue
}
const meta = providerMeta(name)
groups.push({
// Advanced = the provider's non-key knobs (base URL, region, deployment).
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
// so we never render a second "Paste key" input — unless one is already
// set, in which case keep it visible so it stays clearable.
advanced: entries
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
.sort(([a], [b]) => a.localeCompare(b)),
description: meta?.description ?? primary[1].description,
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
hasAnySet: entries.some(([, i]) => i.is_set),
name,
primary,
priority: providerPriority(name)
})
}
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
function KeyField({
compact = false,
info,
label,
placeholder,
rowProps,
varKey
}: {
compact?: boolean
info: EnvVarInfo
label?: string
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
// Enter saves; Esc cancels in place without bubbling to the overlay's window
// Escape listener (which would otherwise close the whole settings panel).
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
// Advanced overrides render quieter (xs) than the primary key field so the key
// stays the visual anchor. Padding-driven sizing — no fixed heights.
const inputSize = compact ? 'xs' : 'sm'
const editType = info.is_password ? 'password' : 'text'
// A set value reads as a single filled, read-only field (showing the redacted
// value). Clicking it drops into edit mode in place — no Replace/Cancel chrome.
const control =
info.is_set && !editing ? (
<Input
className="cursor-pointer font-mono text-muted-foreground"
onFocus={startEdit}
readOnly
size={inputSize}
value={masked}
/>
) : (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className="min-w-0 flex-1 font-mono"
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
size={inputSize}
type={editType}
value={draft}
/>
{dirty && (
<Button disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
// Standard stacked form field: small muted label above, input below. Same shape
// for the primary key and every advanced override — just smaller when compact.
// Empty advanced inputs (not labels) fade back, brightening on hover/focus/set.
const dim = compact && !info.is_set
return (
<div className="grid gap-1.5">
{label && (
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{label}
</label>
)}
{dim ? (
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
) : (
control
)}
</div>
)
}
function ProviderKeyCard({
expanded,
group,
onExpand,
onToggle,
rowProps
}: {
expanded: boolean
group: ProviderKeyGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}) {
// Expandable when there's anything to reveal — advanced overrides and/or a
// "Get a key" docs link (which lives at the bottom of the expanded panel).
const expandable = group.advanced.length > 0 || Boolean(group.docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-2 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
<span
className={cn('size-2 shrink-0 rounded-full', group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
/>
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="w-full sm:w-80 sm:shrink-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="mt-3 grid gap-2.5 pl-4" onClick={e => e.stopPropagation()}>
{group.advanced.map(([key, info]) => (
<KeyField
compact
info={info}
key={key}
label={isKeyVar(key, info) ? key : friendlyFieldLabel(key, info)}
placeholder={advancedPlaceholder(key, info)}
rowProps={rowProps}
varKey={key}
/>
))}
{group.docsUrl && (
<a
className="inline-flex w-fit items-center gap-1 justify-self-end text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={group.docsUrl}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)}
</div>
)}
</div>
)
}
// Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
// are the exact shared components, so the two surfaces stay visually identical.
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
const [showAll, setShowAll] = useState(false)
const ordered = useMemo(() => sortProviders(providers), [providers])
if (ordered.length === 0) {
return null
}
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
const showOthers = !collapsible || showAll
return (
<section className="mb-5 grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
<Button
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
type="button"
variant="textStrong"
>
Have an API key instead?
</Button>
</div>
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Sign in with a subscription no API key to copy. Hermes runs the browser sign-in for you, right here in the
app.
</p>
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
Connected
</p>
{connected.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
</>
)}
{showOthers && (
<>
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={onWantApiKey} />
</>
)}
{collapsible && (
<Button
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
type="button"
variant="text"
>
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</Button>
)}
</section>
)
}
function NoProviderKeys() {
return (
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
No provider API keys available.
</div>
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
// Single-open accordion for the per-provider "advanced options" panels.
const [openProvider, setOpenProvider] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
useEffect(() => {
if (onboardingActive) {
return
}
let cancelled = false
// OAuth providers are best-effort — a failure here just hides the panel.
void (async () => {
try {
const { providers } = await listOAuthProviders()
if (!cancelled) {
setOauthProviders(providers)
}
} catch {
// Ignore — the OAuth panel just won't render.
}
})()
return () => void (cancelled = true)
}, [onboardingActive])
if (!vars) {
return <LoadingState label="Loading providers..." />
}
const hasOauth = oauthProviders.length > 0
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
// providers there's nothing for the "Accounts" view to show, so fall to keys.
const showApiKeys = view === 'keys' || !hasOauth
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
return (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyCard
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<NoProviderKeys />
)}
</SettingsContent>
)
}
return (
<SettingsContent>
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
</SettingsContent>
)
}
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
priority: number
}
interface ProvidersSettingsProps {
onViewChange: (view: ProviderView) => void
view: ProviderView
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
@@ -0,0 +1,161 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $approvalRequest } from '@/store/prompts'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
// Regression coverage for the "approval buried behind a collapsed tool group"
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the
// pending tool's inline ApprovalBar lives inside the group body — which is
// `hidden` until expanded. A live approval must surface WITHOUT the user
// expanding anything, so ToolGroupSlot force-opens its body while an approval
// targeting one of its pending tools is in flight.
const createdAt = new Date('2026-06-03T00:00:00.000Z')
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
// A running assistant message with two tools: a completed read_file plus a
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them
// behind a collapsed "Tool actions · 2 steps" header.
function groupedPendingMessage(): ThreadMessage {
return {
id: 'assistant-group-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-1',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
},
{
type: 'tool-call',
toolCallId: 'term-1',
toolName: 'terminal',
args: { command: 'rm -rf /tmp/x' },
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
$approvalRequest.set(null)
$toolDisclosureStates.set({})
})
afterEach(() => {
cleanup()
$approvalRequest.set(null)
})
describe('ToolGroupSlot approval surfacing', () => {
it('hides the grouped pending tool body when there is no approval', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Group header renders collapsed; the inline approval strip lives in the
// hidden body, so with no live approval it must not render at all (the
// ApprovalBar returns null when $approvalRequest is empty).
await waitFor(() => {
expect(screen.getByText(/Tool actions/)).toBeTruthy()
})
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
})
it('force-opens the group body so the approval surfaces without expanding', async () => {
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Even though the group defaults collapsed, the live approval forces the
// body open so the inline controls are visible (and reachable, not in a
// hidden subtree) immediately.
await waitFor(() => {
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
expect(bar).not.toBeNull()
// The forced-open group body must not be hidden — assert no ancestor
// carries the `hidden` attribute that would keep the bar off-screen.
expect(bar?.closest('[hidden]')).toBeNull()
})
})
})
@@ -39,7 +39,7 @@ import type { ToolPart } from './tool-fallback-model'
// approval at a time, so the single pending row of those tools IS the row that
// raised it. The command/description text comes from `$approvalRequest` (the
// event payload), which is the only place that data reliably exists.
const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
@@ -21,10 +21,11 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './tool-approval'
import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval'
import {
groupCopyText as buildGroupCopyText,
buildToolView,
@@ -458,7 +459,24 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
// tools append to the end), so user-driven open/close persists across
// streaming.
const disclosureId = `tool-group:${messageId}:${startIndex}`
const open = useDisclosureOpen(disclosureId)
const userOpen = useDisclosureOpen(disclosureId)
// A live approval request must NEVER be buried inside a collapsed group —
// the user has to be able to act on it without first expanding "Tool
// actions · N steps". When an approval is in flight and this group hosts
// the pending approval-eligible tool that raised it (terminal /
// execute_code with no result yet — see tool-approval.tsx for why the
// single pending row IS the one that raised it), force the body open so
// the inline ApprovalBar surfaces. The user can still collapse the group
// again once the approval resolves.
const approvalRequest = useStore($approvalRequest)
const hostsLiveApproval =
approvalRequest !== null &&
messageRunning &&
visibleParts.some(p => p.result === undefined && APPROVAL_TOOLS.has(p.toolName))
const open = userOpen || hostsLiveApproval
const enterRef = useEnterAnimation(messageRunning, disclosureId)
const status = groupStatus(visibleParts)
@@ -24,12 +24,14 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
cancelOnboardingFlow,
clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
@@ -47,7 +49,7 @@ interface DesktopOnboardingOverlayProps {
requestGateway: OnboardingContext['requestGateway']
}
interface ApiKeyOption {
export interface ApiKeyOption {
description: string
docsUrl: string
envKey: string
@@ -125,7 +127,7 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
const sortProviders = (providers: OAuthProvider[]) =>
export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
@@ -148,6 +150,36 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
}, [ctx, enabled, onboarding.requested])
// When the Providers settings page asked to connect a specific provider, the
// store stashed its id. Once the provider list has loaded and we're back at
// an idle picker, launch that exact OAuth flow so the user lands directly in
// sign-in instead of the picker they just came from.
useEffect(() => {
if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
return
}
const pendingId = peekPendingProviderOAuth()
if (!pendingId) {
return
}
const provider = onboarding.providers.find(p => p.id === pendingId)
if (provider) {
// Only clear once we've committed to launching it, so a failed/empty
// provider fetch doesn't silently drop the hand-off.
clearPendingProviderOAuth()
void startProviderOAuth(provider, ctx)
} else if (onboarding.providers.length > 0) {
// The list loaded but the id isn't a real provider — drop the stale
// hand-off. An empty list means the fetch isn't ready yet, so keep it
// and let a later refresh retry.
clearPendingProviderOAuth()
}
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
@@ -190,9 +222,12 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
)
}
// The launch reason is a prompt ("why am I seeing this"), not an error — real
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
// Keep it neutral so it never reads as a failure.
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
{reason}
</div>
)
@@ -246,7 +281,7 @@ function Header() {
)
}
const FEATURED_ID = 'nous'
export const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@@ -275,7 +310,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
return (
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
)
}
if (providers === null) {
@@ -324,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
function FeaturedProviderRow({
export function FeaturedProviderRow({
onSelect,
provider
}: {
@@ -335,17 +376,17 @@ function FeaturedProviderRow({
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
loggedIn && 'border-primary'
)}
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
onClick={() => onSelect(provider)}
type="button"
>
<span aria-hidden className="arc-border arc-reverse arc-nous" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
<span className="text-base font-semibold">{providerTitle(provider)}</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
{loggedIn ? (
<ConnectedTag />
) : (
@@ -357,7 +398,7 @@ function FeaturedProviderRow({
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
</div>
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
)
}
@@ -371,15 +412,15 @@ function ConnectedTag() {
)
}
function KeyProviderRow({ onClick }: { onClick: () => void }) {
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={onClick}
type="button"
>
<div className="min-w-0">
<span className="text-sm font-semibold">OpenRouter</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
@@ -387,22 +428,27 @@ function KeyProviderRow({ onClick }: { onClick: () => void }) {
)
}
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
export function ProviderRow({
onSelect,
provider
}: {
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
loggedIn && 'border-primary/30'
)}
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
@@ -412,13 +458,62 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
)
}
function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
// Presentational two-column key picker. Onboarding feeds it its curated
// options + a ctx-bound save; the Providers settings page feeds it the full
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
// double as a manage surface). Keep it free of store/ctx coupling so both
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
isSet,
onBack,
onClear,
onSave,
options = API_KEY_OPTIONS,
redactedValue
}: {
canGoBack: boolean
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
// Providers page wiring its search into this grid). Keep the selection valid
// by snapping back to the first remaining option when the current one drops.
useEffect(() => {
if (options.length > 0 && !options.some(o => o.id === option.id)) {
setOption(options[0])
setValue('')
setError(null)
}
}, [option.id, options])
// The catalog grid can be tall, leaving the entry field far below the fold.
// On selection we scroll the field into view and focus it so it's always
// obvious where to paste next.
const entryRef = useRef<HTMLDivElement>(null)
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
entryRef.current?.querySelector('input')?.focus()
})
}
const isLocal = option.envKey === 'OPENAI_BASE_URL'
const alreadySet = isSet?.(option.envKey) ?? false
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
// placeholder so users can eyeball that the right key is in place.
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
@@ -430,7 +525,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
setSaving(true)
setError(null)
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
const result = await onSave(option.envKey, value, option.name)
if (result.ok) {
setValue('')
@@ -446,7 +541,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
{canGoBack ? (
<button
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('oauth')}
onClick={onBack}
type="button"
>
<ChevronLeft className="size-3" />
@@ -455,30 +550,30 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
) : null}
<div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(o => (
{options.map(o => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={o.id}
onClick={() => {
setOption(o)
setValue('')
setError(null)
}}
onClick={() => pick(o)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.name}</span>
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
{option.id === o.id ? (
<Check className="size-4 text-primary" />
) : isSet?.(o.envKey) ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
</div>
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
</button>
))}
</div>
<div className="grid gap-2">
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
@@ -489,17 +584,24 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={option.placeholder || 'Paste API key'}
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
type={isLocal ? 'text' : 'password'}
value={value}
/>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<div className="flex justify-end">
<div className="flex items-center justify-between gap-3">
<div>
{alreadySet && onClear ? (
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
Remove
</Button>
) : null}
</div>
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Connecting' : 'Connect'}
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
</Button>
</div>
</div>
@@ -10,6 +10,7 @@ export const controlVariants = cva(
{
variants: {
size: {
xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
sm: 'px-2 py-1',
default: 'px-2.5 py-1.5',
lg: 'px-3 py-2 text-sm leading-5'
+38
View File
@@ -4,11 +4,15 @@ declare global {
interface Window {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getGatewayWsUrl: () => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@@ -141,6 +145,7 @@ export interface HermesConnection {
baseUrl: string
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
@@ -163,6 +168,8 @@ export interface HermesWindowState {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@@ -170,6 +177,7 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
}
@@ -180,6 +188,36 @@ export interface DesktopConnectionTestResult {
version: string | null
}
export interface DesktopAuthProvider {
name: string
displayName: string
// True when this provider authenticates with a username + password
// (the gateway's /login page renders a credential form) rather than an
// OAuth redirect. The session/cookie/ws-ticket machinery is identical;
// only the login-page form and the desktop's button copy differ.
supportsPassword?: boolean
}
export interface DesktopConnectionProbeResult {
baseUrl: string
reachable: boolean
authMode: 'oauth' | 'token' | 'unknown'
providers: DesktopAuthProvider[]
version: string | null
error: string | null
}
export interface DesktopOauthLoginResult {
ok: boolean
baseUrl: string
connected: boolean
}
export interface DesktopOauthLogoutResult {
ok: boolean
connected: boolean
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
describe('resolveGatewayWsUrl', () => {
describe('oauth mode', () => {
it('uses the freshly minted URL', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh')
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
expect(getGatewayWsUrl).toHaveBeenCalledOnce()
})
it('throws a reauth error instead of falling back to the stale cached ticket', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired'))
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf(
GatewayReauthRequiredError
)
})
it('preserves the underlying mint failure as the cause', async () => {
const cause = new Error('401 cookie expired')
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
expect(error).toBeInstanceOf(GatewayReauthRequiredError)
expect((error as GatewayReauthRequiredError).cause).toBe(cause)
})
it('throws a reauth error when the preload cannot mint (no method)', async () => {
await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError)
})
it('never returns the stale cached ticket on failure', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom'))
const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw')
expect(result).toBe('threw')
expect(result).not.toBe(oauthConn.wsUrl)
})
})
describe('token / local mode', () => {
it('uses the minted URL when available', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh')
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
})
it('falls back to the cached URL when minting fails (token is long-lived)', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient'))
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl)
})
it('falls back to the cached URL when the preload method is absent', async () => {
await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl)
})
it('treats a missing authMode as non-oauth (falls back safely)', async () => {
await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl)
})
})
})
describe('isGatewayReauthRequired', () => {
it('detects the dedicated error class', () => {
expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true)
})
it('detects plain objects tagged with needsOauthLogin (from the main process)', () => {
expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true)
})
it('rejects generic errors', () => {
expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false)
expect(isGatewayReauthRequired(null)).toBe(false)
expect(isGatewayReauthRequired('string')).toBe(false)
})
})
+85
View File
@@ -0,0 +1,85 @@
import type { HermesConnection } from '@/global'
/**
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
* consumed). For local/token gateways the URL carries a long-lived token and
* never needs re-minting.
*
* Resolution rules:
*
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
* fail with an opaque "connection closed" error. Instead, let the mint error
* propagate so the caller can surface the gateway's reauth message
* ("session has expired… Sign in again").
*
* - token / local, or when the preload method is genuinely absent (older
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
* the fallback is safe and preserves compatibility.
*
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
* callers can distinguish "the user must re-authenticate" from a generic
* transport failure.
*/
export interface ResolveGatewayWsUrlDeps {
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */
getGatewayWsUrl?: () => Promise<string>
}
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(
desktop: ResolveGatewayWsUrlDeps,
conn: Pick<HermesConnection, 'authMode' | 'wsUrl'>
): Promise<string> {
const mint = desktop.getGatewayWsUrl
if (conn.authMode === 'oauth') {
if (!mint) {
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
// dead, so connecting with it cannot succeed. Surface a reauth error
// rather than silently attempting a doomed connect.
throw new GatewayReauthRequiredError(
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
)
}
try {
return await mint()
} catch (error) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
{ cause: error }
)
}
}
// token / local: the URL carries a long-lived token. Re-mint when available
// (cheap, keeps parity), but the cached URL is a safe fallback.
if (mint) {
const fresh = await mint().catch(() => null)
if (fresh) {
return fresh
}
}
return conn.wsUrl
}
+2
View File
@@ -49,6 +49,7 @@ import {
IconLoader2 as Loader2,
IconLoader2 as Loader2Icon,
IconLock as Lock,
IconLogin as LogIn,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@@ -148,6 +149,7 @@ export {
Loader2,
Loader2Icon,
Lock,
LogIn,
MessageCircle,
MessageSquareText,
Mic,
+31 -2
View File
@@ -346,20 +346,49 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
// duplicating provider UI. Sets manual=true so the overlay shows the picker
// even though configured===true, and refreshes the provider list.
export function startManualOnboarding(reason = 'Add or switch inference provider.') {
export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
patch({
manual: true,
requested: true,
reason: reason.trim() || DEFAULT_ONBOARDING_REASON,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
void refreshProviders()
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
// flow instead of re-showing the picker the user just clicked through.
// Module-level (not store state) because it's consumed immediately on the next
// overlay render and never needs to persist or re-render anything itself.
let pendingProviderOAuthId: null | string = null
export function startManualProviderOAuth(providerId: string, reason: null | string = null) {
pendingProviderOAuthId = providerId
startManualOnboarding(reason)
}
// Read the pending provider id without clearing it. The overlay only clears it
// (via clearPendingProviderOAuth) once it has actually launched that provider,
// so a transient empty/failed provider fetch doesn't drop the hand-off and the
// deep-link can still auto-start after the list loads.
export function peekPendingProviderOAuth(): null | string {
return pendingProviderOAuthId
}
export function clearPendingProviderOAuth() {
pendingProviderOAuthId = null
}
// Dismiss a manually-opened provider selector without touching the existing
// (working) configuration. Only valid in the manual path — the unconfigured
// first-run flow has no close affordance because the app can't run yet.
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, flow: { status: 'idle' } })
}
+16 -11
View File
@@ -562,6 +562,19 @@
animation: arc-border var(--arc-duration) linear infinite;
}
/* Flip the arc's travel direction (e.g. the Nous Portal hero row). */
.arc-border.arc-reverse::before {
animation-direction: reverse;
}
/* Nous Portal hero: slower, blue → orange arc. */
.arc-border.arc-nous,
:root.dark .arc-border.arc-nous {
--arc-c1: #4f8cff;
--arc-c2: #ff8c42;
--arc-duration: 3.27s;
}
@media (prefers-reduced-motion: reduce) {
.arc-border::before {
animation: none;
@@ -669,11 +682,10 @@ canvas {
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
var(--ring-fall)
);
box-shadow: var(--shadow-composer);
box-shadow: none;
transition:
background-color 200ms ease-out,
border-color 200ms ease-out,
box-shadow 200ms ease-out;
border-color 200ms ease-out;
}
.desktop-input-chrome:hover {
@@ -685,7 +697,7 @@ canvas {
--ring-pct: 45%;
--ring-fall: transparent;
background: var(--dt-card);
box-shadow: var(--shadow-composer-focus);
box-shadow: none;
outline: none;
}
@@ -693,13 +705,6 @@ canvas {
border-color: var(--dt-destructive);
}
.desktop-input-chrome[aria-invalid='true']:focus {
box-shadow:
0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent),
0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent);
}
@layer components {
.scrollbar-dt,
.scrollbar-dt * {
+4
View File
@@ -96,6 +96,10 @@ export interface OAuthPollResponse {
export interface EnvVarInfo {
advanced: boolean
category: string
// True when this var is a messaging-platform credential owned by a card on
// the dedicated Messaging page. The Keys page hides these to avoid
// duplicating the richer channel-configuration UI.
channel_managed?: boolean
description: string
is_password: boolean
is_set: boolean
+5
View File
@@ -9,6 +9,11 @@ export default defineConfig({
build: {
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
// default, and electron-builder can OOM scanning thousands of files.
// Collapsing to a single chunk is intentional, so the renderer bundle is
// large by design (~22 MB). Raise the warning ceiling above that so the
// cosmetic "chunk larger than 500 kB" nag stays quiet, while still acting
// as a regression alarm if the bundle balloons well past today's size.
chunkSizeWarningLimit: 25000,
rolldownOptions: {
output: {
codeSplitting: false
+43 -7
View File
@@ -197,10 +197,15 @@ def check_whatsapp_requirements() -> bool:
WhatsApp requires a Node.js bridge for most implementations.
"""
# Check for Node.js. Resolve via shutil.which so we respect PATHEXT
# (node.exe vs node) and get a meaningful "not installed" signal
# instead of spawning a cmd flash on Windows.
_node = shutil.which("node")
# Check for Node.js. Resolve with bundled-fallback awareness (PATH first,
# then <HERMES_HOME>/node/bin) so a bundled-but-off-PATH install (e.g. a
# root FHS install whose symlink is missing, #38889) doesn't make the
# WhatsApp bridge silently unavailable.
try:
from hermes_constants import find_node_executable
_node = find_node_executable("node")
except Exception:
_node = shutil.which("node")
if not _node:
return False
try:
@@ -592,8 +597,16 @@ class WhatsAppAdapter(BasePlatformAdapter):
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
# Resolve npm path so Windows can execute the .cmd shim.
# shutil.which honours PATHEXT; on POSIX it returns the
# plain executable path.
_npm_bin = shutil.which("npm") or "npm"
# plain executable path. Fall back to the bundled npm at
# <HERMES_HOME>/node/bin when off-PATH (#38889).
_npm_bin = shutil.which("npm")
if not _npm_bin:
try:
from hermes_constants import find_node_executable
_npm_bin = find_node_executable("npm")
except Exception:
_npm_bin = None
_npm_bin = _npm_bin or "npm"
try:
# Read timeout from environment variable, default to 300 seconds (5 minutes)
# to accommodate slower systems like Unraid NAS
@@ -659,9 +672,32 @@ class WhatsAppAdapter(BasePlatformAdapter):
if self._reply_prefix is not None:
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
# Resolve node with bundled fallback and ensure the bundled node bin
# dir is on the bridge's PATH. The requirement check and `npm install`
# above already use find_node_executable, so on a bundled-but-off-PATH
# install (root FHS w/ missing symlink, #38889) they pass — but a bare
# "node" argv0 here would still raise FileNotFoundError, and the bridge
# itself shells out to node tooling, so it needs the dir on PATH too.
node_bin = "node"
try:
from hermes_constants import (
find_node_executable,
bundled_node_bin_dir,
)
node_bin = find_node_executable("node") or "node"
_node_dir = str(bundled_node_bin_dir())
_path = bridge_env.get("PATH", "")
if _node_dir not in _path.split(os.pathsep):
bridge_env["PATH"] = (
_node_dir + os.pathsep + _path if _path else _node_dir
)
except Exception:
node_bin = shutil.which("node") or "node"
self._bridge_process = subprocess.Popen(
[
"node",
node_bin,
str(bridge_path),
"--port", str(self._bridge_port),
"--session", str(self._session_path),
+3 -2
View File
@@ -448,9 +448,10 @@ def run_import(args) -> None:
if skipped:
print(f" Profile aliases skipped: {', '.join(skipped)}")
if not _is_wrapper_dir_in_path():
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
_wd = _get_wrapper_dir()
print(f"\n Note: {_wd} is not in your PATH.")
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
print(' export PATH="$HOME/.local/bin:$PATH"')
print(f' export PATH="{_wd}:$PATH"')
except ImportError:
# hermes_cli.profiles might not be available (fresh install)
if any(profiles_dir.iterdir()):
+28
View File
@@ -1482,6 +1482,34 @@ DEFAULT_CONFIG = {
"client_id": "", # agent:{instance_id} — Portal provisions this
"portal_url": "", # blank → use plugin default (production Portal)
},
# Username/password gate configuration — read by the bundled
# ``dashboard_auth/basic`` plugin (a self-hosted "just put a
# password on my dashboard" provider that needs no OAuth IDP).
# The plugin registers a password provider when ``username`` plus
# either ``password_hash`` (preferred — no plaintext at rest) or
# ``password`` (plaintext, hashed in-memory at load) are set. Each
# key is overridable by an env var
# (``HERMES_DASHBOARD_BASIC_AUTH_USERNAME`` /
# ``_PASSWORD_HASH`` / ``_PASSWORD`` / ``_SECRET`` /
# ``_TTL_SECONDS``), env winning when non-empty. Leave ``username``
# empty (the default) to keep the plugin a no-op — loopback /
# ``--insecure`` operators and OAuth users are unaffected.
#
# ``secret`` is the HMAC key used to sign the stateless session
# tokens this provider mints. When empty, a random per-process key
# is generated — fine for a single process, but sessions then
# don't survive a restart or span multiple workers. Set an
# explicit ``secret`` (32+ random bytes, base64/hex/raw) for
# stable multi-worker / restart-surviving sessions. Compute a
# ``password_hash`` with
# ``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"``.
"basic_auth": {
"username": "", # blank → plugin no-op (no password provider)
"password_hash": "", # scrypt$... (preferred — no plaintext at rest)
"password": "", # plaintext fallback (hashed in-memory at load)
"secret": "", # token-signing key; blank → random per-process
"session_ttl_seconds": 0, # 0 → plugin default (12h)
},
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
# When set, this is the complete authority — scheme + host +
# optional path prefix (e.g. ``https://example.com/hermes``) —
+2
View File
@@ -14,6 +14,7 @@ from hermes_cli.dashboard_auth.base import (
Session,
LoginStart,
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
assert_protocol_compliance,
@@ -30,6 +31,7 @@ __all__ = [
"Session",
"LoginStart",
"InvalidCodeError",
"InvalidCredentialsError",
"ProviderError",
"RefreshExpiredError",
"assert_protocol_compliance",
+62
View File
@@ -55,6 +55,16 @@ class InvalidCodeError(Exception):
"""
class InvalidCredentialsError(Exception):
"""A username/password pair was rejected by a password provider.
Raised by :meth:`DashboardAuthProvider.complete_password_login`. The
``/auth/password-login`` route translates this to HTTP 401 with a
deliberately generic detail (never distinguishing "unknown user" from
"wrong password") so the endpoint can't be used as a username oracle.
"""
class RefreshExpiredError(Exception):
"""The refresh token is dead.
@@ -94,11 +104,33 @@ class DashboardAuthProvider(ABC):
Subclasses MUST set ``name`` (lowercase identifier, stable forever)
and ``display_name`` (user-facing label on the login page).
Password (non-redirect) providers:
A provider that authenticates with a username + password instead of
an OAuth redirect sets ``supports_password = True`` and implements
``complete_password_login``. The login page then renders a
credential form (POSTing to ``/auth/password-login``) instead of a
"Log in with X" redirect button. Everything downstream of login
``verify_session`` / ``refresh_session`` / ``revoke_session``, the
session cookies, the WS-ticket mint is identical to the OAuth
path, because a password session is just a :class:`Session` with
provider-minted opaque tokens. The OAuth methods (``start_login`` /
``complete_login``) remain abstract; a pure-password provider that
will never be reached via the redirect flow may implement them as
stubs that raise ``NotImplementedError``.
"""
name: str = ""
display_name: str = ""
# When True, this provider authenticates via username + password
# (``complete_password_login``) rather than (or in addition to) the
# OAuth redirect flow. The login page renders a credential form for
# such providers; the ``/auth/password-login`` route dispatches to
# ``complete_password_login``. OAuth-only providers leave this False
# and are completely unaffected.
supports_password: bool = False
@abstractmethod
def start_login(self, *, redirect_uri: str) -> LoginStart: ...
@@ -121,6 +153,36 @@ class DashboardAuthProvider(ABC):
@abstractmethod
def revoke_session(self, *, refresh_token: str) -> None: ...
def complete_password_login(
self, *, username: str, password: str
) -> "Session":
"""Verify a username/password pair and mint a :class:`Session`.
Only called when ``supports_password`` is True (the
``/auth/password-login`` route guards on the flag). The default
raises ``NotImplementedError`` so an OAuth-only provider that
forgets to set the flag fails loudly rather than silently
accepting credentials.
The returned ``Session`` carries provider-minted opaque
``access_token`` / ``refresh_token`` exactly like the OAuth path,
so all downstream session handling (cookies, verify, refresh,
ws-tickets, logout) is identical.
Failure semantics:
* ``InvalidCredentialsError`` username/password rejected. The
route surfaces a generic 401 (no user-vs-password
distinction). Implementations SHOULD spend constant time on
unknown users (dummy hash verify) to avoid a timing oracle.
* ``ProviderError`` the backing credential store is
unreachable (LDAP/DB down); the route surfaces 503.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support password login "
"(set supports_password = True and override "
"complete_password_login)"
)
def assert_protocol_compliance(cls: type) -> None:
"""Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol.
+156 -6
View File
@@ -225,6 +225,56 @@ _LOGIN_HTML_TEMPLATE = """\
outline-offset: 3px;
}}
/* Password provider form same visual language as the OAuth buttons:
squared inputs, hairline borders, amber focus ring. */
.provider-form {{
display: grid;
gap: 0.75rem;
text-align: left;
}}
.form-title {{
font-family: 'Rules Compressed', 'Collapse', sans-serif;
font-weight: 600;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}}
.field {{
display: grid;
gap: 0.3rem;
}}
.field-label {{
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}}
.field-input {{
width: 100%;
box-sizing: border-box;
padding: 0.7rem 0.8rem;
background: color-mix(in srgb, #000000 25%, var(--background-base));
color: var(--foreground);
border: 1px solid var(--hairline-strong);
border-radius: 0;
font-family: 'Collapse', sans-serif;
font-size: 0.95rem;
}}
.field-input:focus-visible {{
outline: none;
border-color: var(--midground);
box-shadow: 0 0 0 1px var(--midground);
}}
.form-error {{
color: #ff6b6b;
font-size: 0.82rem;
letter-spacing: 0.02em;
}}
.provider-form .provider-btn {{
margin-top: 0.25rem;
}}
footer {{
margin-top: 1.75rem;
text-align: center;
@@ -264,6 +314,7 @@ _LOGIN_HTML_TEMPLATE = """\
<span class="sep"></span>Public bind &middot; Auth required<span class="sep"></span>
</footer>
</main>
{password_script}
</body>
</html>
"""
@@ -350,6 +401,60 @@ auth gate (not recommended on untrusted networks).</p>
"""
# Inline script that wires every password provider form to POST JSON to
# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
# least one ``supports_password`` provider is listed (OAuth-only login
# pages stay script-free, preserving the no-JS contract for that case).
#
# Plain string (NOT run through ``str.format``), so braces are literal —
# do not double them. A single delegated submit handler covers all forms;
# the provider name is read from the form's ``data-provider`` attribute.
_PASSWORD_FORM_SCRIPT = """\
<script>
(function () {
function handle(form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var err = form.querySelector('.form-error');
var btn = form.querySelector('button[type=submit]');
if (err) { err.hidden = true; err.textContent = ''; }
if (btn) { btn.disabled = true; }
var body = {
provider: form.getAttribute('data-provider') || '',
username: (form.querySelector('input[name=username]') || {}).value || '',
password: (form.querySelector('input[name=password]') || {}).value || '',
next: (form.querySelector('input[name=next]') || {}).value || ''
};
fetch('/auth/password-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'same-origin'
}).then(function (resp) {
if (resp.ok) {
return resp.json().then(function (data) {
window.location.assign((data && data.next) || '/');
});
}
var msg = resp.status === 429
? 'Too many attempts. Please wait and try again.'
: (resp.status === 401 ? 'Invalid username or password.'
: 'Sign-in failed. Please try again.');
if (err) { err.textContent = msg; err.hidden = false; }
if (btn) { btn.disabled = false; }
}).catch(function () {
if (err) { err.textContent = 'Network error. Please try again.'; err.hidden = false; }
if (btn) { btn.disabled = false; }
});
});
}
var forms = document.querySelectorAll('form.provider-form');
for (var i = 0; i < forms.length; i++) { handle(forms[i]); }
})();
</script>
"""
def render_login_html(*, next_path: str = "") -> str:
"""Return the full HTML for ``GET /login``.
@@ -375,10 +480,55 @@ def render_login_html(*, next_path: str = "") -> str:
next_qs = ""
buttons = []
needs_password_script = False
for p in providers:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))
if getattr(p, "supports_password", False):
needs_password_script = True
buttons.append(_render_password_form(p, next_path))
else:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
script = _PASSWORD_FORM_SCRIPT if needs_password_script else ""
return _LOGIN_HTML_TEMPLATE.format(
provider_buttons="\n".join(buttons),
password_script=script,
)
def _render_password_form(provider, next_path: str) -> str:
"""Render a username/password form for a ``supports_password`` provider.
The form is wired by :data:`_PASSWORD_FORM_SCRIPT` (a single delegated
submit handler) to POST JSON to ``/auth/password-login`` and navigate
on success. ``next_path`` is carried in a hidden field; it has already
been validated same-origin by the caller and is HTML-escaped here as
defence in depth. The provider ``name`` is emitted in a ``data-``
attribute (not a hidden input) so the script reads it without trusting
form-field ordering.
"""
pname = html.escape(provider.name, quote=True)
plabel = html.escape(provider.display_name)
safe_next = html.escape(next_path, quote=True) if next_path else ""
return (
f' <form class="provider-form" data-provider="{pname}" '
f'autocomplete="on">\n'
f' <div class="form-title">Sign in with {plabel}</div>\n'
f' <input type="hidden" name="next" value="{safe_next}">\n'
f' <label class="field">\n'
f' <span class="field-label">Username</span>\n'
f' <input class="field-input" type="text" name="username" '
f'autocomplete="username" autocapitalize="none" '
f'autocorrect="off" spellcheck="false" required>\n'
f' </label>\n'
f' <label class="field">\n'
f' <span class="field-label">Password</span>\n'
f' <input class="field-input" type="password" name="password" '
f'autocomplete="current-password" required>\n'
f' </label>\n'
f' <div class="form-error" role="alert" hidden></div>\n'
f' <button class="provider-btn" type="submit">Sign in</button>\n'
f' </form>'
)
+1
View File
@@ -38,6 +38,7 @@ _log = logging.getLogger(__name__)
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/auth/login",
"/auth/callback",
"/auth/password-login",
"/auth/logout",
"/login",
"/api/auth/providers",
+158 -2
View File
@@ -16,11 +16,14 @@ The routes:
from __future__ import annotations
import logging
import threading
import time
from typing import Any
from collections import defaultdict, deque
from typing import Any, Deque, Dict, Tuple
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from hermes_cli.dashboard_auth import (
get_provider,
@@ -29,6 +32,7 @@ from hermes_cli.dashboard_auth import (
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import (
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
)
from hermes_cli.dashboard_auth.cookies import (
@@ -154,7 +158,13 @@ async def api_auth_providers() -> Any:
)
return {
"providers": [
{"name": p.name, "display_name": p.display_name}
{
"name": p.name,
"display_name": p.display_name,
"supports_password": bool(
getattr(p, "supports_password", False)
),
}
for p in providers
],
}
@@ -377,6 +387,152 @@ def _validate_post_login_target(raw: str) -> str:
return decoded
# ---------------------------------------------------------------------------
# Public: password (non-redirect) login
# ---------------------------------------------------------------------------
#
# Brute-force throttle. The OAuth flow has no guessable secret on our side
# (the IDP owns credentials), but ``/auth/password-login`` accepts a
# password we verify locally, so it's a credential-stuffing target. A
# simple in-process sliding-window limiter per client IP raises the cost
# of online guessing without any external dependency. It is intentionally
# best-effort: process-local (resets on restart), and behind a trusting
# proxy the IP is the proxy's unless X-Forwarded-For is set — which is why
# this is defence-in-depth on top of the provider's own constant-time
# verify, not the only line of defence.
_PW_RATE_MAX_ATTEMPTS = 10
_PW_RATE_WINDOW_SEC = 60.0
_pw_attempts: Dict[str, Deque[float]] = defaultdict(deque)
_pw_attempts_lock = threading.Lock()
def _password_rate_limited(ip: str) -> bool:
"""True if ``ip`` has exceeded the password-login attempt budget.
Sliding window: prune attempts older than the window, then check the
count. Records the attempt timestamp when allowed. An empty IP (no
discernible client) shares a single bucket fail-safe toward
throttling rather than letting unattributable traffic through
unmetered.
"""
now = time.monotonic()
cutoff = now - _PW_RATE_WINDOW_SEC
key = ip or "_unknown_"
with _pw_attempts_lock:
bucket = _pw_attempts[key]
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
return True
bucket.append(now)
return False
def _reset_password_rate_limit() -> None:
"""Test-only: clear all rate-limit buckets."""
with _pw_attempts_lock:
_pw_attempts.clear()
class _PasswordLoginBody(BaseModel):
provider: str
username: str
password: str
next: str = ""
@router.post("/auth/password-login", name="auth_password_login")
async def auth_password_login(request: Request, body: _PasswordLoginBody):
"""Authenticate a username/password against a password provider.
Mirrors the cookie-minting tail of ``/auth/callback`` but skips the
PKCE/state/code machinery (those are OAuth-only). On success sets the
session cookies and returns JSON ``{"ok": true, "next": <path>}``
the credential form POSTs via fetch and navigates client-side, so a
302 (which fetch follows opaquely) is the wrong shape here.
Failure modes, all deliberately generic so the endpoint can't be used
as a username oracle or a provider-enumeration oracle:
* unknown provider / provider lacks password support 404
* bad credentials 401 ("Invalid credentials")
* backing store unreachable 503
* too many attempts from this IP 429
"""
ip = _client_ip(request)
if _password_rate_limited(ip):
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="rate_limited",
ip=ip,
)
raise HTTPException(
status_code=429,
detail="Too many login attempts. Try again shortly.",
)
p = get_provider(body.provider)
if p is None or not getattr(p, "supports_password", False):
# Don't leak which providers exist or which support passwords —
# same 404 whether the provider is unknown or OAuth-only.
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="unknown_password_provider",
ip=ip,
)
raise HTTPException(status_code=404, detail="Unknown provider")
try:
session = p.complete_password_login(
username=body.username, password=body.password
)
except InvalidCredentialsError:
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="invalid_credentials",
ip=ip,
)
# Generic message — never distinguish unknown-user from wrong-password.
raise HTTPException(status_code=401, detail="Invalid credentials")
except NotImplementedError:
# supports_password was True but the method isn't actually
# implemented — a provider bug, not a client error.
raise HTTPException(status_code=500, detail="Provider misconfigured")
except ProviderError as e:
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=body.provider,
reason="provider_unreachable",
ip=ip,
)
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider=body.provider,
user_id=session.user_id,
email=session.email,
org_id=session.org_id,
ip=ip,
)
expires_in = max(60, session.expires_at - int(time.time()))
landing = _validate_post_login_target(body.next) or "/"
resp = JSONResponse({"ok": True, "next": landing})
set_session_cookies(
resp,
access_token=session.access_token,
refresh_token=session.refresh_token,
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
)
return resp
@router.post("/auth/logout", name="auth_logout")
async def auth_logout(request: Request):
_at, rt = read_session_cookies(request)
+300
View File
@@ -0,0 +1,300 @@
"""``hermes dashboard register`` — register a self-hosted dashboard OAuth client.
Automates what a user otherwise does by hand: open the Nous Portal
``/local-dashboards`` page in a browser, click "register", copy the
resulting ``agent:{id}`` OAuth client ID, and paste it into ``~/.hermes/.env``
as ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``.
This command:
1. Resolves a fresh Nous Portal access token from the existing login
(``~/.hermes/auth.json``), refreshing it if needed. Fails fast with a
"run `hermes setup`" hint when the user isn't logged in.
2. POSTs to ``{portal}/api/oauth/self-hosted-client`` with that bearer
token, which creates a SELF_HOSTED agent client owned by the caller's
org and returns the fully-formed ``agent:{id}`` client_id.
3. Writes ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and (if absent)
``HERMES_DASHBOARD_PORTAL_URL`` into ``~/.hermes/.env`` idempotently.
4. Prints a post-register hint explaining that the OAuth gate only engages
on a non-loopback bind.
The portal endpoint is the NAS half of this feature (POST
/api/oauth/self-hosted-client). The ``agent:`` prefix is applied server-side,
so this client never needs to know the namespace convention.
"""
from __future__ import annotations
import json
import os
import random
import sys
import urllib.error
import urllib.request
from typing import Optional
# Docker-style name generator. Same vibe as Docker's adjective_surname, but
# adjective_noun with a space-free underscore join so it drops cleanly into a
# label field. There is NO uniqueness constraint on the portal side (the row
# id is the key), so collisions are harmless and we don't retry.
_NAME_ADJECTIVES = (
"amber", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp",
"dreamy", "eager", "electric", "fancy", "gentle", "golden", "happy",
"hidden", "jolly", "keen", "lively", "lucid", "lunar", "mellow", "merry",
"mighty", "nimble", "noble", "polished", "quiet", "quirky", "rapid",
"serene", "sharp", "shiny", "silent", "snappy", "solar", "spry", "stellar",
"sunny", "swift", "tidy", "vivid", "vibrant", "witty", "zesty",
)
_NAME_NOUNS = (
"albatross", "antelope", "badger", "beacon", "comet", "condor", "cypress",
"dolphin", "ember", "falcon", "ferret", "galaxy", "glacier", "harbor",
"heron", "ibex", "jaguar", "kestrel", "lantern", "lynx", "meadow", "nebula",
"ocelot", "orchid", "otter", "panther", "petrel", "quasar", "raven", "reef",
"sparrow", "summit", "tundra", "vortex", "walrus", "willow", "yarrow",
# A couple of scientist surnames in the Docker spirit.
"kepler", "tesla", "curie", "hopper", "turing", "lovelace",
)
def _generate_dashboard_name() -> str:
"""Return a human-readable ``adjective_noun`` name (Docker-style)."""
return f"{random.choice(_NAME_ADJECTIVES)}_{random.choice(_NAME_NOUNS)}"
def _resolve_portal_base_url(override: Optional[str] = None) -> str:
"""Resolve the portal base URL for the registration request.
Precedence:
1. ``override`` explicit ``--portal-url`` flag or
``HERMES_DASHBOARD_PORTAL_URL`` env (used for testing against a
preview/staging portal). NOTE: the access token must be valid at
this portal it's minted by whatever portal you logged into, so an
override only works if the token's issuer matches (e.g. you logged
into the same staging/preview portal).
2. The ``portal_base_url`` stored on the Nous login this is the
portal that issued the token, so it's the correct default target.
3. The production default.
"""
if isinstance(override, str) and override.strip():
return override.rstrip("/")
try:
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL, get_provider_auth_state
state = get_provider_auth_state("nous") or {}
base = state.get("portal_base_url")
if isinstance(base, str) and base.strip():
return base.rstrip("/")
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
except Exception:
return "https://portal.nousresearch.com"
def _register_self_hosted_client(
*,
access_token: str,
portal_base_url: str,
name: str,
custom_redirect_uri: Optional[str],
timeout: float = 15.0,
) -> dict:
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
Raises RuntimeError with a user-facing message on any non-2xx response or
transport failure.
"""
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
body: dict[str, str] = {"name": name}
if custom_redirect_uri:
body["custom_redirect_uri"] = custom_redirect_uri
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
# The endpoint returns structured JSON errors ({error, error_description}).
detail = ""
try:
err_body = json.loads(exc.read().decode())
detail = (
err_body.get("error_description")
or err_body.get("error")
or ""
)
except Exception:
pass
if exc.code == 401:
raise RuntimeError(
"Nous Portal rejected the access token (401). "
"Try `hermes auth login nous` to re-authenticate."
) from exc
if exc.code == 403:
raise RuntimeError(
detail
or "Your account is not permitted to register a self-hosted dashboard."
) from exc
raise RuntimeError(
f"Portal returned HTTP {exc.code}"
+ (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
) from exc
if not isinstance(payload, dict) or not payload.get("client_id"):
raise RuntimeError("Portal returned an unexpected response (no client_id).")
return payload
def _print_post_register_hint(
*,
client_id: str,
portal_base_url: str,
custom_redirect_uri: Optional[str],
wrote_portal_url: bool,
) -> None:
"""Print the success summary + the gate-engagement caveat."""
from hermes_cli.config import get_env_path
env_path = get_env_path()
print()
print(f" Wrote to {env_path}:")
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
if wrote_portal_url:
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
print()
print(
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
" without auth, which is fine for your own machine."
)
print()
if custom_redirect_uri:
# Derive the host the user registered so the example matches it.
try:
from urllib.parse import urlparse
host = urlparse(custom_redirect_uri).hostname or "your-host"
except Exception:
host = "your-host"
print(" To require Nous login on your registered host, run the dashboard")
print(f" bound publicly (it must be reachable at https://{host}) and log in")
print(" at its /login page.")
else:
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
print(" hermes dashboard --host 0.0.0.0")
print(" …then log in at the dashboard's /login page.")
print()
print(
" If the dashboard is already running, restart it to pick up the new env."
)
print(
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
)
def cmd_dashboard_register(args) -> None:
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
from hermes_cli.auth import AuthError, resolve_nous_access_token
from hermes_cli.config import get_env_value, is_managed, save_env_value
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
# via buildContainerEnvVars). Registering from inside such a container is a
# mistake — and save_env_value refuses to write anyway.
if is_managed():
print(
"✗ `hermes dashboard register` is not available in a managed/hosted "
"install.\n"
" The dashboard OAuth client is provisioned by the hosting platform."
)
sys.exit(1)
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
# with a setup hint when the user isn't logged in.
try:
access_token = resolve_nous_access_token()
except AuthError as exc:
if getattr(exc, "relogin_required", False):
print("✗ You're not logged into Nous Portal.")
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
else:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
except Exception as exc:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
# Portal override: explicit --portal-url flag wins, else the
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
portal_override = getattr(args, "portal_url", None) or os.environ.get(
"HERMES_DASHBOARD_PORTAL_URL"
)
portal_base_url = _resolve_portal_base_url(portal_override)
name = getattr(args, "name", None) or _generate_dashboard_name()
custom_redirect_uri = getattr(args, "redirect_uri", None)
# 2. Register with the portal.
try:
result = _register_self_hosted_client(
access_token=access_token,
portal_base_url=portal_base_url,
name=name,
custom_redirect_uri=custom_redirect_uri,
)
except RuntimeError as exc:
print(f"✗ Registration failed: {exc}")
sys.exit(1)
client_id = str(result["client_id"])
registered_name = str(result.get("name") or name)
print(f'✓ Registered dashboard "{registered_name}"')
# 3. Write env vars idempotently. Always set the client_id. Only set the
# portal URL when it isn't already configured (env or config) AND differs
# from the production default, so we don't clutter .env for the common case
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
try:
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
except Exception as exc:
print(f"✗ Failed to write HERMES_DASHBOARD_OAUTH_CLIENT_ID to .env: {exc}")
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
sys.exit(1)
wrote_portal_url = False
default_portal = "https://portal.nousresearch.com"
existing_portal = None
try:
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
except Exception:
existing_portal = None
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
try:
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
wrote_portal_url = True
except Exception:
# Non-fatal: the client_id is the load-bearing value.
pass
# 4. Hint.
_print_post_register_hint(
client_id=client_id,
portal_base_url=portal_base_url,
custom_redirect_uri=custom_redirect_uri,
wrote_portal_url=wrote_portal_url,
)
+97 -13
View File
@@ -13,6 +13,11 @@ from pathlib import Path
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
from hermes_constants import (
command_link_dir as _command_link_dir,
command_link_display_dir as _command_link_display_dir,
bundled_node_bin_dir as _bundled_node_bin_dir,
)
PROJECT_ROOT = get_project_root()
HERMES_HOME = get_hermes_home()
@@ -198,6 +203,80 @@ 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")
# Use lexists()/is_symlink(), not exists(): exists() follows the
# symlink, so a *dangling* link (target removed) would otherwise be
# misreported as "missing" and skip the stale-target diagnostic.
if not os.path.lexists(fhs_link):
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 not fhs_link.exists() or fhs_link.resolve() != bundled.resolve():
# Present but dangling (target gone) or pointing at the wrong node.
_actual = os.readlink(fhs_link) if fhs_link.is_symlink() else fhs_link
check_warn(
"/usr/local/bin/node points to the wrong target",
f"(→ {_actual}, 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 +1264,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 +1319,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 +1448,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 +1528,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)"),
+153 -9
View File
@@ -6817,6 +6817,7 @@ def _run_with_idle_timeout(
*,
idle_timeout_seconds: int = 180,
indent: str = " ",
env: dict | None = None,
) -> subprocess.CompletedProcess:
"""Run a subprocess that streams output, with an idle-output timeout.
@@ -6851,6 +6852,7 @@ def _run_with_idle_timeout(
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
)
except OSError as exc:
# E.g. npm not on PATH between the which() check and now.
@@ -6915,6 +6917,7 @@ def _run_npm_install_deterministic(
*,
extra_args: tuple[str, ...] = (),
capture_output: bool = True,
env: dict | None = None,
) -> subprocess.CompletedProcess:
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
@@ -6936,6 +6939,7 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
if ci_result.returncode == 0:
return ci_result
@@ -6950,6 +6954,7 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
@@ -6981,12 +6986,44 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
# Resolve npm with bundled-fallback awareness: on a root FHS install whose
# PATH symlink is missing, or any context with a stripped PATH (systemd
# service, RHEL non-login shell), shutil.which("npm") returns None even
# though the bundled npm exists at <HERMES_HOME>/node/bin/npm. See #38889.
npm = shutil.which("npm")
if not npm:
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
if not npm:
if fatal:
_say("Web UI frontend not built and npm is not available.")
_say("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
# Ensure the bundled node/bin dir is on PATH for the build subprocesses so
# the `npm run build` step (which shells out to tsc / vite from
# node_modules/.bin, and those re-invoke `node`) can find node even when the
# caller's PATH doesn't include it.
_build_env = None
try:
from hermes_constants import bundled_node_bin_dir
_node_bin = bundled_node_bin_dir()
if _node_bin.is_dir():
_build_env = os.environ.copy()
_existing = _build_env.get("PATH", "")
if str(_node_bin) not in _existing.split(os.pathsep):
_build_env["PATH"] = str(_node_bin) + os.pathsep + _existing
# Also fold in the resolved npm's own dir (covers system node installs).
_npm_dir = str(Path(npm).resolve().parent)
if _build_env is None:
_build_env = os.environ.copy()
if _npm_dir not in _build_env.get("PATH", "").split(os.pathsep):
_build_env["PATH"] = _npm_dir + os.pathsep + _build_env.get("PATH", "")
except Exception:
_build_env = None
_say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None:
@@ -7008,6 +7045,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
env=_build_env,
)
if r1.returncode != 0:
_say(
@@ -7023,13 +7061,13 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# users react by rebooting, which leaves the editable install in a
# half-state. Streaming + idle-kill makes failures observable AND
# recoverable (the stale-dist fallback below handles the kill path).
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
if r2.returncode != 0:
# Retry once after a short delay — covers boot-time races on Windows
# (antivirus scanning Node.js binaries, npm cache not ready, transient
# I/O when launched via Scheduled Task at logon). See issue #23817.
_time.sleep(3)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
if r2.returncode != 0:
# _run_with_idle_timeout merges stderr into stdout; older callers
@@ -8567,6 +8605,48 @@ def _venv_scripts_dir() -> Path | None:
return scripts if scripts.is_dir() else None
def _wait_for_interpreter_venv_ready(*, timeout: float = 15.0) -> bool:
"""Ensure the venv hosting ``sys.executable`` has an intact ``pyvenv.cfg``.
During ``hermes update`` the managed-uv path can rebuild the project venv
(``rebuild_venv`` ``shutil.rmtree`` + ``uv venv``) before the
desktop-rebuild and profile-skills-sync steps run. Both of those steps
spawn a child process with ``sys.executable``. If they fire while the venv
is mid-rewrite, the interpreter launcher finds the venv directory but no
``pyvenv.cfg`` yet and aborts with the bare stderr line
``No pyvenv.cfg file`` surfacing as a spurious "Desktop build failed" /
"sync failed" on an update that otherwise succeeded.
A venv's ``pyvenv.cfg`` sits one level up from the interpreter's ``bin`` /
``Scripts`` dir. If ``sys.executable`` is NOT a venv interpreter (no
sibling marker dir, e.g. a system Python on PATH), there is nothing to
wait for and we return True immediately. Otherwise we poll briefly for the
marker to (re)appear the rewrite window is short and return whether
it's present. Best-effort: never raises, callers proceed regardless.
"""
try:
exe = Path(sys.executable).resolve()
except Exception:
return True
venv_dir = exe.parent.parent # .../venv/{bin,Scripts}/python -> .../venv
bin_dir = venv_dir / ("Scripts" if _is_windows() else "bin")
if not bin_dir.is_dir():
# Not a venv-hosted interpreter — pyvenv.cfg is irrelevant.
return True
cfg = venv_dir / "pyvenv.cfg"
if cfg.is_file():
return True
deadline = _time.monotonic() + max(0.0, timeout)
while _time.monotonic() < deadline:
if cfg.is_file():
return True
_time.sleep(0.25)
return cfg.is_file()
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
@@ -10260,11 +10340,19 @@ def _cmd_update_impl(args, gateway_mode: bool):
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
print("→ Checking if desktop app needs rebuilding...")
build_result = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
cwd=PROJECT_ROOT,
check=False,
)
# The Python-dependency step above may have rebuilt the venv that
# hosts sys.executable. Wait for its pyvenv.cfg to settle before
# spawning, or the child interpreter aborts with "No pyvenv.cfg
# file" and the rebuild spuriously "fails" on a successful update.
_wait_for_interpreter_venv_ready()
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Stream the build output live (long Electron builds otherwise
# look hung). On the rare nonzero exit, retry once after waiting
# again for the venv — this covers a still-settling rebuild window
# the first wait didn't fully catch.
build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False)
if build_result.returncode != 0 and _wait_for_interpreter_venv_ready():
build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
@@ -10320,6 +10408,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
if all_profiles:
print()
print("→ Syncing bundled skills to all profiles...")
# seed_profile_skills spawns sys.executable; if the venv was
# just rebuilt above, wait for pyvenv.cfg before the loop so
# the children don't abort with "No pyvenv.cfg file".
_wait_for_interpreter_venv_ready()
for p in all_profiles:
try:
r = seed_profile_skills(p.path, quiet=True)
@@ -11384,11 +11476,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 +12071,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 +15388,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
#
+48 -21
View File
@@ -240,8 +240,25 @@ def _get_active_profile_path() -> Path:
def _get_wrapper_dir() -> Path:
"""Return the directory for wrapper scripts."""
return Path.home() / ".local" / "bin"
"""Return the directory for profile-alias wrapper scripts.
Uses the canonical command-link directory so aliases land wherever the
``hermes`` command itself lives and is therefore on PATH: ``/usr/local/bin``
for root FHS installs, ``$PREFIX/bin`` on Termux, ``~/.local/bin`` otherwise
(including Windows). Previously hardcoded ``~/.local/bin``, which left
aliases off-PATH on root FHS installs (PR #38889).
"""
from hermes_constants import command_link_dir
return command_link_dir()
def _wrapper_candidate_dirs() -> list[Path]:
"""All dirs a profile alias may live in, for cleanup that must find links
regardless of which layout created them."""
from hermes_constants import command_link_candidate_dirs
return command_link_candidate_dirs()
# ---------------------------------------------------------------------------
@@ -354,13 +371,16 @@ def check_alias_collision(name: str) -> Optional[str]:
def _is_wrapper_dir_in_path() -> bool:
"""Check if ~/.local/bin is in PATH."""
"""Check if the layout-aware wrapper dir (see ``_get_wrapper_dir``) is in PATH."""
wrapper_dir = str(_get_wrapper_dir())
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
"""Create a shell wrapper script at ~/.local/bin/<name>.
"""Create a shell wrapper script at ``<wrapper_dir>/<name>``.
``<wrapper_dir>`` is layout-aware (``_get_wrapper_dir``): ``/usr/local/bin``
for a root FHS install, ``$PREFIX/bin`` on Termux, else ``~/.local/bin``.
The wrapper file is named after ``name`` (the alias). The profile it
activates is ``target`` if given, otherwise ``name`` this lets a custom
@@ -399,27 +419,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
# ---------------------------------------------------------------------------
+63 -35
View File
@@ -9,6 +9,7 @@ Provides options for:
import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -117,45 +118,58 @@ def remove_wrapper_script():
return removed
def _node_symlink_candidate_dirs() -> "list[Path]":
"""Directories where the installer may have placed node/npm/npx symlinks.
Delegates to the canonical helper in hermes_constants so the layout logic
lives in exactly one place (shared with profiles, doctor, backup).
"""
from hermes_constants import command_link_candidate_dirs
return command_link_candidate_dirs()
def remove_node_symlinks(hermes_home: Path) -> list:
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
"""Remove the node/npm/npx symlinks the installer placed on PATH.
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
creates::
symlinks node/npm/npx into the same directory as the ``hermes`` command:
~/.local/bin/node -> $HERMES_HOME/node/bin/node
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
- ``$PREFIX/bin/`` on Termux
- ``~/.local/bin/`` otherwise (the common non-root case)
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
manager such as nvm. Symmetrically remove them on uninstall, but *only*
when the link still resolves into this Hermes home's ``node`` directory.
A link the user has since repointed at nvm (or anything else outside
Hermes) is left untouched so we never break unrelated tooling.
We check all candidate directories so that uninstall works regardless of
how the install was done (e.g. a root FHS install that placed links in
``/usr/local/bin``, or an older install that used ``~/.local/bin`` before
the FHS fix). Only symlinks that resolve into this Hermes home's ``node``
directory are removed links the user has repointed elsewhere (nvm, fnm,
etc.) are left untouched.
"""
node_dir = (hermes_home / "node").resolve()
removed = []
for name in ("node", "npm", "npx"):
link = Path.home() / ".local" / "bin" / name
try:
# Only act on symlinks — never delete a real binary the user put here.
if not link.is_symlink():
continue
for bin_dir in _node_symlink_candidate_dirs():
link = bin_dir / name
try:
# Only act on symlinks — never delete a real binary the user put here.
if not link.is_symlink():
continue
# Resolve the link target and confirm it points into our node dir.
# os.readlink + manual join handles broken (dangling) links too;
# Path.resolve() on a dangling link still returns the target path.
target = Path(os.readlink(link))
if not target.is_absolute():
target = (link.parent / target)
target = target.resolve()
# Resolve the link target and confirm it points into our node dir.
# os.readlink + manual join handles broken (dangling) links too;
# Path.resolve() on a dangling link still returns the target path.
target = Path(os.readlink(link))
if not target.is_absolute():
target = (link.parent / target)
target = target.resolve()
if target == node_dir or node_dir in target.parents:
link.unlink()
removed.append(link)
except Exception as e:
log_warn(f"Could not remove {link}: {e}")
if target == node_dir or node_dir in target.parents:
link.unlink()
removed.append(link)
except Exception as e:
log_warn(f"Could not remove {link}: {e}")
return removed
@@ -458,14 +472,28 @@ def _uninstall_profile(profile) -> None:
except Exception as e:
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 2. Remove the wrapper alias script wherever it landed. Use the
# profiles helper which scans all candidate command-link dirs
# (~/.local/bin, /usr/local/bin, $PREFIX/bin) so root FHS aliases are
# removed too — then fall back to the recorded alias_path for safety.
removed_alias = False
try:
from hermes_cli.profiles import remove_wrapper_script
removed_alias = remove_wrapper_script(name)
if removed_alias:
log_success(f" Removed profile alias '{name}'")
except Exception as e:
log_warn(f" Could not scan for profile alias '{name}': {e}")
if not removed_alias:
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 3. Wipe the profile's HERMES_HOME directory.
try:
+138 -54
View File
@@ -7014,6 +7014,28 @@ _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
"""Return a rejection reason for the client IP, or None when allowed.
Reasons are short machine-parseable tokens logged on the rejection path
so a "WS keeps closing" report can be diagnosed from agent.log without a
repro. ``None`` means the peer IP passed this gate.
See :func:`_ws_client_is_allowed` for the full policy rationale.
"""
if getattr(app.state, "auth_required", False):
return None
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return None
client_host = ws.client.host if ws.client else ""
if not client_host:
return None
if client_host in _LOOPBACK_HOSTS:
return None
return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}"
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""Check if the WebSocket client IP is acceptable.
@@ -7054,6 +7076,40 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
return client_host in _LOOPBACK_HOSTS
def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
"""Return a Host/Origin rejection reason, or None when allowed.
Mirrors :func:`_ws_host_origin_is_allowed` but yields a short
machine-parseable token (``host_mismatch `` / ``origin_mismatch ``)
on rejection so the close path can log *why* the upgrade was refused.
"""
bound_host = getattr(app.state, "bound_host", None)
if not bound_host:
return None
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
return f"host_mismatch host={host_header or '?'} bound={bound_host}"
origin = ws.headers.get("origin", "")
if not origin:
return None
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
# Non-web origin (packaged Electron: file://, null, app://). The
# upstream credential check is the real auth boundary; trust it.
# See _ws_host_origin_is_allowed for the full rationale.
return None
if not parsed.netloc:
return f"origin_mismatch origin={origin} bound={bound_host}"
if not _is_accepted_host(parsed.netloc, bound_host):
return f"origin_mismatch origin={origin} bound={bound_host}"
return None
def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
"""Apply the dashboard Host/Origin guard to WebSocket upgrades.
@@ -7063,45 +7119,12 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
header on WebSocket handshakes; when present, require it to target the
same bound dashboard host.
"""
bound_host = getattr(app.state, "bound_host", None)
if not bound_host:
return True
return _ws_host_origin_reason(ws) is None
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
return False
origin = ws.headers.get("origin", "")
if not origin:
return True
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
# Packaged Electron loads the desktop renderer over a non-web origin
# such as file://, null, or a custom app:// scheme. This helper is
# called only AFTER _ws_auth_ok has already accepted the WS credential,
# which is the real auth boundary in every mode:
# * loopback bind → legacy dashboard session token
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
# A non-web origin can only be produced by a native client (the desktop
# shell); a DNS-rebinding attack always arrives from an http(s) origin
# and is still match-checked against the bound host below. So once the
# credential check upstream has passed, the Origin guard adds nothing
# for a non-web origin — trust it in every mode.
#
# (Earlier revisions restricted this to loopback, then to non-gated
# binds; both excluded the packaged desktop talking to a remote
# OAuth-gated gateway, whose file:// renderer origin then got rejected
# at the WS upgrade even with a valid ticket. The ticket is the gate,
# not the origin.)
return True
if not parsed.netloc:
return False
return _is_accepted_host(parsed.netloc, bound_host)
def _ws_request_reason(ws: "WebSocket") -> Optional[str]:
"""First Host/Origin or peer-IP rejection reason, or None when allowed."""
return _ws_host_origin_reason(ws) or _ws_client_reason(ws)
def _ws_request_is_allowed(ws: "WebSocket") -> bool:
@@ -7109,8 +7132,25 @@ def _ws_request_is_allowed(ws: "WebSocket") -> bool:
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
def _ws_auth_ok(ws: "WebSocket") -> bool:
"""Validate WS-upgrade auth in either loopback or gated mode.
def _ws_auth_mode() -> str:
"""Short label for the active WS auth mode — logged on every connection."""
if getattr(app.state, "auth_required", False):
return "gated"
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return "insecure"
return "loopback"
def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
"""Validate WS-upgrade auth; return ``(reason, credential)``.
``reason`` is None when the credential is accepted, else a short
machine-parseable token explaining the rejection (``no_credential``,
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
``credential`` names which credential type was presented (``ticket``,
``internal``, ``token``, or ``none``) so the accepted path can log *how*
a peer authed, not just that it did.
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
@@ -7131,9 +7171,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
(the SPA bundle isn't carrying the token any longer, and a leaked
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
Returns True if the WS should be accepted; callers close with the
appropriate WS code (4401) on False. Audit-logs the rejection so
operators can debug "WS keeps closing" issues from the log.
Audit-logs the rejection so operators can debug "WS keeps closing"
issues from the log.
"""
auth_required = bool(getattr(app.state, "auth_required", False))
if auth_required:
@@ -7153,7 +7192,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
if internal:
try:
consume_internal_credential(internal)
return True
return None, "internal"
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
@@ -7161,15 +7200,15 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return False
return "internal_invalid", "internal"
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
return "no_credential", "none"
try:
consume_ticket(ticket)
return True
return None, "ticket"
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
@@ -7177,10 +7216,19 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return False
return "ticket_invalid", "ticket"
token = ws.query_params.get("token", "")
return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
if not token:
return "no_credential", "none"
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
return None, "token"
return "token_mismatch", "token"
def _ws_auth_ok(ws: "WebSocket") -> bool:
"""True when the WS-upgrade credential is accepted. See _ws_auth_reason."""
return _ws_auth_reason(ws)[0] is None
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
@@ -7332,22 +7380,58 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
return channel if _VALID_CHANNEL_RE.match(channel) else None
def _ws_close_reason(text: str) -> str:
"""Clamp a WS close reason to the protocol's 123-byte UTF-8 limit.
RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a
longer string is passed. Our reasons embed an attacker-controlled origin,
so truncate defensively rather than crash the close handler.
"""
encoded = text.encode("utf-8", "replace")
if len(encoded) <= 123:
return text
return encoded[:120].decode("utf-8", "ignore") + "..."
@app.websocket("/api/pty")
async def pty_ws(ws: WebSocket) -> None:
peer = ws.client.host if ws.client else "?"
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
_log.info("pty refused: embedded chat disabled peer=%s", peer)
await ws.close(code=4404, reason="embedded chat disabled")
return
# --- auth + loopback check (before accept so we can close cleanly) ---
if not _ws_auth_ok(ws):
await ws.close(code=4401)
# --- auth + host/origin/peer check (before accept so we can close
# cleanly AND tell the client WHY via the close code + reason).
# Each gate maps to a distinct close code so the log and the
# browser banner agree on the cause:
# 4401 bad credential 4403 host/origin mismatch
# 4408 peer not allowed 4404 chat disabled
auth_reason, cred = _ws_auth_reason(ws)
mode = _ws_auth_mode()
if auth_reason is not None:
_log.warning(
"pty auth rejected reason=%s mode=%s cred=%s peer=%s",
auth_reason, mode, cred, peer,
)
await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
return
if not _ws_request_is_allowed(ws):
await ws.close(code=4403)
host_origin_reason = _ws_host_origin_reason(ws)
if host_origin_reason is not None:
_log.warning("pty refused: %s peer=%s", host_origin_reason, peer)
await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
return
client_reason = _ws_client_reason(ws)
if client_reason is not None:
_log.warning("pty refused: %s", client_reason)
await ws.close(code=4408, reason=_ws_close_reason(client_reason))
return
await ws.accept()
_log.info("pty accepted peer=%s mode=%s cred=%s", peer, mode, cred)
# On native Windows, the POSIX PTY bridge can't be imported. Tell the
# client and close cleanly rather than pretending the feature works.
+150
View File
@@ -414,6 +414,156 @@ 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.
Heuristic (not a strict line-by-line mirror) for ``resolve_install_layout()``
in ``scripts/install.sh``: root (uid 0) on Linux normally uses
``/usr/local/lib/hermes-agent`` for code and ``/usr/local/bin`` for the
command link. We can't see the installer's ``--dir``/``$HERMES_INSTALL_DIR``
or legacy-install flags from here, so we infer the layout from on-disk
evidence instead, preferring it over the bare uid check:
* legacy git install at ``<HERMES_HOME>/hermes-agent`` not FHS
(``resolve_install_layout`` keeps ``~/.local/bin`` for it);
* ``/usr/local`` markers present (command link or code dir) FHS;
* a ``~/.local/bin/hermes`` command present (e.g. an explicit ``--dir`` root
install, which ``resolve_install_layout`` does NOT flip to FHS) not FHS;
* no evidence at all (e.g. mid-install) assume FHS, the root default.
"""
if sys.platform != "linux":
return False
try:
if not hasattr(os, "geteuid") or os.geteuid() != 0:
return False
except OSError:
return False
# 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 /usr/local markers: a root user who installed into ~/.local/bin (e.g.
# via --dir/$HERMES_INSTALL_DIR, where resolve_install_layout() does not flip
# to FHS) keeps the command there. Honor that evidence before assuming FHS,
# so command_link_dir() doesn't point at /usr/local/bin for such a box.
if (Path.home() / ".local" / "bin" / "hermes").exists():
return False
# No markers at all (e.g. mid-install): the installer defaults a fresh root
# Linux box 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 ─────────────────────────────────────────────────────
+491
View File
@@ -0,0 +1,491 @@
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
A self-hosted "just put a password on my dashboard" provider. It plugs
into the same ``DashboardAuthProvider`` framework as the Nous OAuth
provider, but authenticates with a username + password instead of an
OAuth redirect: it sets ``supports_password = True`` and implements
``complete_password_login``. The login page renders a credential form for
it; everything downstream of login (session cookies, verify, refresh,
ws-tickets, logout) is identical to the OAuth path because a password
session is just a :class:`Session` with provider-minted opaque tokens.
This provider has **no external IDP and no database**. Credentials are
configured up front; sessions are stateless HMAC-signed tokens this
provider mints and verifies itself. That keeps it zero-infrastructure
appropriate for a single-box self-hosted dashboard.
Configuration surfaces (env wins over config.yaml when set non-empty),
mirroring the Nous provider's precedence convention:
``config.yaml`` canonical surface::
dashboard:
basic_auth:
username: admin # required
# Provide EITHER a precomputed scrypt hash (preferred — no
# plaintext at rest) ...
password_hash: "scrypt$..." # see hash_password()
# ... OR a plaintext password (hashed in-memory at load).
password: "s3cret"
secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
Environment overrides::
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
HERMES_DASHBOARD_BASIC_AUTH_SECRET
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
If ``secret`` is not configured, a random per-process secret is generated
at startup. That's fine for a single-process dashboard, but means all
sessions are invalidated on restart and sessions don't survive across
multiple worker processes set an explicit ``secret`` for stable
multi-worker / restart-surviving sessions.
Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no
third-party dependency). ``complete_password_login`` runs a constant-time
comparison and always performs a hash even for an unknown username, so
the endpoint is not a username-enumeration timing oracle.
Skip reasons:
Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON``
the gate's fail-closed branch can surface when the plugin loads but
declines to register (no username/password configured).
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
import secrets
import time
from typing import Any, Optional
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCredentialsError,
LoginStart,
RefreshExpiredError,
Session,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
# Access-token lifetime. The middleware transparently refreshes via the
# refresh token (30-day) when the access token lapses, so this controls
# how often a refresh round trip happens, not how long the user stays
# logged in.
_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d
# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power
# of two; these are the widely-recommended interactive-login parameters
# (~16 MiB, a few ms on commodity hardware).
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_DKLEN = 32
_SCRYPT_SALT_BYTES = 16
# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to
# signed tokens (no separator — binary HMAC bytes can't be confused with
# a delimiter).
_SIG_LEN = hashlib.sha256().digest_size
LAST_SKIP_REASON: str = ""
# ---------------------------------------------------------------------------
# Password hashing (stdlib scrypt)
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
Use this to precompute ``password_hash`` for config.yaml so plaintext
never sits at rest. Exposed as a module function so operators can run
``python -c "from plugins.dashboard_auth.basic import hash_password;
print(hash_password('pw'))"``.
"""
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
dk = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=_SCRYPT_N,
r=_SCRYPT_R,
p=_SCRYPT_P,
dklen=_SCRYPT_DKLEN,
maxmem=0,
)
return (
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
)
def _verify_password(password: str, encoded: str) -> bool:
"""Constant-time scrypt verify. False on any malformed hash string."""
try:
scheme, n_s, r_s, p_s, salt_b64, dk_b64 = encoded.split("$")
if scheme != "scrypt":
return False
n, r, p = int(n_s), int(r_s), int(p_s)
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(dk_b64)
except (ValueError, TypeError):
return False
try:
actual = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
dklen=len(expected),
maxmem=0,
)
except (ValueError, MemoryError):
return False
return hmac.compare_digest(actual, expected)
# A fixed dummy hash used to spend ~equal time when the username is
# unknown, so an attacker can't distinguish "no such user" (fast) from
# "wrong password" (slow scrypt) by timing. Computed once at import.
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
# ---------------------------------------------------------------------------
# Token signing (stateless HMAC-signed blobs)
# ---------------------------------------------------------------------------
def _sign(payload: dict, secret: bytes) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode()
sig = hmac.new(secret, raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + sig).decode()
def _unsign(token: str, secret: bytes) -> Optional[dict]:
try:
blob = base64.urlsafe_b64decode(token.encode())
if len(blob) <= _SIG_LEN:
return None
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
expected = hmac.new(secret, raw, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return None
return json.loads(raw)
except Exception:
return None
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
class BasicAuthProvider(DashboardAuthProvider):
"""Username/password provider with stateless HMAC-signed sessions."""
name = "basic"
display_name = "Username & Password"
supports_password = True
def __init__(
self,
*,
username: str,
password_hash: str,
secret: bytes,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
) -> None:
if not username:
raise ValueError("username must be non-empty")
if not password_hash:
raise ValueError("password_hash must be non-empty")
if len(secret) < 16:
raise ValueError("secret must be at least 16 bytes")
self._username = username
self._password_hash = password_hash
self._secret = secret
self._ttl = max(60, int(ttl_seconds))
# ---- OAuth methods: not used (pure-password provider) ------------------
def start_login(self, *, redirect_uri: str) -> LoginStart:
raise NotImplementedError(
"BasicAuthProvider is password-only; there is no OAuth redirect "
"flow. The login page POSTs to /auth/password-login instead."
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
raise NotImplementedError(
"BasicAuthProvider is password-only; use complete_password_login."
)
# ---- password login ----------------------------------------------------
def complete_password_login(
self, *, username: str, password: str
) -> Session:
# Constant-time-ish: always run a scrypt verify (against the real
# hash if the username matches, else a dummy hash) so an unknown
# username and a wrong password take comparable time. Compare the
# username with compare_digest too, to avoid a length/byte timing
# leak on the username itself.
username_ok = hmac.compare_digest(
username.encode("utf-8"), self._username.encode("utf-8")
)
target_hash = self._password_hash if username_ok else _DUMMY_HASH
password_ok = _verify_password(password, target_hash)
if not (username_ok and password_ok):
raise InvalidCredentialsError("invalid username or password")
return self._mint_session(self._username)
# ---- session lifecycle -------------------------------------------------
def verify_session(self, *, access_token: str) -> Optional[Session]:
payload = _unsign(access_token, self._secret)
if (
payload is None
or payload.get("kind") != "access"
or payload.get("exp", 0) <= int(time.time())
):
return None
return self._session_from_payload(access_token, "", payload)
def refresh_session(self, *, refresh_token: str) -> Session:
if not refresh_token:
raise RefreshExpiredError("no refresh token present in session")
payload = _unsign(refresh_token, self._secret)
if (
payload is None
or payload.get("kind") != "refresh"
or payload.get("exp", 0) <= int(time.time())
):
raise RefreshExpiredError("refresh token expired or invalid")
return self._mint_session(str(payload.get("sub", self._username)))
def revoke_session(self, *, refresh_token: str) -> None:
# Stateless tokens — nothing to revoke server-side. The session
# expires within its TTL. Best-effort no-op, must not raise.
_ = refresh_token
return None
# ---- internals ---------------------------------------------------------
def _mint_session(self, user_id: str) -> Session:
now = int(time.time())
exp = now + self._ttl
access_token = _sign(
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
)
refresh_token = _sign(
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
self._secret,
)
return Session(
user_id=user_id,
email="",
display_name=user_id,
org_id="",
provider=self.name,
expires_at=exp,
access_token=access_token,
refresh_token=refresh_token,
)
def _session_from_payload(
self, access_token: str, refresh_token: str, payload: dict
) -> Session:
user_id = str(payload.get("sub", ""))
return Session(
user_id=user_id,
email="",
display_name=user_id,
org_id="",
provider=self.name,
expires_at=int(payload["exp"]),
access_token=access_token,
refresh_token=refresh_token,
)
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
def _load_config_basic_auth_section() -> dict:
"""Return ``dashboard.basic_auth`` from config.yaml, or ``{}``.
Robust to load_config() raising, the keys being absent, or the value
not being a dict every shape falls through to ``{}``.
"""
try:
from hermes_cli.config import cfg_get, load_config
cfg = load_config()
except Exception as exc: # noqa: BLE001 — broad catch is intentional
logger.debug(
"dashboard-auth-basic: load_config() raised %s; "
"falling back to env-only configuration",
exc,
)
return {}
section = cfg_get(cfg, "dashboard", "basic_auth", default=None)
return section if isinstance(section, dict) else {}
def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str:
"""Env-wins-over-config resolution; empty env treated as unset."""
env = os.environ.get(env_name, "").strip()
if env:
return env
return str(cfg_section.get(cfg_key, "") or "").strip()
def _resolve_secret(cfg_section: dict) -> bytes:
"""Resolve the token-signing secret.
Accepts base64 or hex or raw text from config/env. When unset,
generates a random per-process secret (sessions then don't survive a
restart or span multiple workers logged at INFO).
"""
raw = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret"
)
if not raw:
logger.info(
"dashboard-auth-basic: no 'secret' configured; generating a "
"random per-process signing key. Sessions will not survive a "
"restart or span multiple workers. Set dashboard.basic_auth."
"secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable "
"sessions."
)
return secrets.token_bytes(32)
# Try base64, then hex, then fall back to the raw UTF-8 bytes.
for decoder in (base64.b64decode, bytes.fromhex):
try:
decoded = decoder(raw)
if len(decoded) >= 16:
return decoded
except (ValueError, TypeError):
pass
return raw.encode("utf-8")
def register(ctx) -> None:
"""Plugin entry — registers BasicAuthProvider when credentials exist.
Loopback / ``--insecure`` operators and anyone using the OAuth
provider leave ``dashboard.basic_auth`` unset, so this plugin is a
no-op for them. When username + (password or password_hash) are
configured, it registers a password provider that the login page
renders as a credential form.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
section = _load_config_basic_auth_section()
username = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username"
)
password_hash = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash"
)
plaintext = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password"
)
ttl_raw = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds"
)
if not username:
LAST_SKIP_REASON = (
"dashboard.basic_auth.username is not set (and "
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username "
"and a password (or password_hash) under dashboard.basic_auth in "
"config.yaml to enable username/password dashboard login, or use "
"the OAuth provider, or pass --insecure to skip the auth gate."
)
logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON)
return
if not password_hash and not plaintext:
LAST_SKIP_REASON = (
"dashboard.basic_auth.username is set but neither password_hash "
"nor password is configured. Provide one of them (password_hash "
"is preferred — compute it with "
"plugins.dashboard_auth.basic.hash_password)."
)
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
return
# Precedence (env-wins convention): a password supplied via the
# HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml
# password_hash, so an operator can rotate the password by setting an
# env var without editing config. A password_hash (precomputed) wins
# over a config-only plaintext password at the same tier — it's the
# preferred at-rest form. Concretely:
# * env password set → hash it (overrides any config hash)
# * else config password_hash set → use it
# * else config plaintext password → hash it in-memory
plaintext_from_env = os.environ.get(
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
).strip()
if plaintext_from_env:
password_hash = hash_password(plaintext_from_env)
logger.info(
"dashboard-auth-basic: hashed env-supplied password in-memory "
"(overrides any config password_hash)."
)
elif not password_hash:
# config-only plaintext password.
password_hash = hash_password(plaintext)
logger.info(
"dashboard-auth-basic: hashed plaintext password in-memory. "
"For production, precompute dashboard.basic_auth.password_hash "
"and remove the plaintext password from config."
)
secret = _resolve_secret(section)
try:
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
except ValueError:
ttl = _DEFAULT_TTL_SECONDS
try:
provider = BasicAuthProvider(
username=username,
password_hash=password_hash,
secret=secret,
ttl_seconds=ttl,
)
except ValueError as exc:
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
return
ctx.register_dashboard_auth_provider(provider)
logger.info(
"dashboard-auth-basic: registered password provider (username=%s)",
username,
)
+7
View File
@@ -0,0 +1,7 @@
name: basic
version: 1.0.0
description: "Dashboard auth provider — username/password (no OAuth IDP). A self-hosted 'just put a password on my dashboard' provider. Activates when dashboard.basic_auth.username plus a password (or password_hash) are configured via config.yaml (canonical surface) or the HERMES_DASHBOARD_BASIC_AUTH_* env vars. Sessions are stateless HMAC-signed tokens minted by the provider; password hashing uses stdlib scrypt (no third-party dependency). Set dashboard.basic_auth.secret for restart-surviving / multi-worker sessions."
author: NousResearch
kind: backend
requires_env:
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME
+5 -9
View File
@@ -383,21 +383,17 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
"""Surface obviously-broken redirect_uris before bouncing to Portal.
The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
this is a fast-fail for the common operator-error case.
this is a fast-fail for the common operator-error case. We allow any
``http://`` host (not just localhost) so self-hosted dashboards reached
over plain HTTP LAN IPs, internal hostnames, reverse proxies that
terminate TLS upstream are not rejected here; Portal makes the final
call on which redirect_uris are permitted.
"""
parsed = urllib.parse.urlparse(redirect_uri)
if parsed.scheme not in ("https", "http"):
raise ProviderError(
f"redirect_uri must be http(s), got {redirect_uri!r}"
)
if parsed.scheme == "http" and parsed.hostname not in (
"localhost",
"127.0.0.1",
):
raise ProviderError(
"redirect_uri may only use http:// for localhost/127.0.0.1, "
f"got {redirect_uri!r}"
)
if not parsed.path or not parsed.path.endswith("/auth/callback"):
raise ProviderError(
"redirect_uri path must end with '/auth/callback', "
+49 -5
View File
@@ -731,6 +731,12 @@ check_node() {
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
if [ -x "$HERMES_HOME/node/bin/node" ] && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
export PATH="$HERMES_HOME/node/bin:$PATH"
# Migration repair (#38889): a previously-broken install may have its
# node symlinks only in ~/.local/bin (off-PATH on root FHS) or missing.
# Re-link into the canonical dir + prune stale copies so re-running the
# installer (or `hermes update`) heals the box instead of leaving it
# broken.
link_bundled_node
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
HAS_NODE=true
return 0
@@ -746,6 +752,36 @@ 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
# `|| true`: pruning a shadow link is best-effort. A failing
# rm (read-only parent dir, uid mismatch) must NOT abort the
# whole installer via `set -e` (line 16). See #38889.
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
esac
done
done
# Never let this best-effort helper be the failing last command under set -e.
return 0
}
install_node() {
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Node.js via pkg..."
@@ -836,16 +872,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"
@@ -2175,6 +2210,12 @@ ensure_browser() {
ensure_mode() {
detect_os
# Resolve the install layout so $ROOT_FHS_LAYOUT is set before check_node →
# install_node → get_command_link_dir() decides where to symlink node/npm/npx.
# Without this, a root FHS box reached via `install.sh --ensure node`
# (hermes_cli/dep_ensure.py, acp_adapter, TUI fallback) leaves ROOT_FHS_LAYOUT
# false and links node into ~/.local/bin (off-PATH) — the #38889 regression.
resolve_install_layout
IFS=',' read -ra DEPS <<< "$ENSURE_DEPS"
for dep in "${DEPS[@]}"; do
@@ -2213,6 +2254,9 @@ ensure_mode() {
postinstall_mode() {
print_banner
detect_os
# Set $ROOT_FHS_LAYOUT before check_node/install_node so node/npm/npx are
# symlinked into the same dir as the hermes command on a root FHS box. (#38889)
resolve_install_layout
log_info "Post-install mode: setting up Hermes for pip install"
+77 -4
View File
@@ -44,6 +44,75 @@ _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() leaves FALSE (→ ~/.local/bin) for a root user with
# EITHER a legacy git install at $HERMES_HOME/hermes-agent/.git OR an explicit
# --dir/$HERMES_INSTALL_DIR install (INSTALL_DIR_EXPLICIT). The bootstrap can't
# see those flags, so it can't recompute the layout — instead it puts node where
# the `hermes` command actually landed. That keeps node and the command in the
# same dir no matter how the box was installed, which is the only invariant that
# matters here and avoids re-deriving (and diverging from) the installer's logic.
_nb_get_link_dir() {
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
echo "$PREFIX/bin"
return
fi
if [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
# Legacy git install keeps ~/.local/bin (matches resolve_install_layout).
if [ -d "${HERMES_HOME:-$HOME/.hermes}/hermes-agent/.git" ]; then
echo "$HOME/.local/bin"
return
fi
# Explicit --dir root installs keep the command in ~/.local/bin; detect
# that from where `hermes` actually is rather than re-deriving the flag.
if [ -e "$HOME/.local/bin/hermes" ] && [ ! -e "/usr/local/bin/hermes" ]; then
echo "$HOME/.local/bin"
return
fi
# Fresh/standard root FHS install.
echo "/usr/local/bin"
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
# `|| true`: best-effort prune must never fail a caller that
# runs under `set -e` (install.sh sources/mirrors this). #38889
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
esac
done
done
return 0
}
_nb_node_major() {
local v
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
@@ -187,10 +256,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 +281,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
@@ -0,0 +1,448 @@
"""Tests for the password (non-redirect) dashboard-auth login flow.
Covers the protocol extension (``supports_password`` +
``complete_password_login``), the ``/auth/password-login`` route end-to-end
through the REAL ``gated_auth_middleware`` (session-cookie mint
authenticated request transparent refresh), the login-page credential
form rendering, and the route's rate limiter.
The E2E harness mirrors ``test_dashboard_auth_401_reauth.py``: register a
provider, flip ``app.state.auth_required = True``, drive a ``TestClient``.
"""
from __future__ import annotations
import time
import pytest
# These tests mutate ``web_server.app.state.auth_required`` at module level,
# so they share the dashboard-auth app-state xdist group to avoid racing
# other gate tests.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCredentialsError,
ProviderError,
Session,
assert_protocol_compliance,
clear_providers,
register_provider,
)
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE, SESSION_RT_COOKIE
from hermes_cli.dashboard_auth.login_page import render_login_html
from hermes_cli.dashboard_auth.routes import _reset_password_rate_limit
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Test password provider — minimal, in-memory, signed tokens.
# ---------------------------------------------------------------------------
def _sign(secret: bytes, sub: str, kind: str, ttl: int) -> str:
import base64
import hashlib
import hmac
import json
raw = json.dumps(
{"sub": sub, "kind": kind, "exp": int(time.time()) + ttl},
separators=(",", ":"),
).encode()
sig = hmac.new(secret, raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + sig).decode()
def _unsign(secret: bytes, token: str):
import base64
import hashlib
import hmac
import json
try:
blob = base64.urlsafe_b64decode(token.encode())
raw, sig = blob[:-32], blob[-32:]
if not hmac.compare_digest(
sig, hmac.new(secret, raw, hashlib.sha256).digest()
):
return None
return json.loads(raw)
except Exception:
return None
class PasswordProvider(DashboardAuthProvider):
"""In-test username/password provider (admin / hunter2)."""
name = "testpw"
display_name = "Test Password"
supports_password = True
def __init__(self, *, ttl: int = 3600, secret: bytes = b"test-secret-1234567890"):
self._ttl = ttl
self._secret = secret
self.unreachable = False # flip to simulate a ProviderError
def start_login(self, *, redirect_uri: str):
raise NotImplementedError
def complete_login(self, **kwargs):
raise NotImplementedError
def complete_password_login(self, *, username: str, password: str) -> Session:
if self.unreachable:
raise ProviderError("backing store down")
if username != "admin" or password != "hunter2":
raise InvalidCredentialsError("bad creds")
exp = int(time.time()) + self._ttl
return Session(
user_id="admin",
email="",
display_name="admin",
org_id="",
provider=self.name,
expires_at=exp,
access_token=_sign(self._secret, "admin", "access", self._ttl),
refresh_token=_sign(self._secret, "admin", "refresh", 30 * 86400),
)
def verify_session(self, *, access_token: str):
p = _unsign(self._secret, access_token)
if not p or p.get("kind") != "access" or p["exp"] <= int(time.time()):
return None
return Session(
user_id=p["sub"], email="", display_name=p["sub"], org_id="",
provider=self.name, expires_at=p["exp"],
access_token=access_token, refresh_token="",
)
def refresh_session(self, *, refresh_token: str) -> Session:
from hermes_cli.dashboard_auth import RefreshExpiredError
p = _unsign(self._secret, refresh_token)
if not p or p.get("kind") != "refresh" or p["exp"] <= int(time.time()):
raise RefreshExpiredError("dead rt")
exp = int(time.time()) + self._ttl
return Session(
user_id=p["sub"], email="", display_name=p["sub"], org_id="",
provider=self.name, expires_at=exp,
access_token=_sign(self._secret, p["sub"], "access", self._ttl),
refresh_token=_sign(self._secret, p["sub"], "refresh", 30 * 86400),
)
def revoke_session(self, *, refresh_token: str) -> None:
return None
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def pw_provider():
return PasswordProvider()
@pytest.fixture
def gated_app(pw_provider):
clear_providers()
register_provider(pw_provider)
_reset_password_rate_limit()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
_reset_password_rate_limit()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Protocol extension
# ---------------------------------------------------------------------------
class TestProtocolExtension:
def test_password_provider_is_protocol_compliant(self):
assert assert_protocol_compliance(PasswordProvider) is None
def test_default_supports_password_is_false(self):
# OAuth providers (the Stub) inherit the False default.
assert StubAuthProvider.supports_password is False
def test_default_complete_password_login_raises_not_implemented(self):
# A provider that doesn't override the method (the Stub) raises,
# rather than silently accepting any credentials.
with pytest.raises(NotImplementedError):
StubAuthProvider().complete_password_login(
username="x", password="y"
)
# ---------------------------------------------------------------------------
# /api/auth/providers exposes the supports_password flag
# ---------------------------------------------------------------------------
class TestProviderListFlag:
def test_providers_endpoint_reports_supports_password(self, gated_app):
resp = gated_app.get("/api/auth/providers")
assert resp.status_code == 200
prov = {p["name"]: p for p in resp.json()["providers"]}
assert prov["testpw"]["supports_password"] is True
def test_oauth_provider_reports_false(self):
clear_providers()
register_provider(StubAuthProvider())
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev"
)
resp = client.get("/api/auth/providers")
prov = {p["name"]: p for p in resp.json()["providers"]}
assert prov["stub"]["supports_password"] is False
finally:
clear_providers()
web_server.app.state.auth_required = prev
# ---------------------------------------------------------------------------
# /auth/password-login — end-to-end through the real middleware
# ---------------------------------------------------------------------------
class TestPasswordLoginRoute:
def test_valid_credentials_set_session_cookies_and_return_next(
self, gated_app
):
resp = gated_app.post(
"/auth/password-login",
json={
"provider": "testpw",
"username": "admin",
"password": "hunter2",
"next": "/sessions",
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "next": "/sessions"}
set_cookie = resp.headers.get("set-cookie", "")
# HTTPS request → __Host- prefixed access-token cookie is set.
assert SESSION_AT_COOKIE in set_cookie
assert SESSION_RT_COOKIE in set_cookie
def test_session_cookie_then_grants_authenticated_access(self, gated_app):
# Log in, then hit an auth-required endpoint with the cookie jar
# the TestClient retains — proving the minted session is accepted
# by the real gated_auth_middleware.
login = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert login.status_code == 200
me = gated_app.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["user_id"] == "admin"
assert me.json()["provider"] == "testpw"
def test_wrong_password_returns_generic_401(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "WRONG"},
)
assert resp.status_code == 401
# Generic detail — no user-vs-password distinction.
assert resp.json()["detail"] == "Invalid credentials"
assert "set-cookie" not in {k.lower() for k in resp.headers}
def test_unknown_user_returns_same_generic_401(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "ghost", "password": "hunter2"},
)
assert resp.status_code == 401
assert resp.json()["detail"] == "Invalid credentials"
def test_unknown_provider_returns_404(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={"provider": "nope", "username": "admin", "password": "hunter2"},
)
assert resp.status_code == 404
def test_oauth_provider_rejects_password_login_with_404(self):
# An OAuth-only provider (supports_password False) must not be
# reachable via the password route — same 404 as unknown, so the
# endpoint isn't a provider-capability oracle.
clear_providers()
register_provider(StubAuthProvider())
_reset_password_rate_limit()
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev"
)
resp = client.post(
"/auth/password-login",
json={"provider": "stub", "username": "x", "password": "y"},
)
assert resp.status_code == 404
finally:
clear_providers()
_reset_password_rate_limit()
web_server.app.state.auth_required = prev
def test_provider_unreachable_returns_503(self, gated_app, pw_provider):
pw_provider.unreachable = True
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert resp.status_code == 503
def test_open_redirect_next_is_dropped(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={
"provider": "testpw",
"username": "admin",
"password": "hunter2",
"next": "https://evil.example/phish",
},
)
assert resp.status_code == 200
# Malicious absolute URL dropped → lands at root.
assert resp.json()["next"] == "/"
def test_route_is_public_unauthenticated(self, gated_app):
# The login route itself must be reachable without a session —
# otherwise you could never log in.
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Transparent refresh — expired access token, live refresh token
# ---------------------------------------------------------------------------
class TestPasswordSessionRefresh:
def test_expired_access_token_refreshes_via_rt_cookie(self):
# TTL=0 → access token born expired; the RT cookie should drive a
# transparent refresh on the next request (the same machinery the
# OAuth provider uses).
clear_providers()
provider = PasswordProvider(ttl=0)
register_provider(provider)
_reset_password_rate_limit()
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev"
)
login = client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert login.status_code == 200
# Give the provider a live TTL so the refreshed token verifies.
provider._ttl = 3600
me = client.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["user_id"] == "admin"
finally:
clear_providers()
_reset_password_rate_limit()
web_server.app.state.auth_required = prev
# ---------------------------------------------------------------------------
# Rate limiter
# ---------------------------------------------------------------------------
class TestRateLimit:
def test_repeated_failures_eventually_429(self, gated_app):
# The limiter caps attempts per IP per window (default 10). After
# the budget is exhausted, even a VALID credential gets 429.
last = None
for _ in range(15):
last = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "WRONG"},
)
assert last.status_code == 429
# Even correct creds are throttled once the window is saturated.
good = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert good.status_code == 429
# ---------------------------------------------------------------------------
# Login page rendering
# ---------------------------------------------------------------------------
class TestLoginPageRender:
def test_password_provider_renders_credential_form_and_script(self):
clear_providers()
register_provider(PasswordProvider())
try:
html = render_login_html(next_path="/sessions")
assert '<form class="provider-form" data-provider="testpw"' in html
assert 'name="username"' in html
assert 'name="password"' in html
assert 'value="/sessions"' in html
assert "<script>" in html
assert "/auth/password-login" in html
finally:
clear_providers()
def test_oauth_only_page_stays_script_free(self):
clear_providers()
register_provider(StubAuthProvider())
try:
html = render_login_html()
assert "provider-btn" in html
assert "<script>" not in html
# No password FORM element rendered (the .provider-form CSS
# rule lives in the template's <style> block unconditionally;
# what must be absent is an actual rendered form + its script).
assert '<form class="provider-form"' not in html
assert "/auth/password-login" not in html
finally:
clear_providers()
def test_mixed_providers_render_both(self):
clear_providers()
register_provider(StubAuthProvider())
register_provider(PasswordProvider())
try:
html = render_login_html()
# OAuth redirect button AND a password form, both present.
assert "/auth/login?provider=stub" in html
assert 'data-provider="testpw"' in html
assert "<script>" in html
finally:
clear_providers()
+217
View File
@@ -0,0 +1,217 @@
"""Tests for ``hermes dashboard register``.
Covers the CLI half of self-hosted dashboard registration:
- Docker-style auto-name generation
- not-logged-in fast-fail (AuthError with relogin_required)
- managed-install refusal
- the happy path: POST shape, env-var writes, custom redirect URI
- portal-URL write logic (only when non-default and not already set)
- portal HTTP error mapping (401/403)
The portal HTTP call and the Nous token resolution are both mocked this
file proves the CLI wiring + env-write behaviour. The live end-to-end token
round-trip against the Vercel preview build is a separate manual step.
"""
from __future__ import annotations
import argparse
import json
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
import hermes_cli.dashboard_register as dr
def _ns(**kw):
defaults = dict(name=None, redirect_uri=None)
defaults.update(kw)
return argparse.Namespace(**defaults)
class TestNameGenerator:
def test_shape_is_adjective_underscore_noun(self):
for _ in range(50):
name = dr._generate_dashboard_name()
assert "_" in name
adj, _, noun = name.partition("_")
assert adj in dr._NAME_ADJECTIVES
assert noun in dr._NAME_NOUNS
class TestFastFails:
def test_not_logged_in_exits_1_with_setup_hint(self, capsys):
from hermes_cli.auth import AuthError
err = AuthError("not logged in", provider="nous", relogin_required=True)
with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register):
with patch(
"hermes_cli.auth.resolve_nous_access_token", side_effect=err
), patch("hermes_cli.config.is_managed", return_value=False):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
assert exc.value.code == 1
out = capsys.readouterr().out
assert "not logged into Nous Portal" in out
assert "hermes setup" in out
def test_managed_install_refuses(self, capsys):
with patch("hermes_cli.config.is_managed", return_value=True):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
assert exc.value.code == 1
out = capsys.readouterr().out
assert "not available in a managed" in out
def _fake_http_ok(payload: dict):
"""Return a context-manager urlopen stub yielding `payload` as JSON."""
cm = MagicMock()
cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
return cm
class TestHappyPath:
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
response=None, captured=None):
response = response or {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
}
def fake_urlopen(req, timeout=None):
if captured is not None:
captured["url"] = req.full_url
captured["headers"] = dict(req.header_items())
captured["body"] = json.loads(req.data.decode())
return _fake_http_ok(response)
saved = {}
def fake_save(key, value):
saved[key] = value
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value=portal
), patch(
"hermes_cli.config.get_env_value", return_value=None
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", side_effect=fake_urlopen
):
dr.cmd_dashboard_register(args)
return saved
def test_writes_client_id_and_posts_generated_name(self, capsys):
captured: dict = {}
saved = self._run(args=_ns(), captured=captured)
# POST shape
assert captured["url"].endswith("/api/oauth/self-hosted-client")
assert captured["headers"]["Authorization"] == "Bearer tok_abc"
assert "name" in captured["body"] and captured["body"]["name"]
assert "custom_redirect_uri" not in captured["body"]
# env write: client_id present, portal URL NOT written (default portal)
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
out = capsys.readouterr().out
assert "Registered dashboard" in out
assert "non-loopback bind" in out # the gate-engagement hint
def test_explicit_name_is_sent(self, capsys):
captured: dict = {}
self._run(args=_ns(name="my_box"), captured=captured)
assert captured["body"]["name"] == "my_box"
def test_custom_redirect_uri_is_forwarded(self, capsys):
captured: dict = {}
self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
captured=captured,
)
assert (
captured["body"]["custom_redirect_uri"]
== "https://hermes.example.com/auth/callback"
)
def test_non_default_portal_is_persisted(self, capsys):
saved = self._run(
args=_ns(),
portal="https://nous-account-service-git-feat-x.vercel.app",
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"]
== "https://nous-account-service-git-feat-x.vercel.app"
)
class TestPortalResolution:
def test_override_arg_wins(self):
assert (
dr._resolve_portal_base_url("https://preview.example.com/")
== "https://preview.example.com"
)
def test_falls_back_to_stored_login_portal(self):
with patch(
"hermes_cli.auth.get_provider_auth_state",
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
):
assert (
dr._resolve_portal_base_url(None)
== "https://portal.staging-nousresearch.com"
)
def test_blank_override_ignored(self):
with patch(
"hermes_cli.auth.get_provider_auth_state",
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
):
assert (
dr._resolve_portal_base_url(" ")
== "https://portal.staging-nousresearch.com"
)
class TestPortalErrors:
def _run_http_error(self, code, body):
err = urllib.error.HTTPError(
url="https://portal.nousresearch.com/api/oauth/self-hosted-client",
code=code,
msg="err",
hdrs=None,
fp=BytesIO(json.dumps(body).encode()),
)
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
), patch.object(dr.urllib.request, "urlopen", side_effect=err):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
return exc.value.code
def test_401_maps_to_reauth_message(self, capsys):
code = self._run_http_error(401, {"error": "invalid_token"})
assert code == 1
assert "re-authenticate" in capsys.readouterr().out
def test_403_surfaces_server_detail(self, capsys):
code = self._run_http_error(
403, {"error": "access_denied", "error_description": "Not permitted here."}
)
assert code == 1
assert "Not permitted here." in capsys.readouterr().out
+45
View File
@@ -1288,3 +1288,48 @@ class TestEdgeCases:
delete_profile("coder", yes=True)
assert get_active_profile() == "default"
class TestWrapperDirLayoutAware:
"""Profile-alias wrapper dir follows the canonical command-link layout (#38889)."""
def test_wrapper_dir_root_fhs(self, monkeypatch):
import hermes_cli.profiles as profiles
import hermes_constants
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
assert profiles._get_wrapper_dir() == Path("/usr/local/bin")
def test_wrapper_dir_nonroot(self, tmp_path, monkeypatch):
import hermes_cli.profiles as profiles
import hermes_constants
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
assert profiles._get_wrapper_dir() == tmp_path / ".local" / "bin"
def test_remove_wrapper_scans_all_dirs(self, tmp_path, monkeypatch):
"""An alias in /usr/local/bin is removed even though _get_wrapper_dir
would (in test) point at ~/.local/bin remove must scan candidates."""
import hermes_cli.profiles as profiles
fake_usr_local = tmp_path / "usr_local_bin"
fake_usr_local.mkdir()
alias = fake_usr_local / "myprof"
alias.write_text('#!/usr/bin/env bash\nexec hermes -p myprof "$@"\n')
alias.chmod(0o755)
monkeypatch.setattr(
profiles, "_wrapper_candidate_dirs", lambda: [fake_usr_local]
)
assert profiles.remove_wrapper_script("myprof") is True
assert not alias.exists()
def test_remove_wrapper_leaves_foreign_files(self, tmp_path, monkeypatch):
"""A file that isn't our wrapper (no 'hermes -p') is left untouched."""
import hermes_cli.profiles as profiles
d = tmp_path / "bin"
d.mkdir()
foreign = d / "myprof"
foreign.write_text("#!/bin/sh\necho not ours\n")
monkeypatch.setattr(profiles, "_wrapper_candidate_dirs", lambda: [d])
assert profiles.remove_wrapper_script("myprof") is False
assert foreign.exists()
@@ -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()
@@ -0,0 +1,83 @@
"""Tests for ``_wait_for_interpreter_venv_ready`` in ``hermes_cli/main.py``.
During ``hermes update`` the managed-uv path can rebuild the project venv
(rmtree + ``uv venv``) before the desktop-rebuild and profile-skills-sync
steps spawn ``sys.executable``. If those children fire while the venv is
mid-rewrite, the interpreter launcher aborts with ``No pyvenv.cfg file`` and
the step spuriously "fails" on an otherwise-successful update. The helper
waits for the marker to settle first.
"""
from __future__ import annotations
import os
import threading
import time
from pathlib import Path
from hermes_cli.main import _wait_for_interpreter_venv_ready
def _make_fake_venv(tmp_path: Path, *, with_cfg: bool) -> Path:
"""Create a venv-shaped dir and return the interpreter path inside it."""
bin_name = "Scripts" if os.name == "nt" else "bin"
bin_dir = tmp_path / bin_name
bin_dir.mkdir(parents=True)
py = bin_dir / ("python.exe" if os.name == "nt" else "python")
py.write_text("#!/bin/sh\n")
if with_cfg:
(tmp_path / "pyvenv.cfg").write_text("home = /usr\n")
return py
class TestWaitForInterpreterVenvReady:
def test_intact_venv_returns_immediately(self, tmp_path, monkeypatch):
py = _make_fake_venv(tmp_path, with_cfg=True)
monkeypatch.setattr("sys.executable", str(py))
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=5) is True
assert time.monotonic() - t0 < 0.5
def test_non_venv_interpreter_returns_immediately(self, tmp_path, monkeypatch):
# A bare interpreter whose parent.parent has no bin/Scripts marker
# dir is not venv-hosted; pyvenv.cfg is irrelevant.
sys_py = tmp_path / "usr" / "bin" / "python"
sys_py.parent.mkdir(parents=True)
sys_py.write_text("#!/bin/sh\n")
# Ensure parent.parent (tmp_path/usr) has no bin sibling shaped like a venv
monkeypatch.setattr("sys.executable", str(sys_py))
# parent.parent == tmp_path/usr; its "bin" child IS tmp_path/usr/bin
# which exists — so this would look venv-ish. Use a deeper layout
# where parent.parent has no bin marker:
deep = tmp_path / "opt" / "py3" / "real" / "python"
deep.parent.mkdir(parents=True)
deep.write_text("#!/bin/sh\n")
monkeypatch.setattr("sys.executable", str(deep))
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=5) is True
assert time.monotonic() - t0 < 0.5
def test_waits_for_cfg_to_appear(self, tmp_path, monkeypatch):
py = _make_fake_venv(tmp_path, with_cfg=False)
monkeypatch.setattr("sys.executable", str(py))
def _write_cfg_later():
time.sleep(0.6)
(tmp_path / "pyvenv.cfg").write_text("home = /usr\n")
th = threading.Thread(target=_write_cfg_later)
th.start()
try:
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=5) is True
elapsed = time.monotonic() - t0
finally:
th.join()
assert 0.5 < elapsed < 2.0
def test_returns_false_when_cfg_never_appears(self, tmp_path, monkeypatch):
py = _make_fake_venv(tmp_path, with_cfg=False)
monkeypatch.setattr("sys.executable", str(py))
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=1) is False
assert 0.9 < time.monotonic() - t0 < 1.6
+1 -1
View File
@@ -3528,7 +3528,7 @@ class TestPtyWebSocket:
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect(self._url()):
pass
assert exc.value.code == 4403
assert exc.value.code == 4404
def test_rejects_missing_token(self, monkeypatch):
monkeypatch.setattr(
@@ -0,0 +1,246 @@
"""Tests for the BasicAuthProvider plugin (username/password, scrypt, signed
tokens).
Loads the plugin module directly (it's a bundled backend plugin, not on the
import path as a package) and exercises the provider behaviour + the
``register(ctx)`` entry point's config/env resolution and skip reasons.
"""
from __future__ import annotations
import secrets
from unittest.mock import MagicMock
import pytest
import plugins.dashboard_auth.basic as basic_plugin
from hermes_cli.dashboard_auth import (
InvalidCredentialsError,
RefreshExpiredError,
assert_protocol_compliance,
)
@pytest.fixture(scope="module")
def basic():
return basic_plugin
@pytest.fixture(autouse=True)
def _clear_basic_env(monkeypatch):
for var in (
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME",
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH",
"HERMES_DASHBOARD_BASIC_AUTH_SECRET",
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS",
):
monkeypatch.delenv(var, raising=False)
# ---------------------------------------------------------------------------
# Hashing
# ---------------------------------------------------------------------------
class TestPasswordHashing:
def test_hash_then_verify_round_trips(self, basic):
h = basic.hash_password("hunter2")
assert h.startswith("scrypt$")
assert basic._verify_password("hunter2", h)
def test_wrong_password_fails(self, basic):
h = basic.hash_password("hunter2")
assert not basic._verify_password("wrong", h)
def test_malformed_hash_returns_false(self, basic):
assert not basic._verify_password("x", "not-a-valid-hash")
assert not basic._verify_password("x", "bcrypt$wrong$scheme")
def test_two_hashes_of_same_password_differ(self, basic):
# Distinct random salts → distinct encoded hashes.
assert basic.hash_password("pw") != basic.hash_password("pw")
# ---------------------------------------------------------------------------
# Provider behaviour
# ---------------------------------------------------------------------------
class TestProvider:
def _make(self, basic, **kw):
h = basic.hash_password("hunter2")
return basic.BasicAuthProvider(
username="admin",
password_hash=h,
secret=secrets.token_bytes(32),
**kw,
)
def test_protocol_compliant(self, basic):
assert assert_protocol_compliance(basic.BasicAuthProvider) is None
def test_supports_password_true(self, basic):
assert basic.BasicAuthProvider.supports_password is True
def test_login_mints_session(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert s.user_id == "admin"
assert s.provider == "basic"
assert s.access_token and s.refresh_token
def test_bad_credentials_raise(self, basic):
p = self._make(basic)
for u, pw in [("admin", "wrong"), ("ghost", "hunter2"), ("", "")]:
with pytest.raises(InvalidCredentialsError):
p.complete_password_login(username=u, password=pw)
def test_verify_round_trips_and_rejects_tamper(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert p.verify_session(access_token=s.access_token) is not None
assert p.verify_session(access_token="garbage") is None
def test_access_token_not_accepted_as_refresh(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
# A refresh token must not verify as an access token and vice
# versa — the ``kind`` claim is enforced.
assert p.verify_session(access_token=s.refresh_token) is None
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token=s.access_token)
def test_refresh_round_trips(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
r = p.refresh_session(refresh_token=s.refresh_token)
assert r.user_id == "admin"
assert p.verify_session(access_token=r.access_token) is not None
def test_refresh_with_garbage_raises(self, basic):
p = self._make(basic)
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token="garbage")
def test_cross_secret_token_does_not_verify(self, basic):
p1 = self._make(basic)
p2 = self._make(basic) # different random secret
s = p1.complete_password_login(username="admin", password="hunter2")
assert p2.verify_session(access_token=s.access_token) is None
def test_revoke_is_silent(self, basic):
p = self._make(basic)
p.revoke_session(refresh_token="anything") # must not raise
def test_oauth_methods_raise_not_implemented(self, basic):
p = self._make(basic)
with pytest.raises(NotImplementedError):
p.start_login(redirect_uri="https://x/auth/callback")
with pytest.raises(NotImplementedError):
p.complete_login(
code="c", state="s", code_verifier="v", redirect_uri="r"
)
def test_construction_validates_inputs(self, basic):
good_hash = basic.hash_password("pw")
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="", password_hash=good_hash, secret=b"x" * 32
)
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="admin", password_hash="", secret=b"x" * 32
)
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="admin", password_hash=good_hash, secret=b"short"
)
# ---------------------------------------------------------------------------
# register() entry point — config/env resolution + skip reasons
# ---------------------------------------------------------------------------
class TestRegister:
def test_skips_when_no_username(self, basic, monkeypatch):
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "username" in basic.LAST_SKIP_REASON
def test_skips_when_username_but_no_password(self, basic, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "password" in basic.LAST_SKIP_REASON
def test_registers_with_env_plaintext_password(self, basic, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert isinstance(provider, basic.BasicAuthProvider)
# Round-trips: the registered provider authenticates the env creds.
s = provider.complete_password_login(username="admin", password="hunter2")
assert s.user_id == "admin"
assert basic.LAST_SKIP_REASON == ""
def test_registers_with_precomputed_hash(self, basic, monkeypatch):
h = basic.hash_password("s3cret")
monkeypatch.setattr(
basic,
"_load_config_basic_auth_section",
lambda: {"username": "ops", "password_hash": h},
)
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert provider.complete_password_login(
username="ops", password="s3cret"
).user_id == "ops"
def test_env_password_overrides_config(self, basic, monkeypatch):
cfg_hash = basic.hash_password("config-pw")
monkeypatch.setattr(
basic,
"_load_config_basic_auth_section",
lambda: {"username": "admin", "password_hash": cfg_hash},
)
# Env plaintext should win over the config hash.
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "env-pw")
ctx = MagicMock()
basic.register(ctx)
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
# env password works ...
assert provider.complete_password_login(
username="admin", password="env-pw"
)
# ... and the config password no longer does.
with pytest.raises(InvalidCredentialsError):
provider.complete_password_login(username="admin", password="config-pw")
def test_explicit_secret_makes_sessions_portable(self, basic, monkeypatch):
# Two providers built from the SAME explicit secret accept each
# other's tokens (the restart-/multi-worker-survival contract).
shared = secrets.token_bytes(32).hex()
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_SECRET", shared)
ctx1, ctx2 = MagicMock(), MagicMock()
basic.register(ctx1)
basic.register(ctx2)
p1 = ctx1.register_dashboard_auth_provider.call_args.args[0]
p2 = ctx2.register_dashboard_auth_provider.call_args.args[0]
s = p1.complete_password_login(username="admin", password="hunter2")
assert p2.verify_session(access_token=s.access_token) is not None
@@ -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.
+63
View File
@@ -298,3 +298,66 @@ class TestSecureParentDir:
assert len(called_with) == 1
assert called_with[0] == (str(real_dir), 0o700)
class TestCommandLinkDir:
"""Tests for the canonical command-link / bundled-node helpers (#38889)."""
def test_nonroot_returns_local_bin(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("PREFIX", raising=False)
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
assert hermes_constants.command_link_dir() == tmp_path / ".local" / "bin"
assert hermes_constants.command_link_display_dir() == "~/.local/bin"
def test_root_fhs_returns_usr_local_bin(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
assert hermes_constants.command_link_dir() == Path("/usr/local/bin")
assert hermes_constants.command_link_display_dir() == "/usr/local/bin"
def test_termux_returns_prefix_bin(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_termux", lambda: True)
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
assert hermes_constants.command_link_dir() == Path(
"/data/data/com.termux/files/usr/bin"
)
assert hermes_constants.command_link_display_dir() == "$PREFIX/bin"
def test_candidate_dirs_includes_both_on_linux(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.delenv("PREFIX", raising=False)
dirs = hermes_constants.command_link_candidate_dirs()
assert tmp_path / ".local" / "bin" in dirs
assert Path("/usr/local/bin") in dirs
def test_candidate_dirs_deduped(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.delenv("PREFIX", raising=False)
dirs = hermes_constants.command_link_candidate_dirs()
assert len(dirs) == len({str(d) for d in dirs})
def test_bundled_node_bin_dir(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert hermes_constants.bundled_node_bin_dir() == tmp_path / "node" / "bin"
def test_find_node_prefers_path(self, monkeypatch):
monkeypatch.setattr("shutil.which", lambda n: "/usr/bin/" + n)
assert hermes_constants.find_node_executable("node") == "/usr/bin/node"
def test_find_node_falls_back_to_bundled(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("shutil.which", lambda n: None)
node_bin = tmp_path / "node" / "bin"
node_bin.mkdir(parents=True)
(node_bin / "npm").write_text("#!/bin/sh\n")
(node_bin / "npm").chmod(0o755)
assert hermes_constants.find_node_executable("npm") == str(node_bin / "npm")
def test_find_node_returns_none_when_absent(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("shutil.which", lambda n: None)
assert hermes_constants.find_node_executable("node") is None
+213
View File
@@ -0,0 +1,213 @@
"""Regression coverage for the #38889 node migration-heal + stale-prune.
The user-facing fix reviewers cared about most re-running the installer (or
``hermes update``) on a box whose node symlinks landed in the wrong (off-PATH)
dir re-links node into the canonical command dir AND prunes the stale shadow
copies lives entirely in shell (``link_bundled_node`` in ``install.sh`` and
``_nb_link_bundled_node`` in ``scripts/lib/node-bootstrap.sh``). Before this
file it had no automated coverage at all (only a manual VM run).
These tests drive the sourceable ``node-bootstrap.sh`` helper directly. The FHS
``/usr/local/bin`` target requires root, so to exercise the same relink+prune
code path with a *writable* link dir we run in Termux mode (``$PREFIX/bin`` is
the link dir), which makes ``~/.local/bin`` one of the scanned stale dirs. The
prune logic, safety guards, idempotency, and the ``set -e`` hardening are
identical across the FHS and Termux link dirs.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import NamedTuple
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
pytestmark = pytest.mark.skipif(
sys.platform.startswith("win") or shutil.which("bash") is None,
reason="POSIX shell required to drive node-bootstrap.sh",
)
class _Layout(NamedTuple):
home: Path
hermes_home: Path
node_bin: Path
prefix: Path
link_dir: Path
local_bin: Path
def _layout(tmp_path: Path) -> _Layout:
"""The fixed dir layout these tests share.
Termux mode (PREFIX contains ``com.termux/files/usr``) makes the link dir
``$PREFIX/bin``, so ``~/.local/bin`` is a *scanned, writable* stale dir
the only way to exercise the relink+prune without being root.
"""
home = tmp_path / "home"
hermes_home = tmp_path / "hermes"
prefix = tmp_path / "termux" / "com.termux" / "files" / "usr"
return _Layout(
home=home,
hermes_home=hermes_home,
node_bin=hermes_home / "node" / "bin",
prefix=prefix,
link_dir=prefix / "bin",
local_bin=home / ".local" / "bin",
)
def _make_bundled_node(hermes_home: Path) -> Path:
"""Create dummy <HERMES_HOME>/node/bin/{node,npm,npx} executables."""
node_bin = hermes_home / "node" / "bin"
node_bin.mkdir(parents=True)
for name in ("node", "npm", "npx"):
exe = node_bin / name
exe.write_text("#!/bin/sh\necho dummy\n")
exe.chmod(0o755)
return node_bin
def _run_nb_link(tmp_path: Path, *, extra: str = "") -> subprocess.CompletedProcess:
"""Source node-bootstrap.sh in Termux mode and run _nb_link_bundled_node.
Runs under ``set -e`` so the prune's best-effort ``rm`` failures must not
abort (the #38889 hardening); ``SENTINEL_OK`` after the call proves we
returned normally.
"""
lay = _layout(tmp_path)
lay.link_dir.mkdir(parents=True, exist_ok=True)
lay.local_bin.mkdir(parents=True, exist_ok=True)
env = {
"HOME": str(lay.home),
"PREFIX": str(lay.prefix),
"HERMES_HOME": str(lay.hermes_home),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
}
script = textwrap.dedent(
f"""
set -e
source "{NODE_BOOTSTRAP}"
{extra}
_nb_link_bundled_node
echo SENTINEL_OK
"""
)
return subprocess.run(
["bash", "-c", script], env=env, capture_output=True, text=True
)
def test_relinks_and_prunes_stale_hermes_shadows(tmp_path: Path) -> None:
"""node/npm links into the bundle's node dir are pruned; the canonical link
dir gets fresh links; non-hermes links and real files are left alone."""
lay = _layout(tmp_path)
node_bin = _make_bundled_node(lay.hermes_home)
link_dir = lay.link_dir
local_bin = lay.local_bin
# Simulate an old/broken install: hermes-owned shadow links in ~/.local/bin.
local_bin.mkdir(parents=True, exist_ok=True)
(local_bin / "node").symlink_to(node_bin / "node") # hermes → PRUNE
(local_bin / "npm").write_text("#!/bin/sh\n") # real file → KEEP
(local_bin / "npm").chmod(0o755)
nvm_npx = tmp_path / "fake_nvm" / "bin" / "npx"
nvm_npx.parent.mkdir(parents=True)
nvm_npx.write_text("#!/bin/sh\n")
(local_bin / "npx").symlink_to(nvm_npx) # user link → KEEP
result = _run_nb_link(tmp_path)
assert result.returncode == 0, result.stderr
assert "SENTINEL_OK" in result.stdout
# Canonical link dir now has all three pointing into the bundle.
for name in ("node", "npm", "npx"):
link = link_dir / name
assert link.is_symlink(), f"{name} not linked into canonical dir"
assert link.resolve() == (node_bin / name).resolve()
# Stale hermes shadow was pruned; the real file and the nvm link survived.
assert not (local_bin / "node").exists() and not (local_bin / "node").is_symlink(), (
"stale hermes-owned ~/.local/bin/node should have been pruned"
)
assert (local_bin / "npm").is_file() and not (local_bin / "npm").is_symlink(), (
"a real binary must never be removed by the prune"
)
assert (local_bin / "npx").is_symlink() and (local_bin / "npx").resolve() == nvm_npx.resolve(), (
"a user's nvm/fnm link must never be removed by the prune"
)
def test_idempotent_across_repeated_runs(tmp_path: Path) -> None:
"""Running the heal twice converges to the same state (no thrash/dup)."""
lay = _layout(tmp_path)
node_bin = _make_bundled_node(lay.hermes_home)
link_dir = lay.link_dir
first = _run_nb_link(tmp_path)
assert first.returncode == 0, first.stderr
# Second run with the canonical links already in place.
second = _run_nb_link(tmp_path)
assert second.returncode == 0, second.stderr
assert "SENTINEL_OK" in second.stdout
for name in ("node", "npm", "npx"):
link = link_dir / name
assert link.is_symlink()
assert link.resolve() == (node_bin / name).resolve()
def test_prune_failure_does_not_abort_under_set_e(tmp_path: Path) -> None:
"""A non-removable stale shadow (read-only parent dir) must NOT abort the
caller under ``set -e`` the #38889 prune-abort hardening."""
lay = _layout(tmp_path)
node_bin = _make_bundled_node(lay.hermes_home)
local_bin = lay.local_bin
local_bin.mkdir(parents=True)
(local_bin / "node").symlink_to(node_bin / "node") # hermes shadow to prune
# Make the stale dir read-only so unlinking the shadow fails with EACCES
# (non-root cannot unlink in a dir without write perm).
local_bin.chmod(0o555)
try:
result = _run_nb_link(tmp_path)
finally:
local_bin.chmod(0o755) # restore so tmp cleanup can proceed
assert result.returncode == 0, (
"set -e abort regression (#38889): a failing best-effort prune must not "
f"fail the caller.\nstdout={result.stdout}\nstderr={result.stderr}"
)
assert "SENTINEL_OK" in result.stdout
def test_install_sh_prune_is_set_e_safe_static() -> None:
"""Static guard for the same fix in install.sh's link_bundled_node (which
can't be sourced standalone): the stale-prune rm must be guarded and the
function must end with `return 0` so it never trips `set -e`."""
text = INSTALL_SH.read_text()
match = re.search(
r"link_bundled_node\(\)\s*\{.*?\n\}",
text,
re.DOTALL,
)
assert match is not None, "could not locate link_bundled_node() in install.sh"
body = match.group(0)
assert 'rm -f "$stale_dir/$name" 2>/dev/null || true' in body, (
"link_bundled_node prune rm must be `2>/dev/null || true` so a failed "
"unlink under `set -e` doesn't abort the installer (#38889)"
)
assert re.search(r"return 0\s*\n\}", body), (
"link_bundled_node must end with `return 0` so a failing prune is never "
"the function's exit status under `set -e` (#38889)"
)
+8 -2
View File
@@ -100,7 +100,10 @@ class WSTransport:
return not self._closed
except Exception as exc:
self._closed = True
_log.warning("ws write failed peer=%s error=%s", self._peer, exc)
_log.warning(
"ws write failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
return False
async def write_async(self, obj: dict) -> bool:
@@ -115,7 +118,10 @@ class WSTransport:
await self._ws.send_text(line)
except Exception as exc:
self._closed = True
_log.warning("ws send failed peer=%s error=%s", self._peer, exc)
_log.warning(
"ws send failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
def close(self) -> None:
self._closed = True
+34 -3
View File
@@ -603,19 +603,50 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
if (unmounting) {
return;
}
// Surface the real cause to the browser console on every close so a
// "chat won't connect" report can be diagnosed without server access.
// The server sends a machine-parseable reason on every rejection (see
// pty_ws in web_server.py); echo it verbatim alongside the close code.
const why = ev.reason ? ` reason=${ev.reason}` : "";
console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);
if (ev.code === 4401) {
setBanner("Auth failed. Reload the page to refresh the session token.");
setBanner(
ev.reason
? `Auth failed (${ev.reason}). Reload to refresh the session.`
: "Auth failed. Reload the page to refresh the session token.",
);
return;
}
if (ev.code === 4403) {
setBanner("Chat is only reachable from localhost.");
// Host/Origin mismatch (DNS-rebinding guard).
setBanner(
ev.reason
? `Refused: ${ev.reason}.`
: "Refused: request host/origin doesn't match the dashboard.",
);
return;
}
if (ev.code === 4404) {
setBanner(
"Embedded chat is disabled on this server (start it with --tui).",
);
return;
}
if (ev.code === 4408) {
setBanner(
ev.reason
? `Refused: ${ev.reason}.`
: "Refused: your client isn't permitted (server bound to localhost only).",
);
return;
}
if (ev.code === 1011) {
// Server already wrote an ANSI error frame.
return;
}
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
term.write(
`\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`,
);
};
// Keystrokes → PTY.
@@ -429,6 +429,11 @@ Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connect
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard. Overrides `dashboard.oauth.client_id`. Provisioned by the Nous Portal for hosted deploys. |
| `HERMES_DASHBOARD_PORTAL_URL` | OAuth portal URL (default: `https://portal.nousresearch.com`). Override only for staging/custom deploys. |
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. |
| `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | Username for the bundled username/password dashboard-auth provider (`plugins/dashboard_auth/basic`). Activates the provider when set together with a password. Overrides `dashboard.basic_auth.username`. |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | scrypt password hash for the basic provider (preferred — no plaintext at rest). Compute with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Overrides `dashboard.basic_auth.password_hash`. |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` | Plaintext password for the basic provider (hashed in-memory at load). Wins over a config `password_hash` so you can rotate via env. Overrides `dashboard.basic_auth.password`. |
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly for restart-surviving / multi-worker sessions; blank → random per-process. Overrides `dashboard.basic_auth.secret`. |
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | Access-token lifetime for the basic provider (default 12h). Overrides `dashboard.basic_auth.session_ttl_seconds`. |
### Microsoft Graph (Teams Meetings)
@@ -558,6 +558,51 @@ Or pass --insecure to skip the auth gate (NOT recommended on untrusted
networks).
```
### Username/password provider (no OAuth IDP)
If you don't want to wire up an OAuth identity provider — a self-hosted "just put a password on my dashboard" deployment — the bundled `plugins/dashboard_auth/basic` plugin registers a `DashboardAuthProvider` named `basic` that authenticates with a **username and password** instead of an OAuth redirect.
It plugs into the same gate as the OAuth provider: the gate engages on a non-loopback bind without `--insecure`, the login page renders a credential form for this provider (instead of a "Log in with X" button), and everything downstream of login — session cookies, transparent refresh, WS tickets, logout, the audit log — is identical to the OAuth path. Sessions are stateless HMAC-signed tokens the provider mints itself, so there's **no database and no external IDP**. Password hashing uses stdlib `scrypt` (no third-party dependency).
#### Configuration
Like the Nous provider, it reads from `config.yaml` (canonical) with environment variables winning when set non-empty. It activates only when `username` plus either `password_hash` (preferred) or `password` are configured — otherwise it's a no-op, so OAuth users and loopback/`--insecure` operators are unaffected.
**`config.yaml`:**
```yaml
dashboard:
basic_auth:
username: admin
# Preferred — no plaintext at rest. Compute with:
# python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"
password_hash: "scrypt$16384$8$1$…$…"
# ...or a plaintext password (hashed in-memory at load; less safe at rest):
# password: "s3cret"
secret: "<32+ random bytes, base64 or hex>" # token-signing key
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
```
**Environment overrides:**
| Env var | Overrides | Notes |
|---------|-----------|-------|
| `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | `dashboard.basic_auth.username` | required to activate |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | `dashboard.basic_auth.password_hash` | preferred (no plaintext at rest) |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` | `dashboard.basic_auth.password` | plaintext; **wins over a config `password_hash`** so you can rotate via env |
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | `dashboard.basic_auth.secret` | token-signing key |
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | `dashboard.basic_auth.session_ttl_seconds` | access-token lifetime |
:::caution Set an explicit `secret` for stable sessions
When `secret` is empty, a random per-process signing key is generated. That's fine for a single process, but it means **every session is invalidated on restart** and sessions **don't span multiple workers**. Set an explicit `secret` for restart-surviving / multi-worker deployments.
:::
The `/auth/password-login` endpoint is rate-limited per client IP (default 10 attempts/minute → HTTP 429) and returns a single generic `401 Invalid credentials` for both unknown users and wrong passwords, so it can't be used as a username-enumeration oracle.
#### Writing your own password provider
`basic` is just one implementation of an extension point. Any plugin can register a password provider: set `supports_password = True` on your `DashboardAuthProvider` subclass and implement `complete_password_login(*, username, password) -> Session` (raise `InvalidCredentialsError` on rejection, `ProviderError` if your backing store is down). The OAuth `start_login` / `complete_login` methods can be left as `NotImplementedError` stubs for a pure-password provider. This is the path for LDAP-bind, a credentials database, or any other non-redirect auth scheme — the framework handles the form, the route, the cookies, and refresh for you.
### Public URL override
By default, the dashboard reconstructs the OAuth callback URL from the request — `X-Forwarded-Host` + `X-Forwarded-Proto` + `X-Forwarded-Prefix` (when uvicorn is configured with `proxy_headers=True`, which `start_server` enables under the gate). This works out of the box on Fly.io, which sets all three headers correctly.