Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson ffe5558bd0 docs(desktop): clarify macOS Gatekeeper first-open (no Apple Developer account needed)
Users hitting macOS Gatekeeper's "unidentified developer" dialog on first
launch sometimes read it as a request for an Apple Developer account or
"developer password." The desktop builds are signed + notarized, so no such
account is required — but the docs had zero macOS first-open guidance to say so.

- Add a Troubleshooting subsection to the Desktop App page explaining that
  Gatekeeper is not an account requirement, with right-click->Open and
  Privacy & Security "Open Anyway" steps, and a note that any password macOS
  asks for is the user's own Mac login password, not a developer password.
- Add a macOS note to the Installation page's recommended-installer step
  cross-linking to that section.
2026-06-05 09:15:32 -05:00
adybag14-cyber af8b917dab fix(termux): scope frontend npm installs 2026-06-05 06:56:51 -07:00
Teknium 9ca11b35d5 perf(/model): prewarm picker provider-models cache in background (#39847)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* perf(/model): prewarm picker provider-models cache in background

The no-args /model picker calls list_authenticated_providers(), which
fetches each authenticated provider's live /v1/models list serially. On a
cold or stale (>1h TTL) cache that blocks ~1.5s on the user's critical path
the first time /model is opened in a session.

Warm that exact path off-thread during the idle window right after the CLI
banner is shown: a once-per-process daemon thread runs
list_authenticated_providers() to populate provider_models_cache.json for
every authed provider. By the time the user types /model, the picker hits
the warm disk cache (~136ms vs ~1500ms).

Process-level Event guard (mirrors run_agent's _openrouter_prewarm_done)
ensures at most one thread per process; fully exception-isolated so an
offline/no-creds provider can never affect the session.
2026-06-05 06:55:09 -07:00
Teknium ca1fb32c26 docs: remove --include-desktop install instructions (#39762)
* docs: remove --include-desktop install instructions

Drop the --include-desktop curl one-liner from the desktop app docs.
The flag remains in scripts/install.sh; these docs now point to the
desktop installer / website and the 'hermes desktop' path instead.

* docs: remove --include-desktop from install docs

Drop the redundant 'Hermes Desktop installer on Linux' block (which
used --include-desktop) from quickstart, installation, and index docs.
The website installer covers macOS/Windows desktop; the CLI-only path
covers Linux. Removes the flag from all user-facing docs.
2026-06-05 06:53:58 -07:00
Teknium 7583aedacd fix(completion): remove /model <arg> autocomplete from CLI/TUI (#39727)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(completion): remove /model <arg> autocomplete from CLI/TUI

The TUI frontend already suppressed /model argument completion in favor of
the two-step ModelPicker (useCompletion.ts), but the CLI prompt_toolkit
completer and the gateway-backed complete.slash RPC (TUI + desktop) still
emitted model aliases and probed LM Studio on every keystroke.

Drops the /model branch in SlashCommandCompleter.get_completions, the
_model_completions method, and the LM Studio probe/cache helper that only
fed it. Command-name completion (/mod -> model) and sibling arg completers
(/skin, /personality) are untouched. Removes the now-dead TestModelTabCompletion
tests.
2026-06-05 06:43:51 -07:00
brooklyn! 14fee4f112 fix(update/windows): retry handoff hermes update once on first-run crash (#39831)
The in-app updater (Hermes-Setup --update) runs `hermes update`, which lazily
imports the freshly-pulled modules — but the dependency-install step runs the
already-in-memory PRE-pull code for one invocation. When a release changes an
updater-path contract across that boundary, the FIRST update on the parked
population crashes even though the fix is already on disk.

Concretely this is #39780's `_UvResult`: its `__iter__` yields (path, bool), so
Windows `subprocess.list2cmdline([uv_bin, "pip", ...])` injects the bool and
dies with `TypeError: sequence item 1: expected str instance, bool found`
(fixed in #39820). A parked Windows user clicking Update pulls #39820 to disk,
then still crashes on the in-memory pre-merge module; only the SECOND click runs
clean. Field repro: ryanc's bootstrap.log (2026-06-05 12:41:41).

Fix: when the first `hermes update` exits non-zero (and it isn't the
concurrent-instance guard, exit 2, which a retry can't fix), retry once
automatically. The retry loads the now-current module from the start and
succeeds — so the parked user gets a working one-click update instead of a
scary crash + manual second attempt.

Verified: cargo check clean.
2026-06-05 08:37:16 -05:00
brooklyn! 98528c78c1 fix(desktop/windows): stop racing our own backend during in-app update (#39828)
* fix(desktop/windows): stop racing our own backend during in-app update

The Windows in-app update (Update button -> hermes-setup.exe --update handoff)
bricked because it raced a still-locked hermes.exe: the desktop quit
fire-and-forget without reaping its backend child + grandchildren, so when
the updater ran `hermes update`, the venv shim was still open. The quarantine
rename then failed, uv's `pip install -e .` hit "Access is denied", the git
path bailed to a full ZIP re-download, and the deps still couldn't write the
locked shim -- leaving a half-applied install. macOS is fine because it never
blocks REPLACE on a running executable.

Three coordinated fixes restore Mac-style parity (click Update -> progress ->
relaunch, no terminal):

A. Desktop (main.cjs): before spawning the updater, releaseBackendLockForUpdate()
   tree-kills the primary + pool backends (taskkill /T /F on Windows, to catch
   REPL/pty/gateway grandchildren that SIGTERM misses) and polls the venv shim
   until it is actually writable (bounded 15s) -- so the lock is gone before we
   hand off. Also fixes resolveHermesCliBinary to use venv\Scripts\hermes.exe on
   Windows.

B. Updater (update.rs): wait_for_venv_free no longer "proceeds anyway" on
   timeout -- it force-kills any lingering hermes.exe (excluding itself) and
   re-checks, so a straggler can't doom the install.

C. Updater (update.rs): pass --force to `hermes update`. By contract the desktop
   has exited + waited, and the wait force-kills stragglers, so the running-exe
   guard would only produce a false "Hermes is still running" dead-end.

Verified: node --check on main.cjs, cargo check on the updater (clean), and the
Windows-gated taskkill body type-checks standalone. Field repro: ryanc's
update.log (manual + handoff both hit the same lock cascade).

* review: scope backend kill+wait to Windows; drop meaningless POSIX pgid kill
2026-06-05 08:33:53 -05:00
brooklyn! d880b5be09 fix(update/windows): don't return _UvResult on Windows (subprocess argv crash) (#39820)
PR #39780 made ensure_uv() return a _UvResult — a str subclass whose
__iter__ yields (path, fresh_bootstrap) so old `uv_bin, fresh = ensure_uv()`
call sites survive the update boundary. That trick is unsafe on Windows.

The dependency installer passes uv straight into the command list
(`[uv_bin, "pip", "install", ...]`). On Windows, subprocess serializes argv
via subprocess.list2cmdline, which iterates every entry *as a string*
(`for c in arg`). Because _UvResult overrides __iter__, that iteration yields
(path, fresh_bootstrap) instead of characters, injecting the bool into the
command line and crashing the first update with:

    TypeError: sequence item 1: expected str instance, bool found

This bites the common single-assignment caller (`uv_bin = ensure_uv()`) on
its first update after #39780: the freshly pulled _UvResult flows into the
old in-memory call site and into the argv. Reported in the field on a
~10-commits-behind Windows install.

A single return value cannot satisfy both legacy 2-target unpacking and
Windows char-iteration — both use the iterator protocol with contradictory
results. So gate the wrapper to POSIX: Windows returns a plain str/None
(the historical, subprocess-safe contract). POSIX keeps _UvResult and the
#39780 update-boundary fix.

Tests: list2cmdline canary proving _UvResult breaks Windows, plus Windows
returns-plain-str and POSIX dual-contract coverage.
2026-06-05 07:54:08 -05:00
brooklyn! ca8c78e588 fix(desktop): heal stale runtime-id cache + model on profile switch (#39819)
Two switch-time regressions from the multi-profile rail work:

- "Session not found" (4007): pruneSecondaryGateways idle-reaps a
  non-active profile's backend; switching back respawns a *fresh*
  backend that mints new runtime ids, but runtimeIdByStoredSessionId is
  never pruned. resumeSession's cache fast-path then makes a dead runtime
  id active and returns, so session.usage + the next prompt 404. Probe
  the cached id; on rejection drop the stale mapping and fall through to
  a full resume that rebinds a live id.

- "Forgets the LLM setting": $currentModel is a nanostore set only by
  refreshCurrentModel (gatewayState->open, etc). A swap fires
  invalidateQueries() (react-query only) and keeps the socket 'open', so
  the model/pill kept showing the previous profile. Re-pull both when
  $activeGatewayProfile changes.
2026-06-05 12:52:44 +00:00
brooklyn! 1a3e608524 feat(desktop): per-profile remote gateway hosts (#39778)
* feat(desktop): per-profile remote gateway hosts

Profile switching silently failed whenever the desktop was connected to a
remote backend: the rail routed non-active profiles to a local pool backend,
but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a
remote Hermes backend", and the renderer swallowed the error into an infinite
reconnect backoff while still marking the profile active. Remote was also a
single app-global setting, so there was no way to give a profile its own host.

Add per-profile remote hosts so each profile can point at its own backend:

- connection.json gains a validated `profiles` map; profileRemoteOverride()
  (pure, unit-tested) selects an explicit per-profile remote.
- resolveRemoteBackend(profile) precedence: per-profile override → env override
  → global remote → local spawn. spawnPoolBackend now connects to a profile's
  remote (no local child) instead of throwing; startHermes resolves the primary
  profile's remote.
- coerce/sanitize connection config are scope-aware (global vs named profile)
  and preserve each other's entries; IPC get/save/apply/test thread an optional
  profile. Per-profile apply drops only that profile's pool backend.
- Settings → Gateway adds an "Applies to" scope selector reusing the existing
  URL/token/OAuth/test UX per profile.

Tests: connection-config pure suite (+6) and desktop platform suite pass;
tsc/eslint/vitest clean.

* refactor(desktop): DRY per-profile remote helpers

Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the
main.cjs copy), collapse the scope/auth ternaries, route the env remote through
buildRemoteConnection, and fold the duplicated remote-block validation into
buildRemoteBlock. No behavior change; pure suite + live E2E still green.
2026-06-05 12:14:18 +00:00
brooklyn! db204ae203 fix(update): make ensure_uv() survive the update boundary (no first-run crash) (#39780)
* fix(update): make ensure_uv() survive the update boundary (no first-run crash)

`hermes update` runs the `ensure_uv()` call site from the old, already-imported
`hermes_cli.main` against the *freshly pulled* `managed_uv` (managed_uv is only
ever lazily imported, so it loads from disk post-pull). `ensure_uv()`'s return
arity flipped from a single path string to `(path, fresh_bootstrap)` (4df280d51)
and back to a single string (fb853a178). Installs parked on a 2-tuple release
unpack `uv_bin, fresh_bootstrap = ensure_uv()` against the new single-value
module and crash the first update with
`ValueError: not enough values to unpack (expected 2, got 1)` — inside the
dependency-install step, *before* the PR #39763 subprocess hand-off can run.

Return a `_UvResult` (a `str` subclass) that is usable as the bare path AND
unpackable as `(path|None, fresh_bootstrap)`. Missing uv is `""` (falsy) instead
of `None` so legacy 2-target call sites can unpack a failure without raising,
while `if not uv_bin` keeps working for single-value callers. fresh_bootstrap is
always False (the rebuild-venv path it gated was scrapped in fb853a178).

* docs(update): correct the verified error string + mechanism for ensure_uv()

A hermetic repro (old 2-target call site vs the freshly-pulled single-value
module) shows the first-update crash is exactly the string from PR #39763's
report: `ValueError: too many values to unpack (expected 2)` — not "not enough".
The returned path is a plain `str`, which is iterable, so `uv_bin, fresh =
ensure_uv()` walks its characters; the failure path's `None` return raises
`TypeError: cannot unpack non-iterable NoneType`. Both are fixed by `_UvResult`.
Comment/test wording updated to match; no behavior change.
2026-06-05 07:08:43 -05:00
Tekniumandalt-glitch 72eb42d9ec feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean"

This reverts commit c8e80cd0bf.

* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"

This reverts commit 8a19884bf3.

* chore(install): keep npm ci desktop-build fix after stash revert

The destructive-clean reverts (#38542/#39568) pulled the desktop
workspace install back to bare `npm install`. The npm ci -> npm install
fallback is orthogonal build-correctness (avoids the Windows
workspace-hoisting flake where install reports up-to-date against a
stale marker while node_modules is empty, breaking tsc -b). Preserve it.

* feat(update): settable stash-or-discard for non-interactive local changes

Adds updates.non_interactive_local_changes (stash | discard, default
stash). Governs ONLY non-interactive updates (desktop/chat app, gateway,
--yes) — interactive terminal updates always stash-and-ask, unchanged.

- config.py: new key under existing updates section; _config_version 26->27.
- main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY),
  reads the setting; new _discard_stashed_changes() drops the stash
  (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive).
  Post-pull restore site branches on it; the bail-out and up-to-date
  restores always preserve work.
- web_server.py + apps/desktop settings: exposes it as a stash/discard
  select (Advanced section, In-App Update Local Changes).
- docs + tests (discard drops, stash restores, interactive ignores setting,
  missing section defaults to stash).

* fix(install.ps1): stash/restore instead of reset --hard on Windows update

The PR reverted the destructive update path to stash/restore everywhere
except scripts/install.ps1, whose managed-clone update path still ran
`git reset --hard HEAD` before checkout — silently destroying agent-edited
tracked source on Windows (the same #38542 data-loss class the PR fixes).

- Replace `git reset --hard HEAD` with stash-before-checkout +
  restore-after-checkout, mirroring install.sh. Untracked files are
  included so agent-created dirs (e.g. tinker-atropos/) survive.
- Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made
  the stash necessary; it's also load-bearing for a clean restore).
- Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses
  `git pull --ff-only` so local commits are never clobbered.
- Only prompt to restore when a real console is attached (UserInteractive
  + non-redirected stdin/stdout + ConsoleHost); the desktop Update button
  and bootstrap have no usable console, so they default to restore and
  never hang on Read-Host.
- On restore conflict or a failed update, the stash is preserved with
  recovery instructions — work is never silently dropped.

Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean;
E2E non-conflicting restore applies+drops cleanly with ignored paths
(node_modules) untouched; conflicting restore preserves the stash.

---------

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-06-05 17:30:10 +05:30
Teknium 947e21b3d6 fix(gateway): log silent file-delivery drops (#39767)
When the agent's reply references a deliverable file path that does not
exist on disk, extract_local_files dropped it from native delivery with
no log line — the most common reason a promised file never arrives over
a messaging platform. Add an INFO log at that drop point so the gap is
visible in gateway.log instead of vanishing.

Also convert the two print() calls in Telegram's send_document /
send_video exception handlers to logger.warning(exc_info=True). print()
writes to stdout, which 'hermes logs' never captures, so outbound upload
failures (oversized files, Bot API rejections) were invisible.
2026-06-05 04:50:04 -07:00
Teknium d41427504e feat(delegation): uncap max_spawn_depth (floor 1, no ceiling) (#39772)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* feat(delegation): uncap max_spawn_depth to match max_concurrent_children

Removed the hard ceiling of 3 on delegation.max_spawn_depth. Depth now has
a floor of 1 and no upper limit, mirroring max_concurrent_children. Cost
(each level multiplies API spend) is the practical limiter, not a constant.

- delegate_tool.py: drop _MAX_SPAWN_DEPTH_CAP, _get_max_spawn_depth() floors
  at 1 instead of clamping to [1,3]; depth-limit error string reworded
- config.py / cli-config.yaml.example: doc comments say floor 1, no ceiling
- docs (configuration, delegation, delegation-patterns): range 1-3 -> >=1
- tests: convert clamp-above-3 change-detector into a no-ceiling invariant,
  drop the _MAX_SPAWN_DEPTH_CAP==3 snapshot assert, fix warning-text assert
2026-06-05 04:46:02 -07:00
Teknium 06268f11cc feat(gateway): explain /voice usage when toggled bare (#39766)
A bare /voice silently toggled on/off with a one-line result, leaving
users with no idea what the modes mean or that Discord also supports
TTS-all and live voice-channel join/leave. Bare /voice now still
toggles but appends a usage explainer covering on/off/tts/status, with
the Discord voice-channel lines shown only on adapters that support
them.

Adds gateway.voice.help + gateway.voice.help_channels across all 16
locales (placeholders {toggle}/{channels}).
2026-06-05 04:21:13 -07:00
Frowtek 3cd1bd971f fix(cli): require Chromium for local browser readiness in setup/status surfaces 2026-06-05 04:06:17 -07:00
Teknium ec46f5912e fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints (#39730)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints

Two distinct failures hit users on the gemini provider with only Google
AI Studio keys set.

1. Truncation loop: build_gemini_request() only set maxOutputTokens when
   max_tokens was non-None. Hermes passes None to mean "unlimited", but
   Gemini's native generateContent does NOT treat an absent maxOutputTokens
   as full budget — it applies a low internal default and stops early with
   finishReason=MAX_TOKENS, truncating tool calls. The agent then retries
   3x and refuses the incomplete call. Now default to the published 65,535
   ceiling (shared by all current Gemini text models) when max_tokens=None.

2. HTTP 400 on Gemini endpoint: the chat_completions transport assembles
   profile extra_body (Nous portal 'tags', reasoning, provider prefs) and
   sends it via the OpenAI client to whatever base_url is resolved. When a
   profile that emits extra_body (e.g. Nous) is active but the endpoint is a
   native Gemini base_url — typical when only Google creds exist and a
   fallback/aux call lands on Gemini — Google rejects the unknown 'tags'
   field with a non-retryable 400. Strip all non-thinking_config extra_body
   keys when the resolved endpoint is native Gemini.

Verified E2E against real transport code: tags stripped on native Gemini,
preserved on Nous and the /openai compat endpoint; maxOutputTokens=65535
on None, explicit values respected.
2026-06-05 03:53:59 -07:00
Shannon SandsandTeknium 6bf55a473e Add CLI Telegram QR onboarding
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-05 03:20:10 -07:00
Teknium 8a9ded5b21 feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS (#39659)
* feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS

Discord voice mode can now feel conversational: the bot speaks a short
acknowledgement before it starts working, and a subtle ambient 'thinking' bed
plays underneath while tools run, ducking under speech and swelling back — the
Grok-voice-mode feel.

discord.py plays only one audio stream per voice connection, so this adds a
software mixer (VoiceMixer, a discord.AudioSource) installed once per guild on
join. It sums an ambient loop, verbal acks, and TTS replies into that single
20ms/48kHz/stereo stream (numpy int16 add + clip), so they overlap instead of
stop-and-swap. Speech ducks the ambient gain down and releases it smoothly.

- plugins/platforms/discord/voice_mixer.py: VoiceMixer + MixerChild (gain,
  loop, fade, duck/release), decode_to_pcm (ffmpeg), synth_ambient_pcm (no
  asset needed — synthesised pad).
- adapter: install mixer on join, tear down on leave, route
  play_in_voice_channel through the mixer (legacy one-shot path kept as
  fallback), play_ack_in_voice, voice_mixer_active. Defensive getattr for the
  object.__new__ test helpers.
- gateway/run.py: tool_start_callback fires a one-time verbal ack on the first
  tool call of a turn when in a voice channel (independent of the text
  tool-progress gate). No system-prompt or message-flow changes.
- config: discord.voice_fx.* (OFF by default; ambient/duck/speech gains, ack
  phrases). All in config.yaml, not .env.
- docs + tests (mixer unit + adapter integration).

Verified: 19 new tests pass, existing voice suite green (2 pre-existing
davey-module env failures unchanged), and a real-mixer E2E confirms ambient
streams, TTS overlaps it, acks layer in, and teardown is clean.

* fix(discord): make voice mixer numpy import lazy (numpy is voice-extra-only)

numpy ships in the optional 'voice' extra, not [all,dev], so a module-level
'import numpy' broke CI test collection (and would break the always-imported
Discord adapter on any install without the voice extra). Defer numpy to the
functions that actually mix audio via _require_numpy(); guard the test module
with pytest.importorskip('numpy').
2026-06-05 03:10:40 -07:00
teknium1 3da44dbda7 fix(models): use deepseek-v4-flash as Nous silent default
Follow-up on the salvaged fix: point the Nous silent-default override at
deepseek/deepseek-v4-flash (a cheap chat model) instead of the nvidia
nemotron entry. Keeps the no-model-configured fallback off the priciest
flagship while landing on a low-cost, broadly-capable default.
2026-06-05 02:54:34 -07:00
xxxigm ef5e48f3fd test(models): guard Nous silent default against expensive-flagship escalation
Assert get_default_model_for_provider("nous") never returns the priciest
catalog entry (anthropic/claude-opus-4.8) and that an override pointing at a
model absent from the catalog falls back to catalog order. Regression for the
silent flagship-billing footgun.
2026-06-05 02:54:34 -07:00
xxxigm 2a82519b0d fix(models): don't silently default Nous to the most expensive flagship
When a provider is configured but no model is selected (e.g. a profile sets
provider: nous with no model), the gateway/CLI fall back to
get_default_model_for_provider(), which returned the first curated catalog
entry. The Nous Portal list is ordered most-capable-first, so entry [0] is
anthropic/claude-opus-4.8 — the single most expensive model ($5/$25 per Mtok).
A misconfigured profile therefore silently routed every call to the flagship
and billed it for traffic the user never opted into.

Pin the silent (non-interactive) default for metered aggregators to the cheapest
curated tier via _PROVIDER_SILENT_DEFAULT_OVERRIDES so a missing model can never
auto-escalate to the flagship. The interactive default (GUI onboarding /
`hermes model`) keeps using the richer free/paid-tier-aware resolver.

Fixes the unexpected anthropic/claude-opus-4.8 charges reported for a
free-tier Nous account whose new profile had no default model.
2026-06-05 02:54:34 -07:00
teknium1 397d492b3e chore(release): map harjoth.khara@gmail.com → harjothkhara for #38550 salvage 2026-06-05 02:54:32 -07:00
harjoth b459bac02c fix(cli): gitignore Desktop bootstrap marker so hermes update stops autostashing it
The Desktop bootstrap installer writes `.hermes-bootstrap-complete` into the
managed git checkout root. Because it wasn't gitignored, `hermes update`'s
`git stash push --include-untracked` treated it as a local change and created an
autostash on every run — prompting the user to restore "local changes" that were
really Hermes-managed runtime state (and risking the marker getting stranded in a
stash, which re-triggers Desktop bootstrap).

Add the marker to .gitignore; `git stash -u` and `git status --porcelain` both
skip ignored files, so the updater now sees a clean tree.

Fixes #38529
2026-06-05 02:54:32 -07:00
Coy Geek 3278b423d5 fix(dashboard): strip session token from subprocess env
Add HERMES_DASHBOARD_SESSION_TOKEN to the Hermes-managed subprocess environment blocklist so dashboard authorization material does not propagate into shell, PTY, or background process launches.

Extend the local environment blocklist regression coverage to prove the dashboard session token is stripped like other Hermes-managed secrets.
2026-06-05 02:31:19 -07:00
Ben Barclay 9ab9c923da docs(dashboard): clarify auth provider suitability + registration across dashboard/Docker/Desktop docs (#39633)
* docs(dashboard): clarify auth provider suitability + document dashboard registration

- Add a 'Registering a dashboard' subsection under the Nous Research
  provider covering both the 'hermes dashboard register' CLI command
  and the Portal /local-dashboards GUI page.
- Note that the Nous provider is the one suitable for public-internet
  exposure (logins verified against your Nous account).
- Add a warning that the username/password provider is for trusted
  networks / VPN only and is not suitable for direct public-internet
  exposure; point readers to the Nous / OIDC / custom OAuth providers.
- Surface the same distinction in the two-provider intro list.

* docs(dashboard): count three bundled auth providers, add self-hosted OIDC to intro

'Two providers ship in the box' undercounted — the bundled
plugins/dashboard_auth/self_hosted (generic OpenID Connect) is a third.
List all three in the gated-mode intro and link each to its section.

* docs(dashboard): extend auth provider updates to Docker and Desktop pages

- docker.md: list all three bundled gate providers (was username/password
  + OAuth only), adding the self-hosted OIDC provider and its env vars,
  and note username/password is not for public-internet exposure.
- desktop.md: reframe the remote-backend connection so OAuth (Nous Portal)
  is the preferred option for any backend reachable beyond the local
  machine, with username/password positioned for local / trusted-network
  use only. Cover the 'Sign in with <provider>' OAuth flow in the in-app
  steps and scope the VPN warning to the password path.

* docs(dashboard): align env-var, CLI, and remote-Desktop recipe with provider changes

- environment-variables.md: reframe the Web Dashboard & Hermes Desktop
  intro (OAuth preferred for remote/public, username/password for
  trusted networks), add the self-hosted OIDC env vars
  (HERMES_DASHBOARD_OIDC_*) that were missing from the table, and note
  hermes dashboard register provisions the OAuth client_id.
- cli-commands.md: document the 'hermes dashboard register' subcommand
  (flags, behavior, /local-dashboards GUI alternative).
- web-dashboard.md: apply the OAuth-preferred reframe to the bottom
  'Connecting Hermes Desktop to a remote backend' recipe and scope its
  VPN warning to the username/password path, matching desktop.md.

* docs(dashboard): move 'recommended remote Desktop path' framing from username/password to OAuth

The gated-mode intro list claimed the username/password provider was the
recommended path for a remote Hermes Desktop connection, contradicting the
OAuth-preferred framing established elsewhere. Move that recommendation onto
the OAuth (Nous Portal) item so the docs are consistent: OAuth is the
recommended provider for any remote/internet-facing backend; username/password
is for trusted networks only.

* docs(dashboard): drop unreleased managed/hosted-install provisioning notes

Remove the 'not available in managed/hosted installs, where the client id is
provisioned by the hosting platform' line from the dashboard register docs
(web-dashboard.md, cli-commands.md) and the 'provisioned by the Nous Portal for
hosted deploys' clause from the HERMES_DASHBOARD_OAUTH_CLIENT_ID env-var row —
that platform-provisioning path is unreleased.

* docs(dashboard): drop --portal-url / HERMES_DASHBOARD_PORTAL_URL from user docs

The portal-URL override targets a non-production Nous Portal and only works
for internal Nous usage — it won't function for end users (the access token
must be issued by the same portal). Remove it from the register CLI flags,
the Nous-provider config/env tables, and the verify-the-gate example so users
aren't pointed at an option that can't work for them.

* docs(dashboard): add worked examples for Nous and username/password providers

The self-hosted OIDC provider already had a full 'Worked example: Keycloak'
walkthrough; the Nous and username/password providers only had scattered
config snippets. Add parallel '#### Worked example' sections for both
(register/run/login + /api/status verification), mirroring the Keycloak
example's structure so all three bundled providers read consistently.

* docs(env): move HERMES_DESKTOP_REMOTE_URL to end of the dashboard auth table

It was sitting between the HERMES_DASHBOARD_BASIC_AUTH_* block and the
HERMES_DASHBOARD_OAUTH/OIDC block, splitting the dashboard-side vars. As the
only desktop-side var in the table, it belongs at the end so the dashboard
provider vars (basic, OAuth, OIDC) stay grouped together.

* docs(dashboard): remove Fly.io references from dashboard auth docs

Fly.io is the internal hosting implementation for hosted Hermes — it shouldn't
leak into user-facing dashboard auth docs. Reword the OAuth provider intro,
the env-var-path rationale, the public-URL-override section, the cookie Secure
note, and the verify-the-gate example to generic 'hosting platform' / 'reverse
proxy' / 'TLS terminator' phrasing.

Left the legitimate user-facing Fly.io mentions in telegram.md (a deliberate
cloud-deployment walkthrough) and work-with-skills.md (a generic example)
untouched.
2026-06-05 18:34:19 +10:00
AceanandTeknium b0d234f068 fix(cron): don't crash on cron list when a job's repeat is null
`cron_list` read `job.get("repeat", {})`, but the dict-default only
applies to a MISSING key. A one-shot job persisted with `"repeat": null`
returns None, and the next `.get("times")` raised AttributeError, taking
down the whole `cron list` output. Coalesce with `or {}` so a
present-but-null repeat renders as ∞ like the other cron readers already
do. Adds a regression test.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-05 00:19:45 -07:00
helix4u c8e80cd0bf fix(update): require managed marker before destructive clean 2026-06-05 00:05:30 -07:00
Baris SencanandTeknium ad69d3edc7 fix(terminal): guard os.getcwd() against a deleted CWD
`os.getcwd()` raises FileNotFoundError when the process's working
directory was removed out from under it (e.g. a scratch workspace
cleaned up mid-session), crashing terminal env setup.

Extract a `_safe_getcwd()` helper that falls back to TERMINAL_CWD, then
the user's home, on FileNotFoundError, and route all three `os.getcwd()`
call sites in terminal_tool.py through it (local default_cwd, the Docker
cwd-passthrough source, and the debug-config print) so the same crash
can't resurface at a sibling site. Adds unit tests for the real-cwd path
and both fallback branches.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-04 23:39:34 -07:00
Ben Barclay b1e399de95 fix(update-check): stop reporting phantom "N commits behind" inside Docker (#39559)
Inside the published Docker image, both the `--tui` banner and the
dashboard-embedded TUI report `1 commit behind — run docker pull
nousresearch/hermes-agent:latest to update` even though the container
has no git repo and no way to compute a commit delta.

Root cause: two independent update-detection paths, only one of which
knows it's running in Docker.

- `recommended_update_command()` → `detect_install_method()` reads the
  `.install_method` stamp that `docker/stage2-hook.sh` writes at boot →
  returns "docker", so the *command string* correctly says `docker pull`.
- `banner.check_for_updates()` (the source of the "N commits behind"
  *count*) has no notion of the docker install method. It only detects a
  build via `HERMES_REVISION` (nix-only, unset in the image) or a `.git`
  dir (excluded from the image by .dockerignore). Neither matches, so it
  silently falls through to `check_via_pypi()`, whose PyPI-version
  mismatch flag (1) is then rendered verbatim by the CLI banner
  (build_welcome_banner), the Ink TUI badge (branding.tsx), and `hermes
  version` as "1 commit behind" — a phantom count, no commit math
  involved. `hermes update` already refuses to run in-place in the
  container.

The dashboard's REST `/api/hermes/update/check` endpoint already
short-circuits docker (returns behind=None + the docker guidance). This
mirrors that guard inside `check_for_updates()` so the banner/TUI/version
surfaces agree: when `detect_install_method() == "docker"`, return None
before any git/pypi probe (and before writing a cache entry). None makes
the render guards (`typeof === 'number' && > 0`, `behind and behind > 0`)
stay false, so the badge/line disappears entirely — matching the System
page.

Fix is in one place (check_for_updates) because all three consumers route
through it via get_update_result()/_update_result.

Tests: test_check_for_updates_docker_returns_none asserts None + no
git/pypi probe + no cache write; test_check_for_updates_non_docker_still_checks
guards against over-broadening (pip still version-checks). Mutation-tested:
removing the guard fails the docker test.

Verified against a real `docker build` of the image — see PR description.
2026-06-05 15:37:19 +10:00
Ben 439f53cab8 fix(desktop): gate OAuth remote connect on AT-or-RT, not access token alone
The desktop OAuth remote-gateway path gated connectivity on
hasOauthSessionCookie(), which checks only the access-token cookie
(hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age
lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend()
and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing
a full IDP re-login every ~15 min — even though a valid 24h refresh-token
cookie (hermes_session_rt) was sitting in the same jar.

The desktop OAuth code (2026-06-04) was written against the obsolete
"contract v1 issues no refresh token" model, two days after #37247
re-introduced server-side transparent refresh: Portal now issues a 24h
rotating, reuse-detected refresh token, and the gateway middleware
(_attempt_refresh) rotates a fresh AT from the RT on the next
authenticated request. So an expired-AT/live-RT session is fully
connectable — the desktop just never let the request through.

Fix:
- connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession()
  (true when EITHER a live AT or RT cookie is present). Keep
  cookiesHaveSession() AT-only for callers that need that specific signal.
- main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth
  branch now early-outs only when NEITHER cookie is present, otherwise
  uses the ws-ticket mint as the authoritative liveness probe (that POST
  carries the RT cookie and triggers the server-side AT rotation). A real
  401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout
  report against the same AT-or-RT notion.
- Remove the stale "contract v1 / NO refresh token" docstrings in
  cookies.py and the verify_session comments in the Nous provider that
  contradicted #37247.

Tests: +57 lines in connection-config.test.cjs covering the RT-only
"still connectable" case. node --test: 32/32. dashboard-auth +
nous-provider Python suites: 223/223.

Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/)
are comment/docstring-only here, but this touches outside apps/desktop/ so
it needs Teknium review.
2026-06-04 22:18:46 -07:00
Brian DohertyandTeknium 899ee8c23d fix(gateway): tolerate non-UTF-8 status/pid files in gateway status reads
`_read_json_file` caught OSError but not UnicodeDecodeError, so a status
file holding binary/non-UTF-8 bytes (truncated or clobbered write) would
crash the gateway status path instead of being treated as unreadable.
UnicodeDecodeError is a ValueError subclass, not an OSError, so it
escaped the existing guard.

Widen the catch to (OSError, UnicodeDecodeError) at both read sites in
gateway/status.py — `_read_json_file` and the sibling `_read_pid_record`,
which had the identical gap. Adds tests covering binary input (returns
None) and valid input (still parses) for both.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-04 22:05:23 -07:00
TekniumandSahibzada Allahyar 7309f3bef7 fix(line): map inbound message types to the correct MessageType
The LINE adapter classified every non-text inbound message as
`MessageType.IMAGE`, which doesn't exist on the enum — so any image,
video, audio, file, sticker, or location message raised AttributeError
the moment it was constructed.

Beyond fixing the crash, every non-text message was being collapsed onto
a single type. The gateway routes on MessageType (voice → STT, files →
document handling, etc.), so misclassification silently mishandled media.
Replace the inline ternary with a `_LINE_MESSAGE_TYPES` lookup that maps
each LINE webhook type to its proper enum member (audio → VOICE to match
how Telegram/WhatsApp treat voice notes), falling back to TEXT for
unknown types. Adds regression tests covering the mapping and the old
AttributeError.

Co-authored-by: Sahibzada Allahyar <94376830+sahibzada-allahyar@users.noreply.github.com>
2026-06-04 21:55:20 -07:00
Ben 736dc0fd86 fix(nix): use fetchNpmDeps hash for npmDepsHash, not prefetch-npm-deps
The previous fix committed the hash from `prefetch-npm-deps`
(sha256-hgnqc...), but the actual `fetchNpmDeps` FOD (fetcherVersion 2)
that `nix flake check` builds wants sha256-cY+gM... . These two tools
disagree for this lockfile, so the build's npm-deps derivation failed
with a hash mismatch even though `fix-lockfiles --check` reported "ok".

Corrected to the build-verified value. Confirmed `nix build .#tui`,
`.#web`, and `.#desktop` all build cleanly with the new hash.
2026-06-04 21:30:23 -07:00
teknium1 6b77fd2a0f fix(nix): bump npmDepsHash for react-router 7.17.0 lockfile
The react-router-dom 7.14->7.17 lockfile change stales the pinned
npm-deps hash in nix/lib.nix, turning the nix flake checks red. Bump
to the hash CI's prefetch diagnostic computed for the new lockfile.
2026-06-04 21:30:23 -07:00
Ben 46c16b9288 fix(deps): bump react-router-dom to 7.17.0 (GHSA-8x6r-g9mw-2r78)
Clears the npm-audit React Router advisory CVE-2026-42342 in the web
and apps/desktop workspaces by bumping react-router-dom 7.14.x -> ^7.17.0
(patched in 7.15.0; both react-router and react-router-dom now resolve
to 7.17.0 in the root lockfile).

Note: the advisory's DoS only affects React Router *Framework Mode*
(the __manifest server endpoint). Both workspaces use Declarative Mode
(web: <BrowserRouter>, desktop: <HashRouter>) as pure client-side SPAs,
so we were never actually exploitable -- this is audit-hygiene only.

npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds
and tsc typecheck all green on 7.17.0.
2026-06-04 21:30:23 -07:00
102 changed files with 4555 additions and 1065 deletions
+6
View File
@@ -108,6 +108,12 @@ docs/superpowers/*
# logs, and per-session caches are never artifacts of the codebase.
.hermes/
# Desktop/bootstrap install marker written into the managed checkout root by the
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
+55
View File
@@ -2720,6 +2720,61 @@ def run_conversation(
# compress history and retry, not abort immediately.
status_code = getattr(api_error, "status_code", None)
# ── Respect disabled auto-compaction on overflow ──────
# Ported from anomalyco/opencode#30749. When the user has
# turned auto-compaction off (``compression.enabled: false``),
# NO automatic compaction trigger may fire — including the
# provider/request-size overflow recovery paths below
# (long-context-tier 429, 413 payload-too-large, and
# context-overflow). Without this guard the proactive
# threshold path correctly honours the setting (see the
# preflight check and the post-response ``should_compress``
# gate) but a provider overflow error would still silently
# compress + rotate the session, bypassing the user's
# explicit choice. Surface a terminal error instead so the
# user can compact manually (``/compress``), start fresh
# (``/new``), switch to a larger-context model, or reduce
# attachments. Forced compaction via ``/compress``
# (``force=True``) is unaffected — it never reaches this loop.
_overflow_reasons = {
FailoverReason.long_context_tier,
FailoverReason.payload_too_large,
FailoverReason.context_overflow,
}
if (
classified.reason in _overflow_reasons
and not getattr(agent, "compression_enabled", True)
):
agent._flush_status_buffer()
agent._vprint(
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
f"(compression.enabled: false).",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
f"switch to a larger-context model, or reduce attachments.",
force=True,
)
logger.error(
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
f"auto-compaction disabled — not compressing."
)
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": (
"Context overflow and auto-compaction is disabled "
"(compression.enabled: false). Run /compress to compact manually, "
"/new to start fresh, or switch to a larger-context model."
),
"partial": True,
"failed": True,
"compaction_disabled": True,
}
# ── Anthropic Sonnet long-context tier gate ───────────
# Anthropic returns HTTP 429 "Extra usage is required for
# long context requests" when a Claude Max (or similar)
+19
View File
@@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
# passes max_tokens=None, because Gemini's native API otherwise applies a low
# internal default and truncates output (unlike OpenAI-compat endpoints where
# an omitted limit means full budget).
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
@@ -414,6 +421,18 @@ def build_gemini_request(
generation_config["temperature"] = temperature
if max_tokens is not None:
generation_config["maxOutputTokens"] = max_tokens
else:
# Gemini's native generateContent does NOT treat an omitted
# maxOutputTokens as "use the model's full output budget" — it applies
# a low internal default and the model stops early with
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
# then retries 3× and refuses the incomplete call). Every current
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
# 65,535 output tokens, so default to that ceiling when the caller
# passes None ("unlimited"). See the OpenAI-compat path where omitting
# the field genuinely means full budget — that assumption does not
# hold on the native API.
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
if top_p is not None:
generation_config["topP"] = top_p
if stop:
+22 -1
View File
@@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs[k] = v
if extra_body:
api_kwargs["extra_body"] = extra_body
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
# keys (tags, reasoning, provider, plugins, …) are unknown fields
# there and Gemini rejects the whole request with a non-retryable
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
# This happens when a profile that emits extra_body (e.g. the Nous
# profile's portal `tags`) is active but the resolved endpoint is a
# Gemini base_url — typical when only Google credentials are set and
# a fallback/aux call lands on Gemini. The native client only reads
# thinking_config from extra_body, so drop everything else here.
try:
from agent.gemini_native_adapter import is_native_gemini_base_url
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
except Exception:
_native_gemini = False
if _native_gemini:
extra_body = {
k: v for k, v in extra_body.items()
if k in ("thinking_config", "thinkingConfig")
}
if extra_body:
api_kwargs["extra_body"] = extra_body
return api_kwargs
@@ -171,12 +171,19 @@ async fn run_update(app: AppHandle) -> Result<()> {
let child_env = update_child_env(&install_root);
let mut update_args: Vec<String> =
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
update_args.push("--branch".into());
update_args.push(update_branch);
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
let update = run_streamed(
let mut update = run_streamed(
&app,
&hermes,
&update_args,
@@ -185,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("update"),
)
.await?;
// Retry-once for the update-boundary crash. `hermes update` lazily imports
// the FRESHLY PULLED modules, but the dependency-install step still runs the
// already-in-memory pre-pull code for one invocation. A release that changed
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
// whose `__iter__` injected a bool into the argv and crashed Windows
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
// bool found`, fixed in #39820) therefore kills the FIRST update on the
// parked population — even though the fix is already on disk by then. A
// second `hermes update` runs clean because the now-current module is loaded
// from the start. Rather than make the parked user click Update twice (and
// stare at a scary crash first), retry once automatically. Skip the retry
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
// a retry can't fix.
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
emit_log(
&app,
Some("update"),
LogStream::Stdout,
"[update] first update attempt failed; retrying once (the fix it just \
pulled loads on the second run)…",
);
update = run_streamed(
&app,
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
}
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {
@@ -366,18 +405,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] timed out waiting for Hermes to exit; proceeding anyway",
"[update] Hermes still holding the venv shim; force-killing stragglers…",
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
);
}
return;
}
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
}
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
/// ne <self>` also spares this Tauri process, though it isn't named
/// hermes.exe.)
fn force_kill_other_hermes() {
if !cfg!(target_os = "windows") {
return;
}
#[cfg(target_os = "windows")]
{
let my_pid = std::process::id();
// /FI excludes our own PID; /T kills the tree; /F forces.
let _ = std::process::Command::new("taskkill")
.args([
"/F",
"/T",
"/IM",
"hermes.exe",
"/FI",
&format!("PID ne {my_pid}"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Best-effort lock probe: try to open the file for read+write. On Windows an
/// exclusively-held running .exe refuses the open with a sharing violation.
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
-6
View File
@@ -24,12 +24,6 @@
### Install with Hermes (recommended)
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
```
Already have the Hermes CLI? Just run:
```bash
+84 -2
View File
@@ -18,11 +18,24 @@
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// Bare + prefixed variants of the session cookies 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.
//
// Two cookies are in play (see that module):
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
// instant the AT expires.
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
// has lapsed but the RT cookie is still present, the gateway middleware
// transparently rotates a fresh AT on the next authenticated request
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
// AT cookie. A liveness check that looked only at the AT cookie would
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@@ -118,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
return buildGatewayWsUrl(baseUrl, token)
}
// Normalize a profile name to a connection scope key, or null for the global
// (default) connection. Shared by the resolver and the IPC layer.
function connectionScopeKey(profile) {
return String(profile ?? '').trim() || null
}
// Coerce a remote auth mode to one of the two supported values ('token' default).
function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -150,22 +198,56 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* 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).
*
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
* check. `cookiesHaveSession` remains exported for callers that specifically
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
/**
* True if the cookie jar holds a credential that can yield an authenticated
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
* transparently rotates a fresh AT from the RT on the next authenticated
* request. Gating connectivity on the AT alone would force a full IDP
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
*
* This answers "should we even attempt to connect / show as signed in?", not
* "is the access token unexpired?". The authoritative liveness check is still
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -15,16 +15,81 @@ const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- connectionScopeKey / normAuthMode ---
test('connectionScopeKey trims to a name or null for the global scope', () => {
assert.equal(connectionScopeKey(' coder '), 'coder')
assert.equal(connectionScopeKey(''), null)
assert.equal(connectionScopeKey(null), null)
assert.equal(connectionScopeKey(undefined), null)
})
test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('oauth'), 'oauth')
assert.equal(normAuthMode('token'), 'token')
assert.equal(normAuthMode(undefined), 'token')
assert.equal(normAuthMode('weird'), 'token')
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, ''), null)
assert.equal(profileRemoteOverride(config, null), null)
assert.equal(profileRemoteOverride(config, undefined), null)
})
test('profileRemoteOverride returns null when the profile has no entry', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, 'writer'), null)
})
test('profileRemoteOverride ignores local or url-less profile entries', () => {
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
})
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
const config = {
profiles: {
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
token: { value: 'sek' }
})
})
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
@@ -131,7 +196,10 @@ test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
// is NOT an access token, so this returns false. Connectivity callers must
// use cookiesHaveLiveSession instead (see below).
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
@@ -146,6 +214,56 @@ 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'])
})
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
})
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
// but the 24h RT cookie is still alive. The session is still connectable —
// the gateway rotates a fresh AT from the RT on the next request.
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: 'a' },
{ name: 'hermes_session_rt', value: 'r' }
]),
true
)
})
test('cookiesHaveLiveSession is false for empty values', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: '' },
{ name: 'hermes_session_rt', value: '' }
]),
false
)
})
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHaveLiveSession(null), false)
assert.equal(cookiesHaveLiveSession(undefined), false)
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
+360 -93
View File
@@ -32,8 +32,12 @@ const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -1309,6 +1313,111 @@ function resolveUpdaterBinary() {
return fileExists(candidate) ? candidate : null
}
// Path to the venv shim whose lock decides whether `hermes update` can write
// fresh entry points. On Windows this is the file the running backend
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
function venvHermesShimPath(updateRoot) {
return IS_WINDOWS
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
: path.join(updateRoot, 'venv', 'bin', 'hermes')
}
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
// this practically always succeeds (no mandatory locking), so it returns false
// — correct, since the shim-contention brick is Windows-only.
function isShimLocked(shimPath) {
if (!IS_WINDOWS) return false
let fd
try {
fd = fs.openSync(shimPath, 'r+')
return false
} catch (err) {
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
// EACCES) on Windows means a live handle holds it.
return err && err.code !== 'ENOENT'
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd)
} catch {
void 0
}
}
}
}
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
// only signals the direct child, so on Windows a backend `hermes.exe` that
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
// the whole tree synchronously. Windows-only: this is called solely from the
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
}
}
// Before handing off the update on Windows, the desktop MUST stop every backend
// it spawned and WAIT for the venv shim to actually unlock. The old code did
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
// rename failed, uv's `pip install` hit "Access is denied", and the git path
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
// pool backends and poll the shim until it's writable (or a bounded timeout),
// so by the time we spawn the updater the lock is genuinely gone.
//
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
// aggressively SIGKILL-ing the backend here would be an untested behavior change
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
async function releaseBackendLockForUpdate(updateRoot) {
if (!IS_WINDOWS) return { unlocked: true }
// Collect every backend PID the desktop owns: primary window backend + pool.
const pids = []
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
for (const entry of backendPool.values()) {
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
}
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
if (hermesProcess && !hermesProcess.killed) {
try {
hermesProcess.kill('SIGTERM')
} catch {
void 0
}
}
stopAllPoolBackends()
for (const pid of pids) forceKillProcessTree(pid)
const shim = venvHermesShimPath(updateRoot)
const deadlineMs = Date.now() + 15000
while (Date.now() < deadlineMs) {
if (!isShimLocked(shim)) {
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
return { unlocked: true }
}
await new Promise(r => setTimeout(r, 300))
}
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
// line of defense, and we pass --force so the guard won't dead-end. Log it.
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
return { unlocked: false }
}
// applyUpdates — hand off to the installer's --update flow, then exit.
//
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
@@ -1375,6 +1484,12 @@ async function applyUpdates(opts = {}) {
}
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
// spawn the updater. Without this the updater races a still-locked
// hermes.exe (held by the backend child / its grandchildren) and the update
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
await releaseBackendLockForUpdate(updateRoot)
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
const child = spawn(updater, updaterArgs, {
@@ -3167,8 +3282,16 @@ function installMediaPermissions() {
// * 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.
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
// is still alive, the gateway middleware transparently rotates a fresh AT
// on the next authenticated request — so connectivity must NOT be gated on
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
// (which triggers that server-side refresh) and treat a real 401 as
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
// "is the user signed in at all?" gate / display signal.
// ---------------------------------------------------------------------------
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
@@ -3179,8 +3302,9 @@ function getOauthSession() {
return oauthSession
}
// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
// Bare + prefixed variants of the session cookies live in
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
// that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
@@ -3201,6 +3325,30 @@ async function hasOauthSessionCookie(baseUrl) {
}
}
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
// "is the user signed in at all?" check: an expired AT with a live RT is still
// a connectable session because the gateway rotates a fresh AT server-side on
// the next authenticated request. Gating on the AT alone forces a needless full
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
async function hasLiveOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return false
const parsed = new URL(baseUrl)
try {
const cookies = await sess.cookies.get({ url: baseUrl })
return cookiesHaveLiveSession(cookies)
} catch {
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHaveLiveSession(cookies)
} catch {
return false
}
}
}
async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return
@@ -3447,6 +3595,38 @@ function decryptDesktopSecret(secret) {
return value
}
// Validate + normalize the per-profile remote overrides map read from disk.
// Drops malformed names/entries and keeps only the recognized fields so a
// hand-edited or stale connection.json can't inject junk into resolution.
function sanitizeConnectionProfiles(raw) {
if (!raw || typeof raw !== 'object') {
return {}
}
const out = {}
for (const [name, entry] of Object.entries(raw)) {
if (!entry || typeof entry !== 'object') {
continue
}
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
cleaned.url = url
}
cleaned.authMode = normAuthMode(entry.authMode)
if (entry.token && typeof entry.token === 'object') {
cleaned.token = entry.token
}
out[name] = cleaned
}
return out
}
function readDesktopConnectionConfig() {
// Check if file changed on disk since last read (e.g. modified by another
// process or an external tool). Our own writes update the cache inline
@@ -3462,7 +3642,7 @@ function readDesktopConnectionConfig() {
return connectionConfigCache
}
let config = { mode: 'local', remote: {} }
let config = { mode: 'local', remote: {}, profiles: {} }
try {
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
@@ -3476,7 +3656,11 @@ function readDesktopConnectionConfig() {
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
// profileRemoteOverride() can resolve them; normalized lazily on save.
profiles: sanitizeConnectionProfiles(parsed.profiles)
}
}
} catch {
@@ -3528,104 +3712,114 @@ function writeActiveDesktopProfile(name) {
return value || null
}
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 || '')
// Sanitize a connection config into the renderer-facing shape. With no
// `profile` this describes the global/default connection (the existing
// behavior); with a `profile` it describes that profile's per-profile remote
// override (or an empty "local/inherit" view when the profile has none).
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
const key = connectionScopeKey(profile)
const scoped = key ? config.profiles?.[key] || null : null
const block = key ? scoped || {} : config.remote || {}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = String(block.url || '')
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
// Display signal: treat a live RT cookie as "connected" even if the AT
// cookie has lapsed — the gateway refreshes the AT on the next request,
// so the session is still usable. The authoritative liveness check is
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
} catch {
remoteOauthConnected = false
}
}
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
mode,
// Echo the scope back so the UI knows which profile (if any) this reflects.
profile: key,
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
// authenticate via the login-window session cookie (verified at connect time in
// resolveRemoteBackend), so only token-auth remotes require a saved token.
function buildRemoteBlock(remoteUrl, authMode, token) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.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)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
const nextToken = incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingBlock.token
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
// 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.')
if (key) {
// Per-profile scope: a remote entry pins this profile to its own backend; a
// local entry clears the override so the profile inherits the default.
const profiles = { ...(existing.profiles || {}) }
if (mode === 'remote') {
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
} else {
delete profiles[key]
}
} else if (remoteUrl) {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
}
return { mode, remote: nextRemote }
const nextRemote =
mode === 'remote'
? buildRemoteBlock(remoteUrl, authMode, nextToken)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
// Preserve per-profile overrides when saving the global connection.
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
async function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
return {
baseUrl,
mode: 'remote',
source: 'env',
authMode: 'token',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
}
const config = readDesktopConnectionConfig()
if (config.mode !== 'remote') {
return null
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
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))) {
// OAuth gateway: auth comes from the session cookies in the OAuth
// partition. Liveness is NOT "is the access-token cookie present?" —
// Portal issues a 24h rotating refresh token (hermes #37247), and the
// gateway middleware transparently rotates a fresh ~15-min access token
// from it on the next authenticated request. So a session with an expired
// AT cookie but a live RT cookie is still perfectly connectable. We
// early-out only when neither cookie is present, then mint a ws-ticket as
// the authoritative liveness check.
if (!(await hasLiveOauthSession(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.'
@@ -3649,7 +3843,7 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
@@ -3657,8 +3851,6 @@ async function resolveRemoteBackend() {
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
@@ -3669,13 +3861,54 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'token',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
// 3. global remote (connection.json `mode: 'remote'`)
// A null/empty profile resolves the env/global remote, so legacy callers and
// the connection test (which pass no profile) are unchanged.
async function resolveRemoteBackend(profile) {
const config = readDesktopConnectionConfig()
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
}
// 2. Env override (global, token-auth only).
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
}
// 3. Global remote.
if (config.mode !== 'remote') {
return null
}
const authMode = normAuthMode(config.remote?.authMode)
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
}
async function probeRemoteAuthMode(rawUrl) {
// Determine how a remote gateway expects callers to authenticate, WITHOUT
// sending any credentials. ``/api/status`` is public on every Hermes
@@ -3743,6 +3976,11 @@ async function probeRemoteAuthMode(rawUrl) {
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/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;
@@ -3750,17 +3988,17 @@ async function testDesktopConnectionConfig(input = {}) {
let baseUrl
let token = null
let authMode = 'token'
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
if (wantRemote && block?.url) {
baseUrl = normalizeRemoteBaseUrl(block.url)
authMode = normAuthMode(block.authMode)
if (authMode !== 'oauth') {
token = decryptDesktopSecret(config.remote.token)
token = decryptDesktopSecret(block.token)
}
} else {
const remote = (await resolveRemoteBackend()) || (await startHermes())
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
authMode = normAuthMode(remote.authMode)
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
@@ -3932,10 +4170,21 @@ function startPoolIdleReaper() {
// local-spawn portion of startHermes() but without the boot-progress UI,
// bootstrap, or remote handling (those belong to the primary backend only).
async function spawnPoolBackend(profile, entry) {
// Remote deployments are single-tenant; profiles only apply to local backends.
const remote = await resolveRemoteBackend()
// A profile may point at its OWN remote backend (connection.json
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
// In either case there is no local child to spawn — we just verify the
// remote is reachable and hand back its connection descriptor. The pool
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
// tolerate.
const remote = await resolveRemoteBackend(profile)
if (remote) {
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
await waitForHermes(remote.baseUrl, remote.token)
return {
...remote,
profile,
logs: hermesLog.slice(-80),
...getWindowState()
}
}
const port = await pickPort()
@@ -4036,7 +4285,9 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = await resolveRemoteBackend()
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@@ -4373,7 +4624,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
})
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:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
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) => {
@@ -4388,22 +4641,36 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
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 }
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT) so a logout that left any session cookie behind is reflected
// as still-connected rather than silently signed-out.
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
await teardownPrimaryBackendAndWait()
const key = connectionScopeKey(payload?.profile)
mainWindow?.reload()
return sanitizeDesktopConnectionConfig(config)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
// re-resolves against the new remote/local target.
stopPoolBackend(key)
} else {
// Global connection, or the primary profile's connection: re-home the
// window backend by tearing it down and reloading the renderer.
await teardownPrimaryBackendAndWait()
mainWindow?.reload()
}
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
+1 -1
View File
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
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),
+1 -1
View File
@@ -84,7 +84,7 @@
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"shiki": "^4.0.2",
+20 -1
View File
@@ -29,7 +29,7 @@ import {
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -506,6 +506,25 @@ export function DesktopController() {
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill. Both are nanostores, so the blanket
// invalidateQueries() the profile store fires on swap doesn't touch them —
// without this the statusbar keeps showing the previous profile's model
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
// swap (background sockets persist), so the open→open effect won't re-run.
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
}
lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel()
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -453,15 +453,31 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
.then(usage => {
if (isCurrentResume() && usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
})
.catch(() => undefined)
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
return
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
}
}
setFreshDraftReady(false)
+9 -4
View File
@@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'memory.provider': ['', 'builtin', 'honcho'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
export const FIELD_LABELS: Record<string, string> = {
@@ -309,7 +310,8 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
'voice.auto_tts': 'Automatically speak assistant responses.',
'stt.enabled': 'Enable local or provider-backed speech transcription.',
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
'updates.non_interactive_local_changes':
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
}
// Curated desktop config surface: only fields a user might tune from the app.
@@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort'
'delegation.reasoning_effort',
'updates.non_interactive_local_changes'
]
}
]
@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -6,6 +7,7 @@ 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'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
@@ -74,6 +76,23 @@ function ModeCard({
)
}
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
return (
<button
className={cn(
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
{label}
</button>
)
}
export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -83,6 +102,16 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
const [scope, setScope] = useState<null | string>(null)
const profiles = useStore($profiles)
useEffect(() => {
void refreshActiveProfile()
}, [])
// 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).
@@ -100,8 +129,14 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}
setLoading(true)
// Clear scope-local entry state so a token from one scope can't leak into
// the next when switching profiles.
setRemoteToken('')
setLastTest(null)
desktop
.getConnectionConfig()
.getConnectionConfig(scope)
.then(config => {
if (cancelled) {
return
@@ -117,7 +152,7 @@ export function GatewaySettings() {
})
return () => void (cancelled = true)
}, [])
}, [scope])
// 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
@@ -223,6 +258,10 @@ export function GatewaySettings() {
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
// The 'default' profile uses the global ("All profiles") connection, so the
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@@ -239,6 +278,7 @@ export function GatewaySettings() {
const payload = () => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -296,6 +336,7 @@ export function GatewaySettings() {
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
@@ -305,7 +346,7 @@ export function GatewaySettings() {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
@@ -327,7 +368,7 @@ export function GatewaySettings() {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
@@ -357,6 +398,7 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -395,10 +437,35 @@ export function GatewaySettings() {
</div>
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
its own remote host.
</p>
</div>
{namedProfiles.length > 0 ? (
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
Applies to
</div>
<div className="flex flex-wrap gap-1.5">
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
{namedProfiles.map(profile => (
<ScopeChip
active={scope === profile.name}
key={profile.name}
label={profile.name}
onSelect={() => setScope(profile.name)}
/>
))}
</div>
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{scope === null
? 'Default connection for every profile that has no override of its own.'
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
</p>
</div>
) : null}
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
@@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
return {
envOverride: false,
mode: 'remote',
profile: null,
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,
+7 -1
View File
@@ -12,7 +12,7 @@ declare global {
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
@@ -190,6 +190,9 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
@@ -199,6 +202,9 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
+1 -1
View File
@@ -871,7 +871,7 @@ delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
# WARNING: values above 10 multiply API cost linearly.
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
# Raise to 2 to allow workers to spawn their own subagents.
# Requires role="orchestrator" on intermediate agents.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
+10
View File
@@ -13094,6 +13094,16 @@ class HermesCLI:
_welcome_color = "#FFF8DC"
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
# Warm the /model picker's provider-models cache off-thread during this
# idle window (banner shown, user about to type). The no-args picker
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
# it's opened in a session. Fire-and-forget, guarded once-per-process.
try:
from hermes_cli.model_switch import prewarm_picker_cache_async
prewarm_picker_cache_async()
except Exception:
pass
# Redaction opt-out warning (#17691): ON by default, loud when off.
# The redactor snapshots its state at import time so any toggle now
# won't affect the running process — we just want the operator to
+11
View File
@@ -3016,6 +3016,17 @@ class BasePlatformAdapter(ABC):
expanded = os.path.expanduser(raw)
if os.path.isfile(expanded):
found.append((raw, expanded))
else:
# The reply mentions a deliverable-looking path that does not
# exist on disk, so it is silently dropped from native delivery.
# This is the most common reason a promised file never arrives
# (the model said "here's your file" but never wrote it, or
# referenced the wrong path). Log it so the gap is visible in
# gateway.log rather than vanishing without a trace.
logger.info(
"Skipping bare file path in reply (no file on disk): %s",
_log_safe_path(raw),
)
# Deduplicate by expanded path, preserving discovery order
seen: set = set()
+2 -2
View File
@@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send document: {e}")
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
async def send_video(
@@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send video: {e}")
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
async def send_image(
+59 -2
View File
@@ -11998,13 +11998,24 @@ class GatewayRunner:
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
return t("gateway.voice.enabled_short")
toggle_line = t("gateway.voice.enabled_short")
else:
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
return t("gateway.voice.disabled_short")
toggle_line = t("gateway.voice.disabled_short")
# Bare /voice still toggles, but append an explainer so users
# discover the on/off/tts/status subcommands (and, on Discord,
# live voice-channel join/leave). The toggle result is shown
# first via the {toggle} placeholder.
supports_voice_channels = adapter is not None and hasattr(
adapter, "join_voice_channel"
)
channels = (
t("gateway.voice.help_channels") if supports_voice_channels else ""
)
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
"""Join the user's current Discord voice channel."""
@@ -17024,6 +17035,47 @@ class GatewayRunner:
last_progress_msg = [None] # Track last message for dedup
repeat_count = [0] # How many times the same message repeated
# ── Discord voice "verbal ack before tool calls" ────────────────
# When the bot is in a voice channel with the continuous mixer
# installed (discord.voice_fx.enabled), speak a short phrase ("let me
# look into that") over the ambient idle bed on the FIRST tool call of
# the turn. Fires from tool_start_callback (independent of the
# tool-progress text gate), at most once per turn. No-op on every
# other platform / when not in a voice channel.
_voice_ack_fired = [False]
_voice_ack_guild: List[Optional[int]] = [None]
if source.platform == Platform.DISCORD:
_va = self.adapters.get(Platform.DISCORD)
# source.chat_id is the linked text channel; resolve the guild whose
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
_vtc = getattr(_va, "_voice_text_channels", None)
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
for _gid, _tc in _vtc.items():
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
_voice_ack_guild[0] = _gid
break
_voice_ack_loop = asyncio.get_running_loop()
def voice_ack_callback(call_id, tool_name, args):
"""tool_start_callback: speak a one-time ack in the voice channel."""
if _voice_ack_fired[0] or _voice_ack_guild[0] is None:
return
if not _run_still_current():
return
_voice_ack_fired[0] = True
_adapter = self.adapters.get(Platform.DISCORD)
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
return
try:
safe_schedule_threadsafe(
_adapter.play_ack_in_voice(_voice_ack_guild[0]),
_voice_ack_loop,
logger=logger,
log_message="voice ack scheduling error",
)
except Exception as _ack_err:
logger.debug("voice ack schedule failed: %s", _ack_err)
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
# that implements ``delete_message``). When enabled via
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
@@ -17834,6 +17886,11 @@ class GatewayRunner:
# Per-message state — callbacks and reasoning config change every
# turn and must not be baked into the cached agent constructor.
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
# Discord voice verbal-ack hook (fires once per turn on first tool
# call; armed only when in a voice channel with the mixer running).
agent.tool_start_callback = (
voice_ack_callback if _voice_ack_guild[0] is not None else None
)
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
agent.stream_delta_callback = _stream_delta_cb
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
+7 -3
View File
@@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
return None
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError:
except (OSError, UnicodeDecodeError):
# OSError: file vanished or permission flipped between exists() and
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
# (a truncated or clobbered status file). Either way it's unusable.
return None
if not raw:
return None
@@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
try:
raw = pid_path.read_text().strip()
except OSError:
# File was deleted between exists() and read_text(), or permission flipped.
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.
return None
if not raw:
return None
+19
View File
@@ -225,6 +225,25 @@ def check_for_updates() -> Optional[int]:
cache_file = hermes_home / ".update_check"
embedded_rev = os.environ.get("HERMES_REVISION") or None
# Docker images have no working tree to count commits against — the
# published image excludes `.git` (see .dockerignore) and sets no
# HERMES_REVISION (that's nix-only). Without this guard the checks below
# fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1)
# then gets rendered by the CLI banner and the TUI badge as a phantom
# "1 commit behind" — even though no git repo or commit math is involved,
# and `hermes update` correctly refuses to run in-place inside the
# container anyway. The dashboard's REST `/api/hermes/update/check`
# endpoint already short-circuits docker the same way (web_server.py);
# mirror that here so the banner/TUI surfaces agree. Returning None makes
# both the Rich banner (build_welcome_banner) and the Ink badge
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "docker":
return None
except Exception:
pass
# Read cache — invalidate if the embedded rev OR installed version has
# changed since the last check. The version guard matters for pip installs:
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
+1 -85
View File
@@ -1148,41 +1148,6 @@ def slack_subcommand_map() -> dict[str, str]:
# ---------------------------------------------------------------------------
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
# every keystroke would block the UI; a short TTL keeps it live without
# hammering the server.
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
def _lmstudio_completion_models() -> list[str]:
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
global _LMSTUDIO_COMPLETION_CACHE
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
try:
from hermes_cli.auth import _load_auth_store
store = _load_auth_store() or {}
if "lmstudio" not in (store.get("providers") or {}) \
and "lmstudio" not in (store.get("credential_pool") or {}):
return []
except Exception:
return []
now = time.time()
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
return _LMSTUDIO_COMPLETION_CACHE[1]
try:
from hermes_cli.models import fetch_lmstudio_models
models = fetch_lmstudio_models(
api_key=os.environ.get("LM_API_KEY", ""),
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
timeout=0.8,
)
except Exception:
models = []
_LMSTUDIO_COMPLETION_CACHE = (now, models)
return models
class SlashCommandCompleter(Completer):
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
@@ -1599,52 +1564,6 @@ class SlashCommandCompleter(Completer):
except Exception:
pass
def _model_completions(self, sub_text: str, sub_lower: str):
"""Yield completions for /model from config aliases + built-in aliases."""
seen = set()
# Config-based direct aliases (preferred — include provider info)
try:
from hermes_cli.model_switch import (
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
)
_ensure_direct_aliases()
for name, da in DIRECT_ALIASES.items():
if name.startswith(sub_lower) and name != sub_lower:
seen.add(name)
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=f"{da.model} ({da.provider})",
)
# Built-in catalog aliases not already covered
for name in sorted(MODEL_ALIASES.keys()):
if name in seen:
continue
if name.startswith(sub_lower) and name != sub_lower:
identity = MODEL_ALIASES[name]
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=f"{identity.vendor}/{identity.family}",
)
except Exception:
pass
# LM Studio: surface locally-loaded models. Gated on the user actually
# having LM Studio configured (env var or auth-store entry) so we
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
for name in _lmstudio_completion_models():
if name in seen:
continue
if name.startswith(sub_lower) and name != sub_lower:
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta="LM Studio",
)
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
@@ -1668,9 +1587,6 @@ class SlashCommandCompleter(Completer):
# Dynamic completions for commands with runtime lists
if " " not in sub_text:
if base_cmd == "/model":
yield from self._model_completions(sub_text, sub_lower)
return
if base_cmd == "/skin":
yield from self._skin_completions(sub_text, sub_lower)
return
@@ -1788,7 +1704,7 @@ class SlashCommandAutoSuggest(AutoSuggest):
return Suggestion(cmd_name[len(word):])
return None
# Command is complete — suggest subcommands or model names
# Command is complete — suggest subcommands
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
+43 -5
View File
@@ -1677,9 +1677,9 @@ DEFAULT_CONFIG = {
# "low", "minimal", "none" (empty = inherit parent's level)
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
# and _get_orchestrator_enabled). Values are clamped to [1, 3] with a
# warning log if out of range.
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
# raise deliberately, each level multiplies API cost.
"max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper)
"orchestrator_enabled": True, # kill switch for role="orchestrator"
# When a subagent hits a dangerous-command approval prompt, the parent's
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
@@ -1841,6 +1841,28 @@ DEFAULT_CONFIG = {
# real memory cost. Default 32 MiB matches the historical hardcoded
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
"max_attachment_bytes": 33554432,
# Voice-channel audio effects (the continuous mixer). OFF by default.
# When enabled, the bot installs a software mixer on the outgoing voice
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
# TTS replies can OVERLAP (ducking the ambient under speech) instead of
# stop-and-swap — the Grok-voice-mode feel. discord.py ships no mixer;
# this is implemented in plugins/platforms/discord/voice_mixer.py.
"voice_fx": {
"enabled": False, # master switch for the mixer subsystem
"ambient_enabled": True, # play the idle "thinking" bed while tools run
"ambient_path": "", # custom loop audio file; "" = synthesised pad
"ambient_gain": 0.18, # idle bed loudness, 0.01.0
"duck_gain": 0.06, # ambient loudness while speech plays
"speech_gain": 1.0, # TTS / ack loudness, 0.01.0
"ack_enabled": True, # speak a short phrase before the first tool call
"ack_phrases": [ # picked at random; set [] to disable phrases
"Let me look into that.",
"One moment.",
"Checking on that now.",
"Give me a sec.",
"On it.",
],
},
},
# WhatsApp platform settings (gateway mode)
@@ -2253,6 +2275,22 @@ DEFAULT_CONFIG = {
# disable backups entirely, set ``pre_update_backup: false`` above
# rather than ``backup_keep: 0``.
"backup_keep": 5,
# What `hermes update` does with uncommitted local changes to the
# source tree when it runs NON-interactively — i.e. triggered from
# the desktop/chat app or the gateway, where there's no TTY to answer
# a restore prompt. Interactive (terminal) updates are unaffected:
# they always stash the changes and ask whether to restore, exactly
# as they always have.
# "stash" — auto-stash the changes, pull, then auto-restore them
# on top of the updated code (the safe default; nothing
# is ever lost — conflicts are preserved in a git stash).
# "discard" — auto-stash the changes and throw the stash away after
# the pull. Use this only if you never intend to keep
# local edits to the source tree on this machine.
# Stash-and-drop (not `reset --hard` + `clean -fd`) so
# ignored paths — node_modules, venv, build outputs —
# are never touched.
"non_interactive_local_changes": "stash",
},
# Language Server Protocol — semantic diagnostics from real
@@ -2382,7 +2420,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 26,
"_config_version": 27,
}
# =============================================================================
@@ -3937,7 +3975,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions", "streaming",
"sessions", "streaming", "updates",
}
# Valid fields inside a custom_providers list entry
+4 -1
View File
@@ -81,7 +81,10 @@ def cron_list(show_all: bool = False):
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
next_run = job.get("next_run_at", "?")
repeat_info = job.get("repeat", {})
# `repeat` may be present-but-null in the job record (e.g. a one-shot
# job persisted with "repeat": null), so coalesce to {} rather than
# relying on the dict-default, which only applies to a missing key.
repeat_info = job.get("repeat") or {}
repeat_times = repeat_info.get("times")
repeat_completed = repeat_info.get("completed", 0)
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else ""
+32 -19
View File
@@ -2,13 +2,18 @@
Three cookies in play:
- hermes_session_at: the OAuth access token
(HttpOnly, lifetime = token TTL)
(HttpOnly, lifetime = token TTL, ~15 min)
- hermes_session_rt: the OAuth refresh token
(HttpOnly, lifetime = 30 days)
**DEPRECATED in OAuth contract v1** Nous Portal
does not issue refresh tokens; we keep the cookie
name and clear semantics for forward compatibility
and to flush stale cookies from old browsers.
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
Nous Portal issues a rotating refresh token for the
dashboard auth-code grant (Portal NAS #293 / hermes
#37247). ``set_session_cookies`` writes this cookie
whenever the provider returns a non-empty
``refresh_token``; the middleware uses it to rotate a
fresh access token transparently on AT expiry. A
provider that omits the refresh token (empty string)
degrades gracefully to access-token-only sessions
the RT cookie is simply not written.
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)
@@ -39,13 +44,15 @@ The setters and readers BOTH consult the active prefix because the
cookie *name* changes a reader that looked up the bare name when the
setter wrote ``__Secure-hermes_session_at`` would never find the value.
.. deprecated:: contract v1
``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1
default) and silently skips writing the RT cookie in that case.
``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT
cookie so users carrying a stale cookie from an earlier deployment get
it cleared on logout / session expiry. The full refresh-flow machinery
was rewritten as "401 → redirect to /login" in Phase 6.
Refresh-token handling:
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
it) and silently skips writing the RT cookie in that case, so a
refresh-token-less provider degrades to access-token-only sessions.
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
cookie on logout / session expiry so a stale cookie from an earlier
deployment gets cleared. The transparent rotation flow ("expired AT +
live RT rotate server-side, else 401 /login") lives in
``middleware._attempt_refresh``.
"""
from __future__ import annotations
@@ -66,7 +73,13 @@ PKCE_COOKIE = "hermes_session_pkce"
# practice — a single request emits exactly one variant).
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
# real authority — once the RT itself expires/rotates out, a refresh attempt
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
# lifetime to a server-side TTL that can change independently; revisit if the
# stale-cookie refresh churn ever matters.)
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
@@ -126,11 +139,11 @@ def set_session_cookies(
``access_token_expires_in`` is in seconds. Use the provider's reported
TTL for the access token.
``refresh_token`` is accepted for backward / forward compatibility but
SKIPPED when empty Nous Portal contract v1 issues no refresh tokens
so a ``Session.refresh_token == ""`` from the provider means we don't
persist anything. If a future contract revision starts emitting refresh
tokens, this helper will write the RT cookie again with no other change.
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
issues a 24h rotating refresh token (hermes #37247); a provider that
omits it returns ``Session.refresh_token == ""`` and we simply don't
persist the RT cookie the session then behaves as access-token-only
until the AT expires. No other branch changes between the two cases.
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
or ``""`` for a direct deploy. It influences both the cookie name
+51
View File
@@ -4382,6 +4382,35 @@ def _setup_standard_platform(platform: dict):
if not prompt_yes_no(f" Reconfigure {label}?", False):
return
auto_token_saved = False
auto_owner_user_id = None
if platform.get("key") == "telegram":
print()
print_info(" Telegram can be configured automatically with a managed bot:")
print_info(" [1] Automatic (scan QR → confirm in Telegram → done)")
print_info(" [2] Manual BotFather token")
choice = prompt(" Choice [1/2]", default="1")
if choice.strip() == "1":
try:
from hermes_cli.telegram_managed_bot import (
auto_setup_telegram_bot_result,
is_valid_telegram_bot_token,
)
except ImportError:
print_warning(" Automatic setup is unavailable in this install.")
else:
result = auto_setup_telegram_bot_result()
if result and is_valid_telegram_bot_token(result.token):
save_env_value(token_var, result.token)
print_success(" Saved TELEGRAM_BOT_TOKEN")
auto_token_saved = True
auto_owner_user_id = result.owner_user_id
else:
if result:
print_warning(" Automatic setup returned an invalid Telegram token.")
print()
print_info(" Falling back to manual setup...")
allowed_val_set = None # Track if user set an allowlist (for home channel offer)
for var in platform["vars"]:
@@ -4391,8 +4420,30 @@ def _setup_standard_platform(platform: dict):
if existing and var["name"] != token_var:
print_info(f" Current: {existing}")
if auto_token_saved and var["name"] == token_var:
print_info(" Token saved by automatic setup.")
continue
# Allowlist fields get special handling for the deny-by-default security model
if var.get("is_allowlist"):
if "TELEGRAM" in var["name"] and auto_owner_user_id:
detected_id = str(auto_owner_user_id)
print_success(f" Detected your Telegram user ID: {detected_id}")
if prompt_yes_no(" Allow this Telegram account to use the bot?", True):
extra = prompt(
" Additional allowed user IDs (comma-separated, optional)",
password=False,
)
ids = [detected_id]
for uid in extra.replace(" ", "").split(","):
if uid and uid not in ids:
ids.append(uid)
cleaned = ",".join(ids)
save_env_value(var["name"], cleaned)
print_success(" Saved — only these users can interact with the bot.")
allowed_val_set = cleaned
continue
print_info(" The gateway DENIES all users by default for security.")
print_info(" Enter user IDs to create an allowlist, or leave empty")
print_info(" and you'll be asked about open access next.")
+169 -213
View File
@@ -1264,6 +1264,32 @@ def _workspace_root(dir: Path) -> Path:
return dir
def _termux_workspace_install_context(
dir: Path, *, include_child_workspaces: bool = False
) -> tuple[Path, tuple[str, ...]]:
"""Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
ws_root = _workspace_root(dir)
if ws_root == dir:
return dir, ()
try:
workspace = dir.relative_to(ws_root).as_posix()
except ValueError:
return ws_root, ()
workspace_args: list[str] = ["--workspace", workspace]
if include_child_workspaces:
packages_dir = dir / "packages"
if packages_dir.is_dir():
for child in sorted(packages_dir.iterdir()):
if child.is_dir() and (child / "package.json").is_file():
workspace_args.extend(
["--workspace", child.relative_to(ws_root).as_posix()]
)
workspace_args.append("--include-workspace-root=false")
return ws_root, tuple(workspace_args)
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
@@ -1524,16 +1550,43 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
# --dev flow: npm install if needed, then tsx src/entry.tsx.
# npm install runs from the workspace root (where package-lock.json lives);
# npm workspaces resolves ui-tui deps automatically.
# Existing desktop behaviour runs npm from the workspace root. Termux
# scopes the install to ui-tui so launch does not pull desktop/web
# dependencies into the hot path.
did_install = False
if _tui_need_npm_install(tui_dir):
termux_startup = _is_termux_startup_environment()
termux_need_rebuild = False
if termux_startup and not tui_dev:
termux_need_rebuild = _tui_need_rebuild(tui_dir)
skip_install_for_fresh_termux_bundle = (
termux_startup and not tui_dev and not termux_need_rebuild
)
if (
not skip_install_for_fresh_termux_bundle
and _tui_need_npm_install(tui_dir)
):
npm = _node_bin("npm")
if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…")
npm_cwd = _workspace_root(tui_dir)
npm_workspace_args: tuple[str, ...] = ()
if termux_startup:
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
tui_dir,
include_child_workspaces=True,
)
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(_workspace_root(tui_dir)),
[
npm,
"install",
*npm_workspace_args,
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
],
cwd=str(npm_cwd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -1579,8 +1632,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# Termux cold starts use the freshness check because esbuild startup is
# expensive on old mobile CPUs.
should_build = True
if _is_termux_startup_environment():
should_build = did_install or _tui_need_rebuild(tui_dir)
if termux_startup:
should_build = did_install or termux_need_rebuild
if should_build:
npm = _node_bin("npm")
@@ -7004,10 +7057,14 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if text:
_say(text)
npm_cwd = _workspace_root(web_dir)
npm_workspace_args: tuple[str, ...] = ()
if _is_termux_startup_environment():
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
r1 = _run_npm_install_deterministic(
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
npm_cwd,
extra_args=(*npm_workspace_args, "--silent"),
)
if r1.returncode != 0:
_say(
@@ -8148,59 +8205,6 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
return stash_ref
def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool:
"""Discard working-tree dirt on a managed (non-fork) clone.
On a managed install (%LOCALAPPDATA%\\hermes\\hermes-agent or
~/.hermes/hermes-agent) the user never edits the source tree, so any
"dirty" state is pure git artifact: CRLF renormalization, npm lockfile
churn, or files left behind when a directory was deleted upstream (e.g.
apps/bootstrap-installer/). Stashing that dirt and re-applying it after a
pull is actively dangerous the stash/restore cycle has been observed to
clobber freshly-pulled source files (apps/desktop/ deletion
"[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
For a managed clone the correct move is to throw the dirt away with
``git reset --hard HEAD`` + ``git clean -fd`` (mirroring install.ps1's
update path), NOT preserve it. Forks keep the stash machinery because
their local edits are intentional.
Returns True if the tree was cleaned (or was already clean), False on
a git failure (caller should fall back to the stash path).
"""
status = subprocess.run(
git_cmd + ["status", "--porcelain"],
cwd=cwd,
capture_output=True,
text=True,
)
if status.returncode != 0:
return False
if not status.stdout.strip():
return True
print("→ Discarding working-tree changes on managed clone before update...")
reset = subprocess.run(
git_cmd + ["reset", "--hard", "HEAD"],
cwd=cwd,
capture_output=True,
text=True,
)
if reset.returncode != 0:
return False
# Drop untracked files too (e.g. orphaned build artifacts), but never
# touch ignored paths — node_modules, venv, build outputs, and the like
# are expensive to rebuild and not git-artifact dirt.
subprocess.run(
git_cmd + ["clean", "-fd"],
cwd=cwd,
capture_output=True,
text=True,
)
return True
def _resolve_stash_selector(
git_cmd: list[str], cwd: Path, stash_ref: str
) -> Optional[str]:
@@ -8341,6 +8345,54 @@ def _restore_stashed_changes(
return True
def _discard_stashed_changes(
git_cmd: list[str],
cwd: Path,
stash_ref: str,
) -> bool:
"""Throw away a stash created before an update, without applying it.
Used only on a NON-interactive update when the user has set
``updates.non_interactive_local_changes: discard`` i.e. they've opted out
of keeping local source edits on this machine. Drops the stash entry
instead of re-applying it, so the working tree stays clean at the freshly
pulled HEAD. Unlike ``git reset --hard`` + ``git clean -fd``, this only
affects what was stashed (tracked changes + the untracked files we
explicitly captured) ignored paths like node_modules/venv/build outputs
are never touched, since they were never stashed.
Returns True if the stash was dropped, False on a git failure (in which
case the stash is left in place for safety).
"""
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
if stash_selector is None:
print(
"⚠ Configured to discard local changes on non-interactive update, "
"but Hermes couldn't find the stash entry to drop."
)
_print_stash_cleanup_guidance(stash_ref)
return False
drop = subprocess.run(
git_cmd + ["stash", "drop", stash_selector],
cwd=cwd,
capture_output=True,
text=True,
)
if drop.returncode != 0:
print(
"⚠ Configured to discard local changes, but Hermes couldn't drop "
"the saved stash entry."
)
if drop.stderr.strip():
print(f" {drop.stderr.strip().splitlines()[0]}")
_print_stash_cleanup_guidance(stash_ref, stash_selector)
return False
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
return True
# =========================================================================
# Fork detection and upstream management for `hermes update`
# =========================================================================
@@ -10131,62 +10183,6 @@ def _cmd_update_pip(args):
print("✓ Update complete! Restart hermes to use the new version.")
def _should_handoff_after_pull(finalize_only: bool) -> bool:
"""Whether to hand the post-pull steps off to a fresh subprocess on new
code.
Returns False (finish in-process) when:
- this IS already the finalize subprocess (avoid an infinite loop),
- under pytest, so the test runner's interpreter never spawns a real
recursive update mid-suite,
- the ``HERMES_UPDATE_NO_HANDOFF`` escape hatch is set.
Cross-platform: unlike an ``os.exec*`` replacement (which on Windows
spawns a *new* PID and breaks the desktop installer's exit-code wait on
the original process), a child subprocess + exit-code forwarding keeps the
parent PID intact everywhere, so this stays on for Windows too.
"""
if finalize_only or os.environ.get("HERMES_UPDATE_FINALIZE") == "1":
return False
if os.environ.get("HERMES_UPDATE_NO_HANDOFF") == "1":
return False
if "pytest" in sys.modules:
return False
return True
def _handoff_update_to_refreshed_code():
"""Finish the update in a fresh subprocess running the just-pulled code.
Called after a successful git pull + dependency install (so the new source
AND its deps are on disk). Spawns ``hermes update`` again with
``HERMES_UPDATE_FINALIZE=1`` set; that child skips the fetch/pull/snapshot
work and this hand-off, and runs only the post-pull finalize steps with
new code. stdin/stdout/stderr are inherited, so interactive prompts and a
parent streaming our output both keep working.
Returns the child's exit code, or ``None`` if the subprocess could not be
launched at all in which case the caller finishes the update in-process,
the historical behavior.
"""
try:
env = dict(os.environ)
env["HERMES_UPDATE_FINALIZE"] = "1"
cmd = [sys.executable, "-m", "hermes_cli.main", *sys.argv[1:]]
print("→ Finishing update with refreshed code...")
sys.stdout.flush()
sys.stderr.flush()
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env)
return result.returncode
except Exception as exc: # pragma: no cover - spawn almost never fails
logger.warning(
"update: could not hand off to refreshed code (%s); "
"finishing in-process",
exc,
)
return None
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always
restore stdio even on ``sys.exit``."""
@@ -10198,18 +10194,31 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
assume_yes = bool(getattr(args, "yes", False))
# Self-update hand-off marker. ``hermes update`` runs from the *old*
# install, so its post-pull steps (dep install, config migration, gateway
# restart) would otherwise execute stale in-memory code even though the new
# source is already on disk. After a successful pull we re-exec into the
# refreshed code with HERMES_UPDATE_FINALIZE=1; ``finalize_only`` is True on
# that second pass and makes us skip the fetch/pull work and the re-exec.
finalize_only = os.environ.get("HERMES_UPDATE_FINALIZE") == "1"
# Whether this update is running without a human at the keyboard.
# Interactive terminal updates always stash-and-ask (unchanged behavior);
# only non-interactive updates (desktop/chat app, gateway, `--yes`) consult
# the `updates.non_interactive_local_changes` config setting to decide
# whether to auto-restore stashed local source changes or throw them away.
_non_interactive_update = (
gateway_mode
or assume_yes
or not (sys.stdin.isatty() and sys.stdout.isatty())
)
discard_local_changes = False
if _non_interactive_update:
try:
from hermes_cli.config import load_config
if finalize_only:
print("⚕ Finalizing update with refreshed code...")
else:
print("⚕ Updating Hermes Agent...")
_update_cfg = (load_config() or {}).get("updates", {})
if isinstance(_update_cfg, dict):
_mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower()
discard_local_changes = _mode == "discard"
except Exception as exc:
# Never let a config read failure change the safe default.
logger.debug("Could not read updates.non_interactive_local_changes: %s", exc)
discard_local_changes = False
print("⚕ Updating Hermes Agent...")
print()
# On Windows, abort early if another hermes.exe is holding the venv shim
@@ -10225,10 +10234,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
sys.exit(2)
# Pre-update backup — runs before any git/file mutation so users can
# always roll back to the exact state they had before this update. Skipped
# on the finalize re-exec (the original pass already took it).
if not finalize_only:
_run_pre_update_backup(args)
# always roll back to the exact state they had before this update.
_run_pre_update_backup(args)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
@@ -10272,21 +10279,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
# On Windows, Git-for-Windows defaults to core.autocrlf=true, which
# renormalizes the repo's LF-only text files to CRLF in the working tree.
# On a managed, never-user-edited clone that makes tracked files read as
# "locally modified", which forces an autostash on every update (and the
# stash/restore cycle can clobber source files — see _stash_local_changes_
# if_needed below). Pin autocrlf=false so the dirt is never created. This
# mirrors what install.ps1's update path already does (PR #38239).
if sys.platform == "win32" and git_dir.exists():
subprocess.run(
git_cmd + ["config", "core.autocrlf", "false"],
cwd=PROJECT_ROOT,
check=False,
capture_output=True,
)
# Discard npm lockfile churn before any stash/branch logic. npm rewrites
# tracked package-lock.json files non-deterministically at install/build
# time (platform-specific optional deps, ideallyInert annotations, etc.),
@@ -10364,14 +10356,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
else f"branch '{current_branch}'"
)
print(f" ⚠ Currently on {label} — switching to {branch} for update...")
# Stash before checkout so uncommitted work isn't lost — but on a
# managed (non-fork) clone there's nothing to preserve, so discard
# git-artifact dirt instead (a dirty tree would otherwise block the
# checkout). Forks keep the stash so their edits survive.
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
auto_stash_ref = None
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
# Stash before checkout so uncommitted work isn't lost
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
checkout_result = subprocess.run(
git_cmd + ["checkout", branch],
cwd=PROJECT_ROOT,
@@ -10405,16 +10391,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(f" {track_result.stderr.strip().splitlines()[0]}")
sys.exit(1)
else:
# On a managed (non-fork) clone the user never edits the source
# tree, so any dirt is git artifact (CRLF, lockfile churn,
# upstream-deleted dirs). Throw it away rather than stash/restore
# it — the stash/restore cycle has clobbered freshly-pulled source
# (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Forks fall
# through to the stash path so their intentional edits survive.
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
auto_stash_ref = None
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
prompt_for_restore = (
auto_stash_ref is not None
@@ -10432,11 +10409,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
commit_count = int(result.stdout.strip())
# On the finalize re-exec the pull already happened in the original
# pass, so origin is level with HEAD (count == 0). Don't take the
# "Already up to date" early return — fall through and run the
# post-pull steps (now with refreshed code).
if commit_count == 0 and not finalize_only:
if commit_count == 0:
_invalidate_update_cache()
# Even if origin is up to date, the fork may be behind upstream
@@ -10463,30 +10436,24 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("✓ Already up to date!")
return
# The "found N commits" notice and pre-update snapshot belong to the
# original pass only — the finalize re-exec sees count == 0 and the
# snapshot was already taken before the pull.
print(f"→ Found {commit_count} new commit(s)")
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
# Issue #15733 reported missing pairing data after an update; even
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
pre_update_snapshot_id = None
if not finalize_only:
print(f"→ Found {commit_count} new commit(s)")
try:
from hermes_cli.backup import create_quick_snapshot
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
# Issue #15733 reported missing pairing data after an update; even
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
try:
from hermes_cli.backup import create_quick_snapshot
pre_update_snapshot_id = create_quick_snapshot(
label="pre-update", keep=1
)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
print("→ Pulling updates...")
update_succeeded = False
@@ -10576,6 +10543,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
f" ️ Local changes preserved in stash (ref: {auto_stash_ref})"
)
print(f" Restore manually with: git stash apply")
elif discard_local_changes:
# Non-interactive update + user opted into discarding local
# source edits (updates.non_interactive_local_changes:
# discard). Throw the stash away instead of re-applying it.
_discard_stashed_changes(
git_cmd,
PROJECT_ROOT,
auto_stash_ref,
)
else:
_restore_stashed_changes(
git_cmd,
@@ -10658,26 +10634,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
_refresh_active_lazy_features()
# Hand off the remaining post-pull work (node deps, web/desktop build,
# config migration, skills sync, gateway restart) to the freshly-pulled
# code. These are the most frequently-changed and historically most
# fragile update steps, yet they used to run from the modules this
# process imported at startup — so a bug fixed in the pulled version
# still crashed here, forcing users to run ``hermes update`` twice. The
# git pull AND dependency install are done, so the new source and its
# deps are on disk; finish in a fresh subprocess running new code and
# forward its exit code. On the finalize pass (or under pytest /
# opt-out) we skip the hand-off and finish in-process — see
# _should_handoff_after_pull.
if _should_handoff_after_pull(finalize_only):
handoff_rc = _handoff_update_to_refreshed_code()
if handoff_rc is not None:
# The child ran every remaining post-pull step on new code;
# forward its result and stop (cmd_update's finally still
# restores stdio on the way out).
sys.exit(handoff_rc)
# else: spawning the child failed — fall through and finish here.
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")
+71 -4
View File
@@ -51,12 +51,48 @@ def resolve_uv() -> Optional[str]:
return None
def ensure_uv() -> Optional[str]:
"""Return the managed uv path, installing it first if necessary.
class _UvResult(str):
"""``ensure_uv()`` return value that survives an update boundary.
On failure returns ``None`` (never raises) so callers can fall
back to pip gracefully.
``ensure_uv()``'s arity has flipped between a single path string and a
``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs
the call site from the *old*, already-imported ``hermes_cli.main`` against
this *freshly pulled* module, so the two can disagree on how many values
``ensure_uv()`` returns. An install parked on a 2-tuple release runs
``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module
and crashes the first update: the returned path is a plain ``str``, which is
itself iterable, so the 2-target unpack walks its characters and raises
``ValueError: too many values to unpack (expected 2)`` (and on the failure
path the ``None`` return raises ``TypeError: cannot unpack non-iterable
NoneType``). This wrapper answers to both conventions:
uv_bin = ensure_uv() # behaves as the path str ("" when absent)
uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap)
Missing uv is the empty string (falsy) instead of ``None`` so legacy
2-target call sites can still unpack a failure without raising, while
``if not uv_bin`` keeps working for single-value callers.
POSIX only. This wrapper is **never** returned on Windows see
``ensure_uv()`` for why the ``__iter__`` override is unsafe there.
"""
fresh_bootstrap: bool
def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult":
self = super().__new__(cls, path or "")
self.fresh_bootstrap = fresh
return self
def __iter__(self):
# Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.
# First element mirrors the historical contract: the path string, or
# ``None`` when uv is unavailable.
return iter(((str(self) or None), self.fresh_bootstrap))
def _ensure_uv_path() -> Optional[str]:
"""Resolve the managed uv path, installing it if necessary (plain ``str``/``None``)."""
existing = resolve_uv()
if existing:
return existing
@@ -88,6 +124,37 @@ def ensure_uv() -> Optional[str]:
return result
def ensure_uv():
"""Return the managed uv path, installing it first if necessary.
On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is
both usable directly as the path *and* unpackable as
``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple
release see :class:`_UvResult` for the update-boundary rationale.
On **Windows** we deliberately return a plain ``str``/``None`` instead.
``subprocess`` there serializes the argv via ``subprocess.list2cmdline``,
which iterates every entry *as a string* (``for c in arg``). The dependency
installer passes uv straight into the command list (``[uv_bin, "pip", ...]``),
so a ``_UvResult`` whose ``__iter__`` yields ``(path, fresh_bootstrap)``
rather than characters would inject the bool into the command line and
crash the install with ``TypeError: sequence item 1: expected str instance,
bool found``. A plain ``str`` matches the historical Windows contract and is
subprocess-safe. (A single value cannot satisfy both 2-target unpacking and
Windows char-iteration: both use the iterator protocol, with contradictory
results.)
On failure the result is falsy never raises so callers can fall back to
pip gracefully.
"""
result = _ensure_uv_path()
if platform.system() == "Windows":
# See docstring: a str subclass with an overridden __iter__ is unsafe as
# a Windows subprocess argument. Hand back the plain path (or None).
return result
return _UvResult(result)
def update_managed_uv() -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
+56
View File
@@ -1117,6 +1117,62 @@ def switch_model(
# Authenticated providers listing (for /model no-args display)
# ---------------------------------------------------------------------------
# Process-level guard so the picker prewarm thread is spawned at most once per
# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a
# long-lived process (or repeated triggers) would leak one OS thread per call.
import threading as _threading # noqa: E402
_picker_prewarm_done = _threading.Event()
def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
"""Warm the provider-models disk cache in a background daemon thread.
The no-args ``/model`` picker calls ``list_authenticated_providers()``,
which fetches each authenticated provider's live ``/v1/models`` list on a
cold/stale cache. Those fetches are independent HTTP round-trips but run
serially, so the first ``/model`` open in a session (or any open after the
1h cache TTL expires) blocks ~1-2s on the user's critical path.
This pre-warms that exact path off-thread during idle session time: it
runs ``list_authenticated_providers()`` once, which populates
``provider_models_cache.json`` for every authed provider. By the time the
user types ``/model``, the picker hits the warm disk cache and renders in
~100ms.
Fire-and-forget. Process-level Event guard ensures it runs at most once.
Fully exception-isolated a slow or offline provider can never affect the
session. Returns the spawned thread (for tests) or None if already warmed.
"""
if _picker_prewarm_done.is_set():
return None
_picker_prewarm_done.set()
def _warm() -> None:
try:
from hermes_cli.inventory import load_picker_context
ctx = load_picker_context()
# Calling this is what populates cached_provider_model_ids() ->
# provider_models_cache.json for each authed provider. We discard
# the result; the side effect (warm disk cache) is the point.
list_authenticated_providers(
current_provider=ctx.current_provider,
current_base_url=ctx.current_base_url,
current_model=ctx.current_model,
user_providers=ctx.user_providers,
custom_providers=ctx.custom_providers,
max_models=50,
)
except Exception:
# Best-effort warmup — never surface errors into the session.
logger.debug("picker cache prewarm failed", exc_info=True)
t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm")
t.start()
return t
def list_authenticated_providers(
current_provider: str = "",
current_base_url: str = "",
+35 -6
View File
@@ -1150,17 +1150,46 @@ _PROVIDER_ALIASES = {
}
# Cost-safe overrides for the *silent* auto-default
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
# sensible default, but Nous Portal is a per-token *metered aggregator* whose
# list is ordered best-/most-capable-first — entry [0] is the priciest flagship
# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the
# non-interactive fallback when a profile sets ``provider: nous`` with no model
# silently bills the most expensive model for traffic the user never opted into
# (a missing default escalated to Opus and billed 863 requests before the user
# noticed). Pin the silent default to a low-cost curated model instead so a
# missing model can never escalate to the flagship.
#
# This is deliberately a fixed, side-effect-free default for the hot resolution
# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the
# richer free/paid-tier-aware resolver — see ``get_recommended_default_model``
# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can
# hit the Portal; this fallback must stay cheap and network-free.
_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = {
"nous": "deepseek/deepseek-v4-flash",
}
def get_default_model_for_provider(provider: str) -> str:
"""Return the default model for a provider, or empty string if unknown.
"""Return a cost-safe default model for a provider, or "" if unknown.
Uses the first entry in _PROVIDER_MODELS as the default. This is the
model a user would be offered first in the ``hermes model`` picker.
Used as a NON-INTERACTIVE fallback when a provider is configured but no
model was ever selected (e.g. ``hermes auth add openai-codex`` without
``hermes model``, or a profile that sets ``provider`` with no ``model``).
Used as a fallback when the user has configured a provider but never
selected a model (e.g. ``hermes auth add openai-codex`` without
``hermes model``).
For most providers this is the first entry in ``_PROVIDER_MODELS`` the
same model the ``hermes model`` picker offers first. For metered aggregators
whose curated list is ordered most-capable-first, that entry is also the
most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such
providers carry an explicit low-cost override in
``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never
auto-escalate to the flagship.
"""
models = _PROVIDER_MODELS.get(provider, [])
override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider)
if override and override in models:
return override
return models[0] if models else ""
+42 -3
View File
@@ -159,6 +159,33 @@ def _has_agent_browser() -> bool:
return bool(agent_browser_bin or local_bin.exists())
def _local_browser_runnable() -> bool:
"""Return True when the *local* browser backend would actually start.
The ``agent-browser`` CLI being present is necessary but not sufficient for
local mode: agent-browser also needs a Chromium build on disk (without one
it hangs on first use until the command timeout fires), unless the
Lightpanda engine is selected text-only navigation needs no Chromium.
This mirrors the local-mode tail of
:func:`tools.browser_tool.check_browser_requirements`, so the setup/status
surfaces advertise local browser readiness only when the runtime would
actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host
their own Chromium and therefore gate on :func:`_has_agent_browser` alone.
"""
if not _has_agent_browser():
return False
try:
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
except Exception:
# If the runtime probe can't be imported, fall back to binary presence
# (prior behaviour) rather than crashing the setup/status surface.
return True
if _using_lightpanda_engine():
return True
return _chromium_installed()
def _browser_label(current_provider: str) -> str:
mapping = {
"browserbase": "Browserbase",
@@ -188,13 +215,23 @@ def _resolve_browser_feature_state(
browser_provider: str,
browser_provider_explicit: bool,
browser_local_available: bool,
browser_local_runnable: bool,
direct_camofox: bool,
direct_browserbase: bool,
direct_browser_use: bool,
direct_firecrawl: bool,
managed_browser_available: bool,
) -> tuple[str, bool, bool, bool]:
"""Resolve browser availability using the same precedence as runtime."""
"""Resolve browser availability using the same precedence as runtime.
``browser_local_available`` means "the agent-browser CLI is present" the
only local requirement for cloud providers, which host their own Chromium.
``browser_local_runnable`` additionally requires a usable local Chromium
build (or the Lightpanda engine), mirroring the local-mode tail of
:func:`tools.browser_tool.check_browser_requirements`. Local mode must gate
on the latter, or setup/status advertise a browser that fails on first use
when Chromium is missing.
"""
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
@@ -223,7 +260,7 @@ def _resolve_browser_feature_state(
return current_provider, False, False, False
current_provider = "local"
available = bool(browser_local_available)
available = bool(browser_local_runnable)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
@@ -243,7 +280,7 @@ def _resolve_browser_feature_state(
active = bool(browser_tool_enabled and available)
return "browserbase", available, active, False
available = bool(browser_local_available)
available = bool(browser_local_runnable)
active = bool(browser_tool_enabled and available)
return "local", available, active, False
@@ -445,6 +482,7 @@ def get_nous_subscription_features(
tts_active = bool(tts_tool_enabled and tts_available)
browser_local_available = _has_agent_browser()
browser_local_runnable = _local_browser_runnable()
(
browser_current_provider,
browser_available,
@@ -455,6 +493,7 @@ def get_nous_subscription_features(
browser_provider=browser_provider,
browser_provider_explicit=browser_provider_explicit,
browser_local_available=browser_local_available,
browser_local_runnable=browser_local_runnable,
direct_camofox=direct_camofox,
direct_browserbase=direct_browserbase,
direct_browser_use=direct_browser_use,
+104 -17
View File
@@ -415,7 +415,9 @@ def _print_setup_summary(config: dict, hermes_home):
elif browser_provider == "Camofox":
missing_browser_hint = "CAMOFOX_URL"
elif browser_provider == "Local browser":
missing_browser_hint = "npm install -g agent-browser"
missing_browser_hint = (
"npm install -g agent-browser && agent-browser install --with-deps"
)
tool_status.append(
("Browser Automation", False, missing_browser_hint)
)
@@ -1637,6 +1639,52 @@ def setup_agent_settings(config: dict):
# =============================================================================
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
def _is_valid_telegram_bot_token(token: str) -> bool:
return bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
def _setup_telegram_auto_result():
"""Attempt automatic Telegram bot creation via managed QR onboarding."""
try:
from hermes_cli.telegram_managed_bot import auto_setup_telegram_bot_result
except ImportError:
return None
profile_name: str | None = None
try:
hermes_home = str(get_hermes_home())
if "/profiles/" in hermes_home:
profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1]
except Exception:
pass
return auto_setup_telegram_bot_result(profile_name=profile_name)
def _setup_telegram_auto() -> str | None:
"""Attempt automatic Telegram bot creation and return only the token."""
result = _setup_telegram_auto_result()
return result.token if result else None
def _prompt_telegram_bot_token() -> str | None:
print_info("Create a bot via @BotFather on Telegram")
while True:
token = prompt("Telegram bot token", password=True)
if not token:
return None
if not _is_valid_telegram_bot_token(token):
print_error(
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
)
continue
return token
def _setup_telegram():
"""Configure Telegram bot credentials and allowlist."""
print_header("Telegram")
@@ -1655,20 +1703,40 @@ def _setup_telegram():
print_success("Telegram allowlist configured")
return
print_info("Create a bot via @BotFather on Telegram")
import re
print_info("How would you like to create your Telegram bot?")
print()
print_info(" [1] Automatic (recommended)")
print_info(" Scan a QR code → confirm in Telegram → done.")
print_info(" No token copy-paste needed.")
print()
print_info(" [2] Manual")
print_info(" Create a bot via @BotFather yourself and paste the token.")
print()
while True:
token = prompt("Telegram bot token", password=True)
choice = prompt("Choice [1/2]", default="1")
token = None
setup_result = None
if choice.strip() == "1":
setup_result = _setup_telegram_auto_result()
if setup_result:
token = setup_result.token
if not _is_valid_telegram_bot_token(token):
print_error("Automatic setup returned an invalid Telegram bot token.")
token = None
setup_result = None
else:
token = None
if not token:
return
if not re.match(r"^\d+:[A-Za-z0-9_-]{30,}$", token):
print_error(
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
)
continue
break
print()
print_info("Falling back to manual setup...")
print()
if not token:
token = _prompt_telegram_bot_token()
if not token:
return
save_env_value("TELEGRAM_BOT_TOKEN", token)
print_success("Telegram token saved")
@@ -1678,11 +1746,30 @@ def _setup_telegram():
print_info(" 1. Message @userinfobot on Telegram")
print_info(" 2. It will reply with your numeric ID (e.g., 123456789)")
print()
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
detected_user_id = getattr(setup_result, "owner_user_id", None)
if detected_user_id:
detected_id = str(detected_user_id)
print_success(f"Detected your Telegram user ID: {detected_id}")
if prompt_yes_no("Allow this Telegram account to use the bot?", True):
extra = prompt("Additional allowed user IDs (comma-separated, optional)")
ids = [detected_id]
for uid in extra.replace(" ", "").split(","):
if uid and uid not in ids:
ids.append(uid)
allowed_users = ",".join(ids)
else:
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
else:
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
if allowed_users:
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", ""))
allowed_users = allowed_users.replace(" ", "")
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users)
print_success("Telegram allowlist configured - only listed users can use the bot")
else:
print_info("⚠️ No allowlist set - anyone who finds your bot can use it!")
+358
View File
@@ -0,0 +1,358 @@
"""Telegram Managed Bot onboarding client.
Uses Telegram's Managed Bots feature to create a user-owned child bot without
manual BotFather token copy-paste. Hermes talks only to the Nous onboarding
service; the raw Telegram token is saved locally after one-time retrieval.
"""
from __future__ import annotations
import os
import re
import secrets
import sys
import time
import urllib.parse
from dataclasses import dataclass
from typing import Optional
import httpx
# Default pairing API base URL (Nous-hosted Cloudflare Worker).
# Override for PoC/staging with TELEGRAM_ONBOARDING_URL.
DEFAULT_API_URL = "https://setup.hermes-agent.nousresearch.com"
TELEGRAM_ONBOARDING_URL_ENV = "TELEGRAM_ONBOARDING_URL"
# The Nous-hosted manager bot username (without @). The backend returns the
# actual deep link, so this is only used by local helpers/tests.
DEFAULT_MANAGER_BOT = "HermesSetupBot"
DEFAULT_BOT_NAME = "Hermes Agent"
DEFAULT_POLL_TIMEOUT = 180
POLL_INTERVAL = 2
_USERNAME_SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
@dataclass(frozen=True)
class TelegramPairing:
"""Pairing record returned by the Telegram onboarding service."""
pairing_id: str
poll_token: str
suggested_username: str
deep_link: str
qr_payload: str
expires_at: str | None = None
@dataclass(frozen=True)
class TelegramBotSetupResult:
"""Successful Telegram onboarding result returned by the setup service."""
token: str
bot_username: str | None = None
owner_user_id: int | None = None
def _api_url(api_url: str | None = None) -> str:
"""Resolve the onboarding API URL, honoring the PoC env override."""
return (
api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL
).rstrip("/")
def is_valid_telegram_bot_token(token: object) -> bool:
"""Return True when *token* has Telegram's bot-token shape."""
return isinstance(token, str) and bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
def _parse_owner_user_id(value: object) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str) and value.isdecimal():
parsed = int(value)
return parsed if parsed > 0 else None
return None
def render_qr_terminal(url: str) -> str:
"""Render a URL as a QR code string suitable for terminal output."""
try:
import io
import qrcode # type: ignore[import-untyped]
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=1,
)
qr.add_data(url)
qr.make(fit=True)
buf = io.StringIO()
qr.print_ascii(out=buf, invert=True)
return buf.getvalue()
except ImportError:
return ""
def print_qr_code(url: str, *, include_link: bool = True) -> None:
"""Print a QR code to stdout, with URL fallback if qrcode is missing."""
qr_text = render_qr_terminal(url)
if qr_text:
print(qr_text)
else:
print(" (Install 'qrcode' for a scannable QR code: pip install qrcode)")
if include_link:
print(f" Link: {url}")
def generate_username_slug(length: int = 16) -> str:
"""Generate a base32-ish slug for Telegram username correlation.
Sixteen characters from a 32-symbol alphabet gives 80 bits of entropy while
keeping ``hermes_<slug>_bot`` under Telegram's 32-character username limit.
"""
return "".join(secrets.choice(_USERNAME_SLUG_ALPHABET) for _ in range(length))
def generate_bot_username(profile_name: Optional[str] = None) -> str:
"""Generate a secure suggested bot username like ``hermes_<slug>_bot``.
``profile_name`` is accepted for backward compatibility with the original
PoC, but is intentionally not embedded in the username. The username has to
carry enough entropy for backend correlation.
"""
_ = profile_name
return f"hermes_{generate_username_slug()}_bot"
def generate_deep_link(
manager_bot: str = DEFAULT_MANAGER_BOT,
suggested_username: Optional[str] = None,
suggested_name: Optional[str] = None,
) -> str:
"""Build a ``t.me/newbot`` deep link for managed bot creation."""
manager = manager_bot.lstrip("@")
username = suggested_username or generate_bot_username()
base_url = (
"https://t.me/newbot/"
f"{urllib.parse.quote(manager)}/"
f"{urllib.parse.quote(username)}"
)
if suggested_name:
params = urllib.parse.urlencode({"name": suggested_name})
return f"{base_url}?{params}"
return base_url
def generate_pairing_nonce() -> str:
"""Generate a legacy-compatible random nonce string.
The new protocol uses service-created ``pairing_id`` + bearer
``poll_token`` instead of a path nonce, but this helper is harmless and
still useful for callers/tests that need a generic random id.
"""
return secrets.token_hex(16)
def create_pairing(
api_url: str | None = None,
bot_name: str = DEFAULT_BOT_NAME,
timeout: float = 10.0,
) -> TelegramPairing | None:
"""Create a Telegram onboarding pairing.
``POST /v1/telegram/pairings`` returns the deep link, QR payload, public
pairing id, and secret poll token. The token is only used as a bearer
credential while polling.
"""
try:
resp = httpx.post(
f"{_api_url(api_url)}/v1/telegram/pairings",
json={"bot_name": bot_name},
timeout=timeout,
)
if resp.status_code not in (200, 201):
return None
data = resp.json()
except (httpx.HTTPError, ValueError):
return None
required = ("pairing_id", "poll_token", "suggested_username", "deep_link")
if not all(isinstance(data.get(key), str) and data.get(key) for key in required):
return None
qr_payload = data.get("qr_payload") or data["deep_link"]
if not isinstance(qr_payload, str):
return None
expires_at = data.get("expires_at")
return TelegramPairing(
pairing_id=data["pairing_id"],
poll_token=data["poll_token"],
suggested_username=data["suggested_username"],
deep_link=data["deep_link"],
qr_payload=qr_payload,
expires_at=expires_at if isinstance(expires_at, str) else None,
)
def poll_pairing_result_once(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = 10.0,
) -> TelegramBotSetupResult | None:
"""Poll the onboarding service once. Returns setup metadata when ready."""
resp = httpx.get(
f"{_api_url(api_url)}/v1/telegram/pairings/{pairing.pairing_id}",
headers={"Authorization": f"Bearer {pairing.poll_token}"},
timeout=timeout,
)
if resp.status_code != 200:
return None
data = resp.json()
if data.get("status") != "ready":
return None
token = data.get("token")
if not is_valid_telegram_bot_token(token):
return None
bot_username = data.get("bot_username")
return TelegramBotSetupResult(
token=token,
bot_username=bot_username
if isinstance(bot_username, str) and bot_username
else None,
owner_user_id=_parse_owner_user_id(data.get("owner_user_id")),
)
def poll_pairing_once(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = 10.0,
) -> str | None:
"""Poll the onboarding service once. Returns the token when ready."""
result = poll_pairing_result_once(api_url, pairing, timeout=timeout)
return result.token if result else None
def poll_for_setup_result(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = DEFAULT_POLL_TIMEOUT,
interval: float = POLL_INTERVAL,
) -> Optional[TelegramBotSetupResult]:
"""Poll the pairing API until setup metadata is available or timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
result = poll_pairing_result_once(api_url, pairing)
if result:
return result
except (httpx.HTTPError, ValueError):
pass
time.sleep(interval)
return None
def poll_for_token(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = DEFAULT_POLL_TIMEOUT,
interval: float = POLL_INTERVAL,
) -> Optional[str]:
"""Poll the pairing API until the bot token is available or timeout."""
result = poll_for_setup_result(api_url, pairing, timeout=timeout, interval=interval)
return result.token if result else None
def auto_setup_telegram_bot_result(
api_url: str | None = None,
manager_bot: str = DEFAULT_MANAGER_BOT,
profile_name: Optional[str] = None,
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
) -> Optional[TelegramBotSetupResult]:
"""Run the full automatic Telegram bot creation flow."""
_ = manager_bot, profile_name
resolved_api_url = _api_url(api_url)
print()
print(f" Contacting Hermes Telegram onboarding service: {resolved_api_url}")
sys.stdout.flush()
pairing = create_pairing(resolved_api_url)
if not pairing:
print(" ✗ Could not reach the Hermes Telegram onboarding service.")
print(" Try the manual setup instead, or check your network.")
return None
print(" ✓ Pairing created")
print(" Rendering QR code...")
sys.stdout.flush()
print()
print(" Scan this QR code with your phone, or open the link below:")
print()
print_qr_code(pairing.qr_payload, include_link=False)
print()
print(f" Link: {pairing.deep_link}")
print()
print(" When Telegram opens, tap 'Create Bot' to confirm.")
print(" (You can edit the bot display name before confirming)")
print()
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
start = time.monotonic()
deadline = start + poll_timeout
idx = 0
while time.monotonic() < deadline:
char = spinner_chars[idx % len(spinner_chars)]
elapsed = int(time.monotonic() - start)
remaining = max(0, int(poll_timeout - elapsed))
sys.stdout.write(
f"\r {char} Waiting for bot creation... ({remaining}s remaining) "
)
sys.stdout.flush()
idx += 1
try:
result = poll_pairing_result_once(resolved_api_url, pairing)
if result:
sys.stdout.write(
"\r ✓ Bot created successfully! \n"
)
sys.stdout.flush()
return result
except (httpx.HTTPError, ValueError):
pass
time.sleep(POLL_INTERVAL)
sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n")
sys.stdout.flush()
print(" The bot may still be created — check Telegram.")
print(" You can paste the token manually below, or re-run setup.")
return None
def auto_setup_telegram_bot(
api_url: str | None = None,
manager_bot: str = DEFAULT_MANAGER_BOT,
profile_name: Optional[str] = None,
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
) -> Optional[str]:
"""Run automatic Telegram bot creation and return only the bot token."""
result = auto_setup_telegram_bot_result(
api_url=api_url,
manager_bot=manager_bot,
profile_name=profile_name,
poll_timeout=poll_timeout,
)
return result.token if result else None
+11
View File
@@ -439,6 +439,16 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"description": "Reasoning effort for delegated subagents",
"options": ["", "low", "medium", "high"],
},
"updates.non_interactive_local_changes": {
"type": "select",
"description": (
"When the chat app / gateway updates Hermes (no terminal prompt), "
"what to do with uncommitted local source edits. 'stash' keeps them "
"and re-applies them after the update; 'discard' throws them away. "
"Terminal updates always ask, regardless of this setting."
),
"options": ["stash", "discard"],
},
}
# Categories with fewer fields get merged into "general" to avoid tab sprawl.
@@ -455,6 +465,7 @@ _CATEGORY_MERGE: Dict[str, str] = {
"code_execution": "agent",
"prompt_caching": "agent",
"goals": "agent",
"updates": "general",
# Only `telegram.reactions` currently lives under telegram — fold it in
# with the other messaging-platform config (discord) so it isn't an
# orphan tab of one field.
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Af (slegs teks)"
label_voice_only: "Aan (stemantwoord op stemboodskappe)"
label_all: "TTS (stemantwoord op alle boodskappe)"
help: "{toggle}\n\n**Hoe /voice werk**\n• `/voice on` — stemantwoord wanneer jy 'n stemboodskap stuur\n• `/voice tts` — stemantwoord op *elke* boodskap\n• `/voice off` — terug na slegs-teks antwoorde\n• `/voice status` — wys die huidige modus\n• `/voice` (geen argument) — wissel vinnig tussen aan en af{channels}"
help_channels: "\n\n**Lewendige stemkanale (Discord)**\n• Sluit eers by 'n stemkanaal aan, dan `/voice channel` — ek sluit aan, luister en praat my antwoorde\n• `/voice leave` — ontkoppel van die stemkanaal"
yolo:
disabled: "⚠️ YOLO-modus **AF** vir hierdie sessie — gevaarlike opdragte sal goedkeuring vereis."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Aus (nur Text)"
label_voice_only: "An (Sprachantwort auf Sprachnachrichten)"
label_all: "TTS (Sprachantwort auf alle Nachrichten)"
help: "{toggle}\n\n**So funktioniert /voice**\n• `/voice on` — Sprachantwort, wenn du eine Sprachnachricht sendest\n• `/voice tts` — Sprachantwort auf *jede* Nachricht\n• `/voice off` — zurück zu reinen Textantworten\n• `/voice status` — aktuellen Modus anzeigen\n• `/voice` (ohne Argument) — schnelles Umschalten zwischen an und aus{channels}"
help_channels: "\n\n**Live-Sprachkanäle (Discord)**\n• Tritt zuerst einem Sprachkanal bei, dann `/voice channel` — ich trete bei, höre zu und spreche meine Antworten\n• `/voice leave` — vom Sprachkanal trennen"
yolo:
disabled: "⚠️ YOLO-Modus für diese Sitzung **AUS** — gefährliche Befehle benötigen eine Genehmigung."
+2
View File
@@ -358,6 +358,8 @@ gateway:
label_off: "Off (text only)"
label_voice_only: "On (voice reply to voice messages)"
label_all: "TTS (voice reply to all messages)"
help: "{toggle}\n\n**How /voice works**\n• `/voice on` — voice reply when you send a voice message\n• `/voice tts` — voice reply to *every* message\n• `/voice off` — back to text-only replies\n• `/voice status` — show the current mode\n• `/voice` (no argument) — quick toggle between on and off{channels}"
help_channels: "\n\n**Live voice channels (Discord)**\n• Join a voice channel first, then `/voice channel` — I'll join, listen, and speak my replies\n• `/voice leave` — disconnect from the voice channel"
yolo:
disabled: "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Desactivado (solo texto)"
label_voice_only: "Activado (responder con voz a mensajes de voz)"
label_all: "TTS (responder con voz a todos los mensajes)"
help: "{toggle}\n\n**Cómo funciona /voice**\n• `/voice on` — respuesta de voz cuando envías un mensaje de voz\n• `/voice tts` — respuesta de voz a *cada* mensaje\n• `/voice off` — volver a respuestas solo de texto\n• `/voice status` — mostrar el modo actual\n• `/voice` (sin argumento) — alternar rápido entre activado y desactivado{channels}"
help_channels: "\n\n**Canales de voz en vivo (Discord)**\n• Únete primero a un canal de voz, luego `/voice channel` — me uno, escucho y digo mis respuestas\n• `/voice leave` — desconectar del canal de voz"
yolo:
disabled: "⚠️ Modo YOLO **DESACTIVADO** en esta sesión — los comandos peligrosos requerirán aprobación."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Désactivé (texte seulement)"
label_voice_only: "Activé (réponse vocale aux messages vocaux)"
label_all: "TTS (réponse vocale à tous les messages)"
help: "{toggle}\n\n**Comment fonctionne /voice**\n• `/voice on` — réponse vocale quand vous envoyez un message vocal\n• `/voice tts` — réponse vocale à *chaque* message\n• `/voice off` — retour aux réponses texte uniquement\n• `/voice status` — afficher le mode actuel\n• `/voice` (sans argument) — bascule rapide entre activé et désactivé{channels}"
help_channels: "\n\n**Salons vocaux en direct (Discord)**\n• Rejoignez d'abord un salon vocal, puis `/voice channel` — je rejoins, j'écoute et je parle mes réponses\n• `/voice leave` — se déconnecter du salon vocal"
yolo:
disabled: "⚠️ Mode YOLO **DÉSACTIVÉ** pour cette session — les commandes dangereuses nécessiteront une approbation."
+2
View File
@@ -347,6 +347,8 @@ gateway:
label_off: "As (téacs amháin)"
label_voice_only: "Ar (freagra gutha do theachtaireachtaí gutha)"
label_all: "TTS (freagra gutha do gach teachtaireacht)"
help: "{toggle}\n\n**Conas a oibríonn /voice**\n• `/voice on` — freagra gutha nuair a sheolann tú teachtaireacht gutha\n• `/voice tts` — freagra gutha do *gach* teachtaireacht\n• `/voice off` — ar ais go freagraí téacs amháin\n• `/voice status` — taispeáin an mód reatha\n• `/voice` (gan argóint) — scoránaigh go tapa idir air agus as{channels}"
help_channels: "\n\n**Cainéil gutha bheo (Discord)**\n• Téigh isteach i gcainéal gutha ar dtús, ansin `/voice channel` — téim isteach, éistim agus labhraím mo fhreagraí\n• `/voice leave` — dícheangail ón gcainéal gutha"
yolo:
disabled: "⚠️ Mód YOLO **AS** don seisiún seo — beidh cead de dhíth d'orduithe contúirteacha."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Ki (csak szöveg)"
label_voice_only: "Be (hangválasz hangüzenetekre)"
label_all: "TTS (hangválasz minden üzenetre)"
help: "{toggle}\n\n**Hogyan működik a /voice**\n• `/voice on` — hangválasz, amikor hangüzenetet küldesz\n• `/voice tts` — hangválasz *minden* üzenetre\n• `/voice off` — vissza a csak szöveges válaszokhoz\n• `/voice status` — az aktuális mód megjelenítése\n• `/voice` (argumentum nélkül) — gyors váltás be és ki között{channels}"
help_channels: "\n\n**Élő hangcsatornák (Discord)**\n• Először lépj be egy hangcsatornába, majd `/voice channel` — csatlakozom, hallgatok és hangosan válaszolok\n• `/voice leave` — lecsatlakozás a hangcsatornáról"
yolo:
disabled: "⚠️ YOLO mód **KI** ebben a munkamenetben — a veszélyes parancsok jóváhagyást igényelnek."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Off (solo testo)"
label_voice_only: "On (risposta vocale ai messaggi vocali)"
label_all: "TTS (risposta vocale a tutti i messaggi)"
help: "{toggle}\n\n**Come funziona /voice**\n• `/voice on` — risposta vocale quando invii un messaggio vocale\n• `/voice tts` — risposta vocale a *ogni* messaggio\n• `/voice off` — torna alle risposte solo testo\n• `/voice status` — mostra la modalità attuale\n• `/voice` (senza argomento) — alterna rapidamente tra attivo e disattivo{channels}"
help_channels: "\n\n**Canali vocali dal vivo (Discord)**\n• Entra prima in un canale vocale, poi `/voice channel` — mi unisco, ascolto e parlo le mie risposte\n• `/voice leave` — disconnetti dal canale vocale"
yolo:
disabled: "⚠️ Modalità YOLO **OFF** per questa sessione — i comandi pericolosi richiederanno approvazione."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "オフ (テキストのみ)"
label_voice_only: "オン (音声メッセージにのみ音声で返信)"
label_all: "TTS (すべてのメッセージに音声で返信)"
help: "{toggle}\n\n**/voice の使い方**\n• `/voice on` — 音声メッセージを送ると音声で返信\n• `/voice tts` — *すべての*メッセージに音声で返信\n• `/voice off` — テキストのみの返信に戻す\n• `/voice status` — 現在のモードを表示\n• `/voice`(引数なし)— オンとオフをすばやく切り替え{channels}"
help_channels: "\n\n**ライブ音声チャンネル (Discord)**\n• 先に音声チャンネルに参加してから `/voice channel` — 参加して聞き取り、音声で返信します\n• `/voice leave` — 音声チャンネルから切断"
yolo:
disabled: "⚠️ このセッションの YOLO モードは **OFF** — 危険なコマンドには承認が必要です。"
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "꺼짐 (텍스트 전용)"
label_voice_only: "켜짐 (음성 메시지에 음성으로 응답)"
label_all: "TTS (모든 메시지에 음성으로 응답)"
help: "{toggle}\n\n**/voice 사용법**\n• `/voice on` — 음성 메시지를 보내면 음성으로 답변\n• `/voice tts` — *모든* 메시지에 음성으로 답변\n• `/voice off` — 텍스트 전용 답변으로 복귀\n• `/voice status` — 현재 모드 표시\n• `/voice` (인자 없음) — 켜기와 끄기를 빠르게 전환{channels}"
help_channels: "\n\n**라이브 음성 채널 (Discord)**\n• 먼저 음성 채널에 들어간 다음 `/voice channel` — 제가 참여해 듣고 음성으로 답변합니다\n• `/voice leave` — 음성 채널에서 연결 해제"
yolo:
disabled: "⚠️ 이 세션에서 YOLO 모드 **꺼짐** — 위험한 명령은 승인이 필요합니다."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Desativado (apenas texto)"
label_voice_only: "Ativado (resposta de voz a mensagens de voz)"
label_all: "TTS (resposta de voz a todas as mensagens)"
help: "{toggle}\n\n**Como funciona o /voice**\n• `/voice on` — resposta por voz quando envias uma mensagem de voz\n• `/voice tts` — resposta por voz a *todas* as mensagens\n• `/voice off` — voltar às respostas apenas em texto\n• `/voice status` — mostrar o modo atual\n• `/voice` (sem argumento) — alternar rapidamente entre ligado e desligado{channels}"
help_channels: "\n\n**Canais de voz ao vivo (Discord)**\n• Entra primeiro num canal de voz, depois `/voice channel` — eu entro, ouço e falo as minhas respostas\n• `/voice leave` — desligar do canal de voz"
yolo:
disabled: "⚠️ Modo YOLO **DESATIVADO** nesta sessão — comandos perigosos exigirão aprovação."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Выкл. (только текст)"
label_voice_only: "Вкл. (голосовой ответ на голосовые сообщения)"
label_all: "TTS (голосовой ответ на все сообщения)"
help: "{toggle}\n\n**Как работает /voice**\n• `/voice on` — голосовой ответ, когда вы отправляете голосовое сообщение\n• `/voice tts` — голосовой ответ на *каждое* сообщение\n• `/voice off` — вернуться к ответам только текстом\n• `/voice status` — показать текущий режим\n• `/voice` (без аргумента) — быстрое переключение между вкл и выкл{channels}"
help_channels: "\n\n**Живые голосовые каналы (Discord)**\n• Сначала зайдите в голосовой канал, затем `/voice channel` — я подключусь, буду слушать и отвечать голосом\n• `/voice leave` — отключиться от голосового канала"
yolo:
disabled: "⚠️ Режим YOLO для этого сеанса **ОТКЛЮЧЁН** — опасные команды потребуют одобрения."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Kapalı (yalnızca metin)"
label_voice_only: "Açık (sesli mesajlara sesli yanıt)"
label_all: "TTS (tüm mesajlara sesli yanıt)"
help: "{toggle}\n\n**/voice nasıl çalışır**\n• `/voice on` — sesli mesaj gönderdiğinde sesli yanıt\n• `/voice tts` — *her* mesaja sesli yanıt\n• `/voice off` — yalnızca metin yanıtlarına dön\n• `/voice status` — geçerli modu göster\n• `/voice` (argümansız) — açık ve kapalı arasında hızlı geçiş{channels}"
help_channels: "\n\n**Canlı ses kanalları (Discord)**\n• Önce bir ses kanalına katıl, sonra `/voice channel` — katılırım, dinlerim ve yanıtlarımı sesli söylerim\n• `/voice leave` — ses kanalından ayrıl"
yolo:
disabled: "⚠️ Bu oturumda YOLO modu **KAPALI** — tehlikeli komutlar onay gerektirecek."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Вимкнено (лише текст)"
label_voice_only: "Увімкнено (голосова відповідь на голосові повідомлення)"
label_all: "TTS (голосова відповідь на всі повідомлення)"
help: "{toggle}\n\n**Як працює /voice**\n• `/voice on` — голосова відповідь, коли ви надсилаєте голосове повідомлення\n• `/voice tts` — голосова відповідь на *кожне* повідомлення\n• `/voice off` — повернутися до відповідей лише текстом\n• `/voice status` — показати поточний режим\n• `/voice` (без аргументу) — швидке перемикання між увімк і вимк{channels}"
help_channels: "\n\n**Живі голосові канали (Discord)**\n• Спершу зайдіть у голосовий канал, потім `/voice channel` — я підключуся, слухатиму й відповідатиму голосом\n• `/voice leave` — від'єднатися від голосового каналу"
yolo:
disabled: "⚠️ Режим YOLO для цього сеансу **ВИМКНЕНО** — небезпечні команди потребуватимуть схвалення."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "關閉(僅文字)"
label_voice_only: "開啟(僅對語音訊息進行語音回覆)"
label_all: "TTS(對所有訊息進行語音回覆)"
help: "{toggle}\n\n**/voice 用法**\n• `/voice on` — 當你傳送語音訊息時以語音回覆\n• `/voice tts` — 對*每則*訊息都以語音回覆\n• `/voice off` — 恢復為純文字回覆\n• `/voice status` — 顯示目前模式\n• `/voice`(無參數)— 在開啟和關閉之間快速切換{channels}"
help_channels: "\n\n**即時語音頻道 (Discord)**\n• 先加入一個語音頻道,然後 `/voice channel` — 我會加入、聆聽並以語音回覆\n• `/voice leave` — 中斷語音頻道"
yolo:
disabled: "⚠️ 本工作階段 YOLO 模式 **已關閉** — 危險指令將需要批准。"
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "关闭(仅文本)"
label_voice_only: "开启(仅对语音消息进行语音回复)"
label_all: "TTS(对所有消息进行语音回复)"
help: "{toggle}\n\n**/voice 用法**\n• `/voice on` — 当你发送语音消息时用语音回复\n• `/voice tts` — 对*每条*消息都用语音回复\n• `/voice off` — 恢复为纯文本回复\n• `/voice status` — 显示当前模式\n• `/voice`(无参数)— 在开启和关闭之间快速切换{channels}"
help_channels: "\n\n**实时语音频道 (Discord)**\n• 先加入一个语音频道,然后 `/voice channel` — 我会加入、聆听并用语音回复\n• `/voice leave` — 断开语音频道"
yolo:
disabled: "⚠️ 本会话 YOLO 模式 **已关闭** — 危险命令将需要批准。"
+1 -1
View File
@@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0=";
npmDepsHash = "sha256-cY+gM1FnTBjmld/uqt7RsqRtW9uQGs8LGokCcxu7bjQ=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
+22 -48
View File
@@ -100,7 +100,7 @@
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"shiki": "^4.0.2",
@@ -10188,6 +10188,19 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -17785,9 +17798,9 @@
}
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -17807,12 +17820,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
"react-router": "7.17.0"
},
"engines": {
"node": ">=20.0.0"
@@ -17822,19 +17835,6 @@
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/react-shiki": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz",
@@ -19701,7 +19701,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19718,7 +19717,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19735,7 +19733,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19752,7 +19749,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19769,7 +19765,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19786,7 +19781,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19803,7 +19797,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19820,7 +19813,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19837,7 +19829,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19854,7 +19845,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19871,7 +19861,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19888,7 +19877,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19905,7 +19893,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19922,7 +19909,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19939,7 +19925,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19956,7 +19941,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19973,7 +19957,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -19990,7 +19973,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20007,7 +19989,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20024,7 +20005,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20041,7 +20021,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20058,7 +20037,6 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20075,7 +20053,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20092,7 +20069,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20109,7 +20085,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -20126,7 +20101,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22172,7 +22146,7 @@
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.17.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"unicode-animations": "^1.0.3"
+7 -5
View File
@@ -348,9 +348,10 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Contract: returns None on expiry/invalidity (middleware then
# triggers redirect-to-login since refresh_session can never succeed
# under V1); raises ProviderError if the IDP is unreachable.
# Contract: returns None on expiry/invalidity (the middleware then
# tries refresh_session with the RT cookie, falling back to
# redirect-to-login if that also fails); raises ProviderError if the
# IDP is unreachable.
try:
claims = self._verify_jwt(access_token)
except InvalidCodeError:
@@ -359,8 +360,9 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
except ProviderError:
# JWKS unreachable, etc. Bubble up so middleware emits 503.
raise
# verify_session has no access to the original refresh_token; pass
# "" because in contract V1 there is none anyway.
# verify_session validates the AT in isolation and has no access to the
# refresh token (it lives in a separate cookie the middleware reads);
# pass "" here — the RT-driven rotation path is middleware's job.
return self._session_from_claims(access_token, "", claims)
def revoke_session(self, *, refresh_token: str) -> None:
+211 -1
View File
@@ -600,6 +600,12 @@ class DiscordAdapter(BasePlatformAdapter):
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
self._voice_input_callback: Optional[Callable] = None # set by run.py
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
# Installed once per guild on join; lets acks / TTS / the "thinking"
# loop overlap in one outgoing stream instead of stop-and-swap.
self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer
self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed
self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config()
# Track threads where the bot has participated so follow-up messages
# in those threads don't require @mention. Persisted to disk so the
# set survives gateway restarts.
@@ -1925,6 +1931,160 @@ class DiscordAdapter(BasePlatformAdapter):
# Voice channel methods (join / leave / play)
# ------------------------------------------------------------------
def _load_voice_fx_config(self) -> Dict[str, Any]:
"""Read voice mixer / ambient / ack settings from config.yaml.
All settings live under ``discord.voice_fx`` in config.yaml (NOT the
.env file these are behavioral, not secrets). The feature is OFF by
default; users opt in with ``discord.voice_fx.enabled: true``.
Returns a dict with safe defaults so callers never KeyError.
"""
defaults: Dict[str, Any] = {
"enabled": False, # master switch for the mixer subsystem
"ambient_enabled": True, # idle "thinking" bed while tools run
"ambient_path": "", # optional custom loop file; "" = synthesised
"ambient_gain": 0.18, # idle bed loudness (0..1)
"duck_gain": 0.06, # ambient loudness while speech plays
"speech_gain": 1.0, # TTS / ack loudness
"ack_enabled": True, # speak a short phrase before tool calls
"ack_phrases": [
"Let me look into that.",
"One moment.",
"Checking on that now.",
"Give me a sec.",
"On it.",
],
}
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config() or {}
fx = ((cfg.get("discord") or {}).get("voice_fx") or {})
if isinstance(fx, dict):
for k, v in fx.items():
if k in defaults and v is not None:
defaults[k] = v
except Exception as e:
logger.debug("Could not load discord.voice_fx config: %s", e)
return defaults
def _get_ambient_pcm(self) -> Optional[bytes]:
"""Return decoded 48k/stereo/s16le PCM for the ambient idle bed.
Uses a custom file when ``ambient_path`` is set and decodable, else a
synthesised pad. Cached after first build.
"""
if self._ambient_pcm_cache is not None:
return self._ambient_pcm_cache
if not self._voice_fx_cfg.get("ambient_enabled"):
return None
try:
from voice_mixer import decode_to_pcm, synth_ambient_pcm
except ImportError:
from .voice_mixer import decode_to_pcm, synth_ambient_pcm
pcm: Optional[bytes] = None
path = (self._voice_fx_cfg.get("ambient_path") or "").strip()
if path and os.path.isfile(path):
pcm = decode_to_pcm(path)
if not pcm:
logger.warning("Ambient file %s failed to decode; using synth bed", path)
if not pcm:
pcm = synth_ambient_pcm()
self._ambient_pcm_cache = pcm
return pcm
async def _install_voice_mixer(self, guild_id: int, vc) -> None:
"""Create a VoiceMixer, start the ambient bed, and play it on the VC.
The mixer runs continuously for the life of the connection: one
``vc.play(mixer)`` call, never stopped until leave.
"""
try:
from voice_mixer import VoiceMixer
except ImportError:
from .voice_mixer import VoiceMixer
mixer = VoiceMixer(
ambient_gain=float(self._voice_fx_cfg.get("ambient_gain", 0.18)),
duck_gain=float(self._voice_fx_cfg.get("duck_gain", 0.06)),
speech_gain=float(self._voice_fx_cfg.get("speech_gain", 1.0)),
)
ambient = await asyncio.to_thread(self._get_ambient_pcm)
if ambient:
mixer.set_ambient(ambient)
def _after(error):
if error:
logger.error("Voice mixer stream error (guild=%d): %s", guild_id, error)
if vc.is_playing():
vc.stop()
vc.play(mixer, after=_after)
self._voice_mixers[guild_id] = mixer
logger.info("Voice mixer installed (guild=%d, ambient=%s)", guild_id, bool(ambient))
async def play_ack_in_voice(self, guild_id: int, phrase: Optional[str] = None) -> bool:
"""Speak a short acknowledgement over the ambient bed.
Called from the gateway's tool-progress hook on the first tool call of
a turn, so the user hears "let me look into that" before the bot goes
quiet to work. No-op unless the mixer is installed and acks enabled.
"""
if not self._voice_fx_cfg.get("ack_enabled"):
return False
mixer = self._voice_mixers.get(guild_id)
if mixer is None:
return False
if phrase is None:
import random
phrases = self._voice_fx_cfg.get("ack_phrases") or ["One moment."]
phrase = random.choice(phrases)
# Synthesise the ack via the configured TTS provider, then layer it.
import uuid as _uuid
audio_path = os.path.join(
tempfile.gettempdir(), "hermes_voice",
f"ack_{_uuid.uuid4().hex[:12]}.mp3",
)
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
try:
from tools.tts_tool import text_to_speech_tool
result_json = await asyncio.to_thread(
text_to_speech_tool, text=phrase, output_path=audio_path
)
result = json.loads(result_json)
actual = result.get("file_path", audio_path)
if not result.get("success") or not os.path.isfile(actual):
return False
try:
from voice_mixer import decode_to_pcm
except ImportError:
from .voice_mixer import decode_to_pcm
pcm = await asyncio.to_thread(decode_to_pcm, actual)
if not pcm:
return False
mixer.play_speech(
pcm, gain=float(self._voice_fx_cfg.get("speech_gain", 1.0))
)
self._reset_voice_timeout(guild_id)
return True
except Exception as e:
logger.debug("play_ack_in_voice failed: %s", e)
return False
finally:
for p in {audio_path, locals().get("actual")}:
if p and os.path.isfile(p):
try:
os.unlink(p)
except OSError:
pass
def voice_mixer_active(self, guild_id: int) -> bool:
"""True when a continuous mixer is installed for this guild."""
mixers = getattr(self, "_voice_mixers", None)
return bool(mixers) and mixers.get(guild_id) is not None
async def join_voice_channel(self, channel) -> bool:
"""Join a Discord voice channel. Returns True on success."""
if not self._client or not DISCORD_AVAILABLE:
@@ -1957,6 +2117,15 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as e:
logger.warning("Voice receiver failed to start: %s", e)
# Phase 3: install the continuous mixer (ambient bed + ducked
# speech). Best-effort — if it fails we fall back to the legacy
# one-shot FFmpegPCMAudio playback path in play_in_voice_channel.
if getattr(self, "_voice_fx_cfg", {}).get("enabled"):
try:
await self._install_voice_mixer(guild_id, vc)
except Exception as e:
logger.warning("Voice mixer failed to start: %s", e)
return True
async def leave_voice_channel(self, guild_id: int) -> None:
@@ -1970,8 +2139,17 @@ class DiscordAdapter(BasePlatformAdapter):
if listen_task:
listen_task.cancel()
# Tear down the mixer (stops the continuous outgoing stream).
if getattr(self, "_voice_mixers", None) is not None:
self._voice_mixers.pop(guild_id, None)
vc = self._voice_clients.pop(guild_id, None)
if vc and vc.is_connected():
try:
if vc.is_playing():
vc.stop()
except Exception:
pass
await vc.disconnect()
task = self._voice_timeout_tasks.pop(guild_id, None)
if task:
@@ -1983,11 +2161,43 @@ class DiscordAdapter(BasePlatformAdapter):
PLAYBACK_TIMEOUT = 120
async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
"""Play an audio file in the connected voice channel."""
"""Play an audio file in the connected voice channel.
When the continuous mixer is installed for this guild, the clip is
decoded to PCM and layered over the ambient bed (ducking it) so the
reply can overlap the idle "thinking" loop seamlessly. Otherwise we
fall back to the legacy one-shot FFmpegPCMAudio path.
"""
vc = self._voice_clients.get(guild_id)
if not vc or not vc.is_connected():
return False
# ── Mixer path (overlap + ducking) ──────────────────────────────
mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None
if mixer is not None:
try:
from voice_mixer import decode_to_pcm
except ImportError:
from .voice_mixer import decode_to_pcm
pcm = await asyncio.to_thread(decode_to_pcm, audio_path)
if pcm:
speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0))
mixer.play_speech(pcm, gain=speech_gain)
# Block until the speech child drains so callers serialise
# replies (mirrors legacy semantics) but the ambient keeps
# playing underneath the whole time.
wait_start = time.monotonic()
while mixer.speech_active:
if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:
logger.warning("Mixer speech playback timed out after %ds", self.PLAYBACK_TIMEOUT)
mixer.stop_speech()
break
await asyncio.sleep(0.05)
self._reset_voice_timeout(guild_id)
return True
logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path)
# ── Legacy one-shot path (no mixer) ─────────────────────────────
# Pause voice receiver while playing (echo prevention)
receiver = self._voice_receivers.get(guild_id)
if receiver:
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
"""
Continuous PCM audio mixer for Discord voice channels.
discord.py (Rapptz) ships no audio mixer: ``VoiceClient.play()`` accepts a
single :class:`discord.AudioSource` and raises ``ClientException`` if called
while already playing. One opus stream per connection, one source feeding it.
This module adds software mixing *upstream* of that single stream. A
:class:`VoiceMixer` is itself a ``discord.AudioSource`` that discord.py polls
every 20 ms via :meth:`read`. Internally it sums the 20 ms PCM frames of any
number of child sources, clamps to int16, and returns one blended frame.
discord.py never knows several streams were combined underneath it just
encodes and sends the single mixed frame.
This gives us, for one voice connection at once:
* an always-on low-volume **ambient/idle loop** (the "thinking" sound),
* a **speech** channel (TTS replies, verbal acknowledgements) that plays
*over* the ambient bed, automatically **ducking** the ambient gain down
while speech is active and restoring it when speech ends the smooth
Grok-voice-mode feel, instead of stop-and-swap.
Design notes
------------
* The mixer is installed **once** per guild on join (``vc.play(mixer)``) and
runs continuously until the bot leaves. Children come and go; the mixer
itself never stops, so there is no ``is_playing()`` race between an
acknowledgement and the final reply.
* Frame format is Discord-native: 48 kHz, 2 channels, signed 16-bit LE,
20 ms per frame == ``discord.opus.Encoder.FRAME_SIZE`` bytes
(3840 = 960 samples * 2 channels * 2 bytes).
* Mixing is a single vectorised int32 add + clip per 20 ms frame (numpy,
already a core dependency). CPU cost is negligible.
* :meth:`read` is called from discord.py's audio sender **thread**, while
children are added/removed from the asyncio event loop thread, so all
shared state is guarded by a plain ``threading.Lock``.
The mixer NEVER touches the inbound receive path: it only produces the bot's
*outgoing* stream. The :class:`VoiceReceiver` decodes incoming SSRCs only, so
the mixer's output cannot echo back into transcription.
"""
import logging
import threading
from typing import TYPE_CHECKING, List, Optional
if TYPE_CHECKING: # numpy is an optional ("voice" extra) dep — never import at runtime top-level
import numpy as np
logger = logging.getLogger(__name__)
def _require_numpy():
"""Import numpy lazily.
numpy ships in the optional ``voice`` extra, not the base install, so this
module must import cleanly without it (the Discord adapter imports this
file unconditionally). Callers that actually mix audio call this; if the
voice extra isn't installed they get a clear error instead of a top-level
ImportError that would break the whole adapter import.
"""
import numpy as np # noqa: PLC0415 — intentional lazy import
return np
# Discord-native frame geometry (matches discord.opus.Encoder).
SAMPLE_RATE = 48000
CHANNELS = 2
SAMPLE_WIDTH = 2 # bytes per sample (s16)
FRAME_LENGTH_MS = 20
SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_LENGTH_MS // 1000 # 960
FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH # 3840 bytes
SILENCE_FRAME = b"\x00" * FRAME_SIZE
class MixerChild:
"""A single audio stream feeding into :class:`VoiceMixer`.
Wraps raw 48 kHz / stereo / s16le PCM bytes. ``read_frame`` hands back one
20 ms frame at a time, optionally looping, with a per-child gain applied.
"""
__slots__ = (
"name", "_pcm", "_pos", "loop", "gain",
"is_speech", "fade_frames", "_fade_done", "_finished",
)
def __init__(
self,
name: str,
pcm: bytes,
*,
loop: bool = False,
gain: float = 1.0,
is_speech: bool = False,
fade_in_ms: int = 0,
):
# Pad to a whole number of frames so looping is seamless and the final
# partial frame doesn't click.
remainder = len(pcm) % FRAME_SIZE
if remainder:
pcm = pcm + b"\x00" * (FRAME_SIZE - remainder)
self.name = name
self._pcm = pcm
self._pos = 0
self.loop = loop
self.gain = float(gain)
self.is_speech = is_speech
# Linear fade-in over N frames avoids a click when a loud child starts.
self.fade_frames = max(0, fade_in_ms // FRAME_LENGTH_MS)
self._fade_done = 0
self._finished = False
@property
def finished(self) -> bool:
return self._finished
def read_frame(self) -> "Optional[np.ndarray]":
"""Return the next 20 ms frame as an int16 ndarray, or None if done."""
if self._finished:
return None
if self._pos >= len(self._pcm):
if self.loop and self._pcm:
self._pos = 0
else:
self._finished = True
return None
np = _require_numpy()
chunk = self._pcm[self._pos:self._pos + FRAME_SIZE]
self._pos += FRAME_SIZE
if len(chunk) < FRAME_SIZE:
chunk = chunk + b"\x00" * (FRAME_SIZE - len(chunk))
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32)
gain = self.gain
if self.fade_frames and self._fade_done < self.fade_frames:
self._fade_done += 1
gain *= self._fade_done / self.fade_frames
if gain != 1.0:
samples = samples * gain
return samples
class VoiceMixer:
"""A continuous ``discord.AudioSource`` that mixes N child streams.
Use :meth:`set_ambient` to install/replace the looping idle bed and
:meth:`play_speech` to layer a one-shot clip over it (ducking the ambient
while it plays). Both are safe to call from the asyncio loop thread while
discord.py drains :meth:`read` from its sender thread.
"""
# discord.AudioSource subclasses set is_opus()==False to receive PCM.
def is_opus(self) -> bool: # pragma: no cover - trivial
return False
def __init__(
self,
*,
ambient_gain: float = 0.18,
duck_gain: float = 0.06,
speech_gain: float = 1.0,
duck_release_ms: int = 400,
):
self._lock = threading.Lock()
self._ambient: Optional[MixerChild] = None
self._speech: List[MixerChild] = []
self._ambient_gain = float(ambient_gain)
self._duck_gain = float(duck_gain)
self._speech_gain = float(speech_gain)
# When speech ends, ramp the ambient back up over this many frames
# instead of jumping, so the bed swells back smoothly.
self._duck_release_frames = max(1, duck_release_ms // FRAME_LENGTH_MS)
self._duck_release_left = 0
self._closed = False
# Tracks whether speech is currently active, for external callers that
# want to avoid double-ducking or know when a reply is mid-flight.
self._speech_active = False
# ------------------------------------------------------------------
# Ambient (idle / "thinking") bed
# ------------------------------------------------------------------
def set_ambient(self, pcm: Optional[bytes], *, gain: Optional[float] = None) -> None:
"""Install (or clear, with ``pcm=None``) the looping ambient bed."""
with self._lock:
if gain is not None:
self._ambient_gain = float(gain)
if not pcm:
self._ambient = None
return
self._ambient = MixerChild(
"ambient", pcm, loop=True,
gain=self._effective_ambient_gain(), fade_in_ms=200,
)
def _effective_ambient_gain(self) -> float:
return self._duck_gain if self._speech_active else self._ambient_gain
# ------------------------------------------------------------------
# Speech (TTS replies, verbal acks) layered over the ambient bed
# ------------------------------------------------------------------
def play_speech(self, pcm: bytes, *, gain: Optional[float] = None,
fade_in_ms: int = 40) -> None:
"""Layer a one-shot speech clip over the ambient bed (ducks ambient)."""
if not pcm:
return
with self._lock:
child = MixerChild(
"speech", pcm, loop=False,
gain=self._speech_gain if gain is None else float(gain),
is_speech=True, fade_in_ms=fade_in_ms,
)
self._speech.append(child)
self._speech_active = True
self._duck_release_left = 0
if self._ambient is not None:
self._ambient.gain = self._duck_gain
@property
def speech_active(self) -> bool:
with self._lock:
return self._speech_active
def stop_speech(self) -> None:
"""Drop any in-flight speech immediately and release the duck."""
with self._lock:
self._speech.clear()
self._begin_duck_release_locked()
def _begin_duck_release_locked(self) -> None:
self._speech_active = False
self._duck_release_left = self._duck_release_frames
# ------------------------------------------------------------------
# AudioSource interface — called from discord.py's sender thread
# ------------------------------------------------------------------
def read(self) -> bytes:
"""Return one 20 ms mixed PCM frame (always FRAME_SIZE bytes).
Returning a non-empty frame keeps discord.py's player alive; we never
return b"" because that would stop the single underlying stream and we
want the mixer to run continuously for the lifetime of the connection.
"""
with self._lock:
if self._closed:
return SILENCE_FRAME
np = _require_numpy()
acc: "Optional[np.ndarray]" = None
# Speech children (drop exhausted ones; release duck when last ends)
if self._speech:
still_live: List[MixerChild] = []
for child in self._speech:
frame = child.read_frame()
if frame is None:
continue
acc = frame if acc is None else acc + frame
still_live.append(child)
self._speech = still_live
if not self._speech and self._speech_active:
self._begin_duck_release_locked()
# Ambient bed — ramp gain back up during duck-release.
if self._ambient is not None:
if self._duck_release_left > 0 and not self._speech_active:
self._duck_release_left -= 1
frac = 1.0 - (self._duck_release_left / self._duck_release_frames)
self._ambient.gain = (
self._duck_gain
+ (self._ambient_gain - self._duck_gain) * frac
)
elif not self._speech_active and self._duck_release_left == 0:
self._ambient.gain = self._ambient_gain
amb = self._ambient.read_frame()
if amb is not None:
acc = amb if acc is None else acc + amb
if acc is None:
return SILENCE_FRAME
np.clip(acc, -32768, 32767, out=acc)
return acc.astype(np.int16).tobytes()
def cleanup(self) -> None: # called by discord.py when playback stops
with self._lock:
self._closed = True
self._ambient = None
self._speech.clear()
# ----------------------------------------------------------------------
# PCM helpers
# ----------------------------------------------------------------------
def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
"""Decode any audio file to 48 kHz / stereo / s16le PCM via ffmpeg.
Returns the raw PCM bytes, or None on failure. ffmpeg is already a hard
requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``).
"""
import subprocess
try:
proc = subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-i", path,
"-f", "s16le",
"-ar", str(SAMPLE_RATE),
"-ac", str(CHANNELS),
"pipe:1",
],
capture_output=True,
timeout=timeout,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.warning("decode_to_pcm failed for %s: %s", path, e)
return None
if proc.returncode != 0:
logger.warning(
"ffmpeg decode failed for %s (rc=%d): %s",
path, proc.returncode, (proc.stderr or b"").decode("utf-8", "replace")[:200],
)
return None
return proc.stdout or None
def synth_ambient_pcm(seconds: float = 4.0) -> bytes:
"""Synthesise a subtle looping ambient bed (no asset file required).
A soft, slowly-pulsing low pad: two detuned sine partials with a gentle
tremolo, plus a touch of filtered noise. Designed to loop seamlessly
(whole number of cycles, zero-crossing endpoints) and sit quietly under
speech. Mono content duplicated to stereo.
"""
np = _require_numpy()
n = int(SAMPLE_RATE * seconds)
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
# Choose base frequencies that complete whole cycles over the loop so the
# wrap point is click-free.
def _whole_cycle_freq(target: float) -> float:
cycles = max(1, round(target * seconds))
return cycles / seconds
f1 = _whole_cycle_freq(110.0)
f2 = _whole_cycle_freq(110.5)
trem = _whole_cycle_freq(0.5) # ~0.5 Hz tremolo
pad = (
0.55 * np.sin(2 * np.pi * f1 * t)
+ 0.45 * np.sin(2 * np.pi * f2 * t)
)
tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t)))
signal = pad * tremolo
# Smooth filtered noise for air, kept very low.
rng = np.random.default_rng(7)
noise = rng.standard_normal(n)
kernel = np.ones(64) / 64.0
noise = np.convolve(noise, kernel, mode="same")
signal = signal + 0.08 * noise
# Normalise to a modest peak (mixer applies the real ambient gain on top).
peak = float(np.max(np.abs(signal))) or 1.0
signal = (signal / peak) * 0.5
mono16 = (signal * 32767.0).astype(np.int16)
stereo16 = np.repeat(mono16[:, None], CHANNELS, axis=1).reshape(-1)
return stereo16.tobytes()
+16 -1
View File
@@ -133,6 +133,21 @@ MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively
LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs
LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video
# Map LINE webhook message types to the normalized MessageType the gateway
# routes on. LINE has no separate "voice" type — audio messages are recorded
# voice clips, so they map to VOICE (which the gateway sends through STT),
# mirroring how Telegram/WhatsApp classify voice notes. Anything unknown
# falls back to TEXT.
_LINE_MESSAGE_TYPES = {
"text": MessageType.TEXT,
"image": MessageType.PHOTO,
"video": MessageType.VIDEO,
"audio": MessageType.VOICE,
"file": MessageType.DOCUMENT,
"location": MessageType.LOCATION,
"sticker": MessageType.STICKER,
}
# A 1×1 transparent PNG used as fallback video preview thumbnail when no
# explicit preview is supplied — LINE requires ``previewImageUrl`` for
# video messages. Sourced from the Python stdlib (no Pillow dependency).
@@ -968,7 +983,7 @@ class LineAdapter(BasePlatformAdapter):
event_obj = MessageEvent(
text=text,
message_type=MessageType.TEXT if msg_type == "text" else MessageType.IMAGE,
message_type=_LINE_MESSAGE_TYPES.get(msg_type, MessageType.TEXT),
source=source_obj,
raw_message=event,
message_id=message_id,
+70 -6
View File
@@ -1063,6 +1063,7 @@ function Install-Repository {
# EAP=Stop. We rely on $LASTEXITCODE for actual failures.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$autostashRef = ""
try {
# This is a MANAGED checkout, not a repo the user edits. Git for
# Windows defaults to core.autocrlf=true, which renormalizes the
@@ -1071,12 +1072,23 @@ function Install-Repository {
# show as locally modified even though nobody touched them. A
# bare `git checkout` then aborts with "Your local changes would
# be overwritten by checkout", which is exactly the failure GUI
# users hit on update. Two-part fix: (1) stop creating the dirt
# by pinning autocrlf=false on this clone, (2) discard any
# pre-existing dirt with a hard reset before the checkout. Safe
# because nothing here is user-authored.
# users hit on update. Pin autocrlf=false so the dirt is never
# created in the first place.
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
git -c windows.appendAtomically=false reset --hard HEAD 2>$null
# Preserve any real local changes before the checkout instead of
# discarding them with `reset --hard HEAD`. The old hard reset
# silently destroyed agent-edited source on managed clones (the
# #38542 data-loss class). Stash + restore mirrors install.sh:
# nothing is lost, and a failed restore leaves the work in a
# git stash for manual recovery. Untracked files are included so
# agent-created dirs (e.g. tinker-atropos/) survive too.
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
Write-Info "Local changes detected, stashing before update..."
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" }
}
git -c windows.appendAtomically=false fetch origin
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
# Precedence: Commit > Tag > Branch. Commit and Tag check
@@ -1095,10 +1107,62 @@ function Install-Repository {
} else {
git -c windows.appendAtomically=false checkout $Branch
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
git -c windows.appendAtomically=false pull origin $Branch
git -c windows.appendAtomically=false pull --ff-only origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" }
}
if ($autostashRef) {
# Default to restoring so work is never silently dropped.
# Only prompt when we're certain a human can answer: an
# interactive session AND a real, non-redirected console on
# both stdin and stdout. The desktop "Update" button and
# bootstrap run the installer without a usable console -- in
# those cases Read-Host would hang or return empty, so we
# skip the prompt and just restore (the safe default).
$restoreNow = $true
$hasConsole = $false
try {
$hasConsole = (
[Environment]::UserInteractive `
-and (-not [Console]::IsInputRedirected) `
-and (-not [Console]::IsOutputRedirected) `
-and ($Host.Name -eq "ConsoleHost")
)
} catch { $hasConsole = $false }
if ($hasConsole) {
Write-Warn "Local changes were stashed before updating."
Write-Warn "Restoring them may reapply local customizations onto the updated codebase."
$restoreAnswer = Read-Host "Restore local changes now? [Y/n]"
if ($restoreAnswer -match '^(n|no)$') { $restoreNow = $false }
}
if ($restoreNow) {
Write-Info "Restoring local changes..."
git -c windows.appendAtomically=false stash apply $autostashRef
if ($LASTEXITCODE -eq 0) {
git -c windows.appendAtomically=false stash drop $autostashRef 2>$null
Write-Warn "Local changes were restored on top of the updated codebase."
Write-Warn "Review git diff / git status if Hermes behaves unexpectedly."
} else {
Write-Err "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash."
Write-Info "Resolve manually with: git stash apply $autostashRef"
throw "git stash apply failed after update"
}
} else {
Write-Info "Skipped restoring local changes."
Write-Info "Your changes are still preserved in git stash."
Write-Info "Restore manually with: git stash apply $autostashRef"
}
$autostashRef = ""
}
} finally {
if ($autostashRef) {
# We stashed but never reached the restore block (a fetch/
# checkout/pull failure threw). Leave the stash in place and
# tell the user how to recover it -- never silently drop it.
Write-Warn "Update did not complete. Your local changes are preserved in git stash."
Write-Info "Restore manually with: git stash apply $autostashRef"
}
$ErrorActionPreference = $prevEAP
Pop-Location
}
+41 -15
View File
@@ -1097,24 +1097,50 @@ clone_repo() {
log_info "Existing installation found, updating..."
cd "$INSTALL_DIR"
# This is a managed clone the user never edits, so any working-tree
# dirt is git artifact (CRLF renormalization, npm lockfile churn,
# files left behind when a directory was deleted upstream such as
# apps/bootstrap-installer/). The old path stashed that dirt and
# re-applied it after the pull, but the stash/restore cycle has
# clobbered freshly-pulled source files (apps/desktop/ →
# "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
# Discard the dirt with a hard reset instead — mirrors install.ps1's
# update path. Fork users customize via `hermes update`, which keeps
# the stash machinery; the installer is a managed-only entry point.
git fetch origin
local autostash_ref=""
if [ -n "$(git status --porcelain)" ]; then
log_info "Discarding working-tree changes on managed clone before update..."
git reset --hard HEAD >/dev/null 2>&1 || true
git clean -fd >/dev/null 2>&1 || true
local stash_name
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
log_info "Local changes detected, stashing before update..."
git stash push --include-untracked -m "$stash_name"
autostash_ref="stash@{0}"
fi
git fetch origin
git checkout "$BRANCH"
git reset --hard "origin/$BRANCH"
git pull --ff-only origin "$BRANCH"
if [ -n "$autostash_ref" ]; then
local restore_now="yes"
if [ -t 0 ] && [ -t 1 ]; then
echo
log_warn "Local changes were stashed before updating."
log_warn "Restoring them may reapply local customizations onto the updated codebase."
printf "Restore local changes now? [Y/n] "
read -r restore_answer
case "$restore_answer" in
""|y|Y|yes|YES|Yes) restore_now="yes" ;;
*) restore_now="no" ;;
esac
fi
if [ "$restore_now" = "yes" ]; then
log_info "Restoring local changes..."
if git stash apply "$autostash_ref"; then
git stash drop "$autostash_ref" >/dev/null
log_warn "Local changes were restored on top of the updated codebase."
log_warn "Review git diff / git status if Hermes behaves unexpectedly."
else
log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash."
log_info "Resolve manually with: git stash apply $autostash_ref"
exit 1
fi
else
log_info "Skipped restoring local changes."
log_info "Your changes are still preserved in git stash."
log_info "Restore manually with: git stash apply $autostash_ref"
fi
fi
else
log_error "Directory exists but is not a git repository: $INSTALL_DIR"
log_info "Remove it or choose a different directory with --dir"
+1
View File
@@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"harjoth.khara@gmail.com": "harjothkhara",
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"dirtyren@users.noreply.github.com": "dirtyren",
"zhaolei.vc@bytedance.com": "zhaoleibd",
+24
View File
@@ -326,3 +326,27 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0
assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
"""max_tokens=None must send the model's full output ceiling, not omit it.
Gemini's native generateContent applies a low internal default when
maxOutputTokens is absent, truncating tool calls mid-stream. Hermes passes
None to mean "unlimited", so the adapter must translate that to the
published 65,535 ceiling rather than leaving the field unset.
"""
from agent.gemini_native_adapter import (
build_gemini_request,
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS,
)
req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
assert req["generationConfig"]["maxOutputTokens"] == GEMINI_DEFAULT_MAX_OUTPUT_TOKENS == 65535
def test_explicit_max_tokens_is_respected():
from agent.gemini_native_adapter import build_gemini_request
req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=4096)
assert req["generationConfig"]["maxOutputTokens"] == 4096
@@ -859,3 +859,53 @@ class TestChatCompletionsCacheStats:
r = SimpleNamespace(usage=SimpleNamespace(prompt_tokens_details=details))
result = transport.extract_cache_stats(r)
assert result == {"cached_tokens": 500, "creation_tokens": 100}
class TestChatCompletionsGeminiNativeExtraBodyStrip:
"""Profile extra_body (e.g. Nous portal tags) must not reach a native
Gemini endpoint Google's REST API rejects unknown fields with HTTP 400.
"""
def _nous_profile(self):
from providers import get_provider_profile
return get_provider_profile("nous")
def test_tags_stripped_when_endpoint_is_native_gemini(self, transport):
kw = transport.build_kwargs(
"anthropic/claude-sonnet-4.6",
[{"role": "user", "content": "hi"}],
None,
provider_profile=self._nous_profile(),
base_url="https://generativelanguage.googleapis.com/v1beta",
session_id="s1",
max_tokens=None,
)
eb = kw.get("extra_body")
assert not eb or "tags" not in eb
def test_tags_preserved_on_nous_endpoint(self, transport):
kw = transport.build_kwargs(
"hermes-3-405b",
[{"role": "user", "content": "hi"}],
None,
provider_profile=self._nous_profile(),
base_url="https://inference.nousresearch.com/v1",
session_id="s1",
max_tokens=None,
)
eb = kw.get("extra_body")
assert eb and "tags" in eb
def test_tags_pass_through_on_gemini_openai_compat(self, transport):
# /openai compat endpoint is not "native" — unchanged behavior.
kw = transport.build_kwargs(
"anthropic/claude-sonnet-4.6",
[{"role": "user", "content": "hi"}],
None,
provider_profile=self._nous_profile(),
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
session_id="s1",
max_tokens=None,
)
eb = kw.get("extra_body")
assert eb and "tags" in eb
+264
View File
@@ -0,0 +1,264 @@
"""Tests for the Discord continuous voice mixer (ambient + ducked speech)
and the verbal-ack-before-tool-calls hook.
The mixer (plugins/platforms/discord/voice_mixer.py) is pure-PCM and has no
discord.py dependency, so its core is tested directly. The adapter
integration (install on join, play routing, ack) is tested with the standard
``object.__new__(DiscordAdapter)`` helper used elsewhere in the voice suite.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# numpy ships only in the optional "voice" extra (not [all,dev]); the mixer
# math needs it, so skip this whole module when it isn't installed.
np = pytest.importorskip("numpy")
# voice_mixer lives inside the discord plugin package dir; import by path the
# same way the adapter does.
_DISCORD_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"plugins", "platforms", "discord",
)
if _DISCORD_DIR not in sys.path:
sys.path.insert(0, _DISCORD_DIR)
import voice_mixer as vm # noqa: E402
# =====================================================================
# Pure mixer unit tests
# =====================================================================
class TestVoiceMixerCore:
def test_frame_geometry_matches_discord(self):
# 20ms @ 48kHz stereo s16 == 3840 bytes (discord.opus.Encoder.FRAME_SIZE)
assert vm.FRAME_SIZE == 3840
assert vm.SAMPLES_PER_FRAME == 960
assert len(vm.SILENCE_FRAME) == vm.FRAME_SIZE
def test_empty_mixer_returns_silence_frames(self):
mx = vm.VoiceMixer()
for _ in range(5):
frame = mx.read()
assert len(frame) == vm.FRAME_SIZE
assert frame == vm.SILENCE_FRAME
def test_is_opus_false(self):
# discord.py sends raw PCM when is_opus() is False.
assert vm.VoiceMixer().is_opus() is False
def test_ambient_loops_and_is_quiet(self):
mx = vm.VoiceMixer(ambient_gain=0.2)
amb = vm.synth_ambient_pcm(seconds=0.5)
assert len(amb) % vm.FRAME_SIZE == 0 # frame-aligned for seamless loop
mx.set_ambient(amb)
peaks = [int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16))))
for _ in range(100)] # 2s >> 0.5s loop
# Produces audio after the fade-in and stays under the configured gain.
assert any(p > 0 for p in peaks[10:])
assert max(peaks) < int(32767 * 0.5)
def test_speech_audible_over_ambient_then_releases(self):
mx = vm.VoiceMixer(ambient_gain=0.2, duck_gain=0.05, duck_release_ms=200)
mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5))
base = max(int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16))))
for _ in range(10))
tone = (np.sin(2 * np.pi * 440 * np.arange(int(48000 * 0.4)) / 48000)
* 20000).astype(np.int16)
stereo = np.repeat(tone[:, None], 2, axis=1).reshape(-1).tobytes()
mx.play_speech(stereo, fade_in_ms=0)
assert mx.speech_active
speech_peak = max(int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16))))
for _ in range(15))
assert speech_peak > base
# Drain past speech + release ramp; speech_active clears.
for _ in range(40):
mx.read()
assert not mx.speech_active
def test_clipping_prevents_int16_wraparound(self):
mx = vm.VoiceMixer()
loud = (np.ones(vm.SAMPLES_PER_FRAME * 2) * 30000).astype(np.int16).tobytes()
mx.play_speech(loud, fade_in_ms=0)
mx.play_speech(loud, fade_in_ms=0)
out = np.frombuffer(mx.read(), dtype=np.int16)
assert int(out.max()) == 32767 # clamped, not wrapped to negative
assert int(out.min()) >= -32768
def test_stop_speech_clears_in_flight(self):
mx = vm.VoiceMixer()
tone = (np.ones(48000) * 10000).astype(np.int16)
stereo = np.repeat(tone[:, None], 2, axis=1).reshape(-1).tobytes()
mx.play_speech(stereo)
assert mx.speech_active
mx.stop_speech()
mx.read()
assert not mx.speech_active
def test_set_ambient_none_clears(self):
mx = vm.VoiceMixer()
mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5))
mx.set_ambient(None)
# No ambient, no speech -> silence.
assert mx.read() == vm.SILENCE_FRAME
def test_cleanup_silences(self):
mx = vm.VoiceMixer()
mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5))
mx.cleanup()
assert mx.read() == vm.SILENCE_FRAME
def test_pcm_not_frame_aligned_is_padded(self):
# Odd-length PCM must be padded to whole frames (no IndexError, no click).
mx = vm.VoiceMixer()
mx.play_speech(b"\x01\x02\x03", fade_in_ms=0) # 3 bytes << one frame
out = mx.read()
assert len(out) == vm.FRAME_SIZE
def test_synth_ambient_is_stereo_and_frame_aligned(self):
pcm = vm.synth_ambient_pcm(seconds=1.0)
assert len(pcm) % (vm.CHANNELS * vm.SAMPLE_WIDTH) == 0
assert len(pcm) % vm.FRAME_SIZE == 0
# =====================================================================
# Adapter integration
# =====================================================================
def _make_adapter(fx_cfg=None):
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import Platform, PlatformConfig
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
adapter = object.__new__(DiscordAdapter)
adapter.platform = Platform.DISCORD
adapter.config = config
adapter._client = MagicMock()
adapter._voice_clients = {}
adapter._voice_locks = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_timeout_tasks = {}
adapter._voice_receivers = {}
adapter._voice_listen_tasks = {}
adapter._voice_mixers = {}
adapter._ambient_pcm_cache = None
adapter._voice_fx_cfg = fx_cfg if fx_cfg is not None else {
"enabled": True, "ambient_enabled": True, "ambient_path": "",
"ambient_gain": 0.18, "duck_gain": 0.06, "speech_gain": 1.0,
"ack_enabled": True, "ack_phrases": ["One moment."],
}
return adapter
class TestVoiceMixerActive:
def test_false_when_no_mixer(self):
adapter = _make_adapter()
assert adapter.voice_mixer_active(111) is False
def test_true_when_mixer_present(self):
adapter = _make_adapter()
adapter._voice_mixers[111] = object()
assert adapter.voice_mixer_active(111) is True
def test_false_when_attr_missing(self):
# Defensive getattr path (object.__new__ helper that forgot the attr).
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import Platform
bare = object.__new__(DiscordAdapter)
bare.platform = Platform.DISCORD
assert bare.voice_mixer_active(111) is False
class TestPlayInVoiceChannelMixerPath:
@pytest.mark.asyncio
async def test_routes_through_mixer_when_present(self):
adapter = _make_adapter()
vc = MagicMock()
vc.is_connected.return_value = True
adapter._voice_clients[111] = vc
# speech_active returns True once (so play_speech is observed) then
# False so the wait loop exits promptly.
class _Mixer:
def __init__(self):
self._polls = 0
self.play_speech = MagicMock()
@property
def speech_active(self):
self._polls += 1
return self._polls <= 1
mixer = _Mixer()
adapter._voice_mixers[111] = mixer
adapter._reset_voice_timeout = MagicMock()
fake_pcm = b"\x00" * vm.FRAME_SIZE
with patch.object(vm, "decode_to_pcm", return_value=fake_pcm):
ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3")
assert ok is True
mixer.play_speech.assert_called_once()
# Legacy path must NOT have been used.
vc.play.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_when_decode_fails(self):
adapter = _make_adapter()
vc = MagicMock()
vc.is_connected.return_value = True
vc.is_playing.return_value = False
adapter._voice_clients[111] = vc
adapter._voice_mixers[111] = MagicMock()
adapter._reset_voice_timeout = MagicMock()
adapter._voice_receivers[111] = MagicMock()
with patch.object(vm, "decode_to_pcm", return_value=None), \
patch("plugins.platforms.discord.adapter.discord") as mock_discord:
mock_discord.FFmpegPCMAudio.return_value = MagicMock()
mock_discord.PCMVolumeTransformer.return_value = MagicMock()
# Make the legacy wait loop resolve immediately without leaving the
# real Event.wait() coroutine unawaited.
async def _fast(coro, *a, **k):
if hasattr(coro, "close"):
coro.close()
return None
with patch("asyncio.wait_for", _fast):
ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3")
# Fell through to legacy path -> vc.play called.
assert vc.play.called
class TestPlayAckInVoice:
@pytest.mark.asyncio
async def test_noop_when_ack_disabled(self):
adapter = _make_adapter({"ack_enabled": False})
adapter._voice_mixers[111] = MagicMock()
assert await adapter.play_ack_in_voice(111) is False
@pytest.mark.asyncio
async def test_noop_when_no_mixer(self):
adapter = _make_adapter()
assert await adapter.play_ack_in_voice(111) is False
@pytest.mark.asyncio
async def test_plays_speech_when_armed(self, tmp_path):
adapter = _make_adapter()
mixer = MagicMock()
adapter._voice_mixers[111] = mixer
adapter._reset_voice_timeout = MagicMock()
ack_file = tmp_path / "ack.mp3"
ack_file.write_bytes(b"id3")
import json as _json
with patch("tools.tts_tool.text_to_speech_tool",
return_value=_json.dumps({"success": True, "file_path": str(ack_file)})), \
patch.object(vm, "decode_to_pcm", return_value=b"\x00" * vm.FRAME_SIZE):
ok = await adapter.play_ack_in_voice(111, phrase="Testing one two.")
assert ok is True
mixer.play_speech.assert_called_once()
+33
View File
@@ -641,3 +641,36 @@ class TestAdapterInit:
assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm"
assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group"
assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel"
# ---------------------------------------------------------------------------
# 9. Inbound message-type classification
# ---------------------------------------------------------------------------
class TestMessageTypeMapping:
"""LINE webhook message types must map to the right normalized
MessageType so the gateway routes media correctly (e.g. voice STT,
files document handling). Regression guard for the old code that
referenced the non-existent ``MessageType.IMAGE`` and collapsed every
non-text message onto a single type."""
def test_image_event_not_attributeerror_regression(self):
# The bug: MessageType.IMAGE doesn't exist on the enum.
MessageType = _line.MessageType
assert not hasattr(MessageType, "IMAGE")
def test_every_line_type_maps_to_correct_enum(self):
MessageType = _line.MessageType
mapping = _line._LINE_MESSAGE_TYPES
assert mapping["text"] == MessageType.TEXT
assert mapping["image"] == MessageType.PHOTO
assert mapping["video"] == MessageType.VIDEO
# LINE has no separate voice type — audio clips are voice notes.
assert mapping["audio"] == MessageType.VOICE
assert mapping["file"] == MessageType.DOCUMENT
assert mapping["location"] == MessageType.LOCATION
assert mapping["sticker"] == MessageType.STICKER
def test_unknown_type_falls_back_to_text(self):
MessageType = _line.MessageType
assert _line._LINE_MESSAGE_TYPES.get("flex", MessageType.TEXT) == MessageType.TEXT
+25
View File
@@ -1036,3 +1036,28 @@ class TestReadProcessCmdlinePsFallback:
)
result = status._read_process_cmdline(12345)
assert "hermes_cli/main.py" in result
class TestCorruptStatusFiles:
"""A status / pid file holding non-UTF-8 (binary) bytes must read as
None, not crash the gateway status path with UnicodeDecodeError."""
def test_read_json_file_returns_none_on_binary_garbage(self, tmp_path):
p = tmp_path / "runtime.json"
p.write_bytes(b"\xff\xfe\x00\x80not utf-8\x81")
assert status._read_json_file(p) is None
def test_read_json_file_still_parses_valid_json(self, tmp_path):
p = tmp_path / "runtime.json"
p.write_text(json.dumps({"pid": 7}), encoding="utf-8")
assert status._read_json_file(p) == {"pid": 7}
def test_read_pid_record_returns_none_on_binary_garbage(self, tmp_path):
p = tmp_path / "gateway.pid"
p.write_bytes(b"\xff\xfe\x00\x80\x81")
assert status._read_pid_record(p) is None
def test_read_pid_record_still_parses_bare_pid(self, tmp_path):
p = tmp_path / "gateway.pid"
p.write_text("4242", encoding="utf-8")
assert status._read_pid_record(p) == {"pid": 4242}
+16
View File
@@ -111,3 +111,19 @@ class TestCronCommandLifecycle:
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo"
assert jobs[0]["profile"] == "default"
def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
"""A one-shot job can be persisted with ``"repeat": null``. `cron
list` must render it as rather than crashing on .get(...)\\.get."""
from cron.jobs import load_jobs, save_jobs
create_job(prompt="One shot", schedule="every 1h")
# Force the present-but-null shape that .get("repeat", {}) mishandles.
jobs = load_jobs()
jobs[0]["repeat"] = None
save_jobs(jobs)
cron_command(Namespace(cron_command="list", all=True))
out = capsys.readouterr().out
assert "Repeat: ∞" in out
+98 -2
View File
@@ -92,12 +92,108 @@ class TestEnsureUv:
assert path == str(tmp_path / "bin" / "uv")
mock_install.assert_called_once()
def test_install_failure_returns_none(self, tmp_path):
def test_install_failure_returns_falsy(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
path = ensure_uv()
assert path is None
# Failure is a falsy sentinel (not None) so legacy 2-target call
# sites can still unpack it without raising — see
# TestEnsureUvUpdateBoundary for why.
assert not path
class TestEnsureUvUpdateBoundary:
"""``ensure_uv()`` must answer to both the single-value and the legacy
``(path, fresh_bootstrap)`` call conventions **on POSIX**.
``hermes update`` runs the call site from the old, already-imported
``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release
parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()``
against the single-value module; the path is an iterable ``str`` so the
2-target unpack walked its characters and raised
``ValueError: too many values to unpack (expected 2)`` (root cause behind
PR #39763), or ``TypeError`` on the ``None`` failure path. On POSIX the
result must therefore be usable as a bare path *and* unpackable as a
2-tuple, in both the success and failure cases.
The dual contract is intentionally **not** offered on Windows see
``TestEnsureUvWindowsSafe`` for why so these tests pin ``platform.system``
to a POSIX value.
"""
def test_success_usable_as_single_value(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
assert uv_bin == str(tmp_path / "bin" / "uv")
assert bool(uv_bin) is True
def test_success_unpacks_as_legacy_two_tuple(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import ensure_uv
uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv()
assert uv_bin == str(tmp_path / "bin" / "uv")
assert fresh is False
def test_failure_unpacks_without_raising(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
uv_bin, fresh = ensure_uv()
assert uv_bin is None
assert fresh is False
class TestEnsureUvWindowsSafe:
"""On Windows ``ensure_uv()`` must return a plain ``str``/``None``.
``subprocess`` on Windows serializes argv through
``subprocess.list2cmdline``, which iterates every entry *as a string*
(``for c in arg``). The dependency installer feeds uv straight into the
command list (``[uv_bin, "pip", "install", ...]``). A ``str`` subclass
whose ``__iter__`` yields ``(path, fresh_bootstrap)`` instead of characters
therefore injects the bool into the command line and crashes the install
with ``TypeError: sequence item 1: expected str instance, bool found``
(a real field report on a 10-commits-behind Windows install). A single
return value cannot serve both the legacy 2-tuple unpack and Windows
char-iteration both use the iterator protocol so Windows opts out of
the wrapper entirely.
"""
def test_uvresult_would_break_windows_list2cmdline(self):
# Canary: this is *why* the wrapper is gated off Windows. If a future
# change makes _UvResult char-iterable (and thus list2cmdline-safe),
# the gate may be revisited.
import subprocess
from hermes_cli.managed_uv import _UvResult
with pytest.raises(TypeError):
subprocess.list2cmdline([_UvResult("C:\\hermes\\uv.exe"), "pip"])
def test_windows_returns_plain_str_safe_for_subprocess(self, tmp_path):
import subprocess
# On (mocked) Windows the managed binary is uv.exe.
_make_executable(tmp_path / "bin" / "uv.exe")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
from hermes_cli.managed_uv import _UvResult, ensure_uv
uv_bin = ensure_uv()
assert type(uv_bin) is str and not isinstance(uv_bin, _UvResult)
# The exact operation that crashed in the field must now succeed.
cmdline = subprocess.list2cmdline([uv_bin, "pip", "install", "-e", "."])
assert "pip" in cmdline and "install" in cmdline
def test_windows_failure_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
assert ensure_uv() is None
# ---------------------------------------------------------------------------
+108
View File
@@ -257,6 +257,114 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
def _stub_browser_probes(monkeypatch, *, has_agent_browser, chromium, lightpanda=False):
"""Common monkeypatches for local-browser readiness scenarios.
``chromium`` / ``lightpanda`` drive the runtime probes that
``_local_browser_runnable`` reuses from ``tools.browser_tool`` (lazy import,
so patching the module attributes is enough).
"""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: has_agent_browser)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: chromium)
monkeypatch.setattr(
"tools.browser_tool._using_lightpanda_engine", lambda: lightpanda
)
def test_local_browser_unavailable_without_chromium(monkeypatch):
"""agent-browser present but Chromium absent must NOT advertise local browser.
The runtime (``check_browser_requirements``) refuses local mode without a
Chromium build, so the setup/status surface must report unavailable too
otherwise the user sees "Browser Automation available" and the first real
call fails. Regression for the false-positive setup bug.
"""
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "local"}}
)
assert features.browser.available is False
assert features.browser.active is False
assert features.browser.managed_by_nous is False
assert features.browser.current_provider == "Local browser"
def test_local_browser_available_with_chromium(monkeypatch):
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=True)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "local"}}
)
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.current_provider == "Local browser"
def test_local_browser_available_with_lightpanda_without_chromium(monkeypatch):
"""Lightpanda is text-only and needs no Chromium, so it stays available.
Guards against the fix over-correcting into a false-negative for the
legitimate Lightpanda-without-Chromium configuration.
"""
_stub_browser_probes(
monkeypatch, has_agent_browser=True, chromium=False, lightpanda=True
)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "local"}}
)
assert features.browser.available is True
assert features.browser.active is True
def test_default_local_browser_unavailable_without_chromium(monkeypatch):
"""The implicit (no cloud_provider) local fallthrough is gated on Chromium too."""
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
features = ns.get_nous_subscription_features({})
assert features.browser.available is False
assert features.browser.current_provider == "Local browser"
def test_cloud_browserbase_available_without_local_chromium(monkeypatch):
"""Cloud providers host their own Chromium, so the new local gate must not
regress them: agent-browser binary present + Browserbase creds is enough."""
env = {"BROWSERBASE_API_KEY": "bb-key", "BROWSERBASE_PROJECT_ID": "bb-project"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
# Chromium absent locally — must not matter for a cloud provider.
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False)
monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "browserbase"}}
)
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.current_provider == "Browserbase"
def test_get_gateway_eligible_tools_pool_excludes_video(monkeypatch):
"""A free-tool-pool user is offered the covered tools but NOT video gen."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
@@ -178,72 +178,6 @@ class TestModelSwitchPersistence:
assert result.base_url == "https://api.anthropic.com"
# ---------------------------------------------------------------------------
# /model tab completion
# ---------------------------------------------------------------------------
class TestModelTabCompletion:
"""SlashCommandCompleter provides model alias completions for /model."""
def test_model_completions_yields_direct_aliases(self, monkeypatch):
"""_model_completions yields direct aliases with model and provider info."""
from hermes_cli.commands import SlashCommandCompleter
from hermes_cli.model_switch import DirectAlias
import hermes_cli.model_switch as ms
test_aliases = {
"opus": DirectAlias("claude-opus-4-6", "anthropic", ""),
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
}
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
completer = SlashCommandCompleter()
completions = list(completer._model_completions("", ""))
names = [c.text for c in completions]
assert "opus" in names
assert "qwen" in names
def test_model_completions_filters_by_prefix(self, monkeypatch):
"""Completions filter by typed prefix."""
from hermes_cli.commands import SlashCommandCompleter
from hermes_cli.model_switch import DirectAlias
import hermes_cli.model_switch as ms
test_aliases = {
"opus": DirectAlias("claude-opus-4-6", "anthropic", ""),
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
}
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
completer = SlashCommandCompleter()
completions = list(completer._model_completions("o", "o"))
names = [c.text for c in completions]
assert "opus" in names
assert "qwen" not in names
def test_model_completions_shows_metadata(self, monkeypatch):
"""Completions include model name and provider in display_meta."""
from hermes_cli.commands import SlashCommandCompleter
from hermes_cli.model_switch import DirectAlias
import hermes_cli.model_switch as ms
test_aliases = {
"glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"),
}
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
completer = SlashCommandCompleter()
completions = list(completer._model_completions("g", "g"))
assert len(completions) >= 1
glm_comp = [c for c in completions if c.text == "glm"][0]
meta_str = str(glm_comp.display_meta)
assert "glm-4.7" in meta_str
assert "custom" in meta_str
# ---------------------------------------------------------------------------
# Fallback base_url passthrough
# ---------------------------------------------------------------------------
+60
View File
@@ -0,0 +1,60 @@
"""Tests for the /model picker background cache prewarm.
``prewarm_picker_cache_async()`` warms the provider-models disk cache off the
user's critical path so the first ``/model`` open in a session is fast instead
of blocking ~1-2s on serial /v1/models fetches. These pin the two contracts
that matter: it runs the warm path exactly once per process (no thread leak),
and it delegates to ``list_authenticated_providers`` to do the warming.
"""
from __future__ import annotations
from unittest.mock import patch
import hermes_cli.model_switch as ms
def _reset_guard():
ms._picker_prewarm_done.clear()
def test_prewarm_runs_list_authenticated_providers_once():
"""First call spawns a thread that calls list_authenticated_providers;
the warm side effect is delegated there (which disk-caches per provider)."""
_reset_guard()
with patch.object(ms, "list_authenticated_providers", return_value=[]) as mock_list:
t = ms.prewarm_picker_cache_async()
assert t is not None, "first call must spawn a prewarm thread"
t.join(timeout=10)
assert not t.is_alive(), "prewarm thread should finish promptly"
mock_list.assert_called_once()
_reset_guard()
def test_prewarm_guard_is_once_per_process():
"""The process-level Event guard must make repeat calls no-ops so a
long-lived process never leaks one OS thread per call."""
_reset_guard()
with patch.object(ms, "list_authenticated_providers", return_value=[]):
t1 = ms.prewarm_picker_cache_async()
assert t1 is not None
t1.join(timeout=10)
# Subsequent calls return None (guard set) — no new thread.
assert ms.prewarm_picker_cache_async() is None
assert ms.prewarm_picker_cache_async() is None
_reset_guard()
def test_prewarm_never_raises_on_failure():
"""A failing/offline provider path must be fully swallowed — the prewarm
is best-effort and must never surface errors into the session."""
_reset_guard()
with patch.object(
ms, "list_authenticated_providers", side_effect=RuntimeError("boom")
):
t = ms.prewarm_picker_cache_async()
assert t is not None
# join must not raise; the worker swallows the exception internally.
t.join(timeout=10)
assert not t.is_alive()
_reset_guard()
@@ -344,3 +344,43 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat
assert "Browser Automation (Browserbase)" not in output
assert "Browser Automation" in output
assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output
def test_setup_summary_local_browser_unavailable_without_chromium(
tmp_path, monkeypatch, capsys
):
"""End-to-end: agent-browser present but no Chromium in local mode must
render as unavailable with an install hint not a false 'available'.
Unlike the mocked-feature tests above, this drives the real
``get_nous_subscription_features`` so the surface stays aligned with the
runtime gate in ``tools.browser_tool.check_browser_requirements``.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
cfg = load_config()
browser_cfg = cfg.get("browser")
if not isinstance(browser_cfg, dict):
browser_cfg = {}
cfg["browser"] = browser_cfg
browser_cfg["cloud_provider"] = "local"
save_config(cfg)
# Only stub the readiness probes; the feature resolver itself is real.
monkeypatch.setattr("hermes_cli.nous_subscription._has_agent_browser", lambda: True)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda *a, **k: None,
)
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False)
monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False)
monkeypatch.setattr(
"agent.auxiliary_client.get_available_vision_backends", lambda: []
)
_print_setup_summary(load_config(), tmp_path)
output = capsys.readouterr().out
assert "Browser Automation (Local browser)" not in output
assert "agent-browser install --with-deps" in output
@@ -0,0 +1,323 @@
"""Tests for hermes_cli.telegram_managed_bot — QR codes, deep links, pairing."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from hermes_cli.telegram_managed_bot import (
DEFAULT_MANAGER_BOT,
TELEGRAM_ONBOARDING_URL_ENV,
TelegramBotSetupResult,
TelegramPairing,
create_pairing,
generate_bot_username,
generate_deep_link,
generate_pairing_nonce,
poll_for_setup_result,
poll_for_token,
print_qr_code,
render_qr_terminal,
)
VALID_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
SECOND_VALID_TOKEN = "987654321:abcdefghijklmnopqrstuvwxyzABCDEF"
class TestGenerateBotUsername:
def test_secure_default_format(self):
name = generate_bot_username()
assert name.startswith("hermes_")
assert name.endswith("_bot")
assert len(name) == len("hermes_") + 16 + len("_bot")
assert len(name) <= 32
def test_profile_name_not_embedded(self):
name = generate_bot_username("work")
assert "work" not in name
assert name.startswith("hermes_")
assert name.endswith("_bot")
def test_slug_uses_telegram_safe_base32_chars(self):
name = generate_bot_username()
slug = name.removeprefix("hermes_").removesuffix("_bot")
assert len(slug) == 16
assert set(slug) <= set("abcdefghijklmnopqrstuvwxyz234567")
def test_uniqueness(self):
names = {generate_bot_username() for _ in range(20)}
assert len(names) == 20
class TestGenerateDeepLink:
def test_basic_format(self):
link = generate_deep_link(
manager_bot="TestBot",
suggested_username="my_bot",
)
assert link == "https://t.me/newbot/TestBot/my_bot"
def test_with_name(self):
link = generate_deep_link(
manager_bot="@TestBot",
suggested_username="my_bot",
suggested_name="My Agent",
)
assert "https://t.me/newbot/TestBot/my_bot?" in link
assert "name=My+Agent" in link
def test_defaults(self):
link = generate_deep_link()
assert f"https://t.me/newbot/{DEFAULT_MANAGER_BOT}/" in link
assert "hermes_" in link
def test_name_url_encoded(self):
link = generate_deep_link(
manager_bot="Bot",
suggested_username="test_bot",
suggested_name="Hermes & Friends",
)
assert "Hermes+%26+Friends" in link
class TestPairingNonce:
def test_length(self):
nonce = generate_pairing_nonce()
assert len(nonce) == 32
def test_hex_chars(self):
nonce = generate_pairing_nonce()
assert all(c in "0123456789abcdef" for c in nonce)
def test_uniqueness(self):
nonces = {generate_pairing_nonce() for _ in range(100)}
assert len(nonces) == 100
class TestQRCode:
def test_render_returns_string(self):
result = render_qr_terminal("https://example.com")
if result:
assert isinstance(result, str)
assert len(result) > 10
def test_render_graceful_without_qrcode(self):
with patch.dict("sys.modules", {"qrcode": None}):
render_qr_terminal("https://example.com")
def test_print_qr_code_with_url(self, capsys):
print_qr_code("https://t.me/newbot/Bot/test_bot")
captured = capsys.readouterr()
assert "https://t.me/newbot/Bot/test_bot" in captured.out
class TestCreatePairing:
def test_success(self):
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.json.return_value = {
"pairing_id": "abcdefghijklmnop",
"poll_token": "secret-token",
"suggested_username": "hermes_abcdefghijklmnop_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
"expires_at": "2026-05-18T00:00:00.000Z",
}
with patch(
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
) as post:
pairing = create_pairing("https://api.example.com", bot_name="Hermes Agent")
assert pairing == TelegramPairing(
pairing_id="abcdefghijklmnop",
poll_token="secret-token",
suggested_username="hermes_abcdefghijklmnop_bot",
deep_link="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
qr_payload="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
expires_at="2026-05-18T00:00:00.000Z",
)
post.assert_called_once_with(
"https://api.example.com/v1/telegram/pairings",
json={"bot_name": "Hermes Agent"},
timeout=10.0,
)
def test_failure_status(self):
mock_resp = MagicMock()
mock_resp.status_code = 500
with patch(
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
):
assert create_pairing("https://api.example.com") is None
def test_invalid_payload(self):
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.json.return_value = {"pairing_id": "missing-poll-token"}
with patch(
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
):
assert create_pairing("https://api.example.com") is None
def test_uses_env_override(self, monkeypatch):
monkeypatch.setenv(TELEGRAM_ONBOARDING_URL_ENV, "https://worker.example")
mock_resp = MagicMock()
mock_resp.status_code = 500
with patch(
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
) as post:
create_pairing()
assert post.call_args.args[0] == "https://worker.example/v1/telegram/pairings"
class TestPollForToken:
def pairing(self):
return TelegramPairing(
pairing_id="abcdefghijklmnop",
poll_token="secret-token",
suggested_username="hermes_abcdefghijklmnop_bot",
deep_link="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot",
qr_payload="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot",
)
def test_immediate_success(self):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"bot_username": "hermes_abcdefghijklmnop_bot",
"owner_user_id": 42,
"status": "ready",
"token": VALID_TOKEN,
}
with patch(
"hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp
) as get:
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
token = poll_for_token(
"https://api.example.com", self.pairing(), timeout=5
)
assert token == VALID_TOKEN
assert (
get.call_args.args[0]
== "https://api.example.com/v1/telegram/pairings/abcdefghijklmnop"
)
assert get.call_args.kwargs["headers"] == {
"Authorization": "Bearer secret-token"
}
def test_setup_result_includes_owner_user_id(self):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"bot_username": "hermes_abcdefghijklmnop_bot",
"owner_user_id": 42,
"status": "ready",
"token": VALID_TOKEN,
}
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
result = poll_for_setup_result(
"https://api.example.com", self.pairing(), timeout=5
)
assert result == TelegramBotSetupResult(
token=VALID_TOKEN,
bot_username="hermes_abcdefghijklmnop_bot",
owner_user_id=42,
)
def test_setup_result_accepts_string_owner_user_id(self):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"bot_username": "hermes_abcdefghijklmnop_bot",
"owner_user_id": "42",
"status": "ready",
"token": VALID_TOKEN,
}
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
result = poll_for_setup_result(
"https://api.example.com", self.pairing(), timeout=5
)
assert result == TelegramBotSetupResult(
token=VALID_TOKEN,
bot_username="hermes_abcdefghijklmnop_bot",
owner_user_id=42,
)
def test_invalid_ready_token_returns_none(self):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"bot_username": "hermes_abcdefghijklmnop_bot",
"owner_user_id": 42,
"status": "ready",
"token": "not-a-real-token",
}
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
with patch(
"hermes_cli.telegram_managed_bot.time.monotonic"
) as mock_time:
mock_time.side_effect = [0, 0, 999]
assert (
poll_for_token(
"https://api.example.com", self.pairing(), timeout=1
)
is None
)
def test_timeout_returns_none(self):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"status": "waiting"}
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
with patch(
"hermes_cli.telegram_managed_bot.time.monotonic"
) as mock_time:
mock_time.side_effect = [0, 0, 999]
token = poll_for_token(
"https://api.example.com", self.pairing(), timeout=1
)
assert token is None
def test_eventual_success(self):
not_ready = MagicMock()
not_ready.status_code = 200
not_ready.json.return_value = {"status": "waiting"}
ready = MagicMock()
ready.status_code = 200
ready.json.return_value = {"status": "ready", "token": SECOND_VALID_TOKEN}
call_count = 0
def fake_get(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
return not_ready
return ready
with patch("hermes_cli.telegram_managed_bot.httpx.get", side_effect=fake_get):
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
token = poll_for_token(
"https://api.example.com", self.pairing(), timeout=30
)
assert token == SECOND_VALID_TOKEN
class TestSetupTelegramAuto:
def test_setup_helper_exists(self):
from hermes_cli.setup import _setup_telegram_auto
assert callable(_setup_telegram_auto)
+91
View File
@@ -172,6 +172,97 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
assert cwd == tmp_path
def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
def fail_run(*_args, **_kwargs):
raise AssertionError("fresh Termux TUI launch must not run npm")
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
assert cwd == tmp_path
def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
tmp_path: Path, main_mod, monkeypatch
) -> None:
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
ink_dir = tui_dir / "packages" / "hermes-ink"
ink_dir.mkdir(parents=True)
(ink_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tui_dir, tui_dev=False)
install_cmd = calls[0][0][0]
assert install_cmd[:7] == [
"/bin/npm",
"install",
"--workspace",
"ui-tui",
"--workspace",
"ui-tui/packages/hermes-ink",
"--include-workspace-root=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tui_dir, tui_dev=False)
assert calls[0][0][0] == [
"/bin/npm",
"install",
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
+130 -82
View File
@@ -592,10 +592,6 @@ def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatc
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: "abc123deadbeef",
)
# Force the stash path (not the managed-clone clean path) so this test
# exercises stash restore. A real fork, or a clone where the managed
# clean fails, falls through to stash.
monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False)
restore_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",
@@ -634,81 +630,6 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):
assert len(checkout_calls) == 0
def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_path):
"""On a non-fork (managed) clone, working-tree dirt is discarded via
_clean_managed_worktree, NOT preserved via stash/restore.
The stash/restore cycle has clobbered freshly-pulled source files
(apps/desktop/ deletion [UNRESOLVED_ENTRY] index.html). A managed clone
has nothing the user authored, so the correct move is to throw the
git-artifact dirt away and pull cleanly.
"""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
# Official origin → not a fork.
monkeypatch.setattr(
hermes_main, "_get_origin_url",
lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git",
)
clean_calls = []
monkeypatch.setattr(
hermes_main, "_clean_managed_worktree",
lambda *a, **kw: clean_calls.append(1) or True,
)
stash_calls = []
monkeypatch.setattr(
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: stash_calls.append(1) or "shouldnotbeused",
)
restore_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",
lambda *a, **kw: restore_calls.append(1) or True,
)
side_effect, _ = _make_update_side_effect(commit_count="0")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
# Managed clean path used; stash path never touched.
assert len(clean_calls) == 1
assert len(stash_calls) == 0
assert len(restore_calls) == 0
def test_cmd_update_fork_still_uses_stash(monkeypatch, tmp_path):
"""A fork (non-official origin) keeps the stash machinery so the user's
intentional local edits survive the update."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(
hermes_main, "_get_origin_url",
lambda *a, **kw: "https://github.com/someuser/hermes-agent.git",
)
clean_calls = []
monkeypatch.setattr(
hermes_main, "_clean_managed_worktree",
lambda *a, **kw: clean_calls.append(1) or True,
)
stash_calls = []
monkeypatch.setattr(
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: stash_calls.append(1) or "abc123",
)
monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True)
monkeypatch.setattr(hermes_main, "_sync_with_upstream_if_needed", lambda *a, **kw: None)
side_effect, _ = _make_update_side_effect(commit_count="0")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
# Fork: stash path used, managed clean NOT used.
assert len(stash_calls) == 1
assert len(clean_calls) == 0
# ---------------------------------------------------------------------------
# Fetch failure — friendly error messages
# ---------------------------------------------------------------------------
@@ -759,9 +680,6 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path,
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: "abc123deadbeef",
)
# Force the stash path so this test exercises the reset-failure handling
# of the stash branch (not the managed-clone clean path).
monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False)
restore_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",
@@ -779,3 +697,133 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path,
out = capsys.readouterr().out
assert "preserved in stash" in out
# ---------------------------------------------------------------------------
# Non-interactive update.non_interactive_local_changes setting
# (chat app / gateway): "discard" throws stashed changes away, "stash"
# (default) restores them. Interactive terminal updates ignore the setting
# and always go through the restore path.
# ---------------------------------------------------------------------------
def _setup_setting_test(monkeypatch, tmp_path, mode):
"""Common wiring: real stash returns a ref, restore + discard are
recorded, and load_config reports the given non_interactive_local_changes
mode."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: "abc123deadbeef",
)
restore_calls = []
discard_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",
lambda *a, **kw: restore_calls.append(1) or True,
)
monkeypatch.setattr(
hermes_main, "_discard_stashed_changes",
lambda *a, **kw: discard_calls.append(1) or True,
)
monkeypatch.setattr(
hermes_config, "load_config",
lambda *a, **kw: {"updates": {"non_interactive_local_changes": mode}},
)
side_effect, recorded = _make_update_side_effect()
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
return restore_calls, discard_calls, recorded
def test_non_interactive_discard_throws_changes_away(monkeypatch, tmp_path):
"""Gateway/chat-app update with discard mode drops the stash, never restores."""
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard")
hermes_main.cmd_update(SimpleNamespace(gateway=True))
assert len(discard_calls) == 1
assert len(restore_calls) == 0
def test_non_interactive_stash_restores_changes(monkeypatch, tmp_path):
"""Gateway/chat-app update with the default stash mode restores, never discards."""
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash")
hermes_main.cmd_update(SimpleNamespace(gateway=True))
assert len(restore_calls) == 1
assert len(discard_calls) == 0
def test_interactive_update_ignores_discard_setting(monkeypatch, tmp_path):
"""An interactive (TTY) terminal update always restores — the discard
setting only governs non-interactive updates."""
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard")
# Force an interactive TTY so _non_interactive_update is False even though
# the config says discard.
monkeypatch.setattr(hermes_main.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(hermes_main.sys.stdout, "isatty", lambda: True)
hermes_main.cmd_update(SimpleNamespace()) # no gateway, no --yes
assert len(restore_calls) == 1
assert len(discard_calls) == 0
def test_non_interactive_defaults_to_stash_when_setting_absent(monkeypatch, tmp_path):
"""A config with no update section falls back to stash (safe default)."""
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash")
# Override load_config to return a config with NO update section at all.
monkeypatch.setattr(hermes_config, "load_config", lambda *a, **kw: {"model": {}})
hermes_main.cmd_update(SimpleNamespace(gateway=True))
assert len(restore_calls) == 1
assert len(discard_calls) == 0
def test_bootstrap_marker_not_autostashed_by_update(tmp_path):
"""#38529: the Desktop bootstrap marker must be git-ignored so that
``hermes update``'s ``git stash push --include-untracked`` does not sweep it
into an autostash on every run.
Behavioral + hermetic: build a throwaway repo that adopts the project's real
``.gitignore`` (the contract under test), drop the marker, and confirm the
same stash invocation the updater uses leaves it untouched.
"""
import shutil
import subprocess
if shutil.which("git") is None:
pytest.skip("git not available")
repo_gitignore = Path(hermes_main.__file__).resolve().parents[1] / ".gitignore"
def git(*args):
return subprocess.run(
["git", *args], cwd=tmp_path, capture_output=True, text=True, check=True
)
git("init", "-q")
git("config", "user.email", "t@example.com")
git("config", "user.name", "t")
(tmp_path / ".gitignore").write_text(repo_gitignore.read_text())
(tmp_path / "tracked.txt").write_text("x\n")
git("add", "-A")
git("commit", "-qm", "init")
marker = tmp_path / ".hermes-bootstrap-complete"
marker.write_text("")
# Exact flags used by hermes update (hermes_cli/main.py).
git("stash", "push", "--include-untracked", "-m", "hermes-update-autostash")
assert marker.exists(), (
".hermes-bootstrap-complete was swept into the update autostash — it must "
"be listed in .gitignore so `git stash -u` skips it (#38529)."
)
# It must not even register as a dirty/untracked change.
status = subprocess.run(
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True
).stdout
assert ".hermes-bootstrap-complete" not in status
+56
View File
@@ -131,6 +131,62 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
assert mock_run.call_count >= 1
def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch):
"""Inside the Docker image, check_for_updates() must short-circuit to None.
Regression: the published image excludes .git (.dockerignore) and sets no
HERMES_REVISION (nix-only), so without a docker guard check_for_updates()
falls through to check_via_pypi(), whose version-mismatch flag (1) gets
rendered by both the Rich banner and the Ink TUI badge as a phantom
"1 commit behind" despite there being no git repo or commit math in the
container, and `hermes update` correctly refusing to run there. The guard
must return None (so the > 0 render guards stay false) AND not reach the
git/pypi probes or write a cache entry.
"""
import hermes_cli.banner as banner
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cache_file = tmp_path / ".update_check"
with patch("hermes_cli.config.detect_install_method", return_value="docker"), \
patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi") as mock_pypi:
result = banner.check_for_updates()
assert result is None
# Neither the git probe nor the PyPI probe should have run.
mock_run.assert_not_called()
mock_pypi.assert_not_called()
# And no phantom "behind" count should be cached for the next 6h.
assert not cache_file.exists()
def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch):
"""The docker guard must NOT over-broaden: a pip install still version-checks.
Invariant guarding against the guard firing for non-docker methods pip
installs legitimately reach check_via_pypi() and surface a real update.
"""
import hermes_cli.banner as banner
# No local git checkout -> the PyPI (pip-install) path is exercised.
fake_banner = tmp_path / "hermes_cli" / "banner.py"
fake_banner.parent.mkdir(parents=True, exist_ok=True)
fake_banner.touch()
monkeypatch.setattr(banner, "__file__", str(fake_banner))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_REVISION", raising=False)
with patch("hermes_cli.config.detect_install_method", return_value="pip"), \
patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi:
result = banner.check_for_updates()
assert result == 1
mock_pypi.assert_called_once()
mock_run.assert_not_called()
def test_prefetch_non_blocking():
"""prefetch_update_check() should return immediately without blocking."""
import hermes_cli.banner as banner
-280
View File
@@ -1,280 +0,0 @@
"""Tests for the post-pull subprocess hand-off in ``hermes update``.
``hermes update`` runs from the *old* install. Before this hand-off, the
post-pull steps (dep install, config migration, gateway restart) executed
stale in-memory code even though the new source was already on disk, so a bug
fixed in the pulled version still crashed the first run users had to run
``hermes update`` a second time. After a successful pull + dep install we now
finish in a fresh subprocess running the refreshed code and forward its exit
code. Unlike an ``os.exec*`` replacement, a child subprocess keeps the parent
PID intact, so this works on Windows too.
These tests cover the gate (when we hand off vs. finish in-process), the
subprocess mechanics, and the finalize pass the child process takes.
"""
import sys
from types import SimpleNamespace
import pytest
from hermes_cli import config as hermes_config
from hermes_cli import main as hermes_main
# ---------------------------------------------------------------------------
# Managed-uv compatibility: make managed_uv helpers follow shutil.which mocking
# (mirrors the autouse fixture in test_update_autostash.py).
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _patch_managed_uv():
import shutil
from unittest.mock import patch
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=lambda: shutil.which("uv")), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=lambda: shutil.which("uv")), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=lambda: None):
yield
def _clear_handoff_env(monkeypatch):
monkeypatch.delenv("HERMES_UPDATE_FINALIZE", raising=False)
monkeypatch.delenv("HERMES_UPDATE_NO_HANDOFF", raising=False)
# ---------------------------------------------------------------------------
# _should_handoff_after_pull — the gate
# ---------------------------------------------------------------------------
def test_should_handoff_false_in_finalize_mode(monkeypatch):
_clear_handoff_env(monkeypatch)
assert hermes_main._should_handoff_after_pull(finalize_only=True) is False
def test_should_handoff_false_when_finalize_env_set(monkeypatch):
_clear_handoff_env(monkeypatch)
monkeypatch.setenv("HERMES_UPDATE_FINALIZE", "1")
assert hermes_main._should_handoff_after_pull(finalize_only=False) is False
def test_should_handoff_false_with_opt_out_env(monkeypatch):
_clear_handoff_env(monkeypatch)
monkeypatch.delitem(sys.modules, "pytest", raising=False)
monkeypatch.setenv("HERMES_UPDATE_NO_HANDOFF", "1")
assert hermes_main._should_handoff_after_pull(finalize_only=False) is False
def test_should_handoff_false_under_pytest(monkeypatch):
# Safety invariant: never spawn a real recursive update while the test
# suite is running. ``pytest`` is in sys.modules during the suite, so the
# gate must stay closed even with a clean env.
_clear_handoff_env(monkeypatch)
assert "pytest" in sys.modules
assert hermes_main._should_handoff_after_pull(finalize_only=False) is False
def test_should_handoff_true_when_allowed(monkeypatch):
_clear_handoff_env(monkeypatch)
monkeypatch.delitem(sys.modules, "pytest", raising=False)
assert hermes_main._should_handoff_after_pull(finalize_only=False) is True
def test_should_handoff_stays_on_for_windows(monkeypatch):
# The whole reason we use a subprocess instead of os.exec*: it keeps the
# parent PID intact, so the hand-off works on Windows too (the previous
# exec-based attempt had to disable itself there).
_clear_handoff_env(monkeypatch)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.delitem(sys.modules, "pytest", raising=False)
assert hermes_main._should_handoff_after_pull(finalize_only=False) is True
# ---------------------------------------------------------------------------
# _handoff_update_to_refreshed_code — the subprocess mechanics
# ---------------------------------------------------------------------------
def test_handoff_runs_subprocess_with_finalize_env(monkeypatch):
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = list(cmd)
captured["env"] = dict(kwargs.get("env") or {})
return SimpleNamespace(returncode=0)
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
monkeypatch.setattr(hermes_main.sys, "argv", ["hermes", "update", "--yes", "--gateway"])
rc = hermes_main._handoff_update_to_refreshed_code()
assert rc == 0
assert captured["cmd"][0] == sys.executable
assert captured["cmd"][1:3] == ["-m", "hermes_cli.main"]
# The original CLI args (minus argv[0]) are carried through verbatim.
assert captured["cmd"][3:] == ["update", "--yes", "--gateway"]
# The loop-breaker guard the child reads.
assert captured["env"]["HERMES_UPDATE_FINALIZE"] == "1"
def test_handoff_forwards_nonzero_exit_code(monkeypatch):
monkeypatch.setattr(
hermes_main.subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=42)
)
monkeypatch.setattr(hermes_main.sys, "argv", ["hermes", "update"])
assert hermes_main._handoff_update_to_refreshed_code() == 42
def test_handoff_returns_none_when_spawn_fails(monkeypatch):
def boom(*_a, **_kw):
raise OSError("could not spawn")
monkeypatch.setattr(hermes_main.subprocess, "run", boom)
monkeypatch.setattr(hermes_main.sys, "argv", ["hermes", "update"])
# None signals the caller to finish in-process instead of bailing out.
assert hermes_main._handoff_update_to_refreshed_code() is None
# ---------------------------------------------------------------------------
# cmd_update integration — original pass hands off, finalize pass finishes
# ---------------------------------------------------------------------------
def _setup_update_mocks(monkeypatch, tmp_path):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: None)
monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True)
monkeypatch.setattr(hermes_config, "get_missing_env_vars", lambda required_only=True: [])
monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: [])
monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5))
monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []})
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda: None)
def _fake_git_run(commit_count):
recorded = []
def side_effect(cmd, **kwargs):
recorded.append(cmd)
joined = " ".join(str(c) for c in cmd)
if "fetch" in joined and "origin" in joined:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if "rev-list" in joined:
return SimpleNamespace(stdout=f"{commit_count}\n", stderr="", returncode=0)
if "--ff-only" in joined:
return SimpleNamespace(stdout="Already up to date.\n", stderr="", returncode=0)
return SimpleNamespace(returncode=0, stdout="", stderr="")
return side_effect, recorded
def test_original_pass_hands_off_and_forwards_exit_code(monkeypatch, tmp_path):
_clear_handoff_env(monkeypatch)
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
# Force the gate open (pytest normally closes it) and stub the child run.
monkeypatch.setattr(hermes_main, "_should_handoff_after_pull", lambda finalize_only: True)
monkeypatch.setattr(hermes_main, "_handoff_update_to_refreshed_code", lambda: 0)
node_called = []
monkeypatch.setattr(
hermes_main, "_update_node_dependencies", lambda *a, **k: node_called.append(True)
)
side_effect, _recorded = _fake_git_run(commit_count="3")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
with pytest.raises(SystemExit) as exc:
hermes_main.cmd_update(SimpleNamespace())
assert exc.value.code == 0
# After a successful hand-off the parent must NOT run the remaining
# post-pull steps — the child already did them on new code.
assert node_called == [], "parent ran post-handoff steps after a successful hand-off"
def test_handoff_failure_falls_back_in_process(monkeypatch, tmp_path):
_clear_handoff_env(monkeypatch)
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
monkeypatch.setattr(hermes_main, "_should_handoff_after_pull", lambda finalize_only: True)
# Child couldn't be spawned -> None -> parent finishes in-process.
monkeypatch.setattr(hermes_main, "_handoff_update_to_refreshed_code", lambda: None)
node_called = []
monkeypatch.setattr(
hermes_main, "_update_node_dependencies", lambda *a, **k: node_called.append(True)
)
monkeypatch.setattr(hermes_main, "_build_web_ui", lambda *a, **k: None)
side_effect, _recorded = _fake_git_run(commit_count="3")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
assert node_called == [True], "fallback should finish the post-pull steps in-process"
def test_finalize_pass_does_not_hand_off(monkeypatch, tmp_path):
# In finalize mode the gate is closed, so we must never spawn another
# update. Make the hand-off explode so the test fails loudly if reached.
_clear_handoff_env(monkeypatch)
monkeypatch.setenv("HERMES_UPDATE_FINALIZE", "1")
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
def no_handoff(): # pragma: no cover - asserts it's not called
raise AssertionError("finalize pass must not hand off")
monkeypatch.setattr(hermes_main, "_handoff_update_to_refreshed_code", no_handoff)
side_effect, recorded = _fake_git_run(commit_count="0")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
# The whole point of the finalize pass: even with zero new commits (the
# pull already happened in the original pass) it does NOT take the "Already
# up to date" early return — it runs the post-pull dependency install.
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
assert install_cmds, "finalize pass should run the dependency install, not early-return"
def test_finalize_pass_skips_pre_update_backup(monkeypatch, tmp_path):
_clear_handoff_env(monkeypatch)
monkeypatch.setenv("HERMES_UPDATE_FINALIZE", "1")
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
backup_calls = []
monkeypatch.setattr(hermes_main, "_run_pre_update_backup", lambda args: backup_calls.append(args))
side_effect, _recorded = _fake_git_run(commit_count="0")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
assert backup_calls == [], "finalize pass must not retake the pre-update backup"
def test_original_pass_still_runs_pre_update_backup(monkeypatch, tmp_path):
# Sanity counter-check: a normal (non-finalize) run still takes the backup.
# Under pytest the gate is closed, so the run finishes in-process exactly
# as it always did.
_clear_handoff_env(monkeypatch)
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
backup_calls = []
monkeypatch.setattr(hermes_main, "_run_pre_update_backup", lambda args: backup_calls.append(args))
side_effect, _recorded = _fake_git_run(commit_count="3")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
assert len(backup_calls) == 1
+44
View File
@@ -164,6 +164,50 @@ class TestBuildWebUISkipsWhenFresh:
assert args[0] == ["/usr/bin/npm", "run", "build"]
assert kwargs["cwd"] == web_dir
def test_termux_web_install_is_workspace_scoped(self, tmp_path, monkeypatch):
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("TERMUX_VERSION", "1")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
result = _build_web_ui(web_dir)
assert result is True
args, kwargs = mock_run.call_args
assert args[0] == [
"/usr/bin/npm",
"ci",
"--workspace",
"web",
"--include-workspace-root=false",
"--silent",
]
assert kwargs["cwd"] == tmp_path
def test_desktop_web_install_uses_existing_workspace_root(
self, tmp_path, monkeypatch
):
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
result = _build_web_ui(web_dir)
assert result is True
args, kwargs = mock_run.call_args
assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
assert kwargs["cwd"] == tmp_path
class TestBuildWebUIRetryAndStaleFallback:
"""Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""
+104 -1
View File
@@ -94,7 +94,11 @@ def agent():
a._cached_system_prompt = "You are helpful."
a._use_prompt_caching = False
a.tool_delay = 0
a.compression_enabled = False
# Default matches production (`compression.enabled` defaults to True).
# Overflow-recovery tests below verify that 413 / context-overflow
# errors DO trigger compression; the disabled-path behavior is
# covered explicitly by TestOverflowWithCompactionDisabled.
a.compression_enabled = True
a.save_trajectories = False
return a
@@ -415,6 +419,13 @@ class TestPreflightCompression:
def test_compress_context_emits_lifecycle_status_before_work(self, agent):
"""Direct context compression should tell gateway users why the turn paused."""
# This test calls _compress_context directly and asserts the FIRST
# status event is the lifecycle "Compacting context" message. With
# compaction enabled the lazy feasibility probe would emit an
# aux-provider warning first (no aux key in the hermetic test env),
# displacing events[0]. The flag value is irrelevant to what this
# test asserts, so disable it to suppress the probe.
agent.compression_enabled = False
events = []
agent.status_callback = lambda ev, msg: events.append((ev, msg))
@@ -802,3 +813,95 @@ class TestToolResultPreflightCompression:
mock_compress.assert_called_once()
assert result["completed"] is True
# ---------------------------------------------------------------------------
# Disabled auto-compaction on overflow (port of anomalyco/opencode#30749)
# ---------------------------------------------------------------------------
class TestOverflowWithCompactionDisabled:
"""When ``compression.enabled`` is False, NO automatic compaction may
fire including the provider/request-size overflow recovery paths.
Ported from anomalyco/opencode#30749: the proactive token-threshold
path already honoured the setting, but provider overflow errors
(413 payload-too-large, context-overflow, long-context-tier 429) still
silently compressed + rotated the session. The fix surfaces a terminal
error so the user can compact manually, start fresh, or switch models.
"""
@staticmethod
def _prefill():
return [
{"role": "user", "content": "previous question"},
{"role": "assistant", "content": "previous answer"},
]
def test_413_does_not_compress_when_disabled(self, agent):
"""413 must NOT call _compress_context when compaction is disabled."""
agent.compression_enabled = False
err_413 = _make_413_error()
# If the guard fails, a second (success) response would be consumed.
agent.client.chat.completions.create.side_effect = [err_413, _mock_response()]
with (
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session") as mock_persist,
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hello", conversation_history=self._prefill())
mock_compress.assert_not_called()
mock_persist.assert_called()
assert result.get("failed") is True
assert result.get("compaction_disabled") is True
assert "auto-compaction is disabled" in result["error"]
def test_context_overflow_does_not_compress_when_disabled(self, agent):
"""400 'prompt is too long' must NOT compress when compaction disabled."""
agent.compression_enabled = False
err_400 = Exception(
"Error code: 400 - {'type': 'error', 'error': {'type': "
"'invalid_request_error', 'message': 'prompt is too long: "
"233153 tokens > 200000 maximum'}}"
)
err_400.status_code = 400
agent.client.chat.completions.create.side_effect = [err_400, _mock_response()]
with (
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hello", conversation_history=self._prefill())
mock_compress.assert_not_called()
assert result.get("compaction_disabled") is True
def test_413_still_compresses_when_enabled(self, agent):
"""Control: with compaction enabled, 413 still triggers compression.
Guards against the disabled-path guard accidentally swallowing the
enabled path.
"""
agent.compression_enabled = True
err_413 = _make_413_error()
ok_resp = _mock_response(content="Recovered", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [err_413, ok_resp]
with (
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
mock_compress.return_value = (
[{"role": "user", "content": "hello"}], "compressed",
)
result = agent.run_conversation("hello", conversation_history=self._prefill())
mock_compress.assert_called_once()
assert result["completed"] is True
assert result.get("compaction_disabled") is not True
+3
View File
@@ -3903,6 +3903,7 @@ class TestRunConversation:
def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent):
"""GLM/Z.AI uses 'Prompt exceeds max length' for context overflow."""
self._setup_agent(agent)
agent.compression_enabled = True # this test verifies overflow→compression fires
err_400 = Exception(
"Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}"
)
@@ -3937,6 +3938,7 @@ class TestRunConversation:
to the generic 128K fallback tier.
"""
self._setup_agent(agent)
agent.compression_enabled = True # this test verifies overflow→compression fires
agent.provider = "minimax"
agent.model = "MiniMax-M2.7-highspeed"
agent.base_url = "https://api.minimax.io/anthropic"
@@ -3982,6 +3984,7 @@ class TestRunConversation:
rely on compression see #33669 / PR #33826.
"""
self._setup_agent(agent)
agent.compression_enabled = True # this test verifies overflow→compression fires
agent.provider = "openrouter"
agent.model = "some/unknown-model"
agent.base_url = "https://openrouter.ai/api/v1"
+40
View File
@@ -30,6 +30,46 @@ class TestGetDefaultModelForProvider:
# Custom providers don't have entries in _PROVIDER_MODELS
assert get_default_model_for_provider("some-random-custom") == ""
def test_nous_silent_default_is_not_the_expensive_flagship(self):
"""Nous Portal is a metered aggregator whose curated list is ordered
most-capable-first, so entry [0] is the priciest flagship
(anthropic/claude-opus-4.8). The silent fallback (provider set, no model)
must NOT escalate to it otherwise an unconfigured profile silently
bills the most expensive model. Regression for the billing footgun.
"""
from hermes_cli.models import (
_PROVIDER_MODELS,
_PROVIDER_SILENT_DEFAULT_OVERRIDES,
get_default_model_for_provider,
)
result = get_default_model_for_provider("nous")
assert result, "nous must resolve to a usable default model"
assert "opus" not in result.lower(), (
f"silent default escalated to an expensive flagship: {result!r}"
)
assert result != _PROVIDER_MODELS["nous"][0], (
"silent default must not be the most-capable/priciest catalog entry"
)
# The override must point at a model that actually exists in the catalog.
assert result == _PROVIDER_SILENT_DEFAULT_OVERRIDES["nous"]
assert result in _PROVIDER_MODELS["nous"]
def test_override_falls_back_to_catalog_when_missing(self):
"""If an override model is no longer in the catalog, fall back to [0]
rather than returning a stale/absent id."""
from unittest.mock import patch
from hermes_cli import models as models_mod
with patch.dict(
models_mod._PROVIDER_SILENT_DEFAULT_OVERRIDES,
{"openai-codex": "does-not-exist-model"},
clear=False,
):
result = models_mod.get_default_model_for_provider("openai-codex")
assert result == models_mod._PROVIDER_MODELS["openai-codex"][0]
class TestGatewayEmptyModelFallback:
"""Test that _resolve_session_agent_runtime fills in empty model from provider catalog."""
+5 -9
View File
@@ -838,14 +838,13 @@ class TestBlockedTools(unittest.TestCase):
def test_constants(self):
from tools.delegate_tool import (
_get_max_spawn_depth, _get_orchestrator_enabled,
_MIN_SPAWN_DEPTH, _MAX_SPAWN_DEPTH_CAP,
_MIN_SPAWN_DEPTH,
)
self.assertEqual(_get_max_concurrent_children(), 3)
self.assertEqual(MAX_DEPTH, 1)
self.assertEqual(_get_max_spawn_depth(), 1) # default: flat
self.assertTrue(_get_orchestrator_enabled()) # default
self.assertEqual(_MIN_SPAWN_DEPTH, 1)
self.assertEqual(_MAX_SPAWN_DEPTH_CAP, 3)
class TestDelegationCredentialResolution(unittest.TestCase):
@@ -2084,17 +2083,14 @@ class TestMaxSpawnDepth(unittest.TestCase):
with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm:
result = _get_max_spawn_depth()
self.assertEqual(result, 1)
self.assertTrue(any("clamping to 1" in m for m in cm.output))
self.assertTrue(any("below floor 1" in m for m in cm.output))
@patch("tools.delegate_tool._load_config",
return_value={"max_spawn_depth": 99})
def test_max_spawn_depth_clamped_above_three(self, mock_cfg):
import logging
def test_max_spawn_depth_no_upper_ceiling(self, mock_cfg):
"""No upper ceiling — high values pass through unchanged (cost is the limiter)."""
from tools.delegate_tool import _get_max_spawn_depth
with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm:
result = _get_max_spawn_depth()
self.assertEqual(result, 3)
self.assertTrue(any("clamping to 3" in m for m in cm.output))
self.assertEqual(_get_max_spawn_depth(), 99)
@patch("tools.delegate_tool._load_config",
return_value={"max_spawn_depth": "not-a-number"})
+2
View File
@@ -176,6 +176,7 @@ class TestProviderEnvBlocklist:
"HASS_TOKEN": "ha-secret",
"EMAIL_PASSWORD": "email-secret",
"FIRECRAWL_API_KEY": "fc-secret",
"HERMES_DASHBOARD_SESSION_TOKEN": "dashboard-session-secret",
"BROWSERBASE_PROJECT_ID": "bb-project",
"ELEVENLABS_API_KEY": "el-secret",
"GITHUB_TOKEN": "ghp_secret",
@@ -362,6 +363,7 @@ class TestBlocklistCoverage:
"EMAIL_SMTP_HOST",
"EMAIL_HOME_ADDRESS",
"EMAIL_HOME_ADDRESS_NAME",
"HERMES_DASHBOARD_SESSION_TOKEN",
"GATEWAY_ALLOWED_USERS",
"GH_TOKEN",
"GITHUB_APP_ID",
+24
View File
@@ -218,3 +218,27 @@ def test_registering_non_cwd_override_leaves_live_env_cwd_untouched(monkeypatch)
terminal_tool.register_task_env_overrides(task_id, {"modal_image": "custom:latest"})
assert fake_env.cwd == "/workspace/keep"
def test_safe_getcwd_returns_real_cwd(monkeypatch):
monkeypatch.setattr(terminal_tool.os, "getcwd", lambda: "/home/user/project")
assert terminal_tool._safe_getcwd() == "/home/user/project"
def test_safe_getcwd_falls_back_to_terminal_cwd_when_cwd_deleted(monkeypatch):
def _boom():
raise FileNotFoundError("[Errno 2] No such file or directory")
monkeypatch.setattr(terminal_tool.os, "getcwd", _boom)
monkeypatch.setenv("TERMINAL_CWD", "/srv/work")
assert terminal_tool._safe_getcwd() == "/srv/work"
def test_safe_getcwd_falls_back_to_home_when_no_terminal_cwd(monkeypatch):
def _boom():
raise FileNotFoundError()
monkeypatch.setattr(terminal_tool.os, "getcwd", _boom)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(terminal_tool.os.path, "expanduser", lambda p: "/home/me")
assert terminal_tool._safe_getcwd() == "/home/me"
+15 -11
View File
@@ -134,7 +134,9 @@ MAX_DEPTH = 1 # flat by default: parent (0) -> child (1); grandchild rejected u
# Configurable depth cap consulted by _get_max_spawn_depth; MAX_DEPTH
# stays as the default fallback and is still the symbol tests import.
_MIN_SPAWN_DEPTH = 1
_MAX_SPAWN_DEPTH_CAP = 3
# No upper ceiling on spawn depth — like max_concurrent_children, depth has a
# floor of 1 and no ceiling. Deeper trees multiply API cost, so the default
# stays flat (MAX_DEPTH = 1); raising the config knob is an explicit opt-in.
# ---------------------------------------------------------------------------
@@ -392,7 +394,7 @@ def _get_child_timeout() -> float:
def _get_max_spawn_depth() -> int:
"""Read delegation.max_spawn_depth from config, clamped to [1, 3].
"""Read delegation.max_spawn_depth from config, floored at 1 (no ceiling).
depth 0 = parent agent. max_spawn_depth = N means agents at depths
0..N-1 can spawn; depth N is the leaf floor. Default 1 is flat:
@@ -400,9 +402,11 @@ def _get_max_spawn_depth() -> int:
(blocked by this guard AND, for leaf children, by the delegation
toolset strip in _strip_blocked_tools).
Raise to 2 or 3 to unlock nested orchestration. role="orchestrator"
removes the toolset strip for depth-1 children when
Raise to 2+ to unlock nested orchestration. role="orchestrator"
removes the toolset strip for spawning children when
max_spawn_depth >= 2, enabling them to spawn their own workers.
Like max_concurrent_children, there is no upper ceiling but each
extra level multiplies API cost, so raise it deliberately.
"""
cfg = _load_config()
val = cfg.get("max_spawn_depth")
@@ -417,16 +421,15 @@ def _get_max_spawn_depth() -> int:
MAX_DEPTH,
)
return MAX_DEPTH
clamped = max(_MIN_SPAWN_DEPTH, min(_MAX_SPAWN_DEPTH_CAP, ival))
if clamped != ival:
floored = max(_MIN_SPAWN_DEPTH, ival)
if floored != ival:
logger.warning(
"delegation.max_spawn_depth=%d out of range [%d, %d]; " "clamping to %d",
"delegation.max_spawn_depth=%d below floor %d; using %d",
ival,
_MIN_SPAWN_DEPTH,
_MAX_SPAWN_DEPTH_CAP,
clamped,
floored,
)
return clamped
return floored
def _get_orchestrator_enabled() -> bool:
@@ -1982,7 +1985,8 @@ def delegate_task(
f"Delegation depth limit reached (depth={depth}, "
f"max_spawn_depth={max_spawn}). Raise "
f"delegation.max_spawn_depth in config.yaml if deeper "
f"nesting is required (cap: {_MAX_SPAWN_DEPTH_CAP})."
f"nesting is required (no hard ceiling, but each level "
f"multiplies API cost)."
)
}
)
+1
View File
@@ -175,6 +175,7 @@ def _build_provider_env_blocklist() -> frozenset:
"EMAIL_SMTP_HOST",
"EMAIL_HOME_ADDRESS",
"EMAIL_HOME_ADDRESS_NAME",
"HERMES_DASHBOARD_SESSION_TOKEN",
"GATEWAY_ALLOWED_USERS",
"GH_TOKEN",
"GITHUB_APP_ID",
+17 -3
View File
@@ -1030,6 +1030,20 @@ def _parse_env_var(name: str, default: str, converter=int, type_label: str = "in
)
def _safe_getcwd() -> str:
"""Return the current working directory, tolerating a deleted CWD.
``os.getcwd()`` raises FileNotFoundError when the process's working
directory has been removed out from under it (e.g. a scratch workspace
that was cleaned up mid-session). Fall back to TERMINAL_CWD, then the
user's home directory, so terminal setup never crashes on a stale CWD.
"""
try:
return os.getcwd()
except FileNotFoundError:
return os.getenv("TERMINAL_CWD") or os.path.expanduser("~")
def _get_env_config() -> Dict[str, Any]:
"""Get terminal environment configuration from environment variables."""
# Default image with Python and Node.js for maximum compatibility
@@ -1042,7 +1056,7 @@ def _get_env_config() -> Dict[str, Any]:
# remote home, and everything else starts in the backend's default
# root-like cwd.
if env_type == "local":
default_cwd = os.getcwd()
default_cwd = _safe_getcwd()
elif env_type == "ssh":
default_cwd = "~"
else:
@@ -1058,7 +1072,7 @@ def _get_env_config() -> Dict[str, Any]:
host_cwd = None
host_prefixes = ("/Users/", "/home/", "C:\\", "C:/")
if env_type == "docker" and mount_docker_cwd:
docker_cwd_source = os.getenv("TERMINAL_CWD") or os.getcwd()
docker_cwd_source = os.getenv("TERMINAL_CWD") or _safe_getcwd()
candidate = os.path.abspath(os.path.expanduser(docker_cwd_source))
if (
any(candidate.startswith(p) for p in host_prefixes)
@@ -2516,7 +2530,7 @@ if __name__ == "__main__":
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}")
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', _safe_getcwd())}")
from hermes_constants import display_hermes_home as _dhh
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")
print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}")
+1 -1
View File
@@ -28,7 +28,7 @@
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.17.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"unicode-animations": "^1.0.3"
+3 -4
View File
@@ -12,10 +12,9 @@ Get Hermes Agent up and running in under two minutes!
### With the Hermes Desktop installer on macOS or Windows (recommended)
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it.
### With the Hermes Desktop installer on Linux:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash
```
:::note macOS: no Apple Developer account needed
The macOS builds are code-signed and notarized, so a normal install just works — you do **not** need an Apple Developer account or any "developer password." If macOS Gatekeeper ever blocks first launch with an *"unidentified developer"* dialog, right-click the app and choose **Open**. See [Desktop App → Troubleshooting](../user-guide/desktop.md#macos-wont-open-the-app-asks-about-the-developer).
:::
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
@@ -50,10 +50,6 @@ Pick the row that matches your goal:
### With the Hermes Desktop installer on macOS or Windows (recommended)
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it.
### With the Hermes Desktop installer on Linux:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash
```
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
+18
View File
@@ -59,6 +59,24 @@ hermes update --check --branch experimental # preview behindness only
If your local checkout is on a different branch, Hermes auto-stashes any uncommitted work, switches HEAD to the target branch, and then pulls. Branches that don't exist locally are auto-tracked from `origin/<name>` (`git checkout -B <name> origin/<name>`). Branches that don't exist anywhere fail cleanly — your stashed changes are restored before exit so you're never stranded in a weird state. The `main`-only fork-upstream sync logic is automatically skipped on non-`main` branches.
### Local changes on non-interactive updates
When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates.
When the update runs **without a terminal** — from the desktop/chat app's "Update" button or a gateway-triggered update — there's no prompt to answer. The `updates.non_interactive_local_changes` setting decides what happens to your stashed changes:
```yaml
# ~/.hermes/config.yaml
updates:
non_interactive_local_changes: stash # default: keep + auto-restore
# non_interactive_local_changes: discard # throw local source edits away
```
- `stash` (default) — auto-stash, pull, then auto-restore your changes on top of the updated code. Nothing is lost; if a restore hits conflicts they're preserved in a git stash for manual recovery.
- `discard` — auto-stash and drop the stash after the pull, so the update always lands on a clean tree. Use this only on machines where you never intend to keep local edits to the Hermes source. It stash-drops (not `git reset --hard` + `git clean -fd`), so ignored paths like `node_modules`, `venv`, and build outputs are never touched.
In the desktop app this is **Settings → Advanced → In-App Update Local Changes**.
### Preview-only: `hermes update --check`
Want to know if an update is available before pulling? Run `hermes update --check` — for git installs it fetches and compares commits against `origin/main`; for pip installs it queries PyPI for the latest release. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update".
+2 -2
View File
@@ -218,14 +218,14 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff
## Constraints
- **Default 3 parallel tasks**: batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml, no hard ceiling, only a floor of 1)
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (floor 1, no ceiling); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
### Tuning Concurrency and Depth
| Config | Default | Range | Effect |
|--------|---------|-------|--------|
| `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call |
| `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further |
| `max_spawn_depth` | 1 | >=1 | How many delegation levels can spawn further |
Example: running 30 parallel workers with nested subagents:
-6
View File
@@ -67,12 +67,6 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com).
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it.
### Linux
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash
```
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
+16
View File
@@ -1361,6 +1361,22 @@ hermes dashboard
hermes dashboard --port 8080 --no-open
```
### `hermes dashboard register`
Register this install as a self-hosted dashboard with your Nous Portal account, so the dashboard's OAuth (Nous) auth gate can be used. Resolves your existing Nous login (run `hermes setup` first if you're not logged in), creates an OAuth client, writes `HERMES_DASHBOARD_OAUTH_CLIENT_ID` into `~/.hermes/.env`, and prints how to engage the login gate. You can also register, name, and revoke dashboards from the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page.
| Option | Default | Description |
|--------|---------|-------------|
| `--name` | auto-generated | Human-readable label for the dashboard |
| `--redirect-uri` | — | Public HTTPS OAuth redirect URI for an internet-facing host, e.g. `https://hermes.example.com/auth/callback`. Omit for localhost-only use. |
```bash
hermes dashboard register
# ✓ Registered dashboard "swift_falcon"
# …writes HERMES_DASHBOARD_OAUTH_CLIENT_ID to ~/.hermes/.env
```
## `hermes profile`
```bash
@@ -418,9 +418,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
### Web Dashboard & Hermes Desktop
Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id`/`portal_url` are better set under `dashboard.oauth` in `config.yaml` (env wins when set).
Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id` is better set under `dashboard.oauth` in `config.yaml` (env wins when set).
The recommended way to expose a dashboard for a remote Hermes Desktop connection is the bundled **username/password** provider: set the `HERMES_DASHBOARD_BASIC_AUTH_*` vars below and run `hermes dashboard --host 0.0.0.0`. The non-loopback bind engages the auth gate, and Desktop signs in with the username and password.
Three dashboard-auth providers ship in the box. For a remote Hermes Desktop connection or any internet-facing dashboard, the recommended provider is **OAuth (Nous Portal)** — set `HERMES_DASHBOARD_OAUTH_CLIENT_ID` (provision it with `hermes dashboard register`). The bundled **username/password** provider (`HERMES_DASHBOARD_BASIC_AUTH_*`) is the quickest option for a backend on a trusted LAN or behind a VPN, but is not suitable for direct public-internet exposure. To authenticate against your own identity provider, use the **self-hosted OIDC** provider (`HERMES_DASHBOARD_OIDC_*`). Either way, a non-loopback bind (`hermes dashboard --host 0.0.0.0`) engages the auth gate. See [Web Dashboard → Authentication](/user-guide/features/web-dashboard#authentication-gated-mode) for the full picture.
| Variable | Description |
|----------|-------------|
@@ -429,10 +429,12 @@ The recommended way to expose a dashboard for a remote Hermes Desktop connection
| `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_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly so sessions survive restarts / span multiple workers; blank → random per-process (you'll be logged out on every restart). 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`. |
| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in with your username and password from the Gateway settings panel. |
| `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_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard, activating the Nous (`plugins/dashboard_auth/nous`) provider. Overrides `dashboard.oauth.client_id`. Provision it with `hermes dashboard register`. |
| `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_OIDC_ISSUER` | OIDC issuer URL for the bundled self-hosted OIDC provider (`plugins/dashboard_auth/self_hosted`). Required to activate it. Overrides `dashboard.oauth.self_hosted.issuer`. |
| `HERMES_DASHBOARD_OIDC_CLIENT_ID` | Public OIDC client id (authorization-code + PKCE) for the self-hosted OIDC provider. Required to activate it. Overrides `dashboard.oauth.self_hosted.client_id`. |
| `HERMES_DASHBOARD_OIDC_SCOPES` | Requested OIDC scopes for the self-hosted OIDC provider (default `openid profile email`). Overrides `dashboard.oauth.self_hosted.scopes`. |
| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in from the Gateway settings panel (OAuth redirect or username/password, whichever the backend advertises). |
### Microsoft Graph (Teams Meetings)
+2 -2
View File
@@ -1672,7 +1672,7 @@ delegation:
# api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY)
# api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect.
max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var.
max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels.
max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3+ = deeper trees.
orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth.
```
@@ -1686,7 +1686,7 @@ The delegation provider uses the same credential resolution as CLI/gateway start
**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (floor of 1, no upper ceiling). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees, and higher for deeper ones. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
## Clarify
+29 -4
View File
@@ -102,7 +102,14 @@ By default the app starts and manages its own **local** backend. You can instead
"Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps.
:::
The connection has two halves: on the backend you protect the dashboard with a **username and password**, and in the app you enter the backend's URL and sign in with those credentials. Binding the dashboard to a non-loopback address automatically engages its auth gate, so the username/password provider is what lets the desktop app through.
The connection has two halves: on the backend you protect the dashboard with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the dashboard to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through.
**Pick a provider based on where the backend lives:**
- **OAuth (Nous Portal) — preferred for anything reachable beyond your own machine.** Logins are verified against your Nous account, so this is the option suitable for a VPS, a public host, or any remote backend. Register the dashboard with `hermes dashboard register` (or the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page) to provision its OAuth client, then sign in from the app with **Sign in with Nous Research**. A self-hosted OIDC provider works the same way if you run your own identity provider.
- **Username/password — local / trusted-network use only.** The simplest option when the backend is on the same trusted LAN or reachable only over a VPN (e.g. Tailscale). It protects a single shared credential with no external identity provider, so **do not use it for a dashboard exposed to the public internet** — reach for OAuth there instead.
The rest of this section shows the username/password path because it's the quickest to stand up on a trusted network; for the OAuth path see [Web Dashboard → Default provider: Nous Research](./features/web-dashboard.md#default-provider-nous-research).
### On the backend (the remote machine)
@@ -134,7 +141,7 @@ Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUT
Running the dashboard as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot.
:::warning
The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. Even behind a username and password, never expose it directly to the open internet put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host <tailscale-ip>`) and use `http://<tailscale-ip>:9119` as the Remote URL so only your tailnet can reach it.
The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected dashboard directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host <tailscale-ip>`) and use `http://<tailscale-ip>:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead.
:::
### In the app
@@ -142,10 +149,10 @@ The dashboard reads and writes your `.env` (API keys, secrets) and can run agent
**Settings → Gateway → Remote gateway:**
1. **Remote URL**`http://<backend-host>:9119` (path prefixes like `/hermes` work if you front it with a reverse proxy)
2. **Sign in** — the app detects that the backend requires a username and password and shows a **Sign in** button. Click it, enter the credentials from step 1, and the app authenticates against the backend's login page.
2. **Sign in** — the app detects which provider the backend advertises and adapts the button. For a username/password backend it shows a **Sign in** button that opens a credential form (enter the credentials from step 1). For an OAuth backend it shows **Sign in with `<provider>`** (e.g. *Sign in with Nous Research*), which runs the provider's browser sign-in. Either way the app ends up with an authenticated session against the backend.
3. **Save and reconnect** — switches the desktop shell onto the remote backend. The session refreshes automatically; you stay signed in across restarts when `HERMES_DASHBOARD_BASIC_AUTH_SECRET` is set.
You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in with your username and password from the Gateway settings panel.
You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in from the Gateway settings panel.
### Troubleshooting
@@ -158,6 +165,24 @@ For the same setup from the web-dashboard angle, see [Web Dashboard → Connecti
## Troubleshooting
### macOS won't open the app / asks about the "developer"
You do **not** need an Apple Developer account, an Apple Developer Program membership, or any "developer password" to install or run Hermes Desktop. The official builds from [our download page](https://hermes-agent.nousresearch.com/desktop) are code-signed and notarized by Apple, so a normal install just works.
If macOS still shows a dialog like *"Hermes can't be opened because Apple cannot check it for malicious software"* or *"…from an unidentified developer"*, that's **Gatekeeper**, not an account requirement. It usually means the download's quarantine attribute is in an odd state, or you built the app locally with `hermes desktop` (which produces an unsigned build from your own source). It is asking you to confirm you trust the app — there is no developer account or password involved.
To open it anyway:
1. **Right-click (or Control-click) the app** in Finder and choose **Open**, then click **Open** in the dialog. macOS remembers this choice, so you only do it once.
2. If there's no **Open** option, go to **System Settings → Privacy & Security**, scroll to the **Security** section, and click **Open Anyway** next to the Hermes entry. (The macOS *login* password it may ask for here is your own Mac password to change a security setting — not a "developer" password.)
If you'd rather not deal with Gatekeeper at all, the CLI install needs no signing prompts:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
hermes desktop # launches the desktop app, building it locally
```
Boot logs land in `HERMES_HOME/logs/desktop.log` (it includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure. You can also tail it from the CLI:
```bash
+7 -1
View File
@@ -118,7 +118,13 @@ The dashboard's auth gate engages automatically when both of the following are t
1. The bind host is non-loopback (e.g. the default `0.0.0.0` inside the container), **and**
2. A `DashboardAuthProvider` plugin is registered.
The simplest way to satisfy the second condition is the bundled **username/password** provider: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). For hosted/public deploys the OAuth (`dashboard_auth/nous`) provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set. Either way the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for both providers.
There are three bundled ways to satisfy the second condition:
- **Username/password** — the simplest for a self-hosted / on-prem / homelab container on a trusted network or behind a VPN: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). Not suitable for direct public-internet exposure.
- **OAuth (Nous Portal)** — for hosted/public deploys: the `dashboard_auth/nous` provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set.
- **Self-hosted OIDC** — to authenticate against your own identity provider via standard OpenID Connect: the `dashboard_auth/self_hosted` provider activates when `HERMES_DASHBOARD_OIDC_ISSUER` + `HERMES_DASHBOARD_OIDC_CLIENT_ID` are set.
Whichever you choose, the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for all three providers.
If no provider is registered and the bind is non-loopback, the dashboard **fails closed at startup** with a specific error pointing at the missing env var. The `HERMES_DASHBOARD_INSECURE=1` escape hatch disables the gate entirely (the bind host alone never implies `--insecure`), but it serves an unauthenticated dashboard — configure a provider instead unless you have your own auth layer in front.
@@ -214,7 +214,7 @@ delegate_task(
```
- `role="leaf"` (default): child cannot delegate further — identical to the flat-delegation behavior.
- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3 for three levels (cap).
- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3+ for deeper trees. There is no upper ceiling — cost is the practical limit.
- `delegation.orchestrator_enabled: false`: global kill switch that forces every child to `leaf` regardless of the `role` parameter.
**Cost warning:** With `max_spawn_depth: 3` and `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. Each extra level multiplies spend — raise `max_spawn_depth` intentionally.
@@ -264,7 +264,7 @@ For **durable long-running work** that must survive interrupts or outlive the cu
delegation:
max_iterations: 50 # Max turns per child (default: 50)
# max_concurrent_children: 3 # Parallel children per batch (default: 3)
# max_spawn_depth: 1 # Tree depth (1-3, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3 for three levels.
# max_spawn_depth: 1 # Tree depth (floor 1, no ceiling, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3+ for deeper trees.
# orchestrator_enabled: true # Disable to force all children to leaf role.
model: "google/gemini-3-flash-preview" # Optional provider/model override
provider: "openrouter" # Optional built-in provider

Some files were not shown because too many files have changed in this diff Show More