Compare commits

..
Author SHA1 Message Date
ethernet a23bbe3348 gui(refactor): unify keybinding ui & types 2026-06-10 11:52:46 -04:00
Davy a72bb03757 fix(docker): optimize image size — .dockerignore, drop dev deps, split build layers (#38749)
* fix(docker): optimize image size with .dockerignore, drop dev deps, split build layers

Three changes to reduce the Docker image size and speed up rebuilds:

1. .dockerignore — exclude ~69 MB of files that are never needed inside
   the container: apps/ (desktop Tauri source), tests/, website/
   (Docusaurus), docs/, infographic/, nix/, plans/, packaging/, and
   various dotfiles (.envrc, .hadolint.yaml, .mailmap, etc.).  The
   existing .dockerignore already covered node_modules and .git; these
   additions prevent the remaining non-runtime content from inflating
   both the build context and the final image (COPY . .).

2. pyproject.toml — add a [docker] extra that mirrors [all] but omits
   [dev] (debugpy, pytest, pytest-asyncio, pytest-timeout, ty, ruff,
   setuptools).  The published image doesn't need test/debug tooling.
   Estimated savings: ~30-50 MB of Python packages.

3. Dockerfile — use --extra docker instead of --extra all in the
   uv sync layer.  Also split the COPY + npm run build so that the
   web/ and ui-tui/ frontend builds are cached independently from
   Python source changes (COPY . .).  A Python-only commit no longer
   invalidates the (slower) frontend build layer.

Note: the build-only apt packages (gcc, python3-dev, libffi-dev,
libolm-dev) are still installed in the final image.  Removing them
requires a true multi-stage build (builder → runtime), which is a
larger refactor tracked separately.

* fix(docker): remove redundant [docker] extra, revert to --extra all

The [docker] extra was identical to [all] on main — the PR had added [dev]
to [all] then created [docker] as [all] minus [dev], a no-op round-trip.
Revert [all] to its original form and drop the [docker] extra.

Keep the .dockerignore additions and frontend build layer reordering.
2026-06-10 03:08:00 -07:00
Gille 47e77ae166 fix(curator): use shared atomic state writer 2026-06-10 03:04:54 -07:00
Gille 4c797d0e23 fix(desktop): hide Windows console children launched by GUI 2026-06-10 03:04:54 -07:00
teknium1 189ffe7362 test: port voice-reply suffix assertions, fix change-detector cap test, add AUTHOR_MAP entry
- Add output_path suffix assertions (.ogg Telegram / .mp3 non-Telegram) to
  _send_voice_reply tests, covering the OGG voice-note path that landed on
  main in ae82eed2b (the PR's third commit was redundant with it).
- Convert test_gemini_default_is_32000 back to an invariant against
  PROVIDER_MAX_TEXT_LENGTH instead of a hardcoded literal.
- Map barronlroth@gmail.com -> barronlroth in scripts/release.py.
2026-06-10 02:57:39 -07:00
Barron Roth 2c19208224 feat(tts): add Gemini audio tag rewrite 2026-06-10 02:57:39 -07:00
Barron Roth 5718811de0 feat(tts): add Gemini persona prompt file 2026-06-10 02:57:39 -07:00
Teknium af3c8b80b5 fix(tests): close pid-file read race in test_grandchild_reaped_via_pgroup (#43447)
The grandchild wrote its pid with open('w').write(...), so the polling
reader in the test could observe the file after creation but before the
write flushed, parsing '' -> ValueError: invalid literal for int().
Write to a temp file and os.replace() it into place so the pid file only
ever appears fully written.
2026-06-10 02:57:27 -07:00
Teknium 70d5d7e39b fix(memory,skills): repair write-approval inline prompt, gateway staging, and gateway /skills review (#43452)
Follow-ups to #38199/#43354 found in post-merge review:

- Inline CLI memory approval never worked: the per-thread approval callback
  was not passed to prompt_dangerous_approval, so the prompt_toolkit
  fail-closed guard (#15216) denied every gated foreground write without
  showing a prompt. Now invokes the registered callback directly; a crashed
  prompt falls back to staging instead of a silent deny.
- Gateway sessions claimed inline support but prompt_dangerous_approval has
  no gateway round-trip (that lives in the pending-approval queue), so gated
  gateway memory writes hit the input() fallback and denied. Gateway
  contexts now stage for /memory pending review.
- /skills pending|approve|reject|diff|approval now works on the gateway
  (gateway_config_gate on skills.write_approval), so skills staged from a
  messaging session can be reviewed there. Diff output truncated for chat.
- memory_tool validates required params before the gate so invalid writes
  are rejected immediately instead of staged and failing at approve time.
- Stale tri-state write_mode docstrings updated to the boolean gate; docs
  table corrected (inline prompt is interactive-CLI-only).
- 6 new tests covering the interactive approve/deny/error paths, gateway
  staging, skills never-prompt invariant, and pre-gate validation.
2026-06-10 02:57:15 -07:00
Teknium a5c32cdf30 fix(update): self-heal a venv left half-built by an interrupted install (#42172)
* fix(update): self-heal a venv left half-built by an interrupted install

An update killed mid dependency-install (Ctrl-C, terminal close, WSL OOM)
could leave the venv with pip wiped and core deps (e.g. Pillow) missing,
with no automatic recovery — the user had to manually run ensurepip +
reinstall.

Drop an install-scoped .update-incomplete breadcrumb right before the dep
install and clear it only after core-dependency verification passes. On the
next launch (any command except 'update' itself), if the marker is present,
unconditionally bootstrap pip via ensurepip then re-run the .[all] install +
verification, then clear the marker. Failure leaves the marker for retry and
prints the manual recovery command. Never raises — recovery cannot block
launch.

* fix(update): address review — stderr-only recovery output, single-flight lock, gitignore marker

- Route all recovery output (status lines + streamed pip/uv install via
  fd-level dup2) to stderr so protocol-on-stdout launches (hermes acp)
  never get install noise on the JSON-RPC stream.
- Single-flight O_EXCL lockfile (.update-incomplete.lock) so a gateway
  start + CLI launch (or two profiles) can't run concurrent installs
  into the shared venv; stale locks (>1h) are broken for the next launch.
- gitignore .update-incomplete + lock so source-tree installs keep a
  clean git status and update's autostash skips them.
- Document why the loose 'update' argv substring match is intentional
  (over-match defers one launch; under-match would race the real update).
- 4 new tests: lock held → skip, stale lock broken, lock released,
  output lands on stderr only.
2026-06-10 02:57:05 -07:00
Ben Barclay 15813336cc fix(config): preserve original .env file mode in remove_env_value too (#43349)
#33699 fixed save_env_value so an operator-set .env mode (e.g. 0640 on a
Docker bind-mount) survives a config write instead of being re-tightened
to 0600 by the unconditional _secure_file() call. The sibling
remove_env_value() had the identical bug: it restores original_mode and
then unconditionally called _secure_file(env_path), clobbering the mode
back to 0600 on every `hermes config remove KEY`.

Apply the same fix: move _secure_file() into the else branch so it only
runs when no original mode was captured (a freshly created .env still
gets 0600 hardening; existing operator-set modes survive).

Added test_remove_env_value_preserves_existing_file_mode_on_posix, which
fails on the unfixed remove path (expected 0o640, got 0o600) and passes
with the fix.
2026-06-10 19:53:07 +10:00
Siddharth Balyan 183d86b3e0 fix(openrouter): route reasoning_effort to verbosity for adaptive Anthropic models (#43436)
* fix(openrouter): route reasoning_effort to verbosity for adaptive Anthropic models

Reasoning-mandatory Anthropic models (Claude 4.6+/fable/mythos-class) over
OpenRouter ignore reasoning.effort and use adaptive thinking. #42991 correctly
stopped Hermes from sending a reasoning field to them (it 400s), but put nothing
in its place — leaving agent.reasoning_effort a silent no-op on the OpenRouter
path: the model always ran at its adaptive default (high) regardless of config.

OpenRouter honors the requested effort on the top-level verbosity field instead
(maps to Anthropic output_config.effort). Route the existing
reasoning_config[effort] there for these models while still never emitting a
reasoning field, preserving the #42991 fix. No new config arg — the value the
user already sets via agent.reasoning_effort now flows to verbosity.

- low/medium/high/xhigh/max pass through verbatim (OpenRouter accepts the
  extended scale for Claude; verified live HTTP 200 + monotonic token spend).
- effort unset/none/disabled omits verbosity so the model keeps its default.
- native Anthropic transport already correct; unchanged.

Fixes #43432

* test(openrouter): cover real effort range (add minimal, frame max as passthrough)

Adversarial review noted the verbosity tests looped over 'max' — a value
parse_reasoning_effort can never produce — while omitting 'minimal', which it
can. Align the routing test with the real config range
(VALID_REASONING_EFFORTS = minimal/low/medium/high/xhigh) and keep a separate
value-agnostic passthrough test that documents why xhigh/max must survive
verbatim (TypedDict, no runtime literal validation; OpenRouter accepts the
extended scale for Claude).

* docs: explain reasoning_effort -> verbosity routing for adaptive Anthropic models

Document that reasoning_effort transparently maps to OpenRouter's verbosity
field for adaptive-thinking Anthropic models (Claude 4.6+/Fable/Mythos), where
reasoning.effort is ignored. Note xhigh is the configurable ceiling (max is wire-
only). Add verbosity as a top-level-kwarg example in the provider-plugin guide.
2026-06-10 15:03:01 +05:30
Teknium cd9a9cd8e5 fix(gateway): Slack approval UX in threads — block-size overflow + typed-prefix instruction text (#43444)
Two fixes for the reported Slack thread approval UX:

1. Slack Block Kit approval/confirm sends silently overflowed the
   3000-char section-block cap (flat 2900-char truncation + header +
   reason), so long execute_code approvals failed with invalid_blocks
   and fell back to the plain-text prompt with no buttons. Budget the
   command preview against the rendered fixed parts so blocks never
   exceed the cap (send_exec_approval + send_slash_confirm).

2. The text fallbacks told users to reply /approve — which Slack blocks
   inside threads and Matrix clients reserve client-side. Add a
   typed_command_prefix capability flag on BasePlatformAdapter
   (default "/"; Slack and Matrix set "!" to match their existing
   bang-prefix rewrite) and use it in the shared fallback prompt
   builders (exec approval, update prompt, destructive slash confirm,
   expensive-model confirm) plus Matrix's reaction-prompt text.
   The slash-confirm text-intercept now also accepts bang-prefixed
   replies (!always, !cancel) since those keywords aren't registered
   commands and the adapters' rewrite doesn't touch them.
2026-06-10 02:30:01 -07:00
Evi Nova 5d8c44a393 fix(docker): pre-install matrix deps in Docker image (#30399) (#42413)
The Matrix gateway requires mautrix[encryption] which pulls in
python-olm. While python-olm was removed from [all] due to missing
Windows/macOS wheels, it has binary manylinux wheels for Linux
amd64/arm64. The Docker image only runs on Linux, so adding --extra
matrix to the uv sync line is safe.

libolm-dev is already in the apt-get install line for runtime linking.

Fixes: #30399
2026-06-10 19:23:06 +10:00
kshitij 2f19512341 fix(cli): repair non-UTF-8 stdout/stderr on all platforms, not just Windows (#43439)
`hermes setup` (and other banner-printing commands) crash with an unhandled
UnicodeEncodeError on Linux hosts whose locale selects a non-UTF-8 codec —
e.g. a fresh Raspberry Pi / minimal Debian with a latin-1 or C/POSIX locale.
The setup wizard prints box-drawing characters (┌│├└─) and the ⚕ glyph before
any stream repair runs, so the command dies before it can start.

The existing _ensure_utf8() shim already knew how to re-wrap the standard
streams as UTF-8, but it returned early on `sys.platform != "win32"`, so the
identical crash class on Linux was never covered.

- Drop the win32 gate: repair any stdout/stderr whose encoding is not UTF-8.
- Prefer TextIOWrapper.reconfigure() so the stream object is fixed in place
  (cached sys.stdout references keep working); fall back to reopening the fd
  with closefd=False (the CPython-recommended safe variant).
- Use errors="replace" — matching the sibling hermes_cli/stdio.py shim — so a
  stray un-encodable byte degrades gracefully instead of crashing.
- Only set the PYTHONUTF8/PYTHONIOENCODING child-process hints when a repair
  actually happened, so a healthy UTF-8 host sees zero footprint (no stream
  swap, no env mutation).

This is intentionally the earliest, platform-agnostic guard, running at import
time before any banner prints. hermes_cli/stdio.py::configure_windows_stdio()
still runs later from the entry points for the Windows-only extras (console
code-page flip, EDITOR default, PATH augmentation); it early-returns on
non-Windows and its stream reconfigure is an idempotent no-op once we've
already repaired the streams here.

Add regression tests covering latin-1 and ascii/POSIX streams, the reconfigure
fallback, already-UTF-8 no-op (identity preserved + no env mutation), the
repair-sets-env and respects-explicit-env contracts, and hostile/None streams.
2026-06-10 02:21:00 -07:00
brooklyn! f222bd26e7 Merge pull request #43430 from NousResearch/bb/desktop-tool-codicons-filled
style(desktop): filled glyphs for in-thread tool icons
2026-06-10 03:52:04 -05:00
Brooklyn Nicholson 38273676ea fix(desktop): carve sticky user bubbles out of the titlebar drag region
Sticky human bubbles park at --sticky-human-top (~4px), sliding under the
titlebar's -webkit-app-region:drag strips. Electron resolves drag regions at
the compositor level — z-index and pointer-events don't apply — so clicking a
stuck bubble dragged the window instead of opening the edit composer. Add
no-drag to the shared bubble base class (read-only bubble + edit composer).

Covers the runtime side with a test: clicking a user bubble opens the inline
edit composer through both the incremental external-store runtime and the
stock one.

(cherry picked from commit db4e1f4f3eaee955fe057aedcfea6122c476535a)
2026-06-10 03:46:03 -05:00
Brooklyn Nicholson c1308ebf3f style(desktop): filled SVG glyphs for in-thread tool icons
Replace the earlier text-stroke approach (which only bolds outline
codicons — a font glyph has no fillable region) with dedicated solid
SVG glyphs for tool rows. Adds ToolIcon, keyed by the same names as
TOOL_META, with a codicon fallback for uncovered tools.
2026-06-10 03:41:55 -05:00
teknium1 fa32af886f fix: dedupe concurrent gateway restarts + surface restart outcome in onboarding UI
Follow-ups to the salvaged Telegram QR onboarding auto-restart:

- _spawn_gateway_restart() reuses a live in-flight 'hermes gateway restart'
  child instead of spawning a second racing one (stale cached frontend +
  new backend both requesting a restart, or restart-button double-click).
  Both /api/gateway/restart and the onboarding apply path go through it.
- ChannelsPage polls /api/actions/gateway-restart/status after a
  server-initiated restart and surfaces a non-zero exit (e.g. systemd
  linger missing) via the manual-restart banner, since restart_started
  only means the child spawned.
- Test for the reuse path + _ACTION_PROCS isolation in existing tests.
2026-06-10 01:35:12 -07:00
Shannon Sands 984e69ff62 Auto-restart gateway after Telegram QR onboarding 2026-06-10 01:35:12 -07:00
Brooklyn Nicholson e80754647c style(desktop): render in-thread tool codicons as filled glyphs
Outline codicons read too thin at conversation-tool scale; a scoped
filled modifier thickens tool-row and code-card icons without changing
icon semantics elsewhere in the shell.
2026-06-10 03:30:25 -05:00
Teknium 298bb93d39 feat(skills): show live per-source progress while browsing (#43398)
do_browse waited on a frozen 'Fetching skills...' spinner while sources
resolved, so a slow source looked like a hang. parallel_search_sources
already exposes an on_source_done(sid, count) callback fired as each source
completes — wire it into the status line so it ticks off sources live
(official (12), + github (4), + clawhub (500)). The page is still rendered
once, after the full set is merged and trust-sorted, so browse's
official-first ordering and pagination contract are untouched.
2026-06-10 01:02:40 -07:00
Teknium eee1da45f0 fix(skills): bound ClawHub catalog walk to requested page on cold start (#43395)
Browse renders one page but the cold-cache fallback walked the entire
50k+ ClawHub catalog, then sliced off the first N — pure waste behind the
12s budget band-aid. _load_catalog_index now takes max_items: browse's
empty-query path bounds the walk to its limit and stops early; the offline
index builder still passes limit=0 (unbounded) and walks to exhaustion.
A bounded walk is partial, so it is not written to the shared full-catalog
cache (same poison-guard as the budget-truncated case).
2026-06-10 01:01:53 -07:00
konsisumer 6a30cfca82 fix(gateway): stop typing before post-delivery callbacks (#37556) 2026-06-10 00:46:00 -07:00
Teknium 888bf96025 chore(release): add tomekpanek to AUTHOR_MAP 2026-06-10 00:34:38 -07:00
tomekpanek 383d44bc9a fix(web): rank explicit credentials above managed-gateway probe
Backend selection ordered firecrawl (including the Nous-managed-tool-gateway
probe) ahead of explicit-credential backends, so a user who had both a
Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) got firecrawl
auto-selected — then the request failed at runtime because the free Nous
tier does not include web search, and there is no fallback to the next
available backend. Explicit user setup lost to a managed convenience.

Reorder so direct-credential backends (tavily > exa > parallel > firecrawl-
direct) are tried first, then the managed-gateway firecrawl probe, then
free-tier fallbacks. Behaviour for users with only Nous OAuth (no
explicit key) is unchanged — firecrawl-via-gateway is still selected.

Behaviour change to flag: a user with BOTH a Nous OAuth token AND a
TAVILY_API_KEY (or EXA/PARALLEL key) now gets the explicit backend
instead of the managed gateway. This matches the principle of least
surprise — a user does not set TAVILY_API_KEY without intent — and
sidesteps the silent runtime failure of the gateway path on free tiers.
2026-06-10 00:34:38 -07:00
Teknium 243cada157 fix(model): cover typed gateway /model path + async-safe pricing lookups
Follow-ups on top of #26016's expensive-model guard:

- gateway/slash_commands.py: typed '/model <name>' now routes through the
  expensive-model confirmation gate (slash-confirm buttons / text fallback)
  instead of bypassing the guard the pickers enforce. Cancel leaves the
  session override and --global config untouched.
- telegram/discord/web_server: run expensive_model_warning() via
  asyncio.to_thread — it can hit models.dev or a /models endpoint on a
  cache miss, which would otherwise block the event loop.
- telegram: picker callback no longer toasts 'Model switched!' when the
  switch callback raised (both mm: and mc: paths).
- tests: new tests/gateway/test_model_command_expensive_confirm.py pins
  the typed-path gate (prompt, confirm-once, cancel, cheap-model no-op).
2026-06-10 00:24:06 -07:00
Robin FernandesandClaude Fable 5 af978ecb17 fix(model): require confirmation for expensive model selections
Rebased onto current main and re-ported across the restructured
surfaces: model flows now thread confirm_provider/base_url/api_key
through hermes_cli/model_setup_flows.py, the Discord picker lives in
plugins/platforms/discord/adapter.py, and the web dashboard picker
applies chat-mode switches via config.set so the expensive-model
confirmation can ride the response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:24:06 -07:00
teknium1 4eadef18a9 fix: guard role_authorized check against MagicMock test sources
Compare source.role_authorized with 'is True' so a MagicMock source
(test fixtures that build bare runners via object.__new__) doesn't
auto-truthy through the gate. The real SessionSource field is a bool,
so production behavior is unchanged. Fixes test_signal_in_allowlist_maps.
2026-06-10 00:18:11 -07:00
teknium1 099146fedd chore: add AUTHOR_MAP entry for PR #33958 contributor 2026-06-10 00:18:11 -07:00
Joel Chan e5580f43c2 fix(discord): propagate role_authorized flag so DISCORD_ALLOWED_ROLES works end-to-end
DISCORD_ALLOWED_ROLES was checked by the Discord adapter (_is_allowed_user)
but gateway._is_user_authorized only read DISCORD_ALLOWED_USERS, so
role-authorized users were rejected with "Unauthorized user" at the
gateway layer despite passing the adapter gate.

- Add role_authorized: bool = False to SessionSource
- Add role_authorized param to build_source (base.py)
- Compute _role_authorized in on_message when user passes via role not user ID
- Thread _role_authorized through _handle_message -> build_source
- Check source.role_authorized early in _is_user_authorized (run.py)

Fixes #33952
2026-06-10 00:18:11 -07:00
达令小新 5a4297a11a fix(model_metadata): prefer hardcoded 1M for MiniMax M3 over stale models.dev probe 2026-06-09 23:24:40 -07:00
xxxigm aea0b7397b test(discord): cover voice timeout under voice-off mode
Assert the inactivity handler skips disconnect (and the channel spam) when the
voice-mode getter reports "off", and still disconnects on genuine inactivity
when the mode is active.
2026-06-09 23:24:26 -07:00
xxxigm 311900842e fix(discord): don't auto-disconnect voice when reply mode is off
The voice inactivity timer (VOICE_TIMEOUT) only counted the bot's OWN audio
playback as activity. Under /voice off (text-only replies, but still in the
channel — leaving is /voice leave) nothing ever reset it, so every 300s the bot
disconnected and spammed "Left voice channel (inactivity timeout)."

The adapter now learns the live voice-reply mode via a getter wired from run.py
and skips the auto-disconnect while mode is off. It also resets the timer when a
user actually speaks to the bot, so an active listener (incl. voice-on
text-only sessions that never play audio) isn't dropped mid-conversation.
2026-06-09 23:24:26 -07:00
briandevans 105625d650 fix(skills): honour overall_timeout and bound ClawHub catalog walk
parallel_search_sources accepted an overall_timeout but never honoured it.
The ThreadPoolExecutor ran inside a `with ... as pool` block, whose __exit__
calls shutdown(wait=True); even after as_completed() raised TimeoutError on
schedule, leaving the block blocked the caller until every worker finished.
A single slow source (e.g. ClawHub) therefore stalled the entire browse for
minutes. Manage the executor manually and shut it down with
wait=False, cancel_futures=True in a finally, so the timeout actually returns
and not-yet-started work is dropped.

ClawHubSource._load_catalog_index walked up to 750 sequential pages with no
wall-clock bound (each request under its own timeout=30, so nothing errored),
and wrote the result to the index cache unconditionally — so an interrupted or
slow walk poisoned the cache with a partial catalog. Add a
CATALOG_WALK_BUDGET_SECONDS deadline that breaks the walk early, and only write
the cache when the walk reaches a natural stop (cursor exhausted or page cap),
never on a budget-truncated walk.

Adds regression tests covering both bugs (timeout honoured + slow source
flagged; budget abort does not poison cache) plus their happy-path invariants.
2026-06-09 23:22:54 -07:00
teknium 2ce3ae3d16 fix(error-classifier): don't misclassify unsupported-param 400s as context overflow
A GPT-5 model rejecting max_tokens returns a 400 whose message contains the
literal substring 'max_tokens' — one of the _CONTEXT_OVERFLOW_PATTERNS. The 400
path in _classify_400 checked overflow patterns before any request-validation
check (which only existed on the 5xx path), so the parameter error was routed
into the compression loop, re-sent with the same bad param, and ended in
'Cannot compress further' on a tiny context.

Hoist a request-validation guard (unsupported/unknown parameter) above the
context-overflow check in _classify_400. Deliberately excludes the generic
invalid_request_error code, which OpenAI also stamps on real overflow 400s, so
genuine overflows still compress. Pairs with the max_completion_tokens param
fix that stops the bad request at the source.

Also adds AUTHOR_MAP entry for the salvaged PR #13902 commit.
2026-06-09 23:22:10 -07:00
Xiangji 19c07c4037 fix(params): send max_completion_tokens for newer OpenAI families on custom endpoints
Third-party OpenAI-compatible endpoints (self-hosted gateways, OpenRouter,
Azure proxies) fronting gpt-4o / gpt-4.1 / gpt-5+ / o1-o4 models silently
received max_tokens and 400'd with unsupported_parameter, because the three
kwarg-selection sites only checked base_url_hostname(...) == "api.openai.com"
and fell through to max_tokens on every other host. The constraint is
enforced server-side by the model family, not by the URL, so name-based
detection is required as a fallback.

Changes:
- utils.py: new shared helper model_forces_max_completion_tokens(model) that
  prefix-matches gpt-4o, gpt-4.1, gpt-5, o1, o3, o4 families on normalized
  (lowercased, vendor-prefix-stripped) names.
- run_agent.py: _max_tokens_param ORs the helper into the URL check.
- agent/auxiliary_client.py:
  - auxiliary_max_tokens_param gains an optional keyword-only model arg.
  - _build_call_kwargs inline branch applies the same check for both
    provider == "custom" and non-custom paths.

Tests:
- tests/test_model_forces_max_completion_tokens.py: 31 new cases covering
  positive families, negatives (classic gpt-4, claude, llama, mistral, qwen,
  deepseek), vendor prefixes, case-insensitivity, whitespace, None/empty,
  and substring-not-prefix guards.
- tests/run_agent/test_run_agent.py::TestMaxTokensParam: 5 new model-based
  cases (custom + gpt-5.4, openrouter + gpt-4o-mini, custom + o1-preview,
  classic gpt-4-turbo keeps max_tokens, llama3 keeps max_tokens).
- tests/agent/test_auxiliary_client.py::TestAuxiliaryMaxTokensParam: new
  class, 7 tests covering the URL x model matrix.
2026-06-09 23:22:10 -07:00
Teknium ab55008631 chore: add AUTHOR_MAP entry for OndrejDrapalik
Maps the salvaged #36781 commit author email to the GitHub login so the
release attribution + CI author check resolve.
2026-06-09 23:21:24 -07:00
Ondrej Drapalik 1c055a4c58 fix(xai): accept Grok Build code during loopback wait + tiny screenshot guard
xAI's consent page renders the authorization code in-page instead of
redirecting to the loopback callback, so the listener just hangs and the
manual-paste flow demands a callback URL that never contains the token.

- auth.py: poll stdin non-blockingly while waiting for the xAI loopback
  callback; accept a pasted bare Grok Build code and substitute the locally
  generated state (PKCE code_verifier still binds the exchange). No need to
  wait for timeout or re-run with --manual-paste.
- computer_use: parse PNG/JPEG dimensions from base64 and fall back to the
  text/AX/SOM payload when the screenshot is below the provider minimum
  (8x8), which xAI rejects with HTTP 400.
- model_setup_flows.py: xAI credential reuse prompt uses the standard radio
  picker via a shared _prompt_auth_credentials_choice helper.
- main.py: thread a title through _prompt_provider_choice; re-home the helper
  import (flows live in model_setup_flows.py post-decomposition).

Salvaged from #36781 onto current main (contributor's main.py edits re-homed
to model_setup_flows.py, where the flows were extracted since the PR opened).
2026-06-09 23:21:24 -07:00
Teknium 095f526b11 refactor(memory,skills): replace tri-state write_mode with boolean write_approval (default off) (#43354)
The shipped tri-state write_mode (on|off|approve) conflated two concepts —
whether writes are enabled and whether they're gated — so 'on' (writes flow
freely, gate inactive) read like 'gating is on'. Replace it with a single
clear boolean gate that defaults off.

  memory.write_approval / skills.write_approval:
    false (default) — write freely; the approval gate is off (pre-gate behaviour)
    true            — require approval: memory foreground prompts inline, memory
                      background-review + all skill writes stage for review

The old 'off = block all writes' mode is dropped; memory_enabled: false already
disables memory entirely, so a third 'block' state was redundant.

- tools/write_approval.py: get_write_mode/MODE_* → write_approval_enabled() bool;
  evaluate_gate() loses the config-driven 'blocked' path (blocked now only comes
  from an interactive user denial).
- tools/memory_tool.py, tools/skill_manager_tool.py: comment + behaviour follow.
- hermes_cli/config.py: memory/skills write_mode → write_approval (False);
  _config_version 28→29 with a 28→29 migration that renames any persisted
  write_mode (approve→true, on/off/unset→false) and drops the old key.
- slash commands: '/memory|/skills mode <on|off|approve>' → 'approval <on|off>'
  ('mode' kept as a back-compat alias); set_mode_fn callback now takes a bool.
- write_approval_commands.py, cli_commands_mixin.py, gateway/slash_commands.py,
  commands.py: handlers + registry args/subcommands updated.
- docs + tests rewritten for the boolean model; added migration tests.
2026-06-09 23:21:14 -07:00
synapsesx 9ca9697342 fix(gateway): return tuple from voice transcription on placeholder caption (#42090)
## What does this PR do?

The voice-during-active-run feature (#41984) changed
`_enrich_message_with_transcription` so that it returns a
`(enriched_text, successful_transcripts)` tuple instead of a bare string,
which lets callers echo the raw transcript back to the user. The signature
and every other return path were updated to match, but one branch was
missed: when a successfully transcribed clip arrives with the Discord
"empty content" placeholder as its caption, the method still returned the
prefix string on its own. All four call sites unpack the result with
`text, transcripts = await self._enrich_message_with_transcription(...)`,
so that path raised `ValueError: too many values to unpack (expected 2)`
and the inbound voice message was dropped instead of reaching the agent.

This is a real user-facing path rather than a corner case: a Discord voice
note sent without a caption is delivered as exactly that placeholder, so a
captionless voice message that transcribed correctly would crash the
handler precisely when transcription had worked. The fix returns the
proper tuple from that branch so the placeholder is still stripped while
the transcripts continue to flow back to the caller for the echo.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ]  New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ]  Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)

## Changes Made

- `gateway/run.py`: in `_enrich_message_with_transcription`, return
  `(prefix, successful_transcripts)` instead of a bare `prefix` from the
  empty-content-placeholder branch, so the contract matches the signature
  and the other return paths.
- `tests/gateway/test_stt_config.py`: add
  `test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder`,
  which drives a successful transcription with the placeholder caption and
  asserts the placeholder is stripped while the transcript is still returned.

## How to Test

1. Check out `main` and run the new test — it fails with
   `ValueError: too many values to unpack (expected 2)`, reproducing the
   crash a captionless Discord voice note would trigger.
2. Apply this change and re-run
   `pytest tests/gateway/test_stt_config.py -q` — all tests pass.
3. `ruff check gateway/run.py tests/gateway/test_stt_config.py` and
   `python scripts/check-windows-footguns.py gateway/run.py
   tests/gateway/test_stt_config.py` both pass.

## Checklist

### Code

- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-06-09 23:16:23 -07:00
Ben Barclay 63a421d4c0 fix(dashboard): _require_token endpoints all 401 behind the OAuth gate (#42578)
* fix(dashboard): let _require_token endpoints work behind the OAuth gate

In gated/OAuth mode (non-loopback bind without --insecure) the dashboard
authenticates the SPA via a session cookie and deliberately does NOT inject
the legacy ephemeral _SESSION_TOKEN into index.html. gated_auth_middleware
verifies the cookie and attaches request.state.session before any non-public
/api/ route runs; the legacy auth_middleware short-circuits in this mode too.

But several handlers call _require_token() directly, which only validated the
(absent) _SESSION_TOKEN header. So every cookie-authenticated request to those
endpoints 401'd — making plugin install/enable/disable, /api/dashboard/plugins/hub,
and the other _require_token routes permanently unreachable behind the gate.
In the UI this surfaced as a 401: {"detail":"Unauthorized"} popup on plugin
install for any publicly-bound (e.g. Fly-hosted NAS) dashboard.

Fix: _require_token now defers to the active gate. When auth_required is True it
accepts the request iff the gate attached a verified session (and 401s otherwise);
loopback/--insecure behavior is unchanged (still validates the session token).

Adds two regression tests driving the full in-process stub OAuth round trip:
the install endpoint must NOT 401 a logged-in request, and must still 401 with
no cookie. Verified the accept-test fails on the pre-fix code.

* test(dashboard): cover the whole _require_token route class under the gate

The install popup was one symptom of a class-wide bug: all 14 endpoints that
call _require_token directly (API-key reveal, provider validation, the
OAuth-provider connect/disconnect flow, and plugin enable/disable/update/
delete/visibility/providers) 401'd cookie-authenticated requests in gated mode.

Add a parametrized test hitting a representative spread (plugins/hub, env/reveal,
providers/validate, an oauth provider route, agent-plugin enable) asserting a
logged-in caller is never 401'd — proving the fix covers the class, not just
agent-plugins/install.
2026-06-09 22:57:49 -07:00
Ben Barclayandblut-agent e4a1b35a39 fix(config): preserve original .env file mode instead of unconditionally tightening to 0600 (#33699)
`save_env_value()` captures the original .env file mode (e.g. 0640 for Docker
volume mounts) and restores it via `os.chmod` — but then unconditionally calls
`_secure_file(env_path)` on the next line, which re-tightens the mode to 0600
and defeats the entire preservation logic. The intent (preserve when
`original_mode` is captured, secure otherwise) was already in the code but
got short-circuited.

Move `_secure_file()` into the `else` branch so it only runs when no original
mode was captured — fresh `.env` files written for the first time still get
the 0600 hardening treatment, but operator-set modes survive subsequent writes.

Salvages #31518 by @blut-agent (config.py portion only). Their PR also bundled
unrelated lowercase-lookup changes in `hermes_cli/commands.py`; this salvage
takes only the focused config fix. The commands.py changes are reasonable on
their own merits but belong in a separate PR.

Co-authored-by: blut-agent <278569635+blut-agent@users.noreply.github.com>
2026-06-10 15:42:16 +10:00
Teknium ea7981eba7 fix(dashboard): point webhook-disabled hint at Channels page (#43324)
The webhook 'platform disabled' card told users to enable it 'in your
messaging settings' — no such page exists. The webhook platform is
enabled on the Channels page (nav label), matching how every other
dashboard page refers to it.
2026-06-09 22:41:52 -07:00
kshitij f1b8519670 Merge pull request #43322 from kshitijk4poor/fix/langfuse-redact-base64-data-uri
fix(langfuse): redact base64 data URIs instead of truncating into invalid base64
2026-06-09 22:41:41 -07:00
mnajafian-nv f8fd30942c fix(cli): prevent duplicate one-shot finalize on interrupted cleanup (#43320)
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
2026-06-09 22:41:04 -07:00
kshitijk4poorandforas910521-lab 4642762289 fix(langfuse): redact base64 data URIs instead of truncating into invalid base64
The Langfuse SDK treats `data:*;base64,...` strings as media and tries to
decode them. `_truncate_text` was slicing those strings mid-payload, producing
invalid base64 and noisy "Error parsing base64 data URI" logs. Observability
only needs the metadata, not raw image/audio bytes, so redact the whole data
URI (type, media_type, length) before it reaches the SDK.

Salvaged the Langfuse fix from #39682 onto current main as a standalone,
single-concern change (the dashboard `dist/**` and plugin-discovery parts of
that PR already landed separately on main).

Co-authored-by: foras910521-lab <foras910521-lab@users.noreply.github.com>
2026-06-10 10:49:36 +05:30
129 changed files with 6121 additions and 3588 deletions
+42
View File
@@ -63,3 +63,45 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container
apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
website/
docs/
# Assets only used by the GitHub README
assets/
infographic/
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
plugins/hermes-achievements/docs/
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
nix/
flake.nix
flake.lock
packaging/
# Design and planning documents
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE
+6
View File
@@ -114,6 +114,12 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
.update-incomplete
.update-incomplete.lock
# 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/
+20 -9
View File
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The Matrix gateway's deps ([matrix] extra) are baked in because
# python-olm (transitive via mautrix[encryption]) builds from source on
# Python/image combinations without usable wheels. The Docker image is
# Linux-only, so keeping the native libolm/build-toolchain packages here
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
# ---------- Frontend build (cached independently from Python source) ----------
# Copy only the frontend source trees first so that Python-only changes don't
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
+10 -5
View File
@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
return _nous_extra_body() if auxiliary_is_nous else {}
def auxiliary_max_tokens_param(value: int) -> dict:
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
for it as well.
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
fronting the newer families are also recognised URL-only detection
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
"""
custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
and _read_nous_auth() is None
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
if model_forces_max_completion_tokens(model):
return {"max_completion_tokens": value}
return {"max_tokens": value}
+2 -15
View File
@@ -25,7 +25,6 @@ import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -33,6 +32,7 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
atomic_json_write(path, data, indent=2, sort_keys=True)
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)
+28
View File
@@ -966,6 +966,34 @@ def _classify_400(
should_fallback=False,
)
# Request-validation errors (unsupported / unknown parameter) MUST be
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
# rejection), so classify as a non-retryable format_error and fall back.
#
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
# so matching it would mis-route real overflows away from compression. The
# unambiguous signals are the explicit "unsupported/unknown parameter"
# message text and the specific parameter-level error codes.
if (
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
if p != "invalid_request_error")
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
):
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
+11
View File
@@ -1838,6 +1838,17 @@ def get_model_context_length(
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
# MiniMax M3: models.dev reports 512K but actual context is 1M.
# Prefer hardcoded catalog over stale probe value.
if _model_name_suggests_minimax_m3(model):
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
if catalog and ctx < catalog:
logger.info(
"Rejecting models.dev context=%s for %r "
"(MiniMax-M3 underreport); using hardcoded default %s",
ctx, model, f"{catalog:,}",
)
ctx = catalog
return ctx
# 6. OpenRouter live API metadata — provider-unaware fallback.
+3
View File
@@ -13,6 +13,7 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -570,6 +571,8 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
+11 -2
View File
@@ -40,6 +40,15 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, {
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
}))
let stdout = ''
let stderr = ''
+22 -15
View File
@@ -107,6 +107,13 @@ const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -1106,7 +1113,7 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
@@ -1142,10 +1149,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
}))
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1280,11 +1287,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
})
}))
let stdout = ''
let stderr = ''
@@ -1494,7 +1501,7 @@ function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
@@ -1680,11 +1687,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(command, args, {
child = spawn(command, args, hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
})
}))
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -2671,7 +2678,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
'--raw',
url
]
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const chunks = []
let bytes = 0
@@ -4491,7 +4498,7 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(backend.command, backend.args, {
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
@@ -4509,7 +4516,7 @@ async function spawnPoolBackend(profile, entry) {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
}))
entry.process = child
entry.port = port
entry.token = token
@@ -4691,7 +4698,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, {
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
@@ -4714,7 +4721,7 @@ async function startHermes() {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
}))
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -5986,11 +5993,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
}))
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -0,0 +1,54 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
})
+1 -1
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+2 -2
View File
@@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeCombo } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
@@ -111,8 +112,7 @@ const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
const SIDEBAR_NAV: SidebarNavItem[] = [
{
@@ -10,6 +10,7 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { modKey } from '@/lib/keybinds/combo'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -133,11 +134,11 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / -click (win/linux) pops the chat into its own
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
if (event[modKey] && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
+2 -1
View File
@@ -91,6 +91,7 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { modKey } from '@/lib/keybinds/combo'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
@@ -271,7 +272,7 @@ export function DesktopController() {
return
}
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
event.preventDefault()
event.stopPropagation()
closeActiveRightRailTab()
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{t.rightSidebar.addToChat}
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
</Button>
</div>
)}
@@ -1,6 +1,7 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import { formatCombo, modKey } from '@/lib/keybinds/combo'
import type { DesktopTerminalPalette } from '@/themes/types'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
@@ -97,12 +98,10 @@ export function resolveSurfaceColor(fallback: string): string {
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export const addSelectionShortcutLabel = formatCombo('mod+l')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
const mod = event[modKey]
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type KeybindActionMeta,
type KeybindReadonly
} from '@/lib/keybinds/actions'
import { formatCombo } from '@/lib/keybinds/combo'
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
import {
$bindings,
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<span className="kbd-cap" key={key}>
{formatCombo(key)}
{formatFakeCombo(key)}
</span>
))}
</div>
@@ -722,8 +722,14 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -50,8 +51,6 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
@@ -127,7 +126,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
@@ -0,0 +1,141 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
})
@@ -0,0 +1,65 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
// *font*, so an outline glyph has no separate fillable region — a filled look
// can't be derived from it (stroke-thickening just bolds the outline). To get
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
//
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
// path data mirrors the existing precedent in `directive-text.tsx`.
const TOOL_ICON_PATHS: Record<string, string> = {
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
'file-media':
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
files:
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
globe:
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
question:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
search:
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
terminal:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
tools:
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
watch:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
}
export interface ToolIconProps {
className?: string
name: string
size?: number | string
}
/** Filled tool glyph. Falls back to the outline codicon font for any name not
* covered by the solid set so new tools still render an icon. */
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
const path = TOOL_ICON_PATHS[name]
if (!path) {
return <Codicon className={className} name={name} size={size} />
}
const dimension: React.CSSProperties = { height: size, width: size }
return (
<svg
aria-hidden="true"
className={cn('shrink-0', className)}
fill="currentColor"
style={dimension}
viewBox="0 0 256 256"
>
<path d={path} />
</svg>
)
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -518,7 +519,7 @@ export const en: Translations = {
loading: 'Loading archived sessions…',
archivedTitle: 'Archived sessions',
archivedIntro:
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
emptyArchivedTitle: 'Nothing archived',
emptyArchivedDesc: 'Archive a chat to hide it here.',
unarchive: 'Unarchive',
@@ -529,7 +530,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -1677,7 +1678,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcut: '⌘/Ctrl + Enter to send',
shortcut: `${formatCombo('mod+enter')} to send`,
back: 'Back',
skip: 'Skip',
send: 'Send'
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -642,7 +643,7 @@ export const ja = defineLocale({
loading: 'アーカイブ済みセッションを読み込み中…',
archivedTitle: 'アーカイブ済みセッション',
archivedIntro:
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
emptyArchivedTitle: 'アーカイブがありません',
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
unarchive: 'アーカイブを解除',
@@ -1811,7 +1812,7 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
shortcut: '⌘/Ctrl + Enter で送信',
shortcut: `${formatCombo('mod+enter')} で送信`,
back: '戻る',
skip: 'スキップ',
send: '送信'
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -627,7 +628,7 @@ export const zhHant = defineLocale({
loading: '正在載入已封存工作階段…',
archivedTitle: '已封存工作階段',
archivedIntro:
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
emptyArchivedTitle: '暫無封存',
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
unarchive: '取消封存',
@@ -1772,7 +1773,7 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
shortcut: '⌘/Ctrl + Enter 傳送',
shortcut: `${formatCombo('mod+enter')} 傳送`,
back: '返回',
skip: '略過',
send: '傳送'
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -712,7 +713,7 @@ export const zh: Translations = {
sessions: {
loading: '正在加载已归档会话…',
archivedTitle: '已归档会话',
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
emptyArchivedTitle: '暂无归档',
emptyArchivedDesc: '归档一个对话后会显示在这里。',
unarchive: '取消归档',
@@ -1856,7 +1857,7 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcut: '⌘/Ctrl + Enter 发送',
shortcut: `${formatCombo('mod+enter')} 发送`,
back: '返回',
skip: '跳过',
send: '发送'
+17 -11
View File
@@ -5,6 +5,9 @@
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
import type { Combo, FakeCombo } from "./combo";
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
@@ -27,15 +30,16 @@ export interface KeybindActionMeta {
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
export const PROFILE_SLOT_COUNT = 18
function comboForSlot(slot: number): string {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
}
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
const slot = i+1
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
return ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [combo]
})
})
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system
@@ -104,10 +108,12 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id)
}
export type KeybindBindings = Record<string, string[]>
export type KeybindBindings = Record<string, Combo[]>
export function defaultBindings(): KeybindBindings {
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
return Object.fromEntries<string, Combo[]>(
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
)
}
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
@@ -117,7 +123,7 @@ export function defaultBindings(): KeybindBindings {
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly string[]
keys: readonly FakeCombo[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
+97 -56
View File
@@ -10,11 +10,13 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY: Record<string, string> = {
const CODE_TO_KEY = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
@@ -35,8 +37,50 @@ const CODE_TO_KEY: Record<string, string> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right'
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type FKey =
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
type BaseKey = Alpha | Digit | FKey | SpecialKey
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
function baseKeyFromCode(code: KeyCode): BaseKey | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase() as Alpha
}
if (code.startsWith('Digit')) {
return code.slice(5) as Digit
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest as Digit : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase() as FKey
}
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
@@ -48,42 +92,20 @@ const MODIFIER_CODES = new Set([
'ShiftRight'
])
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
}
if (code.startsWith('Digit')) {
return code.slice(5)
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase()
}
return CODE_TO_KEY[code] ?? null
}
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): string | null {
export function comboFromEvent(event: KeyboardEvent): Combo | null {
if (MODIFIER_CODES.has(event.code)) {
return null
}
const base = baseKeyFromCode(event.code)
const base = baseKeyFromCode(event.code as KeyCode)
if (!base) {
return null
}
const parts: string[] = []
const parts: Combo[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
@@ -105,7 +127,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
parts.push(base)
return parts.join('+')
return parts.join('+') as Combo
}
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
@@ -115,7 +137,14 @@ export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
}
const TOKEN_LABELS: Record<string, string> = {
const MOD_LABELS = {
mod: IS_MAC ? '⌘' : 'Ctrl',
ctrl: IS_MAC ? '⌃' : 'Ctrl',
alt: IS_MAC ? '⌥' : 'Alt',
shift: IS_MAC ? '⇧' : 'Shift'
} as const
const FANCY_KEY_LABELS = {
enter: '↵',
escape: 'Esc',
backspace: '⌫',
@@ -124,39 +153,47 @@ const TOKEN_LABELS: Record<string, string> = {
up: '↑',
down: '↓',
left: '←',
right: '→'
right: '→',
} as const
const TOKEN_LABELS: Record<string, string> = {
...MOD_LABELS,
...FANCY_KEY_LABELS
}
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
function labelForToken(token: string): string {
if (TOKEN_LABELS[token]) {
return TOKEN_LABELS[token]
}
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
if (/^f\d{1,2}$/.test(token)) {
return token.toUpperCase()
}
return base.length === 1 ? base.toUpperCase() : base
return token.length === 1 ? token.toUpperCase() : token
}
function labelForMod(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
//
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
type ModKey = keyof typeof MOD_LABELS
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
type ModPrefixedCombo<Suffix extends string> =
| `${ModPrefix}${Suffix}`
| ModKey
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
| 'ctrl+tab' | 'ctrl+shift+tab'
| `ctrl+${Digit}`
return mod
export type Combo = ModPrefixedCombo<BaseKey>
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
export function normalizeCombo(combo: Combo): string[] {
const parts = combo.split('+')
return parts.map(p => labelForToken(p.trim()))
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
@@ -165,14 +202,18 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
return [...parts.map(labelForMod), labelForBase(base)]
return [...parts.map(labelForToken), labelForToken(base)]
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: Combo): string {
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
}
return IS_MAC ? tokens.join('') : tokens.join('+')
// like `formatCombo` but allows any input like `@`
export function formatFakeCombo(combo: FakeCombo): string {
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
}
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
@@ -190,6 +231,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: string): boolean {
export function comboAllowedInInput(combo: Combo): boolean {
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
}
+4 -3
View File
@@ -7,6 +7,7 @@ import {
type KeybindBindings
} from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import type { Combo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -28,7 +29,7 @@ function loadBindings(): KeybindBindings {
const value = parsed[id]
if (Array.isArray(value)) {
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
}
}
} catch {
@@ -78,7 +79,7 @@ export const $comboIndex = computed($bindings, bindings => {
return index
})
export function setBinding(actionId: string, combos: string[]): void {
export function setBinding(actionId: string, combos: Combo[]): void {
if (!keybindAction(actionId)) {
return
}
@@ -101,7 +102,7 @@ export function resetAllBindings(): void {
}
// Other actions that already use `combo` (excluding `actionId` itself).
export function conflictsFor(actionId: string, combo: string): string[] {
export function conflictsFor(actionId: string, combo: Combo): string[] {
const bindings = $bindings.get()
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
-17
View File
@@ -1218,23 +1218,6 @@ def main(
# List available distributions
python batch_runner.py --list_distributions
"""
# Cross-process hook delivery: start the forwarder if a dashboard
# is reachable. No-op when no dashboard is running or when
# ``HERMES_HOOK_FORWARDER=0`` is set. Currently the batch runner
# emits no hooks directly (only the gateway and TUI do today), but
# wiring here future-proofs batch-spawned agent runs that may emit
# hooks via tools. Forwarder workers spawned by ``multiprocessing.Pool``
# below would each need their own; that's deferred to a follow-up.
# See gateway/hook_forwarder.py + DESIGN-cross-process-hooks.md.
try:
from gateway import hook_forwarder
from gateway.hooks import get_default_registry
hook_forwarder.start_if_dashboard_available(
get_default_registry(), src="batch"
)
except Exception:
pass
# Handle list distributions
if list_distributions:
from toolset_distributions import print_distribution_info
+24 -1
View File
@@ -415,7 +415,8 @@ prompt_caching:
# Auxiliary Models (Advanced — Experimental)
# =============================================================================
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
# browser screenshot analysis, web page summarization, and context compression.
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
# and context compression.
#
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
# auto-detected from your credentials. You do NOT need to change anything
@@ -460,6 +461,12 @@ prompt_caching:
# provider: "auto"
# model: ""
#
# # Gemini 3.1 TTS hidden audio-tag insertion
# tts_audio_tags:
# provider: "auto" # empty model = your main chat model
# model: ""
# timeout: 30
#
# # Session search — summarizes matching past sessions
# session_search:
# provider: "auto"
@@ -835,6 +842,22 @@ platform_toolsets:
# max_tool_rounds: 5 # tool loop limit (0 = disable)
# log_level: "info" # audit verbosity
# =============================================================================
# Text-to-Speech
# =============================================================================
# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
# audio tags into the TTS script without showing tags in chat.
#
# tts:
# provider: "gemini"
# gemini:
# model: "gemini-3.1-flash-tts-preview"
# voice: "Kore"
# audio_tags: false
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
# =============================================================================
# Voice Transcription (Speech-to-Text)
# =============================================================================
+83 -27
View File
@@ -890,6 +890,10 @@ def _cleanup_all_browsers(*args, **kwargs):
# Guard to prevent cleanup from running multiple times on exit
_cleanup_done = False
# One-shot CLI finalization runs before process cleanup so plugins can observe
# the session boundary while the agent is still attached. If a signal lands in
# that narrow window, atexit cleanup must not emit that session finalize again.
_single_query_finalize_attempted_session_ids: set[str | None] = set()
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
@@ -989,11 +993,13 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
# Shut down memory provider (on_session_end + shutdown_all) at actual
# session boundary — NOT per-turn inside run_conversation().
if notify_session_finalize:
_notify_session_finalize(
session_id=_active_agent_ref.session_id if _active_agent_ref else None,
platform="cli",
reason="shutdown",
)
cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
if _should_emit_cleanup_session_finalize(cleanup_session_id):
_notify_session_finalize(
session_id=cleanup_session_id,
platform="cli",
reason="shutdown",
)
try:
if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):
# Forward the agent's own transcript so memory providers'
@@ -1011,6 +1017,14 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
pass
def _should_emit_cleanup_session_finalize(session_id: str | None) -> bool:
if not _single_query_finalize_attempted_session_ids:
return True
if session_id is None:
return False
return session_id not in _single_query_finalize_attempted_session_ids
def _notify_session_finalize(
*,
session_id: str | None,
@@ -1068,11 +1082,17 @@ def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") ->
def _notify_single_query_session_finalize(cli, *, reason: str = "shutdown") -> None:
agent = getattr(cli, "agent", None)
session_id = getattr(agent, "session_id", None) or getattr(cli, "session_id", None)
_notify_session_finalize(
session_id=session_id,
platform=getattr(agent, "platform", None) or "cli",
reason=reason,
)
if session_id in _single_query_finalize_attempted_session_ids:
return
try:
_notify_session_finalize(
session_id=session_id,
platform=getattr(agent, "platform", None) or "cli",
reason=reason,
)
finally:
_single_query_finalize_attempted_session_ids.add(session_id)
def _finalize_single_query(cli) -> None:
@@ -6496,6 +6516,47 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
}
self._invalidate(min_interval=0.0)
def _confirm_expensive_model_switch(self, result) -> bool:
"""Ask for explicit confirmation before applying costly model switches."""
if not getattr(result, "success", False):
return True
try:
from hermes_cli.model_cost_guard import expensive_model_warning
warning = expensive_model_warning(
result.new_model,
provider=result.target_provider,
base_url=result.base_url or self.base_url or "",
api_key=result.api_key or self.api_key or "",
model_info=result.model_info,
)
except Exception:
warning = None
if warning is None:
return True
choices = [
("once", "Switch anyway", "Use this model for the current Hermes session."),
("cancel", "Cancel", "Keep the current model."),
]
raw = self._prompt_text_input_modal(
title="!!! Expensive Model Warning !!!",
detail=warning.message,
choices=choices,
timeout=120,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
return choice == "once"
def _confirm_and_apply_model_switch_result(self, result, persist_global: bool) -> None:
try:
if result.success and not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
self._apply_model_switch_result(result, persist_global)
except Exception as exc:
_cprint(f" ✗ Model selection failed: {exc}")
def _close_model_picker(self) -> None:
self._model_picker_state = None
self._restore_modal_input_snapshot()
@@ -6672,7 +6733,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
custom_providers=state.get("custom_provs"),
)
self._close_model_picker()
self._apply_model_switch_result(result, persist_global)
if getattr(self, "_app", None):
threading.Thread(
target=self._confirm_and_apply_model_switch_result,
args=(result, persist_global),
daemon=True,
).start()
else:
self._confirm_and_apply_model_switch_result(result, persist_global)
return
self._close_model_picker()
@@ -6773,6 +6841,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
_cprint(f"{result.error_message}")
return
if not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
# Apply to CLI state.
# Update requested_provider so _ensure_runtime_credentials() doesn't
# overwrite the switch on the next turn (it re-resolves from this).
@@ -10614,22 +10686,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if not self._claim_active_session("cli"):
return
# Cross-process hook delivery: start the forwarder if a
# dashboard is reachable. No-op when no dashboard is running
# or when ``HERMES_HOOK_FORWARDER=0`` is set. Currently the
# CLI process emits no hooks directly (only the gateway and
# TUI do today), but wiring here future-proofs CLI-spawned
# agent runs that may emit hooks via tools. See
# gateway/hook_forwarder.py + DESIGN-cross-process-hooks.md.
try:
from gateway import hook_forwarder
from gateway.hooks import get_default_registry
hook_forwarder.start_if_dashboard_available(
get_default_registry(), src="cli"
)
except Exception:
pass
# Detect light/dark terminal mode now (before pt grabs the tty).
# Caches the result so subsequent _hex_to_ansi / style calls
# don't risk re-querying mid-render.
+8
View File
@@ -207,6 +207,14 @@ class GatewayAuthorizationMixin:
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
return True
# Adapter-verified role auth: the Discord adapter already confirmed the
# user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
# Compare with ``is True`` so the real bool field authorizes while a
# MagicMock source (test fixtures using ``object.__new__`` runners with
# mock sources) does not auto-truthy through this gate (see pitfall #13).
if getattr(source, "role_authorized", False) is True:
return True
if getattr(source, "is_bot", False):
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
-450
View File
@@ -1,450 +0,0 @@
"""Cross-process hook forwarder.
Subscribes to a fixed set of namespaces on a process-local
:class:`gateway.hooks.HookRegistry` and POSTs each fired event to the
dashboard's ``/api/hooks/ingest`` endpoint. The dashboard republishes
the event on its own default registry so plugins running in the
dashboard process see events that originated in the gateway, TUI,
subagent, or batch-runner processes.
Design constraints (see ``DESIGN-cross-process-hooks.md``):
* Never blocks the publisher. Handler enqueues onto a bounded queue
and returns immediately; a daemon worker thread does the HTTP POSTs.
* Bounded queue drops *oldest* on overflow. Observability events are
best-effort; recency beats history when the dashboard is slow.
* Loop prevention. Events whose context carries ``_forwarded=True``
are skipped those came from the ingest endpoint and must not be
shipped back.
* No-op when no dashboard is available. Discovery file absent no
registration, no thread. Probe re-checks every 30s so a dashboard
that starts later auto-attaches.
* ``HERMES_HOOK_FORWARDER=0`` short-circuits everything for paranoid
security postures, or for tests that don't want the daemon thread.
The forwarder is wired in by long-lived non-dashboard processes
(gateway, TUI, subagents, batch runners) via
:func:`start_if_dashboard_available`.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from pathlib import Path
from queue import Empty, Full, Queue
from typing import Any, Callable, Optional
from hermes_cli.config import get_hermes_home
_log = logging.getLogger(__name__)
# Namespaces the forwarder ships to the dashboard. Picked to cover every
# event type the registry emits today. Adding a new namespace is a
# one-line append here. Wildcards use the existing registry semantics
# (``<namespace>:*`` matches every ``<namespace>:<anything>`` event).
_FORWARDED_NAMESPACES = (
"tui:*",
"agent:*",
"session:*",
"command:*",
"gateway:*",
)
# Bounded queue size per source process. At peak (~70 events/sec, see the
# design doc's "Performance" section) this is ~14 seconds of backlog
# before drop-oldest kicks in. Generous for a purely-observability feed.
_QUEUE_MAX = 1024
# Probe cadence. When the dashboard isn't running we re-check the
# discovery file every 30s so a delayed ``hermes dashboard`` startup
# eventually attaches. Cheap (one stat + one health GET) so this can
# run forever without overhead.
_PROBE_INTERVAL_S = 30.0
# HTTP timeouts. Connection is loopback in the common case so anything
# beyond 2s probably means the dashboard is wedged; better to drop the
# frame than queue up retries that won't help.
_HTTP_TIMEOUT_S = 2.0
# Error-logging cadence. POST failures get logged once per minute, not
# per failure, so a downed dashboard doesn't spam ``agent.log``.
_ERROR_LOG_INTERVAL_S = 60.0
def _dashboard_discovery_path() -> Path:
"""Return the path the dashboard writes its discovery JSON to.
Always under ``$HERMES_HOME``; no ``/tmp`` fallback. Config
consistency across processes is a precondition, not something the
forwarder patches over.
"""
return get_hermes_home() / "dashboard.json"
def _read_discovery_file() -> Optional[dict]:
"""Read and parse ``dashboard.json``.
Returns ``None`` when the file is absent, unreadable, malformed, or
missing required keys. Never raises callers treat a ``None``
result as "no dashboard available" and re-probe on the next cycle.
"""
path = _dashboard_discovery_path()
try:
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
if "url" not in data or "hooks_ingest_token" not in data:
return None
return data
def _disabled_by_env() -> bool:
"""``HERMES_HOOK_FORWARDER=0`` (or ``false``/``no``) short-circuits."""
val = os.environ.get("HERMES_HOOK_FORWARDER", "").strip().lower()
return val in ("0", "false", "no", "off")
# ---------------------------------------------------------------------------
# HookForwarder — per-process singleton-ish
# ---------------------------------------------------------------------------
class _HookForwarder:
"""The actual forwarder.
Tracks the registry it's attached to, the unregister callables (so
:meth:`stop` can clean up), and the worker thread state.
Multiple instances are technically supported but in practice each
process holds at most one see :func:`start_if_dashboard_available`
and the module-level ``_active`` reference.
"""
def __init__(self, src: str) -> None:
self.src = src
self._queue: Queue[dict] = Queue(maxsize=_QUEUE_MAX)
self._unregisters: list[Callable[[], None]] = []
self._stop = threading.Event()
self._worker: Optional[threading.Thread] = None
# Discovery state — refreshed by the worker thread before each
# POST so a dashboard restart (with a new token) auto-recovers.
self._discovery: Optional[dict] = None
self._discovery_lock = threading.Lock()
# Error-log rate limit state.
self._last_error_log_at: float = 0.0
self._error_count_since_log: int = 0
# -- registry side ---------------------------------------------------
def _handler(self, event_type: str, context: dict) -> None:
"""Sync hook handler — enqueues the event for the worker thread."""
# Loop prevention: events forwarded *into* this process must not
# be shipped back. The dashboard's ingest endpoint stamps every
# republished context with ``_forwarded=True``.
if context.get("_forwarded") is True:
return
try:
self._queue.put_nowait(
{"event_type": event_type, "context": context, "src": self.src}
)
except Full:
# Drop oldest, enqueue newest. Observability is best-effort.
try:
self._queue.get_nowait()
except Empty:
pass
try:
self._queue.put_nowait(
{"event_type": event_type, "context": context, "src": self.src}
)
except Full:
# Pathological — worker isn't draining at all. Give up
# silently; next event will overwrite this slot.
pass
def register(self, registry: "object") -> None:
"""Register the handler for every forwarded namespace."""
for pattern in _FORWARDED_NAMESPACES:
unreg = registry.register( # type: ignore[union-attr]
pattern,
self._handler,
name=f"hook_forwarder({pattern}→dashboard)",
)
self._unregisters.append(unreg)
def unregister(self) -> None:
"""Remove every handler the forwarder installed. Idempotent."""
while self._unregisters:
try:
self._unregisters.pop()()
except Exception: # pragma: no cover — defensive
pass
# -- worker thread ---------------------------------------------------
def start_worker(self) -> None:
"""Spawn the daemon worker thread that drains the queue."""
if self._worker is not None and self._worker.is_alive():
return
self._stop.clear()
self._worker = threading.Thread(
target=self._worker_loop,
name=f"hook-forwarder-{self.src}",
daemon=True,
)
self._worker.start()
def stop_worker(self, *, join_timeout: float = 1.0) -> None:
"""Signal the worker to exit and wait briefly for it to do so.
Idempotent. Safe to call from any thread. Daemon threads die
with the process anyway; the join is only there so tests don't
race on lingering threads between cases.
"""
self._stop.set()
if self._worker is not None:
try:
self._worker.join(timeout=join_timeout)
except RuntimeError:
pass
def _worker_loop(self) -> None:
"""Drain the queue, POSTing each frame, until ``_stop`` is set."""
# httpx is imported lazily so processes that never produce events
# don't pay the import cost. In practice the gateway and TUI
# both have httpx loaded by the time we get here, but defer
# anyway to be polite.
import httpx
next_probe_at: float = 0.0
with httpx.Client(timeout=_HTTP_TIMEOUT_S) as client:
while not self._stop.is_set():
# Probe the discovery file periodically so a dashboard
# that comes up later (or restarts with a new token)
# auto-attaches.
now = time.monotonic()
if now >= next_probe_at:
next_probe_at = now + _PROBE_INTERVAL_S
self._refresh_discovery(client)
# Block on the queue. Short timeout so we re-check the
# stop flag and probe interval often enough.
try:
frame = self._queue.get(timeout=1.0)
except Empty:
continue
with self._discovery_lock:
discovery = self._discovery
if discovery is None:
# No dashboard available right now. Drop the frame;
# the next probe will re-acquire discovery, and any
# events fired between now and then are lost (the
# design accepts best-effort delivery).
continue
self._post_frame(client, discovery, frame)
def _refresh_discovery(self, client: "Any") -> None: # client: httpx.Client
"""Reload the discovery file and probe the dashboard's health.
Updates ``self._discovery`` to the new value (or ``None`` if the
dashboard is unreachable). Called from the worker thread.
"""
data = _read_discovery_file()
if data is None:
with self._discovery_lock:
self._discovery = None
return
url = data["url"].rstrip("/")
try:
resp = client.get(f"{url}/api/hooks/health", timeout=_HTTP_TIMEOUT_S)
if resp.status_code != 200:
with self._discovery_lock:
self._discovery = None
return
except Exception:
with self._discovery_lock:
self._discovery = None
return
with self._discovery_lock:
self._discovery = data
def _post_frame(
self,
client: "Any", # httpx.Client
discovery: dict,
frame: dict,
) -> None:
"""POST one frame to ``/api/hooks/ingest``.
Errors are logged at most once per minute, regardless of how
many failures accumulate. A 401 invalidates the cached
discovery so the next probe re-reads the token (which may have
rotated on a dashboard restart).
"""
url = discovery["url"].rstrip("/") + "/api/hooks/ingest"
token = discovery["hooks_ingest_token"]
# Filter the context: ``_forwarded`` etc. are added by the
# dashboard on republish. Source-side contexts are passed as-is,
# but they should never carry ``_forwarded=True`` (that's how
# loop prevention works above).
try:
resp = client.post(
url,
json={
"event_type": frame["event_type"],
"context": frame["context"],
"src": frame["src"],
},
headers={"Authorization": f"Bearer {token}"},
timeout=_HTTP_TIMEOUT_S,
)
except Exception as exc:
self._log_error(f"POST {url} failed: {exc}")
return
if resp.status_code == 401:
# Token rotated (dashboard restarted with a new one).
# Invalidate the cached discovery; the next probe will
# re-read the file and pick up the new token.
self._log_error(
"POST /api/hooks/ingest returned 401 — token rotated; "
"invalidating discovery cache"
)
with self._discovery_lock:
self._discovery = None
return
if resp.status_code >= 400:
self._log_error(
f"POST /api/hooks/ingest returned {resp.status_code}: "
f"{resp.text[:200]!r}"
)
def _log_error(self, message: str) -> None:
"""Rate-limited error logging — once per minute per process."""
self._error_count_since_log += 1
now = time.monotonic()
if now - self._last_error_log_at < _ERROR_LOG_INTERVAL_S:
return
suppressed = self._error_count_since_log - 1
suffix = f" ({suppressed} similar errors suppressed)" if suppressed else ""
_log.warning("[hook_forwarder] %s%s", message, suffix)
self._last_error_log_at = now
self._error_count_since_log = 0
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
# Per-process forwarder. Multiple ``start_if_dashboard_available`` calls
# in the same process are idempotent (they re-use this instance).
_active: Optional[_HookForwarder] = None
_active_lock = threading.Lock()
def start_if_dashboard_available(
registry: "object",
*,
src: str = "unknown",
) -> Optional[_HookForwarder]:
"""Wire the forwarder into ``registry`` if a dashboard is reachable.
Idempotent: repeated calls in the same process are no-ops. Returns
the active forwarder if one is running (the same instance on every
subsequent call within the process), or ``None`` if the forwarder
was suppressed (no dashboard, or ``HERMES_HOOK_FORWARDER=0``).
The ``src`` argument is a short tag identifying the source process
(``"gateway"``, ``"tui"``, ``"subagent"``, ``"batch"``). It's
included in the wire frame for the dashboard's diagnostic logging
and the republished context's ``_forwarded_from`` field, so a
subscriber can tell which process originated each event.
Args:
registry: The :class:`gateway.hooks.HookRegistry` whose events
should be forwarded. Normally
:func:`gateway.hooks.get_default_registry`.
src: Source-process tag. See above.
Returns:
The active forwarder, or ``None`` if forwarding is suppressed.
"""
global _active
if _disabled_by_env():
_log.debug(
"[hook_forwarder] HERMES_HOOK_FORWARDER=0 — skipping start"
)
return None
discovery = _read_discovery_file()
if discovery is None:
_log.debug(
"[hook_forwarder] no dashboard.json — forwarder not started"
)
return None
with _active_lock:
if _active is not None:
# Already started in this process; nothing to do.
return _active
fwd = _HookForwarder(src=src)
try:
fwd.register(registry)
except Exception as e:
_log.warning(
"[hook_forwarder] failed to register handlers: %s", e
)
return None
fwd.start_worker()
_active = fwd
_log.info(
"[hook_forwarder] started for src=%s, forwarding %d namespaces",
src,
len(_FORWARDED_NAMESPACES),
)
return fwd
def stop() -> None:
"""Tear down the active forwarder. Idempotent.
Primarily for tests; production processes just rely on the daemon
thread dying with the process.
"""
global _active
with _active_lock:
if _active is None:
return
_active.unregister()
_active.stop_worker()
_active = None
def _reset_for_tests() -> None:
"""Test helper — clears active state without preserving its
side-effects (registered handlers etc.)."""
stop()
def is_active() -> bool:
"""Return whether a forwarder is currently registered in this process."""
return _active is not None
+12 -236
View File
@@ -2,40 +2,19 @@
Event Hook System
A lightweight event-driven system that fires handlers at key lifecycle points.
Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
- HOOK.yaml (metadata: name, description, events list)
- handler.py (Python handler with async def handle(event_type, context))
There are two ways to register a handler:
1. **File-system discovery** drop a directory into ``~/.hermes/hooks/``
containing ``HOOK.yaml`` (metadata: name, description, events list) and
``handler.py`` (with ``def handle(event_type, context)``, sync or async).
These are loaded by :meth:`HookRegistry.discover_and_load` at gateway
startup.
2. **Programmatic registration** call :meth:`HookRegistry.register` from
inside the process. Useful for plugins that ship their own bundled hooks
without expecting the user to maintain a ``~/.hermes/hooks/`` entry. Pairs
with :func:`get_default_registry` so plugins don't have to hold a registry
reference threaded through every call site.
Events fired today:
- ``gateway:startup`` Gateway process starts
- ``session:start`` New session created (first message of a new session)
- ``session:end`` Session ends (user ran /new or /reset)
- ``session:reset`` Session reset completed (new session entry created)
- ``agent:start`` Agent begins processing a message
- ``agent:step`` Each turn in the tool-calling loop
- ``agent:end`` Agent finishes processing
- ``command:*`` Any slash command executed (wildcard match)
- ``tui:<sub-event>`` Any TUI gateway dispatch event mirrored to the
bus (``tui:tool.start``, ``tui:message.delta``,
``tui:reasoning.available``, etc.). Subscribe
with the full name for one event, or
``tui:*`` for all of them.
Wildcards match one colon-separated namespace level: a handler registered for
``foo:*`` fires for every ``foo:<anything>`` event, but not for
``bar:something``.
Events:
- gateway:startup -- Gateway process starts
- session:start -- New session created (first message of a new session)
- session:end -- Session ends (user ran /new or /reset)
- session:reset -- Session reset completed (new session entry created)
- agent:start -- Agent begins processing a message
- agent:step -- Each turn in the tool-calling loop
- agent:end -- Agent finishes processing
- command:* -- Any slash command executed (wildcard match)
Errors in hooks are caught and logged but never block the main pipeline.
@@ -70,12 +49,6 @@ from hermes_cli.config import get_hermes_home
HOOKS_DIR = get_hermes_home() / "hooks"
# Tracks handler functions we've already warned about for emit_sync's
# async-without-loop case, so each bad combination only logs once per
# process instead of flooding stderr on every event.
_ASYNC_NO_LOOP_WARNED: "set[int]" = set()
class HookRegistry:
"""
Discovers, loads, and fires event hooks.
@@ -96,60 +69,6 @@ class HookRegistry:
"""Return metadata about all loaded hooks."""
return list(self._loaded_hooks)
def register(
self,
event_type: str,
handler: Callable,
*,
name: Optional[str] = None,
) -> Callable[[], None]:
"""Programmatically register a handler for ``event_type``.
Intended for in-process plugins, tests, and built-in hooks. Pairs with
the file-system discovery path (HOOK.yaml + handler.py) both share
the same dispatch and wildcard rules.
The handler signature matches discovered hooks: ``handle(event_type,
context)`` where ``handler`` may be sync or async.
Returns a callable that, when invoked, removes this specific handler
registration from the registry. Other handlers for the same event are
unaffected.
Args:
event_type: Event identifier such as ``agent:start`` or
``tui:tool.start``. May also be a wildcard like ``command:*``.
handler: Function or coroutine function to invoke when the
event fires.
name: Optional friendly name recorded alongside the
registration metadata for listing/debugging. Defaults to the
handler's ``__name__``.
Returns:
A no-arg callable that unregisters this handler when called.
"""
self._handlers.setdefault(event_type, []).append(handler)
meta = {
"name": name or getattr(handler, "__name__", "<anonymous>"),
"description": "(registered programmatically)",
"events": [event_type],
"path": "<programmatic>",
}
self._loaded_hooks.append(meta)
def _unregister() -> None:
try:
self._handlers.get(event_type, []).remove(handler)
except ValueError:
pass
try:
self._loaded_hooks.remove(meta)
except ValueError:
pass
return _unregister
def _register_builtin_hooks(self) -> None:
"""Register built-in hooks that are always active.
@@ -306,146 +225,3 @@ class HookRegistry:
except Exception as e:
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
return results
def emit_sync(
self,
event_type: str,
context: Optional[Dict[str, Any]] = None,
) -> None:
"""Fire handlers from a synchronous caller.
Companion to :meth:`emit` for hot-path callers that cannot await most
notably ``tui_gateway/server.py:_emit``, which serves both async dispatch
paths and sync callback paths and must remain ``def`` (not ``async
def``).
Behavior:
- Sync handlers run immediately, in registration order. Exceptions are
caught and logged so a buggy handler can't break the host pipeline.
- Async handlers (coroutine functions) are scheduled via
``asyncio.ensure_future`` if a running event loop is available in the
current thread. If no loop is running, the handler is **skipped** and
a one-time warning is logged per handler async handlers in a
purely sync process don't have a way to make forward progress.
Like :meth:`emit`, never raises and never blocks waiting on async
handlers fire-and-forget for the async case.
Args:
event_type: The event identifier (e.g. ``tui:tool.start``).
context: Optional dict with event-specific data.
"""
if context is None:
context = {}
for fn in self._resolve_handlers(event_type):
try:
result = fn(event_type, context)
except Exception as e:
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
continue
if not asyncio.iscoroutine(result):
continue
# Coroutine returned — needs a loop to make progress.
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop in this thread.
handler_id = id(fn)
if handler_id not in _ASYNC_NO_LOOP_WARNED:
_ASYNC_NO_LOOP_WARNED.add(handler_id)
handler_name = getattr(fn, "__name__", "<anonymous>")
print(
f"[hooks] Skipping async handler {handler_name!r} for "
f"'{event_type}' — emit_sync called with no running "
f"event loop. Subsequent skips for this handler are "
f"silent.",
flush=True,
)
# Close the coroutine to suppress "coroutine was never
# awaited" RuntimeWarning noise.
try:
result.close()
except Exception:
pass
continue
try:
# ensure_future schedules the coroutine on the loop and
# returns immediately. Exceptions inside the coroutine
# surface via the task's done callback (or asyncio's
# default exception handler) — we don't await here.
task = asyncio.ensure_future(result, loop=loop)
task.add_done_callback(_log_task_exception)
except Exception as e:
print(
f"[hooks] Failed to schedule async handler for "
f"'{event_type}': {e}",
flush=True,
)
def _log_task_exception(task: "asyncio.Task[Any]") -> None:
"""Surface exceptions from scheduled async hook handlers.
Without this callback, an exception inside a fire-and-forget handler
coroutine becomes "Task exception was never retrieved" noise from
asyncio's default exception handler at GC time. Logging it explicitly
keeps the failure mode visible and consistent with the sync path.
"""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
print(f"[hooks] Async handler raised: {exc}", flush=True)
# ── Module-level default registry ──────────────────────────────────
#
# Plugins and in-process callers (TUI gateway's ``_emit`` etc.) need a
# stable place to find "the" registry without threading a reference
# through every API. The gateway process installs its own
# ``self.hooks`` instance as the default during startup so file-system
# discovery and built-in hooks share state with programmatic
# registrations. Other processes (TUI) lazily get their own default on
# first access and run ``discover_and_load()`` themselves.
_default_registry: Optional["HookRegistry"] = None
def get_default_registry() -> "HookRegistry":
"""Return the process-wide default :class:`HookRegistry`.
Lazily creates one (without auto-running discovery) on first call. Callers
that need file-system hook discovery should invoke
:meth:`HookRegistry.discover_and_load` themselves after first access the
gateway already does this for the registry it installs as the default.
"""
global _default_registry
if _default_registry is None:
_default_registry = HookRegistry()
return _default_registry
def install_as_default(registry: "HookRegistry") -> None:
"""Install ``registry`` as the process-wide default.
Intended for the gateway and other long-lived hosts that want their own
:class:`HookRegistry` instance to be visible to in-process plugins through
:func:`get_default_registry`. Idempotent installing the same registry
twice is a no-op; installing a different registry replaces the previous
default.
"""
global _default_registry
_default_registry = registry
def _reset_default_registry_for_tests() -> None:
"""Test helper — clears the cached default so each test starts fresh."""
global _default_registry
_default_registry = None
_ASYNC_NO_LOOP_WARNED.clear()
+29 -4
View File
@@ -33,6 +33,7 @@ _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
# delivered as a regular document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
def _platform_name(platform) -> str:
@@ -1803,6 +1804,18 @@ class BasePlatformAdapter(ABC):
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
# The command prefix users can always TYPE on this platform to reach
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
# as plain message text). Platforms where typing a leading "/" is
# intercepted or restricted by the client (Slack blocks native slash
# commands inside threads; Matrix clients reserve "/" for client-local
# commands) ship a "!" alias rewrite in their adapter and set this to
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
# users the form that actually works everywhere. Capability flag —
# shared prompt builders read it via getattr(adapter,
# "typed_command_prefix", "/"); no per-platform branching at call sites.
typed_command_prefix: str = "/"
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
@@ -4462,6 +4475,15 @@ class BasePlatformAdapter(ABC):
except Exception:
pass # Last resort — don't let error reporting crash the handler
finally:
# Stop typing before any deferred callback work. Post-delivery
# callbacks may perform platform I/O; a stuck callback must not
# leave the typing refresh task running indefinitely.
await _stop_typing_task()
try:
if hasattr(self, "stop_typing"):
await self.stop_typing(event.source.chat_id)
except Exception:
pass
# Fire any one-shot post-delivery callback registered for this
# session (e.g. deferred background-review notifications).
#
@@ -4489,11 +4511,12 @@ class BasePlatformAdapter(ABC):
try:
_post_result = _post_cb()
if inspect.isawaitable(_post_result):
await _post_result
except Exception:
await asyncio.wait_for(
_post_result,
timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
)
except (asyncio.TimeoutError, Exception):
pass
# Stop typing indicator
await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
@@ -4651,6 +4674,7 @@ class BasePlatformAdapter(ABC):
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
role_authorized: bool = False,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
@@ -4671,6 +4695,7 @@ class BasePlatformAdapter(ABC):
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
role_authorized=role_authorized,
)
@abstractmethod
+9 -4
View File
@@ -422,6 +422,11 @@ class MatrixAdapter(BasePlatformAdapter):
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
# Matrix clients commonly reserve typed "/" for client-local commands;
# the adapter accepts "!command" as the alias that always reaches Hermes
# (see _normalize_matrix_bang_command), so instruction text shows "!".
typed_command_prefix = "!"
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
@@ -1350,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter):
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
"✅ = /approve\n"
"❎ = /deny"
"✅ = approve\n"
"❎ = deny"
)
result = await self.send(chat_id, text, metadata=metadata)
+25 -8
View File
@@ -318,6 +318,11 @@ class SlackAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
# Slack blocks typed native slash commands inside threads ("/approve is
# not supported in threads. Sorry!"). The adapter rewrites a leading
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
# the prefix that works everywhere — instruction text must show it.
typed_command_prefix = "!"
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
@@ -2692,19 +2697,26 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
thread_ts = self._resolve_thread_ts(None, metadata)
# Slack hard-caps a section block's text at 3000 chars; an
# oversized block fails the whole send with ``invalid_blocks``
# and the gateway falls back to the plain-text prompt (no
# buttons). execute_code approvals embed the entire script in
# ``command``, so budget the preview against the fixed parts
# instead of a flat truncation that overflows once the header +
# reason are added.
header = ":warning: *Command Approval Required*\n"
reason = f"Reason: {description[:500]}"
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
cmd_preview = command[:budget] + "..." if len(command) > budget else command
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f":warning: *Command Approval Required*\n"
f"```{cmd_preview}```\n"
f"Reason: {description}"
),
"text": f"{header}```{cmd_preview}```\n{reason}",
},
},
{
@@ -2772,8 +2784,13 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
body = message[:2900] + "..." if len(message) > 2900 else message
thread_ts = self._resolve_thread_ts(None, metadata)
# Same 3000-char section-block cap as send_exec_approval: budget
# the body against the rendered title so the wrapper never pushes
# the block over the limit (overflow → invalid_blocks → no buttons).
_title = (title or "Confirm")[:150]
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
body = message[:budget] + "..." if len(message) > budget else message
# Encode session_key and confirm_id into the button value so the
# callback handler can resolve without extra bookkeeping.
value = f"{session_key}|{confirm_id}"
@@ -2783,7 +2800,7 @@ class SlackAdapter(BasePlatformAdapter):
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{title or 'Confirm'}*\n\n{body}",
"text": f"*{_title}*\n\n{body}",
},
},
{
+86 -3
View File
@@ -3030,7 +3030,7 @@ class TelegramAdapter(BasePlatformAdapter):
async def _handle_model_picker_callback(
self, query, data: str, chat_id: str
) -> None:
"""Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
"""Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
state = self._model_picker_state.get(chat_id)
if not state:
await query.answer(text="Picker expired — use /model again.")
@@ -3115,6 +3115,55 @@ class TelegramAdapter(BasePlatformAdapter):
)
await query.answer()
elif data.startswith("mc:"):
# --- Expensive model confirmed: perform the switch ---
try:
idx = int(data[3:])
except ValueError:
await query.answer(text="Invalid selection.")
return
model_list = state.get("model_list", [])
if idx < 0 or idx >= len(model_list):
await query.answer(text="Invalid model index.")
return
model_id = model_list[idx]
provider_slug = state.get("selected_provider", "")
callback = state.get("on_model_selected")
if not callback:
await query.answer(text="Picker expired.")
return
switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
switch_failed = True
try:
await query.edit_message_text(
text=self.format_message(result_text),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
try:
await query.edit_message_text(
text=result_text,
parse_mode=None,
reply_markup=None,
)
except Exception:
pass
await query.answer(
text="Switch failed." if switch_failed else "Model switched!"
)
self._model_picker_state.pop(chat_id, None)
elif data.startswith("mm:"):
# --- Model selected: perform the switch ---
try:
@@ -3136,11 +3185,43 @@ class TelegramAdapter(BasePlatformAdapter):
await query.answer(text="Picker expired.")
return
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
warning = await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=provider_slug,
)
except Exception:
warning = None
if warning is not None:
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")],
[
InlineKeyboardButton("◀ Back", callback_data="mb"),
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
],
])
await query.edit_message_text(
text=self.format_message(
f"⚠ *Expensive Model Warning*\n\n{warning.message}"
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer(text="Confirm expensive model")
return
switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
switch_failed = True
# Edit message to show confirmation, remove buttons
try:
@@ -3159,7 +3240,9 @@ class TelegramAdapter(BasePlatformAdapter):
)
except Exception:
pass
await query.answer(text="Model switched!")
await query.answer(
text="Switch failed." if switch_failed else "Model switched!"
)
# Clean up state
self._model_picker_state.pop(chat_id, None)
@@ -3260,7 +3343,7 @@ class TelegramAdapter(BasePlatformAdapter):
query_user_name = getattr(query.from_user, "first_name", None)
# --- Model picker callbacks ---
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
if chat_id:
await self._handle_model_picker_callback(query, data, chat_id)
+31 -25
View File
@@ -2144,23 +2144,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self.pairing_store = PairingStore()
# Event hook system
from gateway.hooks import HookRegistry, install_as_default
from gateway.hooks import HookRegistry
self.hooks = HookRegistry()
# Expose this registry as the process-wide default so in-process
# plugins (and any other component that uses ``get_default_registry()``)
# share state with file-system-discovered hooks loaded into ``self.hooks``.
install_as_default(self.hooks)
# Cross-process delivery: start the hook forwarder if a
# dashboard is reachable. No-op when the dashboard isn't
# running, or when ``HERMES_HOOK_FORWARDER=0`` is set. See
# gateway/hook_forwarder.py + DESIGN-cross-process-hooks.md.
try:
from gateway import hook_forwarder
hook_forwarder.start_if_dashboard_available(self.hooks, src="gateway")
except Exception as e: # pragma: no cover — defensive
# Forwarder failure must never break gateway startup. Log
# and move on; dashboard plugins just won't see gateway events.
print(f"[gateway] hook forwarder start failed: {e}", flush=True)
# Per-chat voice reply mode: "off" | "voice_only" | "all"
self._voice_mode: Dict[str, str] = self._load_voice_modes()
@@ -6488,6 +6473,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_tool_approval_live = False
if _pending_confirm and not _tool_approval_live:
_raw_reply = (event.text or "").strip()
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
# is blocked in Slack threads), but the adapters only rewrite
# `!<known-command>` — `always`/`cancel` are confirm keywords,
# not registered commands, so the `!` survives to here.
_norm_reply = _raw_reply.lstrip("!/").lower()
_cmd_reply = event.get_command()
_confirm_choice = None
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
@@ -6496,11 +6487,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_confirm_choice = "always"
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
_confirm_choice = "cancel"
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
elif _norm_reply in {"approve", "approve once", "once"}:
_confirm_choice = "once"
elif _raw_reply.lower() in {"always", "always approve"}:
elif _norm_reply in {"always", "always approve"}:
_confirm_choice = "always"
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
elif _norm_reply in {"cancel", "nevermind", "no"}:
_confirm_choice = "cancel"
if _confirm_choice is not None:
_resolved = await _slash_confirm_mod.resolve(
@@ -7074,6 +7065,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "memory":
return await self._handle_memory_command(event)
if canonical == "skills":
return await self._handle_skills_command(event)
if canonical == "fast":
return await self._handle_fast_command(event)
@@ -9382,6 +9376,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
adapter._voice_input_callback = self._handle_voice_channel_input
if hasattr(adapter, "_on_voice_disconnect"):
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
# Let the adapter's inactivity timer see the live voice-reply mode so it
# doesn't disconnect a deliberately text-only (/voice off) session.
if hasattr(adapter, "_voice_mode_getter"):
adapter._voice_mode_getter = lambda chat_id: self._voice_mode.get(
self._voice_key(Platform.DISCORD, str(chat_id)), "off"
)
try:
success = await adapter.join_voice_channel(voice_channel)
@@ -10637,6 +10637,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return result
return result
_p = self._typed_command_prefix_for(event.source.platform)
prompt_message = (
f"⚠️ **Confirm /{command}**\n\n"
f"{detail}\n\n"
@@ -10644,7 +10645,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"• **Approve Once** — proceed this time only\n"
"• **Always Approve** — proceed and silence this prompt permanently\n"
"• **Cancel** — keep current conversation\n\n"
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
)
return await self._request_slash_confirm(
event=event,
@@ -11039,11 +11040,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("Button-based update prompt failed: %s", btn_err)
if not sent_buttons:
default_hint = f" (default: {default})" if default else ""
_p = getattr(adapter, "typed_command_prefix", "/")
await adapter.send(
chat_id,
f"⚕ **Update needs your input:**\n\n"
f"{prompt_text}{default_hint}\n\n"
f"Reply `/approve` (yes) or `/deny` (no), "
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
f"or type your answer directly.",
metadata=metadata,
)
@@ -11553,7 +11555,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# when we successfully transcribed the audio — it's redundant.
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
return prefix, successful_transcripts
if user_text:
return f"{prefix}\n\n{user_text}", successful_transcripts
return prefix, successful_transcripts
@@ -14110,14 +14112,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Button-based approval failed, falling back to text: %s", _e
)
# Fallback: plain text approval prompt
# Fallback: plain text approval prompt. Use the adapter's
# typed prefix so Slack/Matrix users are told the form they
# can actually type (`!approve`) — typed "/" is blocked in
# Slack threads and reserved by Matrix clients.
_p = getattr(_status_adapter, "typed_command_prefix", "/")
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
msg = (
f"⚠️ **Dangerous command requires approval:**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {desc}\n\n"
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
)
try:
_approval_send_fut = safe_schedule_threadsafe(
+1
View File
@@ -91,6 +91,7 @@ class SessionSource:
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
role_authorized: bool = False # True when adapter granted access via role (not user ID)
@property
def description(self) -> str:
+261 -141
View File
@@ -47,6 +47,19 @@ logger = logging.getLogger("gateway.run")
class GatewaySlashCommandsMixin:
"""In-session slash-command handlers for GatewayRunner."""
def _typed_command_prefix_for(self, platform) -> str:
"""Return the prefix users can always type to reach Hermes commands.
Reads the adapter's ``typed_command_prefix`` capability flag
(default "/"). Slack and Matrix return "!" because typed "/"
commands are blocked in Slack threads / reserved by Matrix clients;
their adapters rewrite "!command" to "/command" on receive.
Instruction text built for those platforms must show the prefix
that actually works when typed.
"""
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /new or /reset command."""
source = event.source
@@ -1146,149 +1159,198 @@ class GatewaySlashCommandsMixin:
if not result.success:
return t("gateway.model.error_prefix", error=result.error_message)
# If there's a cached agent, update it in-place
cached_entry = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached_entry = _cache.get(session_key)
async def _finish_switch() -> str:
"""Apply the resolved switch (agent, session, config) and build the reply."""
# If there's a cached agent, update it in-place
cached_entry = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached_entry = _cache.get(session_key)
if cached_entry and cached_entry[0] is not None:
if cached_entry and cached_entry[0] is not None:
try:
cached_entry[0].switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
f"[Note: model was just switched from {current_model} to {result.new_model} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
"provider": result.target_provider,
"api_key": result.api_key,
"base_url": result.base_url,
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
# Persist to config if --global
if persist_global:
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
# Coerce scalar/None ``model:`` into a dict before mutation —
# otherwise ``cfg.setdefault("model", {})`` returns the existing
# scalar and the next assignment raises
# ``TypeError: 'str' object does not support item assignment``.
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
# string) instead of the proper nested ``model: {default: ...}``.
raw_model = cfg.get("model")
if isinstance(raw_model, dict):
model_cfg = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_cfg = {"default": raw_model.strip()}
cfg["model"] = model_cfg
else:
model_cfg = {}
cfg["model"] = model_cfg
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
if result.base_url:
model_cfg["base_url"] = result.base_url
from hermes_cli.config import save_config
save_config(cfg)
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=result.new_model)]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
_sw2_config_ctx = None
try:
cached_entry[0].switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
except Exception:
pass
ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
if mi:
if mi.max_output:
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
if mi.has_cost_data():
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Cache notice
cache_enabled = (
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
lines.append(t("gateway.model.prompt_caching_enabled"))
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
f"[Note: model was just switched from {current_model} to {result.new_model} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
if result.warning_message:
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
"provider": result.target_provider,
"api_key": result.api_key,
"base_url": result.base_url,
"api_mode": result.api_mode,
}
if persist_global:
lines.append(t("gateway.model.saved_global"))
else:
lines.append(t("gateway.model.session_only_hint"))
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
return "\n".join(lines)
# Persist to config if --global
if persist_global:
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
# Coerce scalar/None ``model:`` into a dict before mutation —
# otherwise ``cfg.setdefault("model", {})`` returns the existing
# scalar and the next assignment raises
# ``TypeError: 'str' object does not support item assignment``.
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
# string) instead of the proper nested ``model: {default: ...}``.
raw_model = cfg.get("model")
if isinstance(raw_model, dict):
model_cfg = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_cfg = {"default": raw_model.strip()}
cfg["model"] = model_cfg
else:
model_cfg = {}
cfg["model"] = model_cfg
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
if result.base_url:
model_cfg["base_url"] = result.base_url
from hermes_cli.config import save_config
save_config(cfg)
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=result.new_model)]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
_sw2_config_ctx = None
# Expensive-model confirmation gate (typed /model <name> path).
# The pickers (Telegram/Discord inline keyboards, TUI, dashboard)
# already confirm via their own UI affordances; this covers the
# direct text command, which previously bypassed the guard.
# expensive_model_warning() may hit models.dev or a /models endpoint
# on a cache miss, so run it off the event loop.
_cost_warning = None
try:
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
from hermes_cli.model_cost_guard import expensive_model_warning
_cost_warning = await asyncio.to_thread(
expensive_model_warning,
result.new_model,
provider=result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=result.model_info,
)
except Exception:
pass
ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
if mi:
if mi.max_output:
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
if mi.has_cost_data():
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
_cost_warning = None
if _cost_warning is not None:
async def _on_cost_confirm(choice: str) -> str:
if choice == "cancel":
return (
f"🟡 Model switch cancelled. Current model unchanged "
f"({current_model or 'unknown'})."
)
# "once" and "always" both proceed — there is no persistent
# opt-out for the cost guard (each expensive switch should be
# an explicit decision).
return await _finish_switch()
# Cache notice
cache_enabled = (
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
lines.append(t("gateway.model.prompt_caching_enabled"))
_p = self._typed_command_prefix_for(event.source.platform)
return await self._request_slash_confirm(
event=event,
command="model",
title="Expensive Model Warning",
message=(
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
"the current model._"
),
handler=_on_cost_confirm,
)
if result.warning_message:
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
if persist_global:
lines.append(t("gateway.model.saved_global"))
else:
lines.append(t("gateway.model.session_only_hint"))
return "\n".join(lines)
return await _finish_switch()
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
"""Handle /codex-runtime command in the gateway.
@@ -1955,12 +2017,12 @@ class GatewaySlashCommandsMixin:
return t("gateway.reasoning.set_session", effort=effort)
async def _handle_memory_command(self, event: MessageEvent) -> str:
"""Handle /memory — review pending memory writes + set write mode.
"""Handle /memory — review pending memory writes + toggle the approval gate.
Memory entries are small enough to review inline in a chat bubble, so
the full pending/approve/reject/mode flow works on every platform.
Mode changes persist to config.yaml and evict the cached agent so the
new write_mode takes effect on the next message.
the full pending/approve/reject/approval flow works on every platform.
Gate changes persist to config.yaml and evict the cached agent so the
new setting takes effect on the next message.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
@@ -1972,15 +2034,15 @@ class GatewaySlashCommandsMixin:
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
def _set_mode(mode: str):
def _set_approval(enabled: bool):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("memory", {})["write_mode"] = mode
user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
atomic_yaml_write(config_path, user_config)
# New write_mode must take effect next message → drop cached agent.
# New setting must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
# Apply approved writes against a fresh on-disk store (the gateway has
@@ -1989,11 +2051,69 @@ class GatewaySlashCommandsMixin:
store.load_from_disk()
out = handle_pending_subcommand(
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_mode,
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
)
if out is None:
out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
"reject <id>, mode <on|off|approve>.")
"reject <id>, approval <on|off>.")
return out
async def _handle_skills_command(self, event: MessageEvent) -> str:
"""Handle /skills on the gateway — pending skill-write review only.
The full skills hub (search/browse/install) stays CLI-only; this
handler covers the write-approval review surface (pending / approve /
reject / diff / approval) so a skill staged from a gateway session can
be reviewed from that same session. Gated by ``skills.write_approval``
via the CommandDef's ``gateway_config_gate``; also answers when staged
writes still exist after the gate was turned off (so they are never
stranded).
``diff`` output is truncated for chat bubbles the full diff lives in
the CLI (``/skills diff <id>``) and the pending JSON file.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
raw_args = event.get_command_args().strip()
args = raw_args.split() if raw_args else []
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
gate_on = wa.write_approval_enabled(wa.SKILLS)
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
return ("Skill write approval is off (skills.write_approval). "
"Enable it with /skills approval on, then review staged "
"writes here with /skills pending.")
def _set_approval(enabled: bool):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
atomic_yaml_write(config_path, user_config)
# New setting must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
out = handle_pending_subcommand(
wa.SKILLS, args, set_mode_fn=_set_approval,
)
if out is None:
return ("Unknown /skills subcommand on this platform. Use: pending, "
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
"(Search/install are CLI-only.)")
# Chat bubbles can't hold a full skill diff — truncate and point at
# the real review surfaces.
if args and args[0].lower() == "diff" and len(out) > 3000:
pending_id = args[1] if len(args) > 1 else "<id>"
out = (out[:3000]
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
return out
async def _handle_fast_command(self, event: MessageEvent) -> str:
+60 -15
View File
@@ -19,29 +19,74 @@ __release_date__ = "2026.6.5"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
Windows services and terminals default to cp1252, which cannot encode
box-drawing characters used in CLI output. This causes unhandled
UnicodeEncodeError crashes on gateway startup.
Several environments select a legacy, non-UTF-8 encoding for the standard
streams:
- Windows services and terminals default to cp1252.
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
installs and Raspberry Pi) select latin-1 or ASCII.
The CLI prints box-drawing characters () and the glyph in the setup
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
raises an unhandled UnicodeEncodeError that crashes the command before it
can even start e.g. `hermes setup` on a fresh Pi.
This runs at import time so it protects every CLI subcommand, on any
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
stream object is fixed in place (cached `sys.stdout` references keep
working) and falling back to reopening the file descriptor with
closefd=False (the CPython-recommended safe variant).
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
stream change and no environment mutation.
Note: this is intentionally the earliest, platform-agnostic guard.
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
points and layers on the Windows-only extras (console code-page flip,
EDITOR default, PATH augmentation); its stream reconfiguration is a
harmless idempotent no-op once we have already repaired the streams here.
"""
if sys.platform != "win32":
return
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
repaired = False
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
except (AttributeError, OSError):
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
if encoding == "utf8":
continue
# Preferred: reconfigure the existing TextIOWrapper in place. This
# preserves object identity so any code already holding a reference
# to the old sys.stdout benefits from the repair too.
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="replace")
repaired = True
continue
# Fallback: reopen the underlying file descriptor as UTF-8. Used
# for streams that don't expose reconfigure() (e.g. some wrapped
# or replaced streams). closefd=False keeps the original fd open.
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
errors="replace", buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
repaired = True
except (AttributeError, OSError, ValueError):
pass
# Only nudge child processes toward UTF-8 when we actually detected a
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
# locale already, so leave the environment untouched (minimal footprint).
if repaired:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
_ensure_utf8()
+91 -7
View File
@@ -2665,12 +2665,23 @@ def _xai_wait_for_callback(
result: dict[str, Any],
*,
timeout_seconds: float = 180.0,
manual_paste_redirect_uri: Optional[str] = None,
) -> dict[str, Any]:
deadline = time.monotonic() + max(5.0, timeout_seconds)
if manual_paste_redirect_uri and sys.stdin.isatty():
print()
print("If xAI shows a Grok Build code instead of redirecting,")
print("paste that code here and press Enter.")
try:
while time.monotonic() < deadline:
if result["code"] or result["error"]:
return result
if manual_paste_redirect_uri:
raw_paste = _read_ready_stdin_line()
if raw_paste and raw_paste.strip():
pasted = _parse_pasted_callback(raw_paste)
pasted["_manual_paste"] = True
return pasted
time.sleep(0.1)
finally:
server.shutdown()
@@ -2694,6 +2705,21 @@ def _xai_wait_for_callback(
)
def _read_ready_stdin_line() -> Optional[str]:
"""Return one pending stdin line without blocking, if the terminal has one."""
try:
if not sys.stdin.isatty():
return None
import select
ready, _, _ = select.select([sys.stdin], [], [], 0)
if not ready:
return None
return sys.stdin.readline()
except Exception:
return None
def _spotify_token_payload_to_state(
token_payload: Dict[str, Any],
*,
@@ -6149,6 +6175,40 @@ def _reset_config_provider() -> Path:
return config_path
def _confirm_expensive_model_selection(
model_id: str,
*,
provider: str = "",
base_url: str = "",
api_key: str = "",
) -> bool:
"""Prompt before saving a model whose known pricing exceeds guardrails."""
try:
from hermes_cli.model_cost_guard import expensive_model_warning
warning = expensive_model_warning(
model_id,
provider=provider,
base_url=base_url,
api_key=api_key,
)
except Exception:
warning = None
if warning is None:
return True
print()
print("=" * 72)
print(warning.message)
print("=" * 72)
try:
response = input("Switch anyway? [y/N]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
return False
return response in {"y", "yes"}
def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
@@ -6156,6 +6216,9 @@ def _prompt_model_selection(
unavailable_models: Optional[List[str]] = None,
portal_url: str = "",
unavailable_message: str = "",
confirm_provider: str = "",
confirm_base_url: str = "",
confirm_api_key: str = "",
) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
@@ -6169,6 +6232,18 @@ def _prompt_model_selection(
_unavailable = unavailable_models or []
def _confirmed_selection(mid: str) -> Optional[str]:
if not mid:
return None
if confirm_provider and not _confirm_expensive_model_selection(
mid,
provider=confirm_provider,
base_url=confirm_base_url,
api_key=confirm_api_key,
):
return None
return mid
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
@@ -6284,13 +6359,13 @@ def _prompt_model_selection(
return None
print()
if idx < len(ordered):
return ordered[idx]
return _confirmed_selection(ordered[idx])
elif idx == len(ordered):
try:
custom = input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return custom if custom else None
return _confirmed_selection(custom) if custom else None
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass
@@ -6322,10 +6397,10 @@ def _prompt_model_selection(
return None
idx = int(choice)
if 1 <= idx <= n:
return ordered[idx - 1]
return _confirmed_selection(ordered[idx - 1])
elif idx == n + 1:
custom = input("Enter model name: ").strip()
return custom if custom else None
return _confirmed_selection(custom) if custom else None
elif idx == n + 2:
return None
print(f"Please enter 1-{n + 2}")
@@ -6669,6 +6744,7 @@ def _xai_oauth_loopback_login(
authorization_endpoint = discovery["authorization_endpoint"]
token_endpoint = discovery["token_endpoint"]
allow_missing_state = False
if manual_paste:
# No HTTP listener — synthesize a redirect_uri matching what
# the server would have bound to so the authorize URL the user
@@ -6695,6 +6771,7 @@ def _xai_oauth_loopback_login(
print("Open this URL to authorize Hermes with xAI:")
print(authorize_url)
callback = _prompt_manual_callback_paste(redirect_uri)
allow_missing_state = True
else:
server, thread, callback_result, redirect_uri = _xai_start_callback_server()
try:
@@ -6734,6 +6811,7 @@ def _xai_oauth_loopback_login(
thread,
callback_result,
timeout_seconds=max(30.0, timeout_seconds * 9),
manual_paste_redirect_uri=redirect_uri,
)
except AuthError as exc:
if (
@@ -6750,6 +6828,7 @@ def _xai_oauth_loopback_login(
callback = _prompt_manual_callback_paste(redirect_uri)
if callback.get("code") is None and callback.get("error") is None:
raise exc
allow_missing_state = True
except Exception:
try:
server.shutdown()
@@ -6770,7 +6849,7 @@ def _xai_oauth_loopback_login(
code="xai_authorization_failed",
)
callback_state = callback.get("state")
# Manual-paste bare-code path: when a user pastes only the opaque
# Manual bare-code paths: when a user pastes only the opaque
# authorization code (no ``code=``/``state=`` query parameters),
# ``_parse_pasted_callback`` returns ``state=None``. xAI's consent
# page renders the code in-page rather than redirecting through the
@@ -6778,10 +6857,12 @@ def _xai_oauth_loopback_login(
# VPS, container consoles) the bare code is the only thing the user
# can obtain. PKCE (code_verifier) still binds the exchange to this
# client, so the local state-equality check is redundant on the
# bare-code path — we substitute the locally generated state to keep
# bare-code paths — we substitute the locally generated state to keep
# the rest of the validation chain (and the token exchange) unchanged.
# See #26923 (AccursedGalaxy comment, 2026-05-20).
if callback_state is None and manual_paste:
if callback.get("_manual_paste"):
allow_missing_state = True
if callback_state is None and (manual_paste or allow_missing_state):
callback_state = state
if callback_state != state:
raise AuthError(
@@ -7698,6 +7779,9 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
unavailable_models=unavailable_models,
portal_url=_portal,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=inference_base_url,
confirm_api_key=runtime_key,
)
elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
+8 -8
View File
@@ -1306,12 +1306,12 @@ class CLICommandsMixin:
parts = cmd.strip().split()
args = parts[1:] if len(parts) > 1 else []
if args and args[0].lower() in {"pending", "approve", "apply", "reject",
"deny", "drop", "diff", "mode"}:
"deny", "drop", "diff", "approval", "mode"}:
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
out = handle_pending_subcommand(
wa.SKILLS, args,
set_mode_fn=lambda m: self._save_write_mode("skills", m),
set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
)
if out is not None:
print(out)
@@ -1320,7 +1320,7 @@ class CLICommandsMixin:
handle_skills_slash(cmd, ChatConsole())
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + write-mode control."""
"""Handle /memory slash command — pending review + approval-gate toggle."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
parts = cmd.strip().split()
@@ -1329,17 +1329,17 @@ class CLICommandsMixin:
out = handle_pending_subcommand(
wa.MEMORY, args,
memory_store=store,
set_mode_fn=lambda m: self._save_write_mode("memory", m),
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled),
)
if out is None:
out = ("Unknown /memory subcommand. "
"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.")
"Use: pending, approve <id>, reject <id>, approval <on|off>.")
print(out)
def _save_write_mode(self, subsystem: str, mode: str):
"""Persist <subsystem>.write_mode to config (for /memory|/skills mode)."""
def _save_write_approval(self, subsystem: str, enabled: bool):
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
from cli import save_config_value
save_config_value(f"{subsystem}.write_mode", mode)
save_config_value(f"{subsystem}.write_approval", bool(enabled))
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.
+5 -4
View File
@@ -167,12 +167,13 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
gateway_config_gate="skills.write_approval",
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "mode")),
CommandDef("memory", "Review pending memory writes / set write mode",
"pending", "approve", "reject", "diff", "approval")),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"Tools & Skills",
args_hint="[pending|approve|reject|mode] [id|on|off|approve]",
subcommands=("pending", "approve", "reject", "mode")),
args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
+83 -26
View File
@@ -1290,6 +1290,14 @@ DEFAULT_CONFIG = {
"timeout": 30,
"extra_body": {},
},
"tts_audio_tags": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
# Triage specifier — flesh out a rough one-liner in the Kanban
# Triage column into a concrete spec, then promote it to ``todo``.
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
@@ -1556,7 +1564,7 @@ DEFAULT_CONFIG = {
# Each provider supports an optional `max_text_length:` override for the
# per-request input-character cap. Omit it to use the provider's documented
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"edge": {
@@ -1572,6 +1580,19 @@ DEFAULT_CONFIG = {
"voice": "alloy",
# Voices: alloy, echo, fable, onyx, nova, shimmer
},
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
"voice": "Kore",
# When true, Gemini 3.1 TTS uses a hidden auxiliary-model rewrite
# pass to insert freeform square-bracket audio tags into the TTS
# script. Visible chat replies are unchanged.
"audio_tags": False,
# Optional local Markdown/text file with Gemini TTS performance
# direction. It may include AUDIO PROFILE, SCENE, DIRECTOR'S NOTES,
# SAMPLE CONTEXT, and either a `{transcript}` placeholder or no
# transcript section; Hermes appends the live transcript when absent.
"persona_prompt_file": "",
},
"xai": {
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
"language": "en",
@@ -1653,18 +1674,19 @@ DEFAULT_CONFIG = {
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
# Write gate for the memory tool (add/replace/remove), applied to BOTH
# Approval gate for memory writes (add/replace/remove), applied to BOTH
# foreground agent turns and the background self-improvement review fork
# (the source of unprompted "wrong assumption" saves users reported):
# on — write freely (default, current behaviour)
# off — never write; the memory tool returns a clean disabled result
# approve — foreground writes block on an inline approve/deny prompt
# (entries are small enough to review in a chat bubble);
# background-review writes are staged for review instead of
# committed (a daemon thread cannot block on a prompt).
# Pending entries: /memory pending, /memory approve <id>,
# /memory reject <id>.
"write_mode": "on",
# (the source of unprompted "wrong assumption" saves users reported).
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: foreground writes prompt inline
# (entries are small enough to review in a chat
# bubble); background-review writes are staged
# instead of committed (a daemon thread cannot block
# on a prompt). Review staged entries with
# /memory pending, /memory approve <id>,
# /memory reject <id>.
# To disable memory entirely, use memory_enabled: false instead.
"write_approval": False,
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# External memory provider plugin (empty = built-in only).
@@ -1769,17 +1791,18 @@ DEFAULT_CONFIG = {
# External hub installs (trusted/community sources) are always
# scanned regardless of this setting.
"guard_agent_created": False,
# Write gate for skill_manage (create/edit/patch/write_file/delete/
# Approval gate for skill_manage (create/edit/patch/write_file/delete/
# remove_file), applied to BOTH foreground agent turns and the
# background self-improvement review fork:
# on — write freely (default, current behaviour)
# off — never write; skill_manage returns a clean disabled result
# approve — stage the write for review instead of committing.
# Pending skills are listed with /skills pending, reviewed
# with /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), and applied with
# /skills approve <id> or dropped with /skills reject <id>.
"write_mode": "on",
# background self-improvement review fork.
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: stage the write for review
# instead of committing (a SKILL.md is too large to
# review inline, so skills always stage rather than
# prompt). List with /skills pending, inspect with
# /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), apply with
# /skills approve <id> or drop with /skills reject <id>.
"write_approval": False,
},
# Curator — background skill maintenance.
@@ -2463,7 +2486,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 28,
"_config_version": 29,
}
# =============================================================================
@@ -4734,6 +4757,34 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
# ── Version 28 → 29: rename memory/skills write_mode → write_approval ──
# The tri-state write_mode (on|off|approve) was replaced by a clear boolean
# write_approval (default false = gate off, writes flow freely; true =
# require approval). Only an explicit "approve" carried gating intent, so
# it maps to true; everything else (on/off/unset) → false. The old
# "off = block all writes" mode is dropped — memory_enabled: false disables
# memory entirely. Only rewrite a key the user actually persisted; never
# invent one.
if current_ver < 29:
config = read_raw_config()
touched = False
for subsystem in ("memory", "skills"):
sub = config.get(subsystem)
if not isinstance(sub, dict) or "write_mode" not in sub:
continue
old = sub.pop("write_mode")
old_norm = old.strip().lower() if isinstance(old, str) else old
sub["write_approval"] = (old_norm == "approve")
config[subsystem] = sub
touched = True
results["config_added"].append(
f"{subsystem}.write_mode → write_approval={sub['write_approval']}"
)
if touched:
save_config(config)
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -5719,19 +5770,21 @@ def save_env_value(key: str, value: str):
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Restore original permissions before _secure_file may tighten them.
# Preserve the original file mode (e.g. 0640 for Docker volume mounts)
# instead of letting _secure_file unconditionally tighten to 0600.
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ[key] = value
invalidate_env_cache()
@@ -5776,18 +5829,22 @@ def remove_env_value(key: str) -> bool:
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Preserve the original file mode (e.g. 0640 for Docker volume
# mounts) instead of letting _secure_file unconditionally tighten
# to 0600. Mirrors save_env_value().
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ.pop(key, None)
invalidate_env_cache()
-6
View File
@@ -47,12 +47,6 @@ _GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/ds-assets/",
"/fonts/",
"/fonts-terminal/",
# Cross-process hook delivery — bearer-token authenticated independently
# of the OAuth gate (see hermes_cli/hook_ingest.py + DESIGN-cross-
# process-hooks.md). Forwarders running outside any logged-in browser
# session need to reach these endpoints; the bearer token is the
# security boundary.
"/api/hooks/",
)
-10
View File
@@ -46,14 +46,4 @@ PUBLIC_API_PATHS: frozenset[str] = frozenset({
# Read-only theme + plugin manifests for the dashboard skin engine.
"/api/dashboard/themes",
"/api/dashboard/plugins",
# Cross-process hook delivery. These bypass the dashboard auth gates
# because they carry their OWN auth model: a bearer token minted fresh
# into ``dashboard.json`` each dashboard startup, verified inside the
# route handlers themselves (gateway/hook_forwarder.py posts to them
# without a session cookie). They are NOT unauthenticated — the
# handler rejects a missing/wrong bearer — so they're safe to expose
# to the three audiences above (the handler 401s anyone without the
# token). See gateway/hook_forwarder.py + hermes_cli/hook_ingest.py.
"/api/hooks/health",
"/api/hooks/ingest",
})
-282
View File
@@ -1,282 +0,0 @@
"""Hook registry cross-process delivery — dashboard-side endpoints.
The companion to ``gateway/hook_forwarder.py``. Provides:
* ``write_dashboard_discovery_file()`` / ``remove_dashboard_discovery_file()``
drops/cleans ``$HERMES_HOME/dashboard.json`` with the bound dashboard URL and
a freshly-generated bearer token. Long-lived source processes (gateway,
TUI, subagents) read this file on startup to find the dashboard and
authenticate ingest POSTs.
* :func:`build_hook_router` returns a FastAPI router with two endpoints:
- ``GET /api/hooks/health`` unauthenticated reachability probe. The
forwarder GETs this before each round of POSTs so a downed dashboard
fails fast without spamming the ingest endpoint with retries.
- ``POST /api/hooks/ingest`` accepts ``{event_type, context, src}``
frames from forwarders and republishes them via
``get_default_registry().emit_sync(event_type, context)``. Bearer
token from ``dashboard.json:hooks_ingest_token`` is the security
boundary, independent of the dashboard's session token / OAuth
gate. Loop-prevention: the republished context is stamped with
``_forwarded=True`` and ``_forwarded_from=<src>`` so source-side
forwarders skip these events if they ever round-trip back.
See ``DESIGN-cross-process-hooks.md`` for the full design rationale.
"""
from __future__ import annotations
import hmac
import json
import logging
import os
import secrets
import stat
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from hermes_cli.config import get_hermes_home
_log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Discovery file
# ---------------------------------------------------------------------------
#
# The dashboard writes this file on startup and removes it on shutdown.
# Forwarders running in non-dashboard processes read it to discover the
# dashboard URL + bearer token. Lives under HERMES_HOME so it's
# profile-aware; no /tmp fallback.
_DISCOVERY_FILENAME = "dashboard.json"
# Token used by forwarders to authenticate POST /api/hooks/ingest.
# Generated fresh on each dashboard startup; lives only in memory + the
# 0600-mode discovery file. Stored at module level so the ingest
# endpoint can compare against it without re-reading the file on every
# request.
_HOOKS_INGEST_TOKEN: str = ""
def _discovery_path() -> Path:
"""Return the absolute path of ``$HERMES_HOME/dashboard.json``."""
return get_hermes_home() / _DISCOVERY_FILENAME
def write_dashboard_discovery_file(host: str, port: int) -> str:
"""Generate a fresh hooks-ingest token and write the discovery file.
The discovery file is written atomically (write to ``.tmp``, rename
to final path) so a forwarder that reads concurrently never sees a
partially-written file. The file mode is ``0600`` owner-only
so a same-host non-root user can't read the token without already
having compromised the dashboard user's account.
Idempotent in the trivial sense: calling twice in the same process
rotates the token and overwrites the file. Not thread-safe; should
only be called from the dashboard's startup path.
Args:
host: The host the dashboard bound to (``127.0.0.1``, ``0.0.0.0``,
a specific interface, ). Written verbatim into the discovery
file so forwarders dial the right address.
port: The port the dashboard bound to.
Returns:
The freshly-generated bearer token (also stored module-locally
so :func:`_hook_ingest_auth_ok` can validate POSTs).
"""
global _HOOKS_INGEST_TOKEN
token = secrets.token_urlsafe(32)
_HOOKS_INGEST_TOKEN = token
payload = {
"url": f"http://{host}:{port}",
"hooks_ingest_token": token,
"pid": os.getpid(),
"started_at": datetime.now(timezone.utc).isoformat(),
}
path = _discovery_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".json.tmp")
try:
tmp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
# 0600 — owner read+write, nothing for group or other. Same
# posture as ~/.hermes/auth.json.
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
tmp_path.replace(path)
except OSError as exc:
_log.warning(
"[hooks-ingest] failed to write dashboard discovery file at %s: %s",
path,
exc,
)
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
raise
_log.debug("[hooks-ingest] wrote %s", path)
return token
def remove_dashboard_discovery_file() -> None:
"""Delete the discovery file. Idempotent; safe to call from atexit."""
global _HOOKS_INGEST_TOKEN
try:
_discovery_path().unlink(missing_ok=True)
except OSError:
pass
_HOOKS_INGEST_TOKEN = ""
_log.debug("[hooks-ingest] removed discovery file")
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _hook_ingest_auth_ok(request: Request) -> bool:
"""True if the request carries a valid hooks-ingest bearer token.
The token is independent of the dashboard session token / OAuth
cookie; only forwarders that have read ``dashboard.json`` know it.
Constant-time comparison via :func:`hmac.compare_digest`.
"""
if not _HOOKS_INGEST_TOKEN:
# No token yet — discovery file not written; refuse everything.
return False
auth = request.headers.get("authorization", "")
expected = f"Bearer {_HOOKS_INGEST_TOKEN}"
return hmac.compare_digest(auth.encode(), expected.encode())
# ---------------------------------------------------------------------------
# FastAPI router
# ---------------------------------------------------------------------------
def build_hook_router() -> APIRouter:
"""Return a FastAPI router with ``/health`` and ``/ingest`` endpoints.
Caller mounts at ``/api/hooks`` to get the documented endpoints
(``GET /api/hooks/health`` and ``POST /api/hooks/ingest``).
"""
router = APIRouter()
@router.get("/health")
async def hook_health() -> dict:
"""Unauthenticated reachability probe for the forwarder."""
return {"ok": True}
@router.post("/ingest")
async def hook_ingest(request: Request) -> dict:
"""Republish a forwarded event on the dashboard's local registry.
Wire shape (POST body)::
{
"event_type": "agent:start",
"context": {"platform": "telegram", "user_id": "u-1", ...},
"src": "gateway"
}
The context is republished with ``_forwarded=True`` and
``_forwarded_from=<src>`` stamped on it so source-side
forwarders skip the event if it ever round-trips back (closing
the loop).
Returns ``{"ok": True}`` on success, raises 401 on missing/bad
token, 400 on malformed body.
``emit_sync`` is the right entry point: handlers run
synchronously on the request thread (fast, push-to-queue style
for typical plugin handlers); async handlers are scheduled on
the running event loop via the existing ``asyncio.ensure_future``
path inside ``emit_sync``.
"""
if not _hook_ingest_auth_ok(request):
raise HTTPException(status_code=401, detail="Unauthorized")
try:
body = await request.json()
except Exception as exc:
raise HTTPException(
status_code=400, detail=f"invalid JSON body: {exc}"
) from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="body must be a JSON object")
event_type = body.get("event_type")
if not isinstance(event_type, str) or not event_type:
raise HTTPException(
status_code=400, detail="missing or empty 'event_type'"
)
context = body.get("context") or {}
if not isinstance(context, dict):
raise HTTPException(
status_code=400, detail="'context' must be a JSON object"
)
src = body.get("src", "?")
if not isinstance(src, str):
src = "?"
# Stamp the context with forwarding metadata so:
# 1. Source-side forwarders skip this event if it ever round-trips
# back to them (loop prevention).
# 2. Subscribers can filter forwarded-vs-original on
# ``context["_forwarded"]`` (useful for hooks that fire in
# every process and don't want 2x firing).
context = {**context, "_forwarded": True, "_forwarded_from": src}
# Lazy import to avoid a hard dependency on gateway.hooks at
# module-load time (web_server can be imported in contexts
# where gateway.hooks isn't ready).
from gateway.hooks import get_default_registry
try:
get_default_registry().emit_sync(event_type, context)
except Exception as exc:
# emit_sync swallows handler exceptions internally — if we
# see one here it's a registry-level bug. Log it but still
# return 200 so the forwarder doesn't retry-storm.
_log.warning(
"[hooks-ingest] emit_sync raised for %s: %s", event_type, exc
)
return {"ok": True}
return router
# ---------------------------------------------------------------------------
# Test helper
# ---------------------------------------------------------------------------
def _reset_for_tests() -> None:
"""Clear the cached token + remove any leftover discovery file."""
global _HOOKS_INGEST_TOKEN
_HOOKS_INGEST_TOKEN = ""
remove_dashboard_discovery_file()
def get_current_token_for_tests() -> str:
"""Read the currently-active hooks-ingest token.
Test-only accessor; do not call from production code.
"""
return _HOOKS_INGEST_TOKEN
+202 -4
View File
@@ -499,6 +499,7 @@ from hermes_cli import __version__, __release_date__
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
from hermes_cli.model_setup_flows import (
_prompt_auth_credentials_choice,
_model_flow_openrouter,
_model_flow_nous,
_model_flow_openai_codex,
@@ -2830,7 +2831,12 @@ def select_provider_and_model(args=None):
member_labels = [
provider_labels.get(m, m) for m in selected_members
]
member_idx = _prompt_provider_choice(member_labels, default=member_default)
group_label = ordered[provider_idx][1].split("", 1)[0]
member_idx = _prompt_provider_choice(
member_labels,
default=member_default,
title=f"Select {group_label} provider:",
)
if member_idx is None:
print("No change.")
return
@@ -2974,6 +2980,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
("skills_hub", "Skills hub", "skills search/install"),
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
@@ -3263,6 +3270,7 @@ def _aux_flow_provider_model(
model_list,
current_model=current_model,
pricing=pricing,
confirm_provider=provider_slug,
)
if selected is None:
print("No change.")
@@ -3331,7 +3339,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
def _prompt_provider_choice(choices, *, default=0):
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
"""Show provider selection menu with curses arrow-key navigation.
Falls back to a numbered list when curses is unavailable (e.g. piped
@@ -3341,7 +3349,7 @@ def _prompt_provider_choice(choices, *, default=0):
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice("Select provider:", choices, default)
idx = _curses_prompt_choice(title, choices, default)
if idx >= 0:
print()
return idx
@@ -3349,7 +3357,7 @@ def _prompt_provider_choice(choices, *, default=0):
pass
# Fallback: numbered list
print("Select provider:")
print(title)
for i, c in enumerate(choices, 1):
marker = "" if i - 1 == default else " "
print(f" {marker} {i}. {c}")
@@ -6385,6 +6393,167 @@ def _load_installable_optional_extras(group: str = "all") -> list[str]:
return referenced
# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the
# venv and cleared only after the dependency install verifies clean. If a user
# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker
# survives and the next ``hermes`` launch finishes the install instead of
# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow
# never landed). Lives next to the venv (not under $HERMES_HOME) because the
# venv is shared across all profiles, so a single marker covers every profile.
def _update_marker_path() -> Path:
return PROJECT_ROOT / ".update-incomplete"
def _write_update_incomplete_marker() -> None:
"""Drop the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().write_text(
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
)
except OSError as exc:
logger.debug("Could not write update-incomplete marker: %s", exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear update-incomplete marker: %s", exc)
def _recover_from_interrupted_install() -> None:
"""Finish a dependency install that a prior ``hermes update`` left half-done.
Triggered on launch when ``.update-incomplete`` is present meaning the
code was pulled but the dep install was killed before it verified clean.
Unconditionally bootstraps pip via ``ensurepip`` (a killed ``pip install``
can wipe pip from the venv entirely, which blocks the venv from recovering
on its own), then re-runs the editable ``.[all]`` install + core-dependency
verification, then clears the marker.
Never raises: a recovery failure must not block launch. If it can't
self-heal it prints the one-line manual command and leaves the marker so
the next launch tries again.
Concurrency: the marker lives next to the shared venv, so a gateway start
plus a CLI launch (or two profiles starting at once) can both see it. An
``O_EXCL`` lockfile ensures only one process runs the reinstall; the
others skip and let the winner clear the marker.
Output: everything our status lines AND the streamed pip/uv install
(which inherits fd 1) is routed to stderr. Launches whose stdout is a
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
if not _update_marker_path().exists():
return
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
# those don't run the source-tree update path, so a stray marker is not ours
# to act on. Just clear it.
if not (PROJECT_ROOT / "pyproject.toml").is_file():
_clear_update_incomplete_marker()
return
# Single-flight guard: atomically claim the recovery lock. If another
# process holds it, skip — it is running the same reinstall into the same
# shared venv right now. A crashed holder leaves a stale lock; break it
# after an hour (well past any realistic install) so recovery can't be
# wedged forever.
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if _time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError as exc:
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
# the install itself will surface the real problem.
logger.debug("Could not create install-recovery lock: %s", exc)
saved_stdout_fd = None
saved_sys_stdout = sys.stdout
try:
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
# for the duration of recovery (see docstring: ACP stdout safety).
try:
saved_stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_stdout_fd = None
sys.stdout = sys.stderr
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now..."
)
try:
from hermes_cli.managed_uv import ensure_uv
# Always bootstrap pip first: a killed install can leave the venv with
# no pip module at all, and uv may also be gone. ensurepip restores a
# known-good pip so at least the plain-pip path below can proceed.
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
capture_output=True,
)
except Exception as exc:
logger.debug("ensurepip during install recovery failed: %s", exc)
uv_bin = ensure_uv()
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"],
env=uv_env,
group="termux-all" if _is_termux_env(uv_env) else "all",
)
else:
_install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"],
group="termux-all" if _is_termux_env() else "all",
)
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker in place so the next launch retries. Give the user
# the exact manual recovery command in the meantime.
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
print(" Recover manually with:")
print(f" cd {PROJECT_ROOT}")
print(f" {sys.executable} -m ensurepip --upgrade")
print(f" {sys.executable} -m pip install -e '.[all]'")
finally:
sys.stdout = saved_sys_stdout
if saved_stdout_fd is not None:
try:
os.dup2(saved_stdout_fd, 1)
os.close(saved_stdout_fd)
except OSError:
pass
try:
lock_path.unlink()
except OSError:
pass
def _run_install_with_heartbeat(
cmd: list[str],
*,
@@ -8316,6 +8485,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
#
# Drop the interrupted-install breadcrumb BEFORE touching the venv. If
# the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),
# the marker survives and the next ``hermes`` launch finishes the
# install via ``_recover_from_interrupted_install``. Cleared only after
# the install + core-dependency verification completes below.
_write_update_incomplete_marker()
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
@@ -8369,6 +8545,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
# Core Python deps installed AND verified (the fallback helper runs
# _verify_core_dependencies_installed). Clear the interrupted-install
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
# UI, desktop rebuild) are non-core and can't brick the venv.
_clear_update_incomplete_marker()
_refresh_active_lazy_features()
_update_node_dependencies()
@@ -10683,6 +10865,22 @@ def main():
except Exception:
pass
# Self-heal a venv left half-built by an interrupted ``hermes update``
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
# *running* update — that flow writes and clears its own marker, and we
# don't want a recovery install racing the real one. Never raises.
#
# The substring match is deliberately loose: argv isn't parsed yet at this
# point, and the failure modes are asymmetric. Over-matching (e.g.
# ``hermes skills install update``) merely defers recovery one launch;
# under-matching (missing ``hermes -p work update``) would race a recovery
# install against the real one. Loose wins.
try:
if "update" not in sys.argv[1:]:
_recover_from_interrupted_install()
except Exception:
pass
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
+134
View File
@@ -0,0 +1,134 @@
"""Expensive-model confirmation helpers for model selection surfaces."""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Optional
from agent.models_dev import ModelInfo
INPUT_COST_WARNING_THRESHOLD = Decimal("20")
OUTPUT_COST_WARNING_THRESHOLD = Decimal("100")
GPT55_PRO_OPENROUTER_ID = "openai/gpt-5.5-pro"
GPT55_SUGGESTION = "did you mean to select openai/gpt-5.5?"
@dataclass(frozen=True)
class ExpensiveModelWarning:
"""Confirmation payload for models above Hermes' cost guardrail."""
model: str
provider: str
input_cost_per_million: Optional[Decimal]
output_cost_per_million: Optional[Decimal]
source: str
message: str
def _to_decimal(value: object) -> Optional[Decimal]:
if value is None:
return None
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
def _format_money(value: Optional[Decimal]) -> str:
if value is None:
return "unknown"
return f"${value:.2f}/M"
def _pricing_from_model_info(
model_info: Optional[ModelInfo],
) -> tuple[Optional[Decimal], Optional[Decimal], str]:
if model_info is None or not model_info.has_cost_data():
return None, None, ""
return (
_to_decimal(model_info.cost_input),
_to_decimal(model_info.cost_output),
"models.dev",
)
def expensive_model_warning(
model_name: str,
*,
provider: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
model_info: Optional[ModelInfo] = None,
) -> Optional[ExpensiveModelWarning]:
"""Return a warning payload when known pricing exceeds safety thresholds.
The guard only triggers when pricing is known. Callers should use this after
model resolution so aliases and provider-specific model IDs have settled.
"""
model = (model_name or "").strip()
if not model:
return None
input_cost, output_cost, source = _pricing_from_model_info(model_info)
if input_cost is None and output_cost is None and provider:
try:
from agent.models_dev import get_model_info
input_cost, output_cost, source = _pricing_from_model_info(
get_model_info(provider, model)
)
except Exception:
pass
if input_cost is None and output_cost is None:
try:
from agent.usage_pricing import get_pricing_entry
entry = get_pricing_entry(
model,
provider=provider,
base_url=base_url,
api_key=api_key,
)
except Exception:
entry = None
if entry is not None:
input_cost = entry.input_cost_per_million
output_cost = entry.output_cost_per_million
source = entry.source
over_input = (
input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD
)
over_output = (
output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD
)
if not over_input and not over_output:
return None
lines = [
"!!! EXPENSIVE MODEL WARNING !!!",
"",
f"{model} has known pricing above Hermes' safety threshold.",
f"Input tokens: {_format_money(input_cost)}",
f"Output tokens: {_format_money(output_cost)}",
(
"Threshold: more than $20/M input tokens or more than "
"$100/M output tokens."
),
]
if source:
lines.append(f"Pricing source: {source}.")
if model.lower() == GPT55_PRO_OPENROUTER_ID:
lines.append(GPT55_SUGGESTION)
lines.append("Confirm only if you intend to use this model.")
return ExpensiveModelWarning(
model=model,
provider=(provider or "").strip(),
input_cost_per_million=input_cost,
output_cost_per_million=output_cost,
source=source or "unknown",
message="\n".join(lines),
)
+131 -43
View File
@@ -25,6 +25,44 @@ import os
import subprocess
def _prompt_auth_credentials_choice(title: str) -> str:
"""Prompt for reuse / reauthenticate / cancel with the standard radio UI.
Returns one of ``"use"``, ``"reauth"``, ``"cancel"``. Falls back to a
numbered prompt when curses is unavailable (piped stdin, non-TTY).
"""
choices = [
"Use existing credentials",
"Reauthenticate (new OAuth login)",
"Cancel",
]
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice(title, choices, 0)
if idx >= 0:
print()
return ("use", "reauth", "cancel")[idx]
except Exception:
pass
print(title)
for i, label in enumerate(choices, 1):
marker = "" if i == 1 else " "
print(f" {marker} {i}. {label}")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
return "reauth"
if choice == "3":
return "cancel"
return "use"
def _model_flow_openrouter(config, current_model=""):
"""OpenRouter provider: ensure API key, then pick model."""
from hermes_cli.main import _prompt_api_key
@@ -64,7 +102,12 @@ def _model_flow_openrouter(config, current_model=""):
pricing = get_pricing_for_provider("openrouter", force_refresh=True)
selected = _prompt_model_selection(
openrouter_models, current_model=current_model, pricing=pricing
openrouter_models,
current_model=current_model,
pricing=pricing,
confirm_provider="openrouter",
confirm_base_url=OPENROUTER_BASE_URL,
confirm_api_key=_resolved or existing_key,
)
if selected:
_save_model_choice(selected)
@@ -273,6 +316,9 @@ def _model_flow_nous(config, current_model="", args=None):
unavailable_models=unavailable_models,
portal_url=_nous_portal_url,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=creds.get("base_url", ""),
confirm_api_key=creds.get("api_key", ""),
)
if selected:
_save_model_choice(selected)
@@ -321,16 +367,9 @@ def _model_flow_openai_codex(config, current_model=""):
if status.get("logged_in"):
print(" OpenAI Codex credentials: ✓")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:")
if choice == "2":
if choice == "reauth":
print("Starting a fresh OpenAI Codex login...")
print()
try:
@@ -350,7 +389,7 @@ def _model_flow_openai_codex(config, current_model=""):
if not status.get("logged_in"):
print("Login failed.")
return
elif choice == "3":
elif choice == "cancel":
return
else:
print("Not logged into OpenAI Codex. Starting login...")
@@ -385,7 +424,13 @@ def _model_flow_openai_codex(config, current_model=""):
codex_models = get_codex_model_ids(access_token=_codex_token)
selected = _prompt_model_selection(codex_models, current_model=current_model)
selected = _prompt_model_selection(
codex_models,
current_model=current_model,
confirm_provider="openai-codex",
confirm_base_url=DEFAULT_CODEX_BASE_URL,
confirm_api_key=_codex_token or "",
)
if selected:
_save_model_choice(selected)
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
@@ -411,16 +456,11 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
if status.get("logged_in"):
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
choice = _prompt_auth_credentials_choice(
"xAI Grok OAuth (SuperGrok / Premium+) credentials:"
)
if choice == "2":
if choice == "reauth":
print("Starting a fresh xAI OAuth login...")
print()
try:
@@ -444,7 +484,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
except Exception as exc:
print(f"Login failed: {exc}")
return
elif choice == "3":
elif choice == "cancel":
return
else:
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
@@ -520,7 +560,12 @@ def _model_flow_qwen_oauth(_config, current_model=""):
models = list(_DEFAULT_QWEN_PORTAL_MODELS)
default = current_model or (models[0] if models else "qwen3-coder-plus")
selected = _prompt_model_selection(models, current_model=default)
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="qwen-oauth",
confirm_base_url=DEFAULT_QWEN_BASE_URL,
)
if selected:
_save_model_choice(selected)
_update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL)
@@ -569,7 +614,12 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
from hermes_cli.models import _PROVIDER_MODELS
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
selected = _prompt_model_selection(model_ids, current_model)
selected = _prompt_model_selection(
model_ids,
current_model,
confirm_provider="minimax-oauth",
confirm_base_url=creds["base_url"],
)
if not selected:
return
_save_model_choice(selected)
@@ -638,7 +688,12 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
default = current_model or (models[0] if models else "gemini-3-flash-preview")
selected = _prompt_model_selection(models, current_model=default)
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="google-gemini-cli",
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
)
if selected:
_save_model_choice(selected)
_update_config_for_provider(
@@ -1563,7 +1618,11 @@ def _model_flow_copilot(config, current_model=""):
if model_list:
selected = _prompt_model_selection(
model_list, current_model=normalized_current_model
model_list,
current_model=normalized_current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=api_key,
)
else:
try:
@@ -1701,6 +1760,9 @@ def _model_flow_copilot_acp(config, current_model=""):
selected = _prompt_model_selection(
model_list,
current_model=normalized_current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=catalog_api_key,
)
else:
try:
@@ -1805,7 +1867,13 @@ def _model_flow_kimi(config, current_model=""):
model_list = _PROVIDER_MODELS.get("moonshot", [])
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
else:
try:
selected = input("Enter model name: ").strip()
@@ -1913,7 +1981,13 @@ def _model_flow_stepfun(config, current_model=""):
)
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
else:
try:
selected = input("Model name: ").strip()
@@ -1989,7 +2063,13 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
print(f" Showing {len(model_list)} curated models")
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="custom",
confirm_base_url=mantle_base_url,
confirm_api_key=existing_key,
)
else:
try:
selected = input(" Model ID: ").strip()
@@ -2178,7 +2258,12 @@ def _model_flow_bedrock(config, current_model=""):
# 4. Model selection
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="bedrock",
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
else:
try:
selected = input(" Model ID: ").strip()
@@ -2462,7 +2547,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
model_list = list(dict.fromkeys(mid for mid in model_list if mid))
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
else:
try:
selected = input("Model name: ").strip()
@@ -2560,20 +2651,13 @@ def _model_flow_anthropic(config, current_model=""):
elif cc_available:
print(" Claude Code credentials: ✓ (auto-detected)")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
choice = _prompt_auth_credentials_choice("Anthropic credentials:")
if choice == "2":
if choice == "reauth":
needs_auth = True
elif choice == "3":
elif choice == "cancel":
return
# choice == "1" or default: use existing, proceed to model selection
# choice == "use" or default: use existing, proceed to model selection
if needs_auth:
# Show auth method choice
@@ -2619,7 +2703,11 @@ def _model_flow_anthropic(config, current_model=""):
# Model selection
model_list = _PROVIDER_MODELS.get("anthropic", [])
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="anthropic",
)
else:
try:
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
+17 -1
View File
@@ -351,13 +351,29 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
"lobehub": 500, "browse-sh": 500,
}
with c.status("[bold]Fetching skills from registries..."):
with c.status("[bold]Fetching skills from registries...") as status:
# Live progress: tick off each source as it resolves so the wait is
# visible instead of a frozen spinner. parallel_search_sources invokes
# this callback from the collecting thread as each source completes;
# the page itself is still rendered once, after the correctly-merged
# and trust-sorted result set is final (browse's ordering contract is
# computed over the whole set, so we never render a half-sorted page).
_done: List[str] = []
def _on_source_done(sid: str, count: int) -> None:
_done.append(f"{sid} ({count})")
status.update(
"[bold]Fetching skills from registries...[/] "
f"[dim]done: {', '.join(_done)}[/]"
)
all_results, source_counts, timed_out = parallel_search_sources(
sources,
query="",
per_source_limits=_PER_SOURCE_LIMIT,
source_filter=source,
overall_timeout=30,
on_source_done=_on_source_done,
)
if not all_results:
+100 -48
View File
@@ -246,7 +246,31 @@ def _has_valid_session_token(request: Request) -> bool:
def _require_token(request: Request) -> None:
"""Validate the ephemeral session token. Raises 401 on mismatch."""
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
Two auth schemes protect the dashboard, exactly one active per bind:
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
Validate it here.
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
NOT injected (the SPA authenticates with a session cookie), so there is
no token to check. The ``gated_auth_middleware`` has already verified the
cookie before the request reached this handler any non-public ``/api/``
route it lets through carries a verified ``request.state.session``. The
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
the (absent) token here would 401 every cookie-authenticated request,
making plugin install/enable/disable and the other ``_require_token``
endpoints permanently unreachable behind the gate. Defer to the gate.
"""
if getattr(request.app.state, "auth_required", False):
# Gate is authoritative. It attaches ``request.state.session`` on
# success and 401s otherwise, so a request that reached us is already
# authenticated. Belt-and-braces: confirm the session is present.
if getattr(request.state, "session", None) is not None:
return
raise HTTPException(status_code=401, detail="Unauthorized")
if not _has_valid_session_token(request):
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -633,21 +657,6 @@ class AudioTranscriptionRequest(BaseModel):
mime_type: Optional[str] = None
class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.
scope="main" writes model.provider + model.default
scope="auxiliary" writes auxiliary.<task>.provider + auxiliary.<task>.model
scope="auxiliary" with task="" applied to every auxiliary.* slot
scope="auxiliary" with task="__reset__" resets every slot to provider="auto"
"""
scope: str
provider: str
model: str
task: str = ""
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac",
"audio/flac": ".flac",
@@ -689,6 +698,7 @@ class ModelAssignment(BaseModel):
# reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
# the path that actually wires a local endpoint into resolution.
base_url: str = ""
confirm_expensive_model: bool = False
def _apply_main_model_assignment(
@@ -1362,11 +1372,28 @@ def _tail_lines(path: Path, n: int) -> List[str]:
return lines[-n:] if n > 0 else lines
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
Multiple dashboard paths can request a restart in quick succession
(restart button double-click, or a stale cached frontend firing its own
restart after the server already auto-restarted post-onboarding). Two
concurrent ``hermes gateway restart`` children race each other on the
manual kill-and-start path, so reuse the live one instead.
Returns ``(proc, reused)``.
"""
existing = _ACTION_PROCS.get("gateway-restart")
if existing is not None and existing.poll() is None:
return existing, True
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
@app.post("/api/gateway/restart")
async def restart_gateway():
"""Kick off a ``hermes gateway restart`` in the background."""
try:
proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
proc, _reused = _spawn_gateway_restart()
except Exception as exc:
_log.exception("Failed to spawn gateway restart")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
@@ -2446,6 +2473,30 @@ async def set_model_assignment(body: ModelAssignment):
try:
cfg = load_config()
if model and not body.confirm_expensive_model:
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
warning = await asyncio.to_thread(
expensive_model_warning,
model,
provider=provider,
base_url=base_url,
)
except Exception:
warning = None
if warning is not None:
return {
"ok": False,
"scope": scope,
"provider": provider,
"model": model,
"confirm_required": True,
"confirm_message": warning.message,
}
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
@@ -3714,6 +3765,34 @@ async def get_telegram_onboarding_status(pairing_id: str):
)
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
"""Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the
saved token waits on a separate dashboard restart click, Hermes appears
broken from the chat side. Keep the config save authoritative, but report
restart failures so the UI can fall back to the existing manual banner.
"""
try:
proc, reused = _spawn_gateway_restart()
except Exception as exc:
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
return {
"restart_started": False,
"restart_error": str(exc),
}
if reused:
_log.info(
"Telegram onboarding: reusing in-flight gateway restart (pid %s)",
proc.pid,
)
return {
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": proc.pid,
}
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
@@ -3768,11 +3847,14 @@ async def apply_telegram_onboarding(
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding()
return {
"ok": True,
"platform": "telegram",
"bot_username": bot_username,
"needs_restart": True,
"needs_restart": not restart_result["restart_started"],
**restart_result,
}
@@ -10028,13 +10110,6 @@ def _mount_plugin_api_routes():
# Mount plugin API routes before the SPA catch-all.
_mount_plugin_api_routes()
# Mount the cross-process hook ingest router (POST /api/hooks/ingest +
# GET /api/hooks/health). These endpoints have their own bearer-token
# auth (independent of _SESSION_TOKEN / the OAuth gate) — see
# hermes_cli/hook_ingest.py and DESIGN-cross-process-hooks.md.
from hermes_cli.hook_ingest import build_hook_router as _build_hook_router # noqa: E402
app.include_router(_build_hook_router(), prefix="/api/hooks")
# Mount the dashboard auth routes (/login, /auth/*, /api/auth/*) before the
# SPA catch-all so /{full_path:path} doesn't swallow them. These are
# always mounted — the gate middleware decides whether to enforce auth,
@@ -10125,29 +10200,6 @@ def start_server(
app.state.bound_host = host
app.state.bound_port = port
# Cross-process hook delivery: drop the discovery file so source
# processes (gateway, TUI, subagents) can find this dashboard and
# authenticate POSTs to /api/hooks/ingest. The file is removed via
# atexit so a clean shutdown stops orphan forwarders from trying to
# POST to a port that's now closed.
import atexit
from hermes_cli.hook_ingest import (
remove_dashboard_discovery_file,
write_dashboard_discovery_file,
)
try:
write_dashboard_discovery_file(host, port)
atexit.register(remove_dashboard_discovery_file)
except OSError as exc:
# Non-fatal — the dashboard still works without cross-process
# hook delivery. Warn so the operator knows the orb (or any
# other event-driven plugin) won't see gateway/TUI events.
_log.warning(
"Failed to write hook discovery file: %s. Cross-process hook "
"delivery to dashboard plugins will be unavailable.",
exc,
)
if open_browser:
import webbrowser
+33 -21
View File
@@ -20,7 +20,10 @@ from typing import List, Optional
from tools import write_approval as wa
_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)
def _fmt_state(subsystem: str) -> str:
on = wa.write_approval_enabled(subsystem)
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
# ---------------------------------------------------------------------------
@@ -63,18 +66,17 @@ def handle_pending_subcommand(
memory_store: live MemoryStore for applying approved memory writes
(CLI passes ``self.agent._memory_store``; gateway applies against a
freshly loaded store).
set_mode_fn: optional callable ``(mode: str) -> None`` that persists the
new write_mode to config (gateway provides this; CLI uses its own
``save_config_value`` and passes a closure).
set_mode_fn: optional callable ``(enabled: bool) -> None`` that
persists the new write_approval boolean to config (gateway provides
this; CLI uses its own ``save_config_value`` and passes a closure).
Returns a text string to show the user. Returns None when the args are not
a write-approval subcommand (caller falls through to its other handling,
e.g. /skills search).
"""
if not args:
# Bare /memory or /skills with no sub → show pending + current mode.
mode = wa.get_write_mode(subsystem)
return f"{subsystem}.write_mode = {mode}\n\n" + _fmt_pending_list(subsystem)
# Bare /memory or /skills with no sub → show pending + gate state.
return f"{_fmt_state(subsystem)}\n\n" + _fmt_pending_list(subsystem)
sub = args[0].lower()
rest = args[1:]
@@ -91,8 +93,8 @@ def handle_pending_subcommand(
if sub == "diff" and subsystem == wa.SKILLS:
return _diff(rest)
if sub == "mode":
return _set_mode(subsystem, rest, set_mode_fn)
if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias
return _set_approval(subsystem, rest, set_mode_fn)
return None # not ours — caller handles
@@ -179,19 +181,29 @@ def _diff(rest: List[str]) -> str:
return header + "\n" + diff
def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:
def _set_approval(subsystem: str, rest: List[str], set_mode_fn) -> str:
"""Turn the approval gate on/off for a subsystem.
``set_mode_fn`` (when provided) persists the new boolean to config.
"""
if not rest:
cur = wa.get_write_mode(subsystem)
return (f"{subsystem}.write_mode = {cur}\n"
f"Set with: /{subsystem} mode <on|off|approve>")
mode = rest[0].lower()
if mode not in _VALID_MODES:
return f"Invalid mode '{mode}'. Use: on, off, approve."
return (f"{_fmt_state(subsystem)}\n"
f"Set with: /{subsystem} approval <on|off>")
arg = rest[0].strip().lower()
truthy = {"on", "true", "yes", "1", "enable", "enabled"}
falsey = {"off", "false", "no", "0", "disable", "disabled"}
if arg in truthy:
enabled = True
elif arg in falsey:
enabled = False
else:
return f"Invalid value '{arg}'. Use: on or off."
if set_mode_fn is None:
return (f"To change {subsystem} write mode, run:\n"
f" hermes config set {subsystem}.write_mode {mode}")
val = "true" if enabled else "false"
return (f"To change the {subsystem} approval gate, run:\n"
f" hermes config set {subsystem}.write_approval {val}")
try:
set_mode_fn(mode)
set_mode_fn(enabled)
except Exception as e:
return f"Failed to set {subsystem}.write_mode: {e}"
return f"{subsystem}.write_mode set to '{mode}'."
return f"Failed to set {subsystem}.write_approval: {e}"
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."
+23 -3
View File
@@ -116,6 +116,8 @@ class OpenRouterProfile(ProviderProfile):
the same backend server across turns.
"""
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
extra_headers: dict[str, Any] = {}
if supports_reasoning:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
@@ -132,18 +134,36 @@ class OpenRouterProfile(ProviderProfile):
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
#
# ``reasoning.effort`` being ignored does NOT mean these models have
# no effort lever — OpenRouter honors the requested effort on the
# top-level ``verbosity`` field instead (it maps to Anthropic's
# ``output_config.effort``; ``reasoning.effort`` is accepted but
# ignored — confirmed by OpenRouter's Claude migration docs and a
# live token-spend probe in hermes-agent#43432). Route the existing
# ``reasoning_config["effort"]`` (sourced from
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
# already sets keeps working for these models. We still send NO
# ``reasoning`` field, preserving the #42991 400 fix.
if _anthropic_reasoning_is_mandatory(model):
pass # omit reasoning entirely → adaptive default
cfg = reasoning_config or {}
effort = cfg.get("effort")
# Only emit when effort is actually requested and reasoning
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
# model keeps its own adaptive default (``high``).
if cfg.get("enabled", True) is not False and effort and effort != "none":
top_level["verbosity"] = effort
elif reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
extra_headers: dict[str, Any] = {}
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
extra_headers["x-grok-conv-id"] = session_id
if extra_headers:
top_level["extra_headers"] = extra_headers
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
return extra_body, top_level
openrouter = OpenRouterProfile(
+24 -1
View File
@@ -227,7 +227,30 @@ def _trace_key(task_id: str, session_id: str) -> str:
return f"thread:{threading.get_ident()}"
def _truncate_text(value: str, max_chars: int) -> str:
def _is_base64_data_uri(value: str) -> bool:
prefix = value[:200].lower()
return prefix.startswith("data:") and ";base64," in prefix
def _redact_data_uri(value: str) -> dict[str, Any]:
header = value.split(",", 1)[0] if "," in value else "data:"
media_type = header[5:].split(";", 1)[0] if header.startswith("data:") else ""
return {
"type": "data_uri",
"media_type": media_type or None,
"omitted": True,
"length": len(value),
}
def _truncate_text(value: str, max_chars: int) -> Any:
# Langfuse SDK treats data:*;base64 strings as media and attempts to
# decode them. Truncating those strings produces invalid base64 and noisy
# "Error parsing base64 data URI" logs. Observability only needs metadata,
# not raw image/audio payloads, so redact the whole data URI before it
# reaches the SDK.
if _is_base64_data_uri(value):
return _redact_data_uri(value)
if len(value) <= max_chars:
return value
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
+114 -4
View File
@@ -602,6 +602,11 @@ 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
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
# linked text-channel id; set by run.py. Lets the inactivity timer leave
# the bot in the channel when the user deliberately picked text-only
# (/voice off) instead of leaving (/voice leave).
self._voice_mode_getter: 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.
@@ -789,6 +794,7 @@ class DiscordAdapter(BasePlatformAdapter):
# Must run BEFORE the user allowlist check so that bots
# permitted by DISCORD_ALLOW_BOTS are not rejected for
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
_role_authorized = False
if getattr(message.author, "bot", False):
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
if allow_bots == "none":
@@ -812,6 +818,7 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=_is_dm,
):
return
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
# Multi-agent filtering: if the message mentions specific bots
# but NOT this bot, the sender is talking to another agent —
@@ -853,7 +860,7 @@ class DiscordAdapter(BasePlatformAdapter):
if "*" not in _free_channels and not (_channel_ids & _free_channels):
return
await self._handle_message(message)
await self._handle_message(message, role_authorized=_role_authorized)
@self._client.event
async def on_voice_state_update(member, before, after):
@@ -2265,6 +2272,20 @@ class DiscordAdapter(BasePlatformAdapter):
except asyncio.CancelledError:
return
text_ch_id = self._voice_text_channels.get(guild_id)
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
# the channel (leaving is ``/voice leave``). The inactivity timer only
# counts the bot's OWN audio as activity, so under voice-off mode it
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
# text channel with "Left voice channel (inactivity timeout)." Honor the
# user's choice: skip the auto-disconnect while voice replies are off.
# (The timer re-arms when the bot next speaks or hears a user.)
_mode_getter = getattr(self, "_voice_mode_getter", None)
if text_ch_id is not None and _mode_getter is not None:
try:
if _mode_getter(str(text_ch_id)) == "off":
return
except Exception:
pass
await self.leave_voice_channel(guild_id)
# Notify the runner so it can clean up voice_mode state
if self._on_voice_disconnect and text_ch_id:
@@ -2395,6 +2416,11 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=False,
):
continue
# A user speaking to the bot is activity too — not just the
# bot's own playback. Reset the inactivity timer so an active
# listener isn't disconnected mid-conversation (this also
# covers voice-on text-only sessions that never play audio).
self._reset_voice_timeout(guild_id)
await self._process_voice_input(guild_id, user_id, pcm_data)
except asyncio.CancelledError:
pass
@@ -4702,7 +4728,7 @@ class DiscordAdapter(BasePlatformAdapter):
raise Exception(f"HTTP {resp.status}")
return await resp.read()
async def _handle_message(self, message: DiscordMessage) -> None:
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
"""Handle incoming Discord messages."""
# In server channels (not DMs), require the bot to be @mentioned
# UNLESS the channel is in the free-response list or the message is
@@ -4886,6 +4912,7 @@ class DiscordAdapter(BasePlatformAdapter):
guild_id=str(guild.id) if guild else None,
parent_chat_id=parent_channel_id,
message_id=str(message.id),
role_authorized=role_authorized,
)
# Build media URLs -- download image attachments to local cache so the
@@ -5611,6 +5638,7 @@ def _define_discord_view_classes() -> None:
self.allowed_role_ids = allowed_role_ids or set()
self.resolved = False
self._selected_provider: str = ""
self._pending_expensive_model: str = ""
self._build_provider_select()
@@ -5693,6 +5721,41 @@ def _define_discord_view_classes() -> None:
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
def _build_expensive_confirm(self, model_id: str):
"""Build confirmation buttons for unusually expensive models."""
self.clear_items()
self._pending_expensive_model = model_id
confirm_btn = discord.ui.Button(
label="Switch anyway",
style=discord.ButtonStyle.red,
custom_id="model_expensive_confirm",
)
confirm_btn.callback = self._on_expensive_confirm
self.add_item(confirm_btn)
cancel_btn = discord.ui.Button(
label="Cancel",
style=discord.ButtonStyle.grey,
custom_id="model_expensive_cancel",
)
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
async def _expensive_warning_for(self, model_id: str):
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
return await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=self._selected_provider,
)
except Exception:
return None
async def _on_provider_selected(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
@@ -5722,7 +5785,11 @@ def _define_discord_view_classes() -> None:
view=self,
)
async def _on_model_selected(self, interaction: discord.Interaction):
async def _switch_selected_model(
self,
interaction: discord.Interaction,
model_id: str,
):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
@@ -5735,7 +5802,6 @@ def _define_discord_view_classes() -> None:
return
self.resolved = True
model_id = interaction.data["values"][0]
self.clear_items()
await interaction.response.edit_message(
embed=discord.Embed(
@@ -5764,6 +5830,50 @@ def _define_discord_view_classes() -> None:
view=None,
)
async def _on_model_selected(self, interaction: discord.Interaction):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
model_id = interaction.data["values"][0]
warning = await self._expensive_warning_for(model_id)
if warning is not None:
self._build_expensive_confirm(model_id)
await interaction.response.edit_message(
embed=discord.Embed(
title="⚠ Expensive Model Warning",
description=warning.message,
color=discord.Color.red(),
),
view=self,
)
return
await self._switch_selected_model(interaction, model_id)
async def _on_expensive_confirm(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
if not self._pending_expensive_model:
await interaction.response.send_message(
"Model selection expired.", ephemeral=True
)
return
await self._switch_selected_model(
interaction,
self._pending_expensive_model,
)
async def _on_back(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
+17 -6
View File
@@ -196,7 +196,7 @@ from agent.tool_dispatch_helpers import (
_extract_error_preview,
_trajectory_normalize_msg, # noqa: F401 # re-exported for tests that `from run_agent import _trajectory_normalize_msg`
)
from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value
from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value, model_forces_max_completion_tokens
@@ -1253,13 +1253,24 @@ class AIAgent:
def _max_tokens_param(self, value: int) -> dict:
"""Return the correct max tokens kwarg for the current provider.
OpenAI's newer models (gpt-4o, o-series, gpt-5+) require
'max_completion_tokens'. Azure OpenAI also requires
'max_completion_tokens' for gpt-5.x models served via the
OpenAI-compatible endpoint. OpenRouter, local models, and older
OpenAI's newer models (gpt-4o, gpt-4.1, gpt-5+, o-series) require
'max_completion_tokens'. Azure OpenAI and GitHub Copilot also require
'max_completion_tokens' for those families served via their
OpenAI-compatible endpoints. OpenRouter, local models, and older
OpenAI models use 'max_tokens'.
The check is URL-first (api.openai.com / Azure / Copilot all use the
new kwarg), then falls back to a model-name check so third-party
OpenAI-compatible endpoints fronting those models are recognised
URL-only detection misses that case and silently sends the wrong
kwarg, which the upstream model rejects with a 400.
"""
if self._is_direct_openai_url() or self._is_azure_openai_url() or self._is_github_copilot_url():
if (
self._is_direct_openai_url()
or self._is_azure_openai_url()
or self._is_github_copilot_url()
or model_forces_max_completion_tokens(self.model)
):
return {"max_completion_tokens": value}
return {"max_tokens": value}
+5
View File
@@ -45,6 +45,9 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"barronlroth@gmail.com": "barronlroth",
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
"tomasz.panek@gmail.com": "tomekpanek",
"philipadsouza@gmail.com": "PhilipAD",
"zhuhaoyu0909@icloud.com": "underthestars-zhy",
"raysun12142006@gmail.com": "yanxue06",
@@ -1039,6 +1042,7 @@ AUTHOR_MAP = {
"zhang9w0v5@qq.com": "zhang9w0v5",
"fuleinist@outlook.com": "fuleinist",
"43494187+Llugaes@users.noreply.github.com": "Llugaes",
"xiangji.chen@centurygame.com": "Llugaes",
"fengtianyu88@users.noreply.github.com": "fengtianyu88",
"l.moncany@gmail.com": "lmoncany",
"fatinghenji@users.noreply.github.com": "fatinghenji",
@@ -1504,6 +1508,7 @@ AUTHOR_MAP = {
"singhsanidhya741@gmail.com": "sanidhyasin", # PR #40403 salvage (model.default_headers for custom OpenAI-compatible providers, #40033)
"josephjohnson.joel@gmail.com": "JoelJJohnson", # PR #39913 salvage (Windows ConPTY dashboard chat bridge)
"andreas@schwarz-ketsch.de": "Nea74", # PR #40022 co-author credit (same Windows ConPTY bridge design)
"chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)
}
+79
View File
@@ -3791,3 +3791,82 @@ class TestAuxUnhealthyCache:
)
# After the 402, OpenRouter is in the unhealthy cache.
assert _is_provider_unhealthy("openrouter") is True
# ── auxiliary_max_tokens_param ──────────────────────────────────────────────
class TestAuxiliaryMaxTokensParam:
"""Verify the kwarg emitted by ``auxiliary_max_tokens_param`` across
URL / provider / model-name combinations. Regression cover: a custom
OpenAI-compatible endpoint serving ``gpt-5.x`` was silently getting
``max_tokens`` and 400-ing on ``unsupported_parameter``."""
def test_direct_openai_returns_max_completion_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://api.openai.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_completion_tokens": 4096}
def test_local_endpoint_without_model_uses_max_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="http://localhost:11434/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
def test_openrouter_api_key_present_keeps_max_tokens_without_model_hint(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://openrouter.ai/api/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
# Model-name fallback — this is the regression guard.
def test_custom_endpoint_serving_gpt5_uses_max_completion_tokens(self):
"""Third-party gateway + gpt-5.x: name-based detection must kick in."""
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="gpt-5.4") == {
"max_completion_tokens": 4096
}
def test_openrouter_serving_gpt4o_uses_max_completion_tokens(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://openrouter.ai/api/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="openai/gpt-4o-mini") == {
"max_completion_tokens": 4096
}
def test_custom_endpoint_serving_classic_llama_keeps_max_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="llama3-70b") == {
"max_tokens": 4096
}
def test_empty_model_falls_back_to_url_only(self):
"""No model hint → only the URL-based rule applies."""
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="") == {"max_tokens": 4096}
assert auxiliary_max_tokens_param(4096, model=None) == {"max_tokens": 4096}
+2 -2
View File
@@ -668,8 +668,8 @@ def test_state_atomic_write_no_tmp_leftovers(curator_env):
c = curator_env["curator"]
c.save_state({"paused": True})
parent = c._state_file().parent
for p in parent.iterdir():
assert not p.name.startswith(".curator_state_"), f"tmp leftover: {p.name}"
tmp_files = [p.name for p in parent.iterdir() if p.name.endswith(".tmp")]
assert tmp_files == []
def test_state_preserves_last_report_path(curator_env):
+51
View File
@@ -964,6 +964,57 @@ class TestClassifyApiError:
assert result.reason == FailoverReason.format_error
assert result.retryable is False
def test_400_unsupported_max_tokens_param_not_context_overflow(self):
"""A GPT-5 model rejecting max_tokens must NOT be misclassified as
context overflow. The OpenAI error string contains the literal
'max_tokens' (a _CONTEXT_OVERFLOW_PATTERNS entry), so without the
request-validation guard it was routed into the compression loop,
re-sent with the same bad param, and ended in "Cannot compress
further". Regression for gpt-5-context-overflow-misclassification."""
msg = ("Unsupported parameter: 'max_tokens' is not supported with this "
"model. Use 'max_completion_tokens' instead.")
e = MockAPIError(
msg,
status_code=400,
body={"error": {"message": msg, "type": "invalid_request_error",
"code": "unsupported_parameter"}},
)
# Tiny context against a huge window — definitely not a real overflow.
result = classify_api_error(e, model="gpt-5.4",
approx_tokens=6962, context_length=1050000)
assert result.reason == FailoverReason.format_error
assert result.retryable is False
assert result.should_compress is False
def test_400_unknown_parameter_not_context_overflow(self):
"""'Unknown parameter' 400s are deterministic request-validation
failures, not overflows."""
e = MockAPIError(
"Unknown parameter: 'foo'.",
status_code=400,
body={"error": {"message": "Unknown parameter: 'foo'.",
"code": "unknown_parameter"}},
)
result = classify_api_error(e, approx_tokens=1000)
assert result.reason == FailoverReason.format_error
assert result.should_compress is False
def test_400_real_overflow_with_invalid_request_error_code_still_compresses(self):
"""Guard the guard: OpenAI stamps genuine context-overflow 400s with
the generic 'invalid_request_error' code. The request-validation guard
must NOT key off that code, or real overflows stop compressing."""
msg = ("This model's maximum context length is 128000 tokens, however "
"you requested 150000 tokens.")
e = MockAPIError(
msg,
status_code=400,
body={"error": {"message": msg, "type": "invalid_request_error"}},
)
result = classify_api_error(e, model="gpt-5.4",
approx_tokens=150000, context_length=128000)
assert result.reason == FailoverReason.context_overflow
assert result.should_compress is True
def test_422_format_error(self):
e = MockAPIError("Unprocessable Entity", status_code=422)
result = classify_api_error(e)
+26
View File
@@ -192,6 +192,32 @@ def test_custom_endpoint_models_api_pricing_is_supported(monkeypatch):
assert float(entry.output_cost_per_million) == 2.0
def test_nous_portal_pricing_preserves_vendor_prefixed_model_ids(monkeypatch):
seen = {}
def _fake_fetch_endpoint_model_metadata(base_url, api_key=None):
seen["base_url"] = base_url
return {
"openai/gpt-5.5-pro": {
"pricing": {
"prompt": "0.000025",
"completion": "0.000125",
}
}
}
monkeypatch.setattr(
"agent.usage_pricing.fetch_endpoint_model_metadata",
_fake_fetch_endpoint_model_metadata,
)
entry = get_pricing_entry("openai/gpt-5.5-pro", provider="nous")
assert seen["base_url"] == "https://inference-api.nousresearch.com/v1"
assert float(entry.input_cost_per_million) == 25.0
assert float(entry.output_cost_per_million) == 125.0
def test_deepseek_v4_pro_pricing_entry_exists():
"""Regression test: deepseek-v4-pro must have a pricing entry.
@@ -5,6 +5,12 @@ import pytest
import cli
@pytest.fixture(autouse=True)
def reset_single_query_finalize_state(monkeypatch):
monkeypatch.setattr(cli, "_single_query_finalize_attempted_session_ids", set())
monkeypatch.setattr(cli, "_cleanup_done", False)
def test_finalize_single_query_runs_cleanup_without_reemitting_finalize_before_release(monkeypatch):
calls = []
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append(("release", {})))
@@ -70,6 +76,54 @@ def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch
assert calls == ["finalize", "cleanup", "release"]
def test_finalize_single_query_signal_window_does_not_reemit_during_atexit(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(
agent=fake_agent,
session_id="cli-session",
_release_active_session=lambda: calls.append(("release", {})),
)
def invoke_hook(name, **kwargs):
calls.append((name, kwargs))
def interrupted_cleanup(**_kwargs):
raise KeyboardInterrupt()
expected_finalize = (
"on_session_finalize",
{
"session_id": "agent-session",
"platform": "cli",
"reason": "shutdown",
},
)
original_run_cleanup = cli._run_cleanup
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
monkeypatch.setattr(cli, "_run_cleanup", interrupted_cleanup)
with pytest.raises(KeyboardInterrupt):
cli._finalize_single_query(fake_cli)
assert calls == [expected_finalize, ("release", {})]
# Simulate later atexit cleanup after the interrupted one-shot path. The
# active agent may already be unavailable by then.
monkeypatch.setattr(cli, "_run_cleanup", original_run_cleanup)
monkeypatch.setattr(cli, "_active_agent_ref", None)
monkeypatch.setattr(cli, "_reset_terminal_input_modes_on_exit", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_terminals", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_browsers", lambda: None)
monkeypatch.setattr("tools.mcp_tool.shutdown_mcp_servers", lambda: None)
monkeypatch.setattr("agent.auxiliary_client.shutdown_cached_clients", lambda: None)
cli._run_cleanup()
assert calls == [expected_finalize, ("release", {})]
def test_notify_single_query_session_finalize_uses_agent_session(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
@@ -80,3 +80,91 @@ async def test_model_picker_clears_controls_before_running_switch_callback():
interaction.response.edit_message.assert_awaited_once()
interaction.response.defer.assert_not_called()
interaction.edit_original_response.assert_awaited_once()
@pytest.mark.asyncio
async def test_expensive_model_requires_confirmation(monkeypatch):
events: list[object] = []
async def on_model_selected(chat_id: str, model_id: str, provider_slug: str) -> str:
events.append(("switch", chat_id, model_id, provider_slug))
return "Model switched"
async def edit_message(**kwargs):
events.append(
(
"edit",
kwargs["embed"].title,
kwargs["embed"].description,
kwargs["view"],
)
)
async def edit_original_response(**kwargs):
events.append((
"final-edit",
kwargs["embed"].title,
kwargs["embed"].description,
kwargs["view"],
))
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(
message="!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?"
),
)
view = ModelPickerView(
providers=[
{
"slug": "openrouter",
"name": "OpenRouter",
"models": ["openai/gpt-5.5-pro"],
"total_models": 1,
"is_current": True,
}
],
current_model="openai/gpt-5.5",
current_provider="openrouter",
session_key="session-1",
on_model_selected=on_model_selected,
allowed_user_ids={"123"}, # matches the interaction user; empty = fail-closed
)
view._selected_provider = "openrouter"
interaction = SimpleNamespace(
user=SimpleNamespace(id=123),
channel_id=456,
data={"values": ["openai/gpt-5.5-pro"]},
response=SimpleNamespace(
send_message=AsyncMock(),
edit_message=AsyncMock(side_effect=edit_message),
),
edit_original_response=AsyncMock(side_effect=edit_original_response),
)
await view._on_model_selected(interaction)
assert events == [
(
"edit",
"⚠ Expensive Model Warning",
"!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?",
view,
),
]
assert view.resolved is False
await view._on_expensive_confirm(interaction)
assert events[1:] == [
(
"edit",
"⚙ Switching Model",
"Switching to `openai/gpt-5.5-pro`...",
None,
),
("switch", "456", "openai/gpt-5.5-pro", "openrouter"),
("final-edit", "⚙ Model Switched", "Model switched", None),
]
-405
View File
@@ -1,405 +0,0 @@
"""Unit tests for ``gateway/hook_forwarder.py``.
Covers:
* No-op behavior when discovery file absent / env var disables
* Idempotent ``start_if_dashboard_available`` (repeat calls re-use same forwarder)
* Handler registration on every forwarded namespace
* Loop prevention via ``_forwarded`` context flag
* Queue overflow drops oldest, enqueues newest
* ``stop()`` and ``_reset_for_tests`` are clean and idempotent
* Error-log rate limiting
The HTTP-POST path is exercised in the integration test
(``test_hook_forwarder_integration.py``) where a real FastAPI app
stands in for the dashboard. Unit tests here focus on the
registry-side behaviors that don't need an HTTP server.
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from gateway import hook_forwarder
from gateway.hooks import HookRegistry
@pytest.fixture(autouse=True)
def _clean_forwarder():
"""Reset forwarder state + env between tests so they're independent."""
hook_forwarder._reset_for_tests()
# Make sure HERMES_HOOK_FORWARDER isn't sticky from a previous test.
saved = os.environ.pop("HERMES_HOOK_FORWARDER", None)
yield
hook_forwarder._reset_for_tests()
if saved is not None:
os.environ["HERMES_HOOK_FORWARDER"] = saved
def _write_discovery(tmp_path: Path, *, url: str = "http://127.0.0.1:9119") -> Path:
"""Write a valid dashboard.json into tmp_path and return its path."""
discovery = tmp_path / "dashboard.json"
discovery.write_text(
json.dumps(
{
"url": url,
"hooks_ingest_token": "test-token-abc",
"pid": 99999,
"started_at": "2026-05-29T12:00:00Z",
}
)
)
return discovery
# ---------------------------------------------------------------------------
# No-op paths
# ---------------------------------------------------------------------------
class TestStartIfDashboardAvailable:
def test_no_dashboard_json_returns_none(self, tmp_path, monkeypatch):
"""Missing discovery file ⇒ forwarder doesn't start."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
reg = HookRegistry()
result = hook_forwarder.start_if_dashboard_available(reg, src="gateway")
assert result is None
assert not hook_forwarder.is_active()
# No handlers registered on the registry.
assert reg._handlers == {}
def test_env_disabled_returns_none_even_with_dashboard(
self, tmp_path, monkeypatch
):
"""``HERMES_HOOK_FORWARDER=0`` short-circuits even when discovery
is otherwise valid."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
monkeypatch.setenv("HERMES_HOOK_FORWARDER", "0")
reg = HookRegistry()
result = hook_forwarder.start_if_dashboard_available(reg, src="gateway")
assert result is None
assert reg._handlers == {}
@pytest.mark.parametrize("value", ["0", "false", "FALSE", "No", "off"])
def test_env_disabled_accepts_common_falsy_values(
self, tmp_path, monkeypatch, value
):
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
monkeypatch.setenv("HERMES_HOOK_FORWARDER", value)
reg = HookRegistry()
assert hook_forwarder.start_if_dashboard_available(reg, src="gateway") is None
def test_malformed_discovery_json_returns_none(self, tmp_path, monkeypatch):
"""Garbage in ``dashboard.json`` ⇒ forwarder doesn't start."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
(tmp_path / "dashboard.json").write_text("{not json")
reg = HookRegistry()
assert hook_forwarder.start_if_dashboard_available(reg, src="gateway") is None
def test_discovery_missing_required_keys_returns_none(
self, tmp_path, monkeypatch
):
"""``dashboard.json`` without ``url`` or ``hooks_ingest_token`` ⇒
no forwarder."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
(tmp_path / "dashboard.json").write_text(
json.dumps({"pid": 123}) # missing url + token
)
reg = HookRegistry()
assert hook_forwarder.start_if_dashboard_available(reg, src="gateway") is None
def test_repeated_calls_are_idempotent(self, tmp_path, monkeypatch):
"""Two ``start_if_dashboard_available`` in the same process re-use
the same forwarder instance and don't double-register handlers."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
reg = HookRegistry()
first = hook_forwarder.start_if_dashboard_available(reg, src="gateway")
try:
second = hook_forwarder.start_if_dashboard_available(reg, src="tui")
assert first is not None
assert second is first # same instance
# And handlers registered exactly once per namespace.
for pattern in hook_forwarder._FORWARDED_NAMESPACES:
assert len(reg._handlers[pattern]) == 1
finally:
hook_forwarder.stop()
# ---------------------------------------------------------------------------
# Registration coverage
# ---------------------------------------------------------------------------
class TestRegistration:
def test_registers_every_forwarded_namespace(self, tmp_path, monkeypatch):
"""Forwarder subscribes to all five canonical namespaces."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
reg = HookRegistry()
try:
hook_forwarder.start_if_dashboard_available(reg, src="gateway")
for pattern in (
"tui:*",
"agent:*",
"session:*",
"command:*",
"gateway:*",
):
assert pattern in reg._handlers
assert len(reg._handlers[pattern]) == 1
finally:
hook_forwarder.stop()
def test_stop_unregisters_all_handlers(self, tmp_path, monkeypatch):
"""``stop()`` removes every handler the forwarder installed."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
reg = HookRegistry()
hook_forwarder.start_if_dashboard_available(reg, src="gateway")
for pattern in hook_forwarder._FORWARDED_NAMESPACES:
assert pattern in reg._handlers
hook_forwarder.stop()
for pattern in hook_forwarder._FORWARDED_NAMESPACES:
assert reg._handlers.get(pattern, []) == []
def test_stop_is_idempotent(self, tmp_path, monkeypatch):
"""Calling ``stop()`` twice doesn't raise."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
reg = HookRegistry()
hook_forwarder.start_if_dashboard_available(reg, src="gateway")
hook_forwarder.stop()
hook_forwarder.stop() # second call is no-op
# ---------------------------------------------------------------------------
# Handler behavior — loop prevention + queue management
# ---------------------------------------------------------------------------
class TestHandlerBehavior:
"""Tests the handler in isolation — skipping the worker thread by
constructing the ``_HookForwarder`` directly so we can inspect the
queue without racing on a daemon thread."""
def test_handler_enqueues_event(self):
fwd = hook_forwarder._HookForwarder(src="gateway")
fwd._handler("agent:start", {"platform": "telegram", "user_id": "u-1"})
assert fwd._queue.qsize() == 1
frame = fwd._queue.get_nowait()
assert frame == {
"event_type": "agent:start",
"context": {"platform": "telegram", "user_id": "u-1"},
"src": "gateway",
}
def test_handler_skips_forwarded_events(self):
"""The ``_forwarded=True`` flag closes the source ↔ dashboard loop."""
fwd = hook_forwarder._HookForwarder(src="gateway")
# This came from the ingest endpoint; must not be shipped back.
fwd._handler(
"agent:start",
{"platform": "telegram", "_forwarded": True, "_forwarded_from": "gateway"},
)
assert fwd._queue.qsize() == 0
def test_handler_does_not_skip_explicit_false_forwarded(self):
"""Only the literal ``True`` triggers loop prevention; a ``False``
or absent flag is fine."""
fwd = hook_forwarder._HookForwarder(src="gateway")
fwd._handler("agent:start", {"_forwarded": False})
fwd._handler("agent:start", {"_forwarded": None})
assert fwd._queue.qsize() == 2
def test_handler_drops_oldest_on_queue_full(self):
"""At ``_QUEUE_MAX`` capacity, oldest frame is evicted to make
room for newest."""
fwd = hook_forwarder._HookForwarder(src="gateway")
# Replace the queue with a tiny one so we can exercise overflow
# without filling 1024 slots.
from queue import Queue as _Queue
fwd._queue = _Queue(maxsize=3)
for i in range(5):
fwd._handler("agent:step", {"iteration": i})
# Only the last 3 should survive (iterations 2, 3, 4).
iterations = []
while not fwd._queue.empty():
iterations.append(fwd._queue.get_nowait()["context"]["iteration"])
assert iterations == [2, 3, 4]
def test_handler_never_raises_on_pathological_queue(self):
"""Even if the queue is wedged (both put_nowait calls fail), the
handler returns cleanly never propagates to the publisher."""
fwd = hook_forwarder._HookForwarder(src="gateway")
from queue import Queue as _Queue, Full as _Full
# Build a queue stub whose put_nowait always raises Full.
class _StubbedFull(_Queue):
def __init__(self):
super().__init__(maxsize=1)
def put_nowait(self, item):
raise _Full()
def get_nowait(self):
from queue import Empty
raise Empty()
fwd._queue = _StubbedFull() # type: ignore[assignment]
# Should not raise.
fwd._handler("agent:step", {"iteration": 1})
# ---------------------------------------------------------------------------
# Discovery refresh / probe behavior — exercised without starting the worker
# ---------------------------------------------------------------------------
class TestDiscoveryRefresh:
def test_refresh_picks_up_new_token(self, tmp_path, monkeypatch):
"""A dashboard restart writes a new token; the next probe must
pick it up."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
discovery_path = tmp_path / "dashboard.json"
discovery_path.write_text(
json.dumps({"url": "http://127.0.0.1:9119", "hooks_ingest_token": "first"})
)
fwd = hook_forwarder._HookForwarder(src="gateway")
# Stub the http client's get() to always return 200 — the
# discovery file is what we want to test here, not the probe.
class _Stub200:
status_code = 200
class _StubClient:
def get(self, *a, **kw):
return _Stub200()
fwd._refresh_discovery(_StubClient())
assert fwd._discovery is not None
assert fwd._discovery["hooks_ingest_token"] == "first"
# Now the dashboard "restarts" with a new token.
discovery_path.write_text(
json.dumps({"url": "http://127.0.0.1:9119", "hooks_ingest_token": "second"})
)
fwd._refresh_discovery(_StubClient())
assert fwd._discovery["hooks_ingest_token"] == "second"
def test_refresh_invalidates_on_probe_failure(self, tmp_path, monkeypatch):
"""If the health probe fails, discovery is cleared even though
the file still exists."""
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
fwd = hook_forwarder._HookForwarder(src="gateway")
# First probe succeeds.
class _Stub200:
status_code = 200
class _OkClient:
def get(self, *a, **kw):
return _Stub200()
fwd._refresh_discovery(_OkClient())
assert fwd._discovery is not None
# Now make the probe fail — connection refused, dashboard gone.
class _FailingClient:
def get(self, *a, **kw):
raise ConnectionError("refused")
fwd._refresh_discovery(_FailingClient())
assert fwd._discovery is None
def test_refresh_invalidates_on_non_200_response(self, tmp_path, monkeypatch):
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
_write_discovery(tmp_path)
fwd = hook_forwarder._HookForwarder(src="gateway")
class _Stub503:
status_code = 503
class _BadClient:
def get(self, *a, **kw):
return _Stub503()
fwd._refresh_discovery(_BadClient())
assert fwd._discovery is None
# ---------------------------------------------------------------------------
# Error-log rate limiting
# ---------------------------------------------------------------------------
class TestErrorRateLimit:
def test_first_error_logs_immediately(self, caplog):
fwd = hook_forwarder._HookForwarder(src="gateway")
caplog.set_level("WARNING", logger="gateway.hook_forwarder")
fwd._log_error("first error")
assert any("first error" in r.message for r in caplog.records)
def test_subsequent_errors_suppressed_until_window_elapses(self, caplog):
fwd = hook_forwarder._HookForwarder(src="gateway")
caplog.set_level("WARNING", logger="gateway.hook_forwarder")
fwd._log_error("first error")
before = len(caplog.records)
# Many follow-up errors within the same minute — all suppressed.
for _ in range(10):
fwd._log_error("nth error")
assert len(caplog.records) == before
def test_suppression_count_surfaces_on_next_log(self, caplog, monkeypatch):
fwd = hook_forwarder._HookForwarder(src="gateway")
caplog.set_level("WARNING", logger="gateway.hook_forwarder")
fwd._log_error("first error")
for _ in range(5):
fwd._log_error("suppressed")
# Simulate the rate-limit window elapsing.
fwd._last_error_log_at = time.monotonic() - hook_forwarder._ERROR_LOG_INTERVAL_S - 1
fwd._log_error("after window")
# Last record should include the "5 similar errors suppressed" suffix.
last = caplog.records[-1].message
assert "after window" in last
assert "5 similar errors suppressed" in last
-253
View File
@@ -314,256 +314,3 @@ class TestEmitCollect:
await reg.emit_collect("agent:start") # no context arg
assert captured == [("agent:start", {})]
class TestRegister:
"""Tests for the programmatic ``HookRegistry.register`` API."""
def test_registers_handler(self):
reg = HookRegistry()
calls: list = []
def handler(event_type, context):
calls.append((event_type, context))
reg.register("agent:start", handler)
assert "agent:start" in reg._handlers
assert reg._handlers["agent:start"] == [handler]
def test_records_metadata_in_loaded_hooks(self):
reg = HookRegistry()
def my_handler(_e, _c):
return None
reg.register("tui:tool.start", my_handler)
assert len(reg.loaded_hooks) == 1
meta = reg.loaded_hooks[0]
assert meta["name"] == "my_handler"
assert meta["events"] == ["tui:tool.start"]
assert meta["path"] == "<programmatic>"
def test_custom_name_override(self):
reg = HookRegistry()
reg.register("agent:end", lambda _e, _c: None, name="orb-collector")
assert reg.loaded_hooks[0]["name"] == "orb-collector"
def test_returns_working_unregister(self):
reg = HookRegistry()
def handler(_e, _c):
return None
unregister = reg.register("agent:start", handler)
assert handler in reg._handlers["agent:start"]
assert len(reg.loaded_hooks) == 1
unregister()
assert handler not in reg._handlers["agent:start"]
assert len(reg.loaded_hooks) == 0
def test_unregister_is_idempotent(self):
reg = HookRegistry()
unregister = reg.register("agent:start", lambda _e, _c: None)
unregister()
# Second call should not raise.
unregister()
def test_multiple_handlers_same_event(self):
reg = HookRegistry()
calls: list = []
def h1(_e, _c):
calls.append("h1")
def h2(_e, _c):
calls.append("h2")
reg.register("agent:start", h1)
reg.register("agent:start", h2)
assert reg._handlers["agent:start"] == [h1, h2]
assert len(reg.loaded_hooks) == 2
def test_unregister_does_not_affect_other_handlers(self):
reg = HookRegistry()
def h1(_e, _c):
return None
def h2(_e, _c):
return None
unreg1 = reg.register("agent:start", h1)
reg.register("agent:start", h2)
unreg1()
assert h1 not in reg._handlers["agent:start"]
assert h2 in reg._handlers["agent:start"]
class TestEmitSync:
"""Tests for the synchronous emit path used from hot non-async callers."""
def test_fires_sync_handler(self):
reg = HookRegistry()
calls: list = []
reg.register(
"tui:tool.start",
lambda e, c: calls.append((e, c)),
)
reg.emit_sync("tui:tool.start", {"session_id": "s1", "payload": {"name": "foo"}})
assert calls == [("tui:tool.start", {"session_id": "s1", "payload": {"name": "foo"}})]
def test_default_context_when_none(self):
reg = HookRegistry()
seen: list = []
reg.register("evt:x", lambda _e, c: seen.append(c))
reg.emit_sync("evt:x") # no context arg
assert seen == [{}]
def test_sync_handler_exception_isolated(self):
reg = HookRegistry()
calls: list = []
def bad(_e, _c):
raise RuntimeError("boom")
def good(_e, _c):
calls.append("good")
reg.register("evt:x", bad)
reg.register("evt:x", good)
# Must not raise; second handler still fires.
reg.emit_sync("evt:x", {})
assert calls == ["good"]
def test_wildcard_matching(self):
reg = HookRegistry()
calls: list = []
reg.register("tui:*", lambda e, _c: calls.append(e))
reg.register("tui:tool.start", lambda e, _c: calls.append(f"exact:{e}"))
reg.emit_sync("tui:tool.start", {})
# Exact match first, then wildcard.
assert calls == ["exact:tui:tool.start", "tui:tool.start"]
def test_no_handlers_does_not_raise(self):
reg = HookRegistry()
# Just shouldn't blow up.
reg.emit_sync("nobody:listening", {"foo": "bar"})
def test_async_handler_skipped_with_no_loop(self, capsys):
from gateway.hooks import _reset_default_registry_for_tests
_reset_default_registry_for_tests()
reg = HookRegistry()
marker: list = []
async def async_handler(_e, _c):
marker.append("ran")
reg.register("evt:x", async_handler, name="async_handler_unique")
# First emit logs a warning and skips.
reg.emit_sync("evt:x", {})
captured = capsys.readouterr()
# The warning uses the handler's __name__ for diagnostic clarity.
assert "async_handler" in captured.out
assert "Skipping async handler" in captured.out
assert marker == [] # async handler never ran
# Second emit is silent (warning suppressed).
reg.emit_sync("evt:x", {})
captured = capsys.readouterr()
assert captured.out == ""
assert marker == []
def test_async_handler_scheduled_when_loop_running(self):
import asyncio as _asyncio
reg = HookRegistry()
marker: list = []
async def async_handler(_e, _c):
marker.append("ran")
reg.register("evt:x", async_handler)
async def driver():
reg.emit_sync("evt:x", {})
# Yield to the loop so the scheduled task can run.
await _asyncio.sleep(0)
await _asyncio.sleep(0)
_asyncio.run(driver())
assert marker == ["ran"]
class TestDefaultRegistry:
"""Tests for the module-level default-registry singleton."""
def test_get_default_returns_same_instance(self):
from gateway.hooks import (
_reset_default_registry_for_tests,
get_default_registry,
)
_reset_default_registry_for_tests()
first = get_default_registry()
second = get_default_registry()
assert first is second
def test_install_as_default_replaces(self):
from gateway.hooks import (
_reset_default_registry_for_tests,
get_default_registry,
install_as_default,
)
_reset_default_registry_for_tests()
custom = HookRegistry()
install_as_default(custom)
assert get_default_registry() is custom
def test_install_then_get_picks_up_handlers(self):
from gateway.hooks import (
_reset_default_registry_for_tests,
get_default_registry,
install_as_default,
)
_reset_default_registry_for_tests()
custom = HookRegistry()
install_as_default(custom)
calls: list = []
get_default_registry().register("agent:x", lambda _e, _c: calls.append("hit"))
# Same handler is visible on the installed instance.
custom.emit_sync("agent:x", {})
assert calls == ["hit"]
@@ -0,0 +1,186 @@
"""Gateway typed ``/model <name>`` must route through the expensive-model
confirmation gate.
The pickers (Telegram/Discord inline keyboards, TUI, dashboard) confirm
expensive models via their own UI affordances; the typed text command
previously bypassed the guard entirely a user typing
``/model openai/gpt-5.5-pro`` switched silently while the picker warned.
These tests pin the typed path:
- warning fires handler returns the slash-confirm prompt, switch NOT applied
- confirm ("once") switch applies (session override set)
- cancel switch not applied, current model unchanged
- no warning (cheap model) switch applies immediately, no prompt
"""
from types import SimpleNamespace
import pytest
import yaml
from gateway.config import Platform
from gateway.platforms.base import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
def _make_runner():
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
runner._session_model_overrides = {}
runner._running_agents = {}
return runner
def _make_event(text):
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
)
def _fake_switch_result():
from hermes_cli.model_switch import ModelSwitchResult
return ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="openrouter",
provider_changed=False,
api_key="sk-test",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
provider_label="OpenRouter",
)
def _fake_warning():
return SimpleNamespace(
message=(
"!!! EXPENSIVE MODEL WARNING !!!\n"
"openai/gpt-5.5-pro has known pricing above Hermes' safety threshold.\n"
"did you mean to select openai/gpt-5.5?"
),
)
def _setup_isolated_home(tmp_path, monkeypatch, *, warn):
import gateway.run as gateway_run
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
cfg_path = hermes_home / "config.yaml"
cfg_path.write_text(
yaml.safe_dump({"model": {"default": "old-model", "provider": "openrouter"}, "providers": {}}),
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model",
lambda **kw: _fake_switch_result(),
)
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
(lambda *a, **kw: _fake_warning()) if warn else (lambda *a, **kw: None),
)
return cfg_path
@pytest.mark.asyncio
async def test_typed_model_expensive_prompts_instead_of_switching(tmp_path, monkeypatch):
"""Expensive model typed directly → confirm prompt, no switch applied."""
_setup_isolated_home(tmp_path, monkeypatch, warn=True)
runner = _make_runner()
captured = {}
async def _fake_request_slash_confirm(**kwargs):
captured.update(kwargs)
return kwargs["message"]
runner._request_slash_confirm = _fake_request_slash_confirm
result = await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
assert result is not None
assert "EXPENSIVE MODEL WARNING" in result
# The switch must NOT have been applied yet.
assert runner._session_model_overrides == {}
assert captured["command"] == "model"
@pytest.mark.asyncio
async def test_typed_model_expensive_confirm_once_applies_switch(tmp_path, monkeypatch):
"""Resolving the confirm with "once" applies the switch."""
_setup_isolated_home(tmp_path, monkeypatch, warn=True)
runner = _make_runner()
runner._evict_cached_agent = lambda session_key: None
captured = {}
async def _fake_request_slash_confirm(**kwargs):
captured.update(kwargs)
return None # buttons rendered
runner._request_slash_confirm = _fake_request_slash_confirm
await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
assert runner._session_model_overrides == {}
reply = await captured["handler"]("once")
assert "gpt-5.5-pro" in reply
overrides = list(runner._session_model_overrides.values())
assert len(overrides) == 1
assert overrides[0]["model"] == "openai/gpt-5.5-pro"
@pytest.mark.asyncio
async def test_typed_model_expensive_cancel_keeps_current_model(tmp_path, monkeypatch):
"""Resolving the confirm with "cancel" leaves everything unchanged."""
cfg_path = _setup_isolated_home(tmp_path, monkeypatch, warn=True)
runner = _make_runner()
captured = {}
async def _fake_request_slash_confirm(**kwargs):
captured.update(kwargs)
return None
runner._request_slash_confirm = _fake_request_slash_confirm
await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro --global"))
reply = await captured["handler"]("cancel")
assert "cancelled" in reply.lower()
assert runner._session_model_overrides == {}
# --global must not have persisted the cancelled switch.
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert written["model"]["default"] == "old-model"
@pytest.mark.asyncio
async def test_typed_model_cheap_switches_without_prompt(tmp_path, monkeypatch):
"""No warning → switch applies immediately; confirm primitive never invoked."""
_setup_isolated_home(tmp_path, monkeypatch, warn=False)
runner = _make_runner()
runner._evict_cached_agent = lambda session_key: None
async def _fail_request_slash_confirm(**kwargs): # pragma: no cover
raise AssertionError("confirm should not be requested for cheap models")
runner._request_slash_confirm = _fail_request_slash_confirm
result = await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
assert result is not None
assert "gpt-5.5-pro" in result
overrides = list(runner._session_model_overrides.values())
assert len(overrides) == 1
+49
View File
@@ -9,6 +9,7 @@ from types import SimpleNamespace
import pytest
import gateway.platforms.base as base_platform
from gateway.config import Platform, PlatformConfig, StreamingConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.session import SessionSource
@@ -1076,6 +1077,54 @@ async def test_base_processing_releases_post_delivery_callback_after_main_send()
assert released == [True]
@pytest.mark.asyncio
async def test_base_processing_stops_typing_before_hung_post_delivery_callback(
monkeypatch,
):
"""A stuck post-delivery callback must not keep the typing task alive."""
monkeypatch.setattr(base_platform, "_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS", 0.01)
adapter = ProgressCaptureAdapter()
events = []
async def _handler(event):
return "done"
async def _post_delivery_cb():
events.append("callback-start")
await asyncio.Event().wait()
async def _stop_typing(chat_id):
events.append("typing-stopped")
await ProgressCaptureAdapter.stop_typing(adapter, chat_id)
adapter.set_message_handler(_handler)
adapter.stop_typing = _stop_typing
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_type="group",
thread_id="17585",
)
event = MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=source,
message_id="msg-1",
)
session_key = "agent:main:telegram:group:-1001:17585"
adapter._active_sessions[session_key] = asyncio.Event()
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
await asyncio.wait_for(
adapter._process_message_background(event, session_key), timeout=1.0
)
assert [call["content"] for call in adapter.sent] == ["done"]
assert events[:2] == ["typing-stopped", "callback-start"]
assert any(call["metadata"] == {"stopped": True} for call in adapter.typing)
@pytest.mark.asyncio
async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path):
import yaml
+39
View File
@@ -102,6 +102,45 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
assert transcripts == []
@pytest.mark.asyncio
async def test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder():
"""A successful transcription whose caption is the empty-content placeholder
must still return the ``(text, transcripts)`` tuple.
The Discord adapter delivers a captionless voice note as the literal
``"(The user sent a message with no text content)"`` placeholder. When STT
succeeds we strip that redundant placeholder and return just the transcript
prefix but the method's contract (and every caller, which unpacks the
result as ``text, transcripts = ...``) requires a 2-tuple. Returning a bare
string here raised ``ValueError: too many values to unpack`` and dropped the
whole voice message on the floor.
"""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(stt_enabled=True)
runner._has_setup_skill = lambda: False
with patch(
"tools.transcription_tools.transcribe_audio",
return_value={
"success": True,
"transcript": "hello from a captionless voice note",
"provider": "local_command",
},
):
result, transcripts = await runner._enrich_message_with_transcription(
"(The user sent a message with no text content)",
["/tmp/voice.ogg"],
)
# The redundant placeholder is stripped, leaving only the transcript prefix.
assert "hello from a captionless voice note" in result
assert "(The user sent a message with no text content)" not in result
# Crucially, the transcripts are still surfaced so callers can echo them.
assert transcripts == ["hello from a captionless voice note"]
@pytest.mark.asyncio
async def test_prepare_inbound_message_text_transcribes_queued_voice_event():
from gateway.run import GatewayRunner
+44 -15
View File
@@ -91,10 +91,6 @@ class TestTelegramModelPicker:
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
await adapter._handle_model_picker_callback(query, "mb", "12345")
edit_kwargs = query.edit_message_text.call_args[1]
@@ -133,17 +129,11 @@ class TestTelegramModelPicker:
await adapter._handle_model_picker_callback(query, "mm:0", "12345")
# The callback was invoked with the selected model
callback.assert_awaited_once()
# edit_message_text MUST be called on the success path (this is the
# regression we're guarding).
query.edit_message_text.assert_awaited()
edit_kwargs = query.edit_message_text.call_args[1]
assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"])
# The dynamic result text was routed through format_message
# (backtick code blocks survive escaping).
assert "`gpt-5`" in edit_kwargs["text"]
# State is cleaned up after a successful switch.
assert "12345" not in adapter._model_picker_state
@pytest.mark.asyncio
@@ -184,7 +174,7 @@ class TestTelegramModelPicker:
providers = [
{"slug": "minimax", "name": "MiniMax", "total_models": 2},
{"slug": "minimax-cn", "name": "MiniMax (China)", "total_models": 3},
{"slug": "xai", "name": "xAI", "total_models": 1}, # lone group member
{"slug": "xai", "name": "xAI", "total_models": 1},
]
await adapter.send_model_picker(
@@ -197,14 +187,11 @@ class TestTelegramModelPicker:
metadata=None,
)
# Top-level keyboard: MiniMax family folded into one group button;
# xai (lone member) degraded to a direct provider button.
assert "mpg:minimax" in built
assert "mp:xai" in built
assert "mp:minimax" not in built
assert "mp:minimax-cn" not in built
# Drill into the MiniMax group → members appear as mp: buttons + back.
built.clear()
query = AsyncMock()
query.message = MagicMock()
@@ -216,7 +203,49 @@ class TestTelegramModelPicker:
assert "mp:minimax" in built
assert "mp:minimax-cn" in built
assert "mb" in built # back-to-providers button present
assert "mb" in built
@pytest.mark.asyncio
async def test_expensive_model_requires_confirmation(self, monkeypatch):
adapter = _make_adapter()
callback = AsyncMock(return_value="Switched to `openai/gpt-5.5-pro`")
adapter._model_picker_state["12345"] = {
"providers": [
{"slug": "openrouter", "name": "OpenRouter", "total_models": 1, "is_current": True}
],
"current_model": "model_1",
"current_provider": "openrouter",
"session_key": "s",
"on_model_selected": callback,
"selected_provider": "openrouter",
"model_list": ["openai/gpt-5.5-pro"],
"msg_id": 42,
}
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(
message="!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?"
),
)
query = AsyncMock()
query.message = MagicMock()
query.message.chat_id = 12345
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
await adapter._handle_model_picker_callback(query, "mm:0", "12345")
callback.assert_not_awaited()
assert "12345" in adapter._model_picker_state
first_edit = query.edit_message_text.call_args[1]
assert "EXPENSIVE MODEL WARNING" in first_edit["text"]
assert first_edit["reply_markup"] is not None
await adapter._handle_model_picker_callback(query, "mc:0", "12345")
callback.assert_awaited_once_with("12345", "openai/gpt-5.5-pro", "openrouter")
assert "12345" not in adapter._model_picker_state
@pytest.mark.asyncio
async def test_retries_without_thread_when_thread_not_found(self):
+70 -1
View File
@@ -415,14 +415,17 @@ class TestSendVoiceReply:
@pytest.mark.asyncio
async def test_calls_tts_and_send_voice(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.TELEGRAM
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
@@ -430,9 +433,32 @@ class TestSendVoiceReply:
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
assert mock_tts.call_args.kwargs["output_path"].endswith(".ogg")
call_args = mock_adapter.send_voice.call_args
assert call_args.kwargs.get("chat_id") == "123"
@pytest.mark.asyncio
async def test_non_telegram_auto_voice_reply_uses_mp3(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.SLACK
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
patch("os.makedirs"):
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
assert mock_tts.call_args.kwargs["output_path"].endswith(".mp3")
@pytest.mark.asyncio
async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
from gateway.config import Platform
@@ -1929,6 +1955,49 @@ class TestVoiceTimeoutCleansRunnerState:
assert 111 not in adapter._voice_clients
@pytest.mark.asyncio
async def test_timeout_skips_disconnect_when_voice_mode_off(self, adapter):
"""Voice-off is deliberate text-only mode, not idle neglect — the
inactivity timer must NOT disconnect or spam the channel (#PanBartosz)."""
disconnect_calls = []
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
adapter._voice_mode_getter = lambda chat_id: "off"
mock_vc = MagicMock()
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 999
adapter._voice_timeout_tasks[111] = MagicMock()
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._voice_timeout_handler(111)
# Still connected, no disconnect callback, no "inactivity timeout" spam.
assert 111 in adapter._voice_clients
assert disconnect_calls == []
mock_vc.disconnect.assert_not_called()
@pytest.mark.asyncio
async def test_timeout_still_disconnects_when_voice_mode_active(self, adapter):
"""A non-off mode still auto-disconnects on genuine inactivity."""
disconnect_calls = []
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
adapter._voice_mode_getter = lambda chat_id: "all"
mock_vc = MagicMock()
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 999
adapter._voice_timeout_tasks[111] = MagicMock()
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._voice_timeout_handler(111)
assert 111 not in adapter._voice_clients
assert disconnect_calls == ["999"]
# =====================================================================
# Bug 6: play_in_voice_channel has playback timeout
+44 -2
View File
@@ -465,7 +465,7 @@ def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
"""Loopback timeout should offer the existing manual-paste path."""
"""Loopback timeout should accept a bare Grok Build code paste."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
@@ -523,7 +523,7 @@ def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
captured["prompt_calls"] += 1
return {
"code": "manual-auth-code",
"state": captured["state"],
"state": None,
"error": None,
"error_description": None,
}
@@ -558,6 +558,48 @@ def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
assert creds["tokens"]["refresh_token"] == "rt-timeout"
def test_xai_wait_for_callback_accepts_ready_stdin_code(monkeypatch):
"""Users can paste the Grok Build code while Hermes is still waiting."""
class _StubServer:
shutdown_called = False
close_called = False
def shutdown(self):
self.shutdown_called = True
def server_close(self):
self.close_called = True
class _StubThread:
joined = False
def join(self, timeout=None):
self.joined = True
server = _StubServer()
thread = _StubThread()
monkeypatch.setattr(
auth_mod,
"_read_ready_stdin_line",
lambda: "ready-grok-build-code\n",
)
out = auth_mod._xai_wait_for_callback(
server,
thread,
{"code": None, "error": None},
timeout_seconds=5,
manual_paste_redirect_uri="http://127.0.0.1:56121/callback",
)
assert out["code"] == "ready-grok-build-code"
assert out["state"] is None
assert out["_manual_paste"] is True
assert server.shutdown_called is True
assert server.close_called is True
assert thread.joined is True
def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch):
"""Non-interactive stdin must keep the original timeout error."""
monkeypatch.setattr(
+3 -3
View File
@@ -133,7 +133,7 @@ def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
captured["access_token"] = access_token
return ["gpt-5.2-codex", "gpt-5.2"]
def _fake_prompt_model_selection(model_ids, current_model=""):
def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs):
captured["model_ids"] = list(model_ids)
captured["current_model"] = current_model
return None
@@ -181,7 +181,7 @@ def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypa
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
lambda model_ids, current_model="", **_kwargs: None,
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
@@ -219,7 +219,7 @@ def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
lambda model_ids, current_model="", **_kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.auth._login_openai_codex",
+88
View File
@@ -292,6 +292,25 @@ class TestSaveEnvValueSecure:
env_mode = (tmp_path / ".env").stat().st_mode & 0o777
assert env_mode == 0o600
def test_save_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
"""Regression for #31518: pre-existing .env mode (e.g. 0640 for a
Docker bind-mount that the operator chose) survives subsequent
writes. Previously _secure_file ran unconditionally after the
mode-restore branch and re-tightened to 0600.
"""
if os.name == "nt":
return
env_path = tmp_path / ".env"
env_path.write_text("EXISTING=value\n")
os.chmod(env_path, 0o640)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_env_value("TENOR_API_KEY", "sk-test-secret")
env_mode = env_path.stat().st_mode & 0o777
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
class TestRemoveEnvValue:
def test_removes_key_from_env_file(self, tmp_path):
@@ -335,6 +354,28 @@ class TestRemoveEnvValue:
remove_env_value("ORPHAN_KEY")
assert "ORPHAN_KEY" not in os.environ
def test_remove_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
"""Regression: pre-existing .env mode (e.g. 0640 for a Docker
bind-mount the operator chose) survives a remove just as it does a
save. Previously _secure_file ran unconditionally after the
mode-restore branch and re-tightened to 0600 the same bug fixed
in save_env_value (#33699), in the sibling remove path.
"""
if os.name == "nt":
return
env_path = tmp_path / ".env"
env_path.write_text("KEEP=value\nDROP=gone\n")
os.chmod(env_path, 0o640)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "DROP": "gone"}):
removed = remove_env_value("DROP")
assert removed is True
assert "DROP" not in env_path.read_text()
env_mode = env_path.stat().st_mode & 0o777
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
class TestSaveConfigAtomicity:
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
@@ -1056,3 +1097,50 @@ class TestEnvWriteDenylist:
# But the write path still refuses to update it
with pytest.raises(ValueError, match="denylist"):
save_env_value("LD_PRELOAD", "/tmp/evil.so")
class TestWriteApprovalMigration:
"""Version 28→29 renames memory/skills write_mode → write_approval (bool).
Only an explicit ``approve`` carried gating intent and maps to ``True``;
``on``/``off``/unset map to ``False`` (gate off). The old ``write_mode`` key
is removed. Only a persisted key is rewritten never invented.
"""
def _write(self, tmp_path, body: str):
(tmp_path / "config.yaml").write_text(body)
def test_approve_maps_to_true(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
self._write(tmp_path,
"_config_version: 28\nmemory:\n write_mode: approve\n"
"skills:\n write_mode: approve\n")
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert raw["memory"]["write_approval"] is True
assert raw["skills"]["write_approval"] is True
assert "write_mode" not in raw["memory"]
assert "write_mode" not in raw["skills"]
def test_on_and_off_map_to_false(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
# YAML 1.1 parses bare on/off as bools — write_mode could be either
# the string or the bool; both legacy "not gating" values → False.
self._write(tmp_path,
"_config_version: 28\nmemory:\n write_mode: 'on'\n"
"skills:\n write_mode: 'off'\n")
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert raw["memory"]["write_approval"] is False
assert raw["skills"]["write_approval"] is False
def test_unset_key_defaults_to_false(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
self._write(tmp_path, "_config_version: 28\nmemory:\n memory_enabled: true\n")
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
# No write_mode was persisted, so the rename is a no-op; the missing-
# field pass then seeds the default (False = gate off). Either way the
# gate ends up off and there's no leftover write_mode key.
assert raw["memory"].get("write_approval", False) is False
assert "write_mode" not in raw.get("memory", {})
@@ -191,6 +191,111 @@ def test_full_login_round_trip_unlocks_gated_api(gated_app):
)
def _complete_stub_login(client) -> None:
"""Walk the stub OAuth round trip so ``client`` carries a valid session.
TestClient persists Set-Cookie across calls, so after this returns the
client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``
and subsequent gated requests authenticate.
"""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):
"""Regression: ``_require_token`` endpoints must work under the OAuth gate.
In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA
(it authenticates with the session cookie). Endpoints that call
``_require_token`` directly plugin install/enable/disable,
``/api/dashboard/plugins/hub``, and others used to re-check the absent
token and 401 every cookie-authenticated request, making them permanently
unreachable behind the gate (the dashboard surfaced a
``401: {"detail":"Unauthorized"}`` popup on plugin install). The fix makes
``_require_token`` defer to the gate, which has already verified the cookie
and attached ``request.state.session`` before the handler runs.
We POST a deliberately invalid plugin identifier: a passing auth layer
lets the request reach the handler, which rejects the identifier with a
400. The assertion is simply "not 401" proving auth succeeded without
coupling to the validation message.
"""
_complete_stub_login(gated_app)
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "definitely not a valid identifier",
"force": False, "enable": False},
)
assert r.status_code != 401, (
"A _require_token endpoint 401'd a cookie-authenticated request under "
f"the OAuth gate (the install-popup bug). Body: {r.text}"
)
# And specifically: it reached the handler's own validation.
assert r.status_code == 400, (
f"Expected the install handler's 400 (bad identifier), got "
f"{r.status_code}: {r.text}"
)
def test_gated_require_token_endpoint_still_rejects_no_cookie(gated_app):
"""The gate must still 401 a ``_require_token`` endpoint with no session.
The fix defers to the gate it does not make these endpoints public. A
request with no cookie is rejected by ``gated_auth_middleware`` before the
handler runs, so the install endpoint stays protected.
"""
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "owner/repo", "force": False, "enable": False},
)
assert r.status_code == 401, (
f"Expected 401 for an unauthenticated install POST under the gate, "
f"got {r.status_code}: {r.text}"
)
# A representative spread of the OTHER ``_require_token`` endpoints (there are
# 14 in total). The install popup was just the reported symptom; the same bug
# made API-key reveal, provider validation, the OAuth-provider connect flow,
# and the rest of plugin management unreachable behind the gate. Each entry is
# (method, path, json_body); we assert only that a logged-in request is NOT
# 401'd — i.e. it cleared the auth layer and reached the handler. The
# handler's own status (400/404/429/etc.) is route-specific and not asserted.
_GATED_REQUIRE_TOKEN_ROUTES = [
("get", "/api/dashboard/plugins/hub", None),
("post", "/api/env/reveal", {"key": "NONEXISTENT_ENV_VAR_FOR_TEST"}),
("post", "/api/providers/validate", {"key": "OPENAI_API_KEY", "value": ""}),
("delete", "/api/providers/oauth/__not_a_real_provider__", None),
("post", "/api/dashboard/agent-plugins/__nope__/enable", None),
]
@pytest.mark.parametrize("method,path,body", _GATED_REQUIRE_TOKEN_ROUTES)
def test_gated_require_token_routes_accept_cookie_session(
gated_app, method, path, body
):
"""Every ``_require_token`` route must clear auth for a logged-in caller.
Same root cause and fix as
``test_gated_require_token_endpoint_accepts_cookie_session`` this just
proves the fix covers the whole class, not only ``agent-plugins/install``.
"""
_complete_stub_login(gated_app)
kwargs = {"json": body} if body is not None else {}
r = gated_app.request(method.upper(), path, **kwargs)
assert r.status_code != 401, (
f"{method.upper()} {path} 401'd a cookie-authenticated request under "
f"the OAuth gate — _require_token still rejecting a valid session. "
f"Body: {r.text}"
)
def test_login_unknown_provider_returns_404(gated_app):
r = gated_app.get("/auth/login?provider=nonexistent", follow_redirects=False)
assert r.status_code == 404
+179
View File
@@ -0,0 +1,179 @@
"""Regression tests for hermes_cli._ensure_utf8().
Covers the crash class where the setup wizard (and other banner-printing
commands) emit box-drawing characters and the glyph, which raise
UnicodeEncodeError when stdout/stderr are bound to a non-UTF-8 codec.
Historically the repair was gated on ``sys.platform == "win32"`` and only
caught the Windows cp1252 case. Linux hosts with a latin-1 / C / POSIX locale
(common on minimal Debian installs and Raspberry Pi) hit the identical crash
in ``hermes setup`` because the repair returned early. See the Raspberry Pi
report: latin-1 locale UnicodeEncodeError before the wizard could start.
"""
import io
import os
import sys
import hermes_cli
# The exact glyphs the setup wizard / banners print (setup.py ~line 2962+).
_BANNER = "┌─────┐\n│ ⚕ Hermes │\n└─────┘"
class _FakeStream:
"""Minimal text stream backed by an in-memory byte buffer with a codec.
Mirrors how CPython binds sys.stdout to the locale encoding: writes that
can't be encoded raise UnicodeEncodeError, just like a real latin-1 TTY.
"""
def __init__(self, encoding, *, supports_reconfigure=True):
self.encoding = encoding
self._supports_reconfigure = supports_reconfigure
self.errors = "strict"
self._buf = io.BytesIO()
def write(self, s):
self._buf.write(s.encode(self.encoding, self.errors))
return len(s)
def flush(self):
pass
def reconfigure(self, *, encoding=None, errors=None):
if not self._supports_reconfigure:
raise AttributeError("reconfigure")
if encoding is not None:
self.encoding = encoding
if errors is not None:
self.errors = errors
def getvalue(self):
return self._buf.getvalue()
def _run_with_streams(monkeypatch, out, err):
monkeypatch.setattr(sys, "stdout", out, raising=False)
monkeypatch.setattr(sys, "stderr", err, raising=False)
hermes_cli._ensure_utf8()
def test_latin1_stdout_is_repaired_to_utf8(monkeypatch):
"""A latin-1 stdout (the Raspberry Pi case) becomes UTF-8 capable."""
out = _FakeStream("latin-1")
err = _FakeStream("latin-1")
# Sanity: before the fix, the banner cannot be encoded.
try:
out.write(_BANNER)
pre_fix_crashes = False
except UnicodeEncodeError:
pre_fix_crashes = True
assert pre_fix_crashes, "fixture should reproduce the original crash"
out = _FakeStream("latin-1")
err = _FakeStream("latin-1")
_run_with_streams(monkeypatch, out, err)
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
assert sys.stderr.encoding.lower().replace("-", "") == "utf8"
# The banner now encodes without raising.
sys.stdout.write(_BANNER)
assert "".encode("utf-8") in sys.stdout.getvalue()
def test_ascii_posix_locale_is_repaired(monkeypatch):
"""C/POSIX locale resolves to ascii stdout — also must be repaired."""
out = _FakeStream("ascii")
err = _FakeStream("ascii")
_run_with_streams(monkeypatch, out, err)
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
sys.stdout.write(_BANNER) # no raise
def test_utf8_stream_left_untouched(monkeypatch):
"""Already-UTF-8 streams are a no-op: object identity preserved AND the
process environment is left untouched (no PYTHONUTF8/PYTHONIOENCODING
burned in on a healthy UTF-8 host)."""
out = _FakeStream("utf-8")
err = _FakeStream("utf-8")
sentinel_out, sentinel_err = out, err
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
_run_with_streams(monkeypatch, out, err)
assert sys.stdout is sentinel_out
assert sys.stderr is sentinel_err
# Healthy UTF-8 host: no environment mutation (minimal footprint).
assert "PYTHONUTF8" not in os.environ
assert "PYTHONIOENCODING" not in os.environ
def test_repair_sets_child_process_env(monkeypatch):
"""When a real repair happens, child-process UTF-8 hints are set."""
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
assert os.environ.get("PYTHONUTF8") == "1"
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
def test_repair_does_not_override_explicit_env(monkeypatch):
"""A user's explicit PYTHONIOENCODING is respected (setdefault, not set)."""
monkeypatch.setenv("PYTHONIOENCODING", "utf-16")
monkeypatch.delenv("PYTHONUTF8", raising=False)
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
assert os.environ["PYTHONIOENCODING"] == "utf-16"
def test_fallback_when_reconfigure_unavailable(monkeypatch, tmp_path):
"""Streams without reconfigure() fall back to reopening the fd as UTF-8."""
real_path = tmp_path / "out.txt"
fh = open(real_path, "w", encoding="latin-1")
class _NoReconfigure:
"""latin-1 stream exposing a real fileno() but no reconfigure()."""
encoding = "latin-1"
def fileno(self):
return fh.fileno()
stream = _NoReconfigure()
monkeypatch.setattr(sys, "stdout", stream, raising=False)
monkeypatch.setattr(sys, "stderr", stream, raising=False)
hermes_cli._ensure_utf8()
# Replaced with a new UTF-8 stream object (not reconfigured in place).
assert sys.stdout is not stream
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
sys.stdout.write(_BANNER)
sys.stdout.flush()
fh.close()
assert "".encode("utf-8") in real_path.read_bytes()
def test_broken_stream_does_not_raise(monkeypatch):
"""A stream whose repair raises must be swallowed, never crash import."""
class _Hostile:
encoding = "latin-1"
def reconfigure(self, *a, **k):
raise OSError("nope")
def fileno(self):
raise OSError("no fd")
monkeypatch.setattr(sys, "stdout", _Hostile(), raising=False)
monkeypatch.setattr(sys, "stderr", _Hostile(), raising=False)
# Must not propagate.
hermes_cli._ensure_utf8()
def test_none_streams_do_not_raise(monkeypatch):
"""pythonw / detached streams (sys.stdout is None) must be tolerated."""
monkeypatch.setattr(sys, "stdout", None, raising=False)
monkeypatch.setattr(sys, "stderr", None, raising=False)
hermes_cli._ensure_utf8()
@@ -1,406 +0,0 @@
"""Unit tests for ``hermes_cli/hook_ingest.py``.
Covers:
* Discovery file write/read/remove lifecycle
* File permissions (0600) and atomic replace semantics
* Auth gating on the ingest endpoint (401 without token, 200 with)
* Body validation (400 on bad shape, missing keys, non-dict context)
* Forwarded events are stamped with ``_forwarded=True`` and
``_forwarded_from=<src>`` and republished via ``emit_sync``
* Health endpoint returns 200 unconditionally and is unauthenticated
* `--insecure` mode does not disable the ingest endpoint (the bearer
token is the security boundary regardless of bind address)
"""
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from gateway.hooks import (
HookRegistry,
_reset_default_registry_for_tests,
get_default_registry,
install_as_default,
)
from hermes_cli import hook_ingest
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
"""Each test gets a fresh hermes home + fresh registry singleton +
fresh ingest-token state. Without this the module-level
``_HOOKS_INGEST_TOKEN`` from one test would leak into the next."""
monkeypatch.setattr(hook_ingest, "get_hermes_home", lambda: tmp_path)
hook_ingest._reset_for_tests()
_reset_default_registry_for_tests()
yield
hook_ingest._reset_for_tests()
_reset_default_registry_for_tests()
@pytest.fixture
def fresh_registry():
"""Install a clean ``HookRegistry`` as the process default and return it."""
reg = HookRegistry()
install_as_default(reg)
return reg
@pytest.fixture
def hook_app(fresh_registry):
"""A FastAPI app with the hook router mounted at /api/hooks."""
app = FastAPI()
app.include_router(hook_ingest.build_hook_router(), prefix="/api/hooks")
return app
@pytest.fixture
def client(hook_app):
return TestClient(hook_app)
# ---------------------------------------------------------------------------
# Discovery file lifecycle
# ---------------------------------------------------------------------------
class TestDiscoveryFile:
def test_write_creates_file_with_expected_shape(self, tmp_path):
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
path = tmp_path / "dashboard.json"
assert path.exists()
data = json.loads(path.read_text())
assert data["url"] == "http://127.0.0.1:9119"
assert data["hooks_ingest_token"] == token
assert isinstance(data["pid"], int)
assert "started_at" in data
# ISO-8601 timestamp.
assert "T" in data["started_at"]
def test_write_returns_fresh_token_each_call(self, tmp_path):
first = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
second = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
assert first != second # 32-byte urlsafe tokens collide ~never
def test_write_records_non_loopback_bind(self, tmp_path):
"""``--insecure`` mode binds non-loopback; the discovery file
must reflect that so forwarders dial the right address."""
hook_ingest.write_dashboard_discovery_file("0.0.0.0", 9119)
data = json.loads((tmp_path / "dashboard.json").read_text())
assert data["url"] == "http://0.0.0.0:9119"
def test_write_creates_parent_dir_if_missing(self, monkeypatch, tmp_path):
"""``$HERMES_HOME`` might not exist yet on first startup."""
nested = tmp_path / "new" / "hermes"
monkeypatch.setattr(hook_ingest, "get_hermes_home", lambda: nested)
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
assert (nested / "dashboard.json").exists()
def test_file_mode_is_0600(self, tmp_path):
"""Discovery file holds a bearer token in cleartext; must be
owner-only readable."""
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
path = tmp_path / "dashboard.json"
mode = stat.S_IMODE(path.stat().st_mode)
# 0o600 = read+write for owner, nothing for group/other.
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
def test_remove_deletes_file(self, tmp_path):
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
assert (tmp_path / "dashboard.json").exists()
hook_ingest.remove_dashboard_discovery_file()
assert not (tmp_path / "dashboard.json").exists()
def test_remove_is_idempotent(self):
# Never written.
hook_ingest.remove_dashboard_discovery_file()
# And again — must not raise.
hook_ingest.remove_dashboard_discovery_file()
def test_remove_clears_in_memory_token(self):
"""Once removed, no one can authenticate to ingest anymore."""
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
assert hook_ingest.get_current_token_for_tests() != ""
hook_ingest.remove_dashboard_discovery_file()
assert hook_ingest.get_current_token_for_tests() == ""
# ---------------------------------------------------------------------------
# /health endpoint
# ---------------------------------------------------------------------------
class TestHealthEndpoint:
def test_health_returns_200_unauthenticated(self, client):
r = client.get("/api/hooks/health")
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_health_returns_200_with_no_token_set(self, client):
"""Health probe must succeed even before any token has been
written (forwarder probes before discovery is established)."""
# No write_dashboard_discovery_file call — token stays "".
r = client.get("/api/hooks/health")
assert r.status_code == 200
# ---------------------------------------------------------------------------
# /ingest endpoint — auth
# ---------------------------------------------------------------------------
class TestIngestAuth:
def test_ingest_401_without_token(self, client):
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
del token # noqa: F841 — we intentionally don't send it.
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
)
assert r.status_code == 401
def test_ingest_401_with_wrong_token(self, client):
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
headers={"Authorization": "Bearer wrong"},
)
assert r.status_code == 401
def test_ingest_401_when_no_token_set(self, client):
"""Discovery file never written ⇒ ingest refuses everything."""
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
headers={"Authorization": "Bearer something"},
)
assert r.status_code == 401
def test_ingest_200_with_valid_token(self, client):
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_ingest_works_for_non_loopback_bind(self, client):
"""``--insecure`` doesn't disable ingest. The bearer token IS
the security boundary regardless of bind address (see
DESIGN-cross-process-hooks.md "Bind-address independence")."""
token = hook_ingest.write_dashboard_discovery_file("0.0.0.0", 9119)
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# /ingest endpoint — body validation
# ---------------------------------------------------------------------------
class TestIngestBodyValidation:
@pytest.fixture
def auth_headers(self):
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
return {"Authorization": f"Bearer {token}"}
def test_400_on_non_json_body(self, client, auth_headers):
# FastAPI itself produces 422 for parse failures, but we explicitly
# request JSON parsing so the response should be 400 with our
# message — actually, request.json() on bad JSON raises and we
# convert it to 400.
r = client.post(
"/api/hooks/ingest",
content=b"not json",
headers={**auth_headers, "Content-Type": "application/json"},
)
assert r.status_code == 400
def test_400_on_non_dict_body(self, client, auth_headers):
r = client.post(
"/api/hooks/ingest",
json=["this", "is", "an", "array"],
headers=auth_headers,
)
assert r.status_code == 400
assert "object" in r.json()["detail"]
def test_400_on_missing_event_type(self, client, auth_headers):
r = client.post(
"/api/hooks/ingest",
json={"context": {}, "src": "gateway"},
headers=auth_headers,
)
assert r.status_code == 400
assert "event_type" in r.json()["detail"]
def test_400_on_empty_event_type(self, client, auth_headers):
r = client.post(
"/api/hooks/ingest",
json={"event_type": "", "context": {}, "src": "gateway"},
headers=auth_headers,
)
assert r.status_code == 400
def test_400_on_non_dict_context(self, client, auth_headers):
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": "string", "src": "gateway"},
headers=auth_headers,
)
assert r.status_code == 400
def test_200_with_missing_optional_fields(self, client, auth_headers):
"""src and context are optional; default to "?" and {}."""
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start"},
headers=auth_headers,
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# /ingest endpoint — republish behavior
# ---------------------------------------------------------------------------
class TestIngestRepublish:
def test_republishes_via_emit_sync(self, client, fresh_registry):
captured: list = []
fresh_registry.register("agent:start", lambda e, c: captured.append((e, c)))
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
r = client.post(
"/api/hooks/ingest",
json={
"event_type": "agent:start",
"context": {"platform": "telegram", "user_id": "u-1"},
"src": "gateway",
},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
assert len(captured) == 1
event, ctx = captured[0]
assert event == "agent:start"
# Original context keys preserved.
assert ctx["platform"] == "telegram"
assert ctx["user_id"] == "u-1"
# Forwarding metadata stamped.
assert ctx["_forwarded"] is True
assert ctx["_forwarded_from"] == "gateway"
def test_forwarded_stamp_overrides_caller_provided_value(
self, client, fresh_registry
):
"""If a malicious/buggy caller tries to set _forwarded=False to
smuggle the event past loop prevention, the endpoint overrides
it. This isn't a security boundary (the auth gate is) but a
defensive sanity check."""
captured: list = []
fresh_registry.register("agent:start", lambda e, c: captured.append(c))
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
client.post(
"/api/hooks/ingest",
json={
"event_type": "agent:start",
"context": {"_forwarded": False},
"src": "gateway",
},
headers={"Authorization": f"Bearer {token}"},
)
assert captured[0]["_forwarded"] is True
def test_wildcard_handlers_see_forwarded_events(self, client, fresh_registry):
"""A subscriber to ``tui:*`` sees forwarded ``tui:tool.start`` events."""
captured: list = []
fresh_registry.register("tui:*", lambda e, c: captured.append(e))
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
client.post(
"/api/hooks/ingest",
json={
"event_type": "tui:tool.start",
"context": {"session_id": "s-1", "payload": {"name": "search_files"}},
"src": "tui",
},
headers={"Authorization": f"Bearer {token}"},
)
assert captured == ["tui:tool.start"]
def test_src_defaults_to_question_mark(self, client, fresh_registry):
captured: list = []
fresh_registry.register("agent:start", lambda e, c: captured.append(c))
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}},
headers={"Authorization": f"Bearer {token}"},
)
assert captured[0]["_forwarded_from"] == "?"
def test_non_string_src_is_normalized(self, client, fresh_registry):
"""If something weird sends src=123, we don't propagate the bad type."""
captured: list = []
fresh_registry.register("agent:start", lambda e, c: captured.append(c))
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:start", "context": {}, "src": 123},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
assert captured[0]["_forwarded_from"] == "?"
def test_handler_exception_does_not_break_ingest(self, client, fresh_registry):
"""A buggy subscriber raising in emit_sync must not 500 the
ingest endpoint emit_sync swallows handler exceptions, but
the route also has a top-level try/except defensive layer."""
fresh_registry.register("agent:end", lambda _e, _c: 1 / 0)
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
r = client.post(
"/api/hooks/ingest",
json={"event_type": "agent:end", "context": {}, "src": "gateway"},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
+97
View File
@@ -0,0 +1,97 @@
from decimal import Decimal
from agent.models_dev import ModelInfo
from agent.usage_pricing import PricingEntry
from hermes_cli.model_cost_guard import expensive_model_warning
def test_no_warning_when_known_prices_are_at_threshold():
info = ModelInfo(
id="edge/model",
name="edge/model",
family="",
provider_id="test",
cost_input=20.0,
cost_output=100.0,
)
assert expensive_model_warning("edge/model", provider="test", model_info=info) is None
def test_warns_when_models_dev_input_price_exceeds_threshold():
info = ModelInfo(
id="expensive/input",
name="expensive/input",
family="",
provider_id="test",
cost_input=20.01,
cost_output=1.0,
)
warning = expensive_model_warning(
"expensive/input",
provider="test",
model_info=info,
)
assert warning is not None
assert warning.input_cost_per_million == Decimal("20.01")
assert "EXPENSIVE MODEL WARNING" in warning.message
assert "$20/M input" in warning.message
def test_warns_when_pricing_entry_output_price_exceeds_threshold(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.get_pricing_entry",
lambda *_args, **_kwargs: PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("100.01"),
source="provider_models_api",
),
)
warning = expensive_model_warning("provider/expensive-output", provider="openrouter")
assert warning is not None
assert warning.output_cost_per_million == Decimal("100.01")
assert "$100.01/M" in warning.message
def test_openai_gpt55_pro_adds_suggestion(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.get_pricing_entry",
lambda *_args, **_kwargs: PricingEntry(
input_cost_per_million=Decimal("25"),
output_cost_per_million=Decimal("125"),
source="provider_models_api",
),
)
warning = expensive_model_warning("openai/gpt-5.5-pro", provider="openrouter")
assert warning is not None
assert "did you mean to select openai/gpt-5.5?" in warning.message
def test_openai_gpt55_pro_warns_for_nous_portal_pricing(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.fetch_endpoint_model_metadata",
lambda base_url, api_key="": {
"openai/gpt-5.5-pro": {
"pricing": {
"prompt": "0.000025",
"completion": "0.000125",
}
}
},
)
warning = expensive_model_warning("openai/gpt-5.5-pro", provider="nous")
assert warning is not None
assert warning.input_cost_per_million == Decimal("25.000000")
assert warning.output_cost_per_million == Decimal("125.000000")
assert "did you mean to select openai/gpt-5.5?" in warning.message
@@ -0,0 +1,64 @@
from types import SimpleNamespace
from hermes_cli.model_switch import ModelSwitchResult
def _bound(fn, instance):
return fn.__get__(instance, type(instance))
def test_prompt_toolkit_model_picker_defers_confirmation_off_key_handler(monkeypatch):
import cli as cli_mod
result = ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="nous",
)
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model",
lambda **_kwargs: result,
)
captured = {}
class _Thread:
def __init__(self, *, target, args, daemon):
captured["target"] = target
captured["args"] = args
captured["daemon"] = daemon
def start(self):
captured["started"] = True
monkeypatch.setattr(cli_mod.threading, "Thread", _Thread)
self_ = SimpleNamespace(
_app=object(),
_model_picker_state={
"stage": "model",
"provider_data": {"slug": "nous"},
"model_list": ["openai/gpt-5.5-pro"],
"selected": 0,
"user_provs": None,
"custom_provs": None,
},
provider="nous",
model="openai/gpt-5.5",
base_url="",
api_key="",
_restore_modal_input_snapshot=lambda: None,
_invalidate=lambda **_kwargs: None,
)
self_._close_model_picker = _bound(cli_mod.HermesCLI._close_model_picker, self_)
self_._confirm_and_apply_model_switch_result = (
lambda *_args: captured.setdefault("ran_inline", True)
)
_bound(cli_mod.HermesCLI._handle_model_picker_selection, self_)()
assert self_._model_picker_state is None
assert captured["started"] is True
assert captured["daemon"] is True
assert captured["args"] == (result, False)
assert "ran_inline" not in captured
+38
View File
@@ -653,6 +653,44 @@ def test_browse_skills_dedup_uses_identifier_not_name(monkeypatch):
)
def test_do_browse_reports_live_per_source_progress():
"""do_browse must pass an on_source_done callback so the status line ticks
off each source as it resolves, instead of showing a frozen spinner while
a slow source blocks. The page is still rendered once, after the full
result set is merged and trust-sorted."""
from hermes_cli.skills_hub import do_browse
from tools.skills_hub import SkillMeta
meta = SkillMeta(
name="demo", description="d", source="official",
identifier="official/demo", trust_level="builtin",
)
captured = {}
def fake_parallel(sources, query="", per_source_limits=None,
source_filter="all", overall_timeout=30,
on_source_done=None):
# Simulate two sources completing — the callback must be wired through.
assert on_source_done is not None, "do_browse must pass on_source_done"
on_source_done("official", 1)
on_source_done("clawhub", 0)
captured["called"] = True
return [meta], {"official": 1, "clawhub": 0}, []
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None, width=120)
with patch("tools.skills_hub.create_source_router", return_value=[]), \
patch("tools.skills_hub.GitHubAuth"), \
patch("tools.skills_hub.parallel_search_sources", side_effect=fake_parallel):
do_browse(page=1, page_size=20, console=console)
assert captured.get("called"), "parallel_search_sources was not invoked"
# The rendered page still shows the (single) merged result.
assert "demo" in sink.getvalue()
# ---------------------------------------------------------------------------
# Regression: full identifier must be recoverable from `hermes skills search`
# even when the slug is too long to fit the terminal width (issue #33674).
@@ -2,6 +2,7 @@
cannot initialize (e.g. non-TTY, curses unavailable, terminal error)."""
import subprocess
from types import SimpleNamespace
from hermes_cli.config import load_config, save_config
@@ -24,6 +25,46 @@ def test_prompt_model_selection_falls_back_on_menu_runtime_error(monkeypatch):
assert selected == "model-b"
def test_prompt_model_selection_requires_expensive_confirmation(monkeypatch, capsys):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
responses = iter(["1", "n"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(
["openai/gpt-5.5-pro"],
confirm_provider="nous",
)
out = capsys.readouterr().out
assert selected is None
assert "EXPENSIVE MODEL WARNING" in out
def test_prompt_model_selection_allows_confirmed_expensive_model(monkeypatch):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
responses = iter(["1", "y"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(
["openai/gpt-5.5-pro"],
confirm_provider="nous",
)
assert selected == "openai/gpt-5.5-pro"
def test_prompt_reasoning_effort_falls_back_on_menu_runtime_error(monkeypatch):
from hermes_cli.main import _prompt_reasoning_effort_selection
@@ -0,0 +1,218 @@
"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).
Covers the breadcrumb lifecycle and the launch-time recovery guard added so a
``hermes update`` killed mid-install (Ctrl-C, terminal close, WSL OOM) gets
finished automatically on the next launch instead of leaving a half-built venv.
"""
from __future__ import annotations
from pathlib import Path
import hermes_cli.main as m
def test_marker_round_trip(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
marker = m._update_marker_path()
assert marker == tmp_path / ".update-incomplete"
assert not marker.exists()
m._write_update_incomplete_marker()
assert marker.exists()
body = marker.read_text()
assert "started=" in body
assert "pid=" in body
m._clear_update_incomplete_marker()
assert not marker.exists()
def test_clear_when_absent_is_noop(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
# Must not raise when the marker was never written.
m._clear_update_incomplete_marker()
assert not m._update_marker_path().exists()
def test_recovery_noop_without_marker(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
called = {"install": False}
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: called.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert called["install"] is False, "recovery must not install when no marker"
def test_recovery_clears_stray_marker_without_pyproject(tmp_path, monkeypatch):
# No pyproject.toml (PyPI/Docker install) — a stray marker is not ours to
# act on; recovery should just clear it without trying to install.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
m._write_update_incomplete_marker()
called = {"install": False}
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: called.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert called["install"] is False
assert not m._update_marker_path().exists()
def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):
# Source-tree install (pyproject present) with marker set → recovery should
# run the dep install and clear the marker on success.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"ensurepip": False, "install": False}
def fake_run(cmd, *a, **k):
if "ensurepip" in cmd:
seen["ensurepip"] = True
class R:
returncode = 0
return R()
monkeypatch.setattr(m.subprocess, "run", fake_run)
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: seen.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert seen["ensurepip"] is True, "ensurepip must run unconditionally first"
assert seen["install"] is True, "dep install must run"
assert not m._update_marker_path().exists(), "marker cleared on success"
def test_recovery_keeps_marker_on_failure(tmp_path, monkeypatch):
# If the install itself blows up, the marker must survive so the next
# launch retries — and recovery must not raise.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
class R:
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
def boom(*a, **k):
raise RuntimeError("install died")
monkeypatch.setattr(
m, "_install_python_dependencies_with_optional_fallback", boom
)
# Must not raise.
m._recover_from_interrupted_install()
assert m._update_marker_path().exists(), "marker preserved for retry on failure"
def _stub_install_env(monkeypatch, m, seen):
"""Common stubs so recovery's install path is inert and observable."""
class R:
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: seen.__setitem__("install", True),
)
def test_recovery_skips_when_lock_held(tmp_path, monkeypatch):
# Another process is mid-recovery (fresh lockfile) — this launch must skip
# the install entirely and leave both marker and lock untouched.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
lock = tmp_path / ".update-incomplete.lock"
lock.write_text("12345\n")
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert seen["install"] is False, "must not install while another holds the lock"
assert m._update_marker_path().exists(), "marker left for the lock holder"
assert lock.exists(), "fresh lock must not be broken"
def test_recovery_breaks_stale_lock(tmp_path, monkeypatch):
# A lock older than an hour is from a crashed holder — it gets removed so
# the NEXT launch can recover (this launch still skips).
import os as _os
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
lock = tmp_path / ".update-incomplete.lock"
lock.write_text("12345\n")
stale = m._time.time() - 7200
_os.utime(lock, (stale, stale))
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert not lock.exists(), "stale lock must be broken"
assert m._update_marker_path().exists()
# Next launch proceeds normally.
m._recover_from_interrupted_install()
assert seen["install"] is True
assert not m._update_marker_path().exists()
assert not lock.exists(), "lock released after recovery"
def test_recovery_releases_lock_after_run(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert seen["install"] is True
assert not (tmp_path / ".update-incomplete.lock").exists()
def test_recovery_output_goes_to_stderr(tmp_path, monkeypatch, capfd):
# ACP speaks JSON-RPC on stdout — recovery output (including the streamed
# install, which inherits fd 1) must land on stderr only.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
out, err = capfd.readouterr()
assert "interrupted mid-install" not in out
assert "interrupted mid-install" in err
assert "recovered" in err
+173 -1
View File
@@ -4,6 +4,7 @@ import os
import json
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
@@ -1069,6 +1070,41 @@ class TestWebServerEndpoints:
assert "GATEWAY_PROXY_URL" not in managed
assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS
def test_model_set_requires_confirmation_for_expensive_model(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "openai/gpt-5.5-pro",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["confirm_required"] is True
assert data["confirm_message"] == "EXPENSIVE MODEL WARNING"
confirmed = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "openai/gpt-5.5-pro",
"confirm_expensive_model": True,
},
)
assert confirmed.status_code == 200
assert confirmed.json()["ok"] is True
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value
@@ -1363,6 +1399,17 @@ class TestWebServerEndpoints:
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
ws._ACTION_PROCS.pop("gateway-restart", None)
restart_calls = []
class FakeRestartProc:
pid = 4242
def fake_spawn_action(subcommand, name):
restart_calls.append((subcommand, name))
return FakeRestartProc()
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
@@ -1384,13 +1431,138 @@ class TestWebServerEndpoints:
"ok": True,
"platform": "telegram",
"bot_username": "hermes_pair_ready_bot",
"needs_restart": True,
"needs_restart": False,
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": 4242,
}
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_telegram_onboarding_apply_reports_restart_failure_after_save(
self, monkeypatch
):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config, load_env
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-restart-fails",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_restart_fails_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
assert method == "GET"
assert path == "/v1/telegram/pairings/pair-restart-fails"
assert bearer_token == "poll-secret"
return {
"status": "ready",
"bot_username": "hermes_pair_restart_fails_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
ws._ACTION_PROCS.pop("gateway-restart", None)
def fail_spawn_action(subcommand, name):
assert subcommand == ["gateway", "restart"]
assert name == "gateway-restart"
raise RuntimeError("supervisor unavailable")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-restart-fails")
assert ready.status_code == 200
assert ready.json()["status"] == "ready"
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-restart-fails/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data["ok"] is True
assert applied_data["needs_restart"] is True
assert applied_data["restart_started"] is False
assert "supervisor unavailable" in applied_data["restart_error"]
assert "token" not in applied_data
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_telegram_onboarding_apply_reuses_inflight_gateway_restart(
self, monkeypatch
):
"""A live in-flight gateway restart is reused instead of spawning a
second racing ``hermes gateway restart`` child (e.g. when a stale
cached frontend also fires its own restart call)."""
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-reuse",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_reuse_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_reuse_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_reuse_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
return {
"status": "ready",
"bot_username": "hermes_pair_reuse_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
class FakeRunningProc:
pid = 5151
def poll(self):
return None # still running
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
def fail_spawn_action(subcommand, name):
raise AssertionError("must not spawn a second concurrent restart")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-reuse")
assert ready.status_code == 200
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-reuse/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data["needs_restart"] is False
assert applied_data["restart_started"] is True
assert applied_data["restart_pid"] == 5151
def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch):
import hermes_cli.web_server as ws
+78
View File
@@ -0,0 +1,78 @@
import argparse
def test_xai_model_flow_reauth_uses_standard_radio_prompt(monkeypatch):
from hermes_cli import main as main_mod
captured = {"login_calls": 0}
monkeypatch.setattr(
"hermes_cli.auth.get_xai_oauth_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.setup._curses_prompt_choice",
lambda title, choices, default, description=None: 1,
)
def _fake_login(args, provider, force_new_login=False):
captured["login_calls"] += 1
captured["force_new_login"] = force_new_login
captured["args"] = args
monkeypatch.setattr("hermes_cli.auth._login_xai_oauth", _fake_login)
monkeypatch.setattr(
"hermes_cli.auth.resolve_xai_oauth_runtime_credentials",
lambda *args, **kwargs: {"base_url": "https://api.x.ai/v1"},
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
)
main_mod._model_flow_xai_oauth(
{},
current_model="grok-build-0.1",
args=argparse.Namespace(manual_paste=True, no_browser=True, timeout=3),
)
assert captured["login_calls"] == 1
assert captured["force_new_login"] is True
assert captured["args"].manual_paste is True
assert captured["args"].no_browser is True
assert captured["args"].timeout == 3
def test_xai_model_flow_cancel_skips_reauth(monkeypatch):
from hermes_cli import main as main_mod
monkeypatch.setattr(
"hermes_cli.auth.get_xai_oauth_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.setup._curses_prompt_choice",
lambda title, choices, default, description=None: 2,
)
monkeypatch.setattr(
"hermes_cli.auth._login_xai_oauth",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not reauthenticate")),
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not pick a model")),
)
main_mod._model_flow_xai_oauth({}, current_model="grok-build-0.1")
def test_auth_credentials_choice_falls_back_to_numbered_prompt(monkeypatch):
from hermes_cli import main as main_mod
monkeypatch.setattr(
"hermes_cli.setup._curses_prompt_choice",
lambda title, choices, default, description=None: -1,
)
monkeypatch.setattr("builtins.input", lambda prompt="": "2")
assert main_mod._prompt_auth_credentials_choice("Credentials:") == "reauth"
+34
View File
@@ -171,6 +171,40 @@ class TestHooksInert:
mod.on_post_tool_call(tool_name="read_file", args={}, result="ok", task_id="t", session_id="s")
class TestPayloadSanitization:
def test_safe_value_redacts_base64_data_uri_instead_of_truncating(self):
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
payload = "data:image/png;base64," + ("a" * 20000)
result = mod._safe_value(payload)
assert result == {
"type": "data_uri",
"media_type": "image/png",
"omitted": True,
"length": len(payload),
}
def test_serialize_messages_redacts_data_uri_parts(self):
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
payload = "data:image/jpeg;base64," + ("b" * 20000)
serialized = mod._serialize_messages([
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": payload}}]}
])
assert serialized[0]["content"][0]["image_url"]["url"] == {
"type": "data_uri",
"media_type": "image/jpeg",
"omitted": True,
"length": len(payload),
}
# ---------------------------------------------------------------------------
# Placeholder-credential guard (#23823).
#
+115
View File
@@ -291,6 +291,121 @@ class TestOpenRouterProfile:
assert eb["reasoning"] == {"enabled": True, "effort": "high"}
assert tl["extra_headers"]["x-grok-conv-id"] == "sess-123"
# --- reasoning-mandatory Anthropic effort → top-level verbosity (#43432) ---
#
# These models (Claude 4.6+ / fable / mythos-class) ignore
# ``reasoning.effort`` and use adaptive thinking. OpenRouter honors the
# requested effort on the top-level ``verbosity`` field instead (maps to
# Anthropic ``output_config.effort``). The profile must route the existing
# ``reasoning_config["effort"]`` there while still NEVER emitting a
# ``reasoning`` field (which would 400 — see #42991). Gate every fixture on
# the real predicate so this stays a behavior contract, not a name snapshot.
@staticmethod
def _is_mandatory(model):
import inspect
p = get_provider_profile("openrouter")
mod = inspect.getmodule(type(p))
return mod._anthropic_reasoning_is_mandatory(model)
def test_mandatory_anthropic_effort_routes_to_verbosity(self):
"""effort set + reasoning enabled → top-level verbosity == effort,
and NO reasoning field in extra_body.
Covers the full real config range produced by
``hermes_constants.parse_reasoning_effort``
``VALID_REASONING_EFFORTS = (minimal, low, medium, high, xhigh)``.
"""
p = get_provider_profile("openrouter")
model = "anthropic/claude-fable-5"
assert self._is_mandatory(model) # fixture really is mandatory
for effort in ("minimal", "low", "medium", "high", "xhigh"):
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
supports_reasoning=True,
model=model,
)
assert tl["verbosity"] == effort, (effort, tl)
assert "reasoning" not in eb, (effort, eb)
def test_mandatory_anthropic_effort_without_enabled_key_routes(self):
"""effort present without an explicit ``enabled`` key still routes to
verbosity (enabled defaults to True)."""
p = get_provider_profile("openrouter")
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl["verbosity"] == "xhigh"
assert "reasoning" not in eb
def test_mandatory_anthropic_verbosity_is_value_agnostic_passthrough(self):
"""The mapping passes the effort value through verbatim — it must NOT
clamp or whitelist. ``xhigh`` is a real config value; ``max`` is not
producible by ``parse_reasoning_effort`` today but OpenRouter accepts it
for Claude (live-proven in #43432), so a forward value must survive
rather than be silently dropped. The OpenAI SDK type only literals
``low|medium|high`` but it's a TypedDict (no runtime validation), so the
extended scale reaches the wire untouched."""
p = get_provider_profile("openrouter")
for effort in ("xhigh", "max"):
_, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl["verbosity"] == effort
def test_mandatory_anthropic_no_verbosity_when_effort_absent(self):
"""No effort / none / disabled → no verbosity emitted, so the model
keeps its own adaptive default. Still no reasoning field."""
p = get_provider_profile("openrouter")
model = "anthropic/claude-fable-5"
for cfg in (
None,
{},
{"enabled": True},
{"effort": "none"},
{"enabled": True, "effort": "none"},
{"enabled": False, "effort": "high"}, # explicitly disabled wins
):
eb, tl = p.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model=model,
)
assert "verbosity" not in tl, (cfg, tl)
assert "reasoning" not in eb, (cfg, eb)
def test_non_mandatory_reasoning_model_unchanged_no_verbosity(self):
"""Non-mandatory reasoning models (DeepSeek, Qwen, GPT) keep getting
``reasoning`` in extra_body and never get a ``verbosity`` field the
new path must not touch them."""
p = get_provider_profile("openrouter")
for model in ("deepseek/deepseek-chat", "qwen/qwen3-max", "openai/gpt-5.4"):
assert not self._is_mandatory(model) # fixture really is non-mandatory
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model=model,
)
assert eb["reasoning"] == {"enabled": True, "effort": "high"}, (model, eb)
assert "verbosity" not in tl, (model, tl)
def test_mandatory_anthropic_verbosity_coexists_with_grok_header(self):
"""A reasoning-mandatory Anthropic model is never a Grok model, but the
top-level dict must remain a single merged dict verify the verbosity
path doesn't clobber the extra_headers slot used by Grok affinity."""
p = get_provider_profile("openrouter")
# mandatory anthropic + effort → verbosity, no extra_headers
_, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl == {"verbosity": "high"}
class TestNousProfile:
def test_tags(self):
+35
View File
@@ -5063,6 +5063,41 @@ class TestMaxTokensParam:
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
# ── Model-name fallback for non-openai.com endpoints serving newer families ──
def test_returns_max_completion_tokens_for_gpt5_on_custom_endpoint(self, agent):
"""Custom OpenAI-compatible endpoint serving gpt-5.x must also use
max_completion_tokens otherwise the server 400s on max_tokens."""
agent.base_url = "https://my-gateway.example.com/v1"
agent.model = "gpt-5.4"
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
def test_returns_max_completion_tokens_for_gpt4o_on_openrouter(self, agent):
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "openai/gpt-4o-mini"
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
def test_returns_max_completion_tokens_for_o1_on_custom_endpoint(self, agent):
agent.base_url = "https://custom.example.com/v1"
agent.model = "o1-preview"
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
def test_returns_max_tokens_for_classic_gpt4_on_openrouter(self, agent):
"""Classic gpt-4 (non-omni) still uses max_tokens. Don't over-match."""
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "openai/gpt-4-turbo"
result = agent._max_tokens_param(4096)
assert result == {"max_tokens": 4096}
def test_returns_max_tokens_for_llama_on_local(self, agent):
agent.base_url = "http://localhost:11434/v1"
agent.model = "llama3"
result = agent._max_tokens_param(4096)
assert result == {"max_tokens": 4096}
class TestGpt5ApiModeRouting:
"""Verify provider-specific GPT-5 API-mode routing."""
-319
View File
@@ -1,319 +0,0 @@
"""End-to-end integration test for cross-process hook delivery.
Wires together (in the SAME process to avoid subprocess complexity, but
end-to-end through real HTTP + a real FastAPI app):
Source HookRegistry forwarderHTTP> /api/hooks/ingest > Dashboard HookRegistry
> handler fires
This validates the wire-format contract between the forwarder and the
ingest endpoint: anything the forwarder produces must be accepted and
republished correctly by the ingest endpoint.
The test runs uvicorn in a daemon thread so we have a real OS socket
forwarders can POST to. ``dashboard.json`` is written into a temp
$HERMES_HOME so the forwarder finds the test server instead of any
real running dashboard on the dev box.
"""
from __future__ import annotations
import json
import socket
import threading
import time
from pathlib import Path
from typing import Callable
import pytest
import uvicorn
from fastapi import FastAPI
from gateway import hook_forwarder
from gateway.hooks import (
HookRegistry,
_reset_default_registry_for_tests,
install_as_default,
)
from hermes_cli import hook_ingest
def _free_port() -> int:
"""Find an unused TCP port on loopback by binding port 0."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_until(predicate: Callable[[], bool], *, timeout: float = 5.0) -> bool:
"""Poll a predicate until it returns True or the timeout elapses.
Cheaper than ``time.sleep`` calls littered through the test body;
keeps test runtime down when the system is fast and bounded when
it's slow.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Each test gets a fresh $HERMES_HOME so ``dashboard.json`` writes
don't bleed across tests (or into the real dev box's home)."""
monkeypatch.setattr(hook_ingest, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
hook_ingest._reset_for_tests()
hook_forwarder._reset_for_tests()
yield tmp_path
hook_ingest._reset_for_tests()
hook_forwarder._reset_for_tests()
@pytest.fixture
def dashboard_server(hermes_home):
"""Start a real uvicorn server with the hook router mounted, write a
matching ``dashboard.json``, and tear it all down on test exit."""
# Build the dashboard's FastAPI app. Just the hook router — no need
# to load the full web_server which would pull in the SPA build,
# auth providers, etc.
app = FastAPI()
app.include_router(hook_ingest.build_hook_router(), prefix="/api/hooks")
# Install a fresh default registry on the (test) dashboard side.
# This is what the ingest endpoint republishes events onto.
_reset_default_registry_for_tests()
dashboard_reg = HookRegistry()
install_as_default(dashboard_reg)
port = _free_port()
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
server_thread = threading.Thread(target=server.run, daemon=True)
server_thread.start()
# Wait for the server to start accepting connections.
if not _wait_until(lambda: server.started, timeout=5.0):
raise RuntimeError("uvicorn failed to start within 5s")
# Drop the discovery file pointing at our test server.
hook_ingest.write_dashboard_discovery_file("127.0.0.1", port)
yield {"app": app, "port": port, "registry": dashboard_reg}
# Teardown: gracefully shut down the server.
server.should_exit = True
server_thread.join(timeout=5.0)
_reset_default_registry_for_tests()
# ---------------------------------------------------------------------------
# End-to-end: source → forwarder → HTTP → ingest → dashboard registry
# ---------------------------------------------------------------------------
def test_end_to_end_event_delivery(dashboard_server, hermes_home):
"""A single fired event in the source registry reaches a subscriber
on the dashboard registry via real HTTP."""
captured: list = []
dashboard_server["registry"].register(
"agent:start", lambda e, c: captured.append((e, c))
)
# Source-side registry — simulates the gateway process.
source_reg = HookRegistry()
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
try:
source_reg.emit_sync(
"agent:start",
{"platform": "telegram", "user_id": "u-1", "session_id": "s-1"},
)
# Wait for the daemon worker thread to flush the queue + POST.
assert _wait_until(lambda: len(captured) >= 1, timeout=10.0), (
"Event never reached the dashboard subscriber"
)
event, ctx = captured[0]
assert event == "agent:start"
# Original context survived the round trip.
assert ctx["platform"] == "telegram"
assert ctx["user_id"] == "u-1"
assert ctx["session_id"] == "s-1"
# And the ingest endpoint stamped forwarding metadata.
assert ctx["_forwarded"] is True
assert ctx["_forwarded_from"] == "gateway"
finally:
hook_forwarder.stop()
def test_multiple_namespaces_round_trip(dashboard_server, hermes_home):
"""The forwarder covers every namespace the design promises."""
captured: dict = {ns: [] for ns in ("tui:*", "agent:*", "session:*", "command:*")}
for ns in captured:
# Closure over ns — bind it as a default arg to avoid late binding.
dashboard_server["registry"].register(
ns, lambda e, _c, _ns=ns: captured[_ns].append(e)
)
source_reg = HookRegistry()
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
try:
# One event per namespace.
source_reg.emit_sync("tui:tool.start", {"session_id": "s", "payload": {}})
source_reg.emit_sync("agent:start", {"user_id": "u"})
source_reg.emit_sync("session:reset", {"session_key": "k"})
source_reg.emit_sync("command:reset", {"command": "reset"})
assert _wait_until(
lambda: all(len(v) >= 1 for v in captured.values()),
timeout=10.0,
), f"Some events missing: {captured}"
assert captured["tui:*"] == ["tui:tool.start"]
assert captured["agent:*"] == ["agent:start"]
assert captured["session:*"] == ["session:reset"]
assert captured["command:*"] == ["command:reset"]
finally:
hook_forwarder.stop()
def test_loop_prevention_forwarded_events_not_reshipped(dashboard_server, hermes_home):
"""An event whose context already has ``_forwarded=True`` is NOT
shipped by the source-side forwarder.
This is what closes the loop: dashboard republishes an event into
its own registry with ``_forwarded=True``, and if a forwarder were
also running in that process (it isn't, by design — but defense in
depth) it would skip the event instead of round-tripping it back.
"""
# Counter on the dashboard side — bumps every time the ingest
# endpoint fires the agent:start handler.
ingest_hits: list = []
dashboard_server["registry"].register(
"agent:start", lambda _e, _c: ingest_hits.append(1)
)
source_reg = HookRegistry()
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
try:
# Fire an event that's already marked as forwarded — simulates
# an event that came back to the source process somehow. The
# forwarder must skip it (no POST to /ingest).
source_reg.emit_sync(
"agent:start",
{
"platform": "telegram",
"_forwarded": True,
"_forwarded_from": "dashboard-echo",
},
)
# Wait long enough that if a POST were going to happen, it
# would have. Then verify NO ingest hits occurred.
time.sleep(0.3)
assert ingest_hits == [], (
f"Forwarder shipped a _forwarded=True event: {ingest_hits}"
)
finally:
hook_forwarder.stop()
def test_forwarder_recovers_from_dashboard_restart(dashboard_server, hermes_home):
"""If the dashboard rotates its token (restart), the forwarder's
next probe re-reads ``dashboard.json`` and picks up the new token.
Unit-tested in detail in ``test_hook_forwarder.py::TestDiscoveryRefresh``.
Here we pin the integration: after a token rotation + probe, events
still land at the dashboard.
"""
captured: list = []
dashboard_server["registry"].register(
"agent:start", lambda _e, c: captured.append(c)
)
source_reg = HookRegistry()
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
try:
# First event lands cleanly with the original token.
source_reg.emit_sync("agent:start", {"seq": 1})
assert _wait_until(lambda: len(captured) >= 1, timeout=5.0)
assert captured[0]["seq"] == 1
# Simulate dashboard restart: token rotates in dashboard.json
# AND in the ingest endpoint's module state (we're sharing the
# same hook_ingest module so write_dashboard_discovery_file
# updates both).
port = dashboard_server["port"]
hook_ingest.write_dashboard_discovery_file("127.0.0.1", port)
# Force a discovery refresh directly — simulates what the
# forwarder's worker does on its 30s probe cycle. Using the
# active forwarder's HTTP client this way avoids a 30s
# real-time wait.
import httpx
with httpx.Client(timeout=2.0) as client:
fwd = hook_forwarder._active
assert fwd is not None
fwd._refresh_discovery(client)
source_reg.emit_sync("agent:start", {"seq": 2})
assert _wait_until(lambda: len(captured) >= 2, timeout=10.0)
assert captured[1]["seq"] == 2
finally:
hook_forwarder.stop()
def test_no_dashboard_available_silent_noop(hermes_home):
"""When no dashboard is reachable (no discovery file), the forwarder
is a complete no-op no thread spawned, no handlers registered."""
# Don't start the dashboard fixture — there's nothing to forward to.
source_reg = HookRegistry()
result = hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
assert result is None
assert source_reg._handlers == {}
# And firing an event doesn't cause anything to happen.
source_reg.emit_sync("agent:start", {}) # must not raise
def test_no_dashboard_then_dashboard_starts_later(hermes_home):
"""``start_if_dashboard_available`` is a one-shot check at call time.
If no dashboard is running when the source process starts, the
forwarder doesn't start. Documented behavior: source-process
consumers must call ``start_if_dashboard_available`` once at startup;
if a dashboard appears later, only newly-started processes pick it
up. (A long-running process with no forwarder will never get one
retroactively.)
This test pins that contract so we notice if we accidentally add
retry behavior that would be a behavior change worth discussing,
not a sneak in.
"""
source_reg = HookRegistry()
# First call: no dashboard yet.
result = hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
assert result is None
# Dashboard appears.
port = _free_port()
hook_ingest.write_dashboard_discovery_file("127.0.0.1", port)
# Calling again does start the forwarder now. (The contract says
# the wire-up sites only call once at startup, but the function
# itself supports retries — useful for tests, and so callers can
# call it from a post-config hook if they want.)
result = hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
assert result is not None
hook_forwarder.stop()
@@ -0,0 +1,137 @@
"""Targeted tests for ``utils.model_forces_max_completion_tokens``.
This helper decides whether a given model name requires the newer
``max_completion_tokens`` kwarg (rather than the legacy ``max_tokens``) on
``/v1/chat/completions``. It protects against the 400 ``unsupported_parameter``
error seen when third-party OpenAI-compatible endpoints serve gpt-4o / 4.1 /
5.x / o-series models by name and the caller only checks the URL host.
"""
from __future__ import annotations
from utils import model_forces_max_completion_tokens
# ─── Positive cases: families that require max_completion_tokens ────────────
class TestPositiveCases:
def test_gpt_5_bare(self):
assert model_forces_max_completion_tokens("gpt-5") is True
def test_gpt_5_point_release(self):
# The case the user actually hit — gpt-5.4 on a custom OpenAI-compatible
# endpoint was being sent max_tokens and getting 400 back.
assert model_forces_max_completion_tokens("gpt-5.4") is True
def test_gpt_5_mini(self):
assert model_forces_max_completion_tokens("gpt-5-mini") is True
def test_gpt_5_nano(self):
assert model_forces_max_completion_tokens("gpt-5-nano") is True
def test_gpt_4o(self):
assert model_forces_max_completion_tokens("gpt-4o") is True
def test_gpt_4o_mini(self):
assert model_forces_max_completion_tokens("gpt-4o-mini") is True
def test_gpt_4_1(self):
assert model_forces_max_completion_tokens("gpt-4.1") is True
def test_gpt_4_1_mini(self):
assert model_forces_max_completion_tokens("gpt-4.1-mini") is True
def test_o1(self):
assert model_forces_max_completion_tokens("o1") is True
def test_o1_preview(self):
assert model_forces_max_completion_tokens("o1-preview") is True
def test_o1_mini(self):
assert model_forces_max_completion_tokens("o1-mini") is True
def test_o3(self):
assert model_forces_max_completion_tokens("o3") is True
def test_o3_mini(self):
assert model_forces_max_completion_tokens("o3-mini") is True
def test_o4_mini(self):
# Future-proofing — o4 is already listed publicly.
assert model_forces_max_completion_tokens("o4-mini") is True
# ─── Negative cases: older or non-OpenAI families still use max_tokens ──────
class TestNegativeCases:
def test_gpt_3_5_turbo(self):
assert model_forces_max_completion_tokens("gpt-3.5-turbo") is False
def test_gpt_4(self):
# Classic gpt-4 (non-omni) still uses max_tokens on chat completions.
assert model_forces_max_completion_tokens("gpt-4") is False
def test_gpt_4_turbo(self):
assert model_forces_max_completion_tokens("gpt-4-turbo") is False
def test_claude_family(self):
assert model_forces_max_completion_tokens("claude-3-opus") is False
assert model_forces_max_completion_tokens("claude-sonnet-4-6") is False
def test_llama_family(self):
assert model_forces_max_completion_tokens("llama3") is False
assert model_forces_max_completion_tokens("llama-3-70b-instruct") is False
def test_mistral_family(self):
assert model_forces_max_completion_tokens("mistral-7b-instruct") is False
def test_qwen_family(self):
assert model_forces_max_completion_tokens("qwen2.5-72b") is False
def test_deepseek_family(self):
assert model_forces_max_completion_tokens("deepseek-chat") is False
# ─── Edge cases ─────────────────────────────────────────────────────────────
class TestEdgeCases:
def test_empty_string(self):
assert model_forces_max_completion_tokens("") is False
def test_none(self):
assert model_forces_max_completion_tokens(None) is False # type: ignore[arg-type]
def test_whitespace_only(self):
assert model_forces_max_completion_tokens(" ") is False
def test_case_insensitive(self):
assert model_forces_max_completion_tokens("GPT-5.4") is True
assert model_forces_max_completion_tokens("Gpt-4o-Mini") is True
assert model_forces_max_completion_tokens("O3-MINI") is True
def test_leading_trailing_whitespace(self):
assert model_forces_max_completion_tokens(" gpt-5 ") is True
def test_vendor_prefix_stripped(self):
# OpenRouter-style "vendor/model" names should match the tail.
assert model_forces_max_completion_tokens("openai/gpt-5.4") is True
assert model_forces_max_completion_tokens("openai/gpt-4o-mini") is True
assert model_forces_max_completion_tokens("openai/o3-mini") is True
def test_vendor_prefix_with_non_matching_tail(self):
assert model_forces_max_completion_tokens("openai/gpt-3.5-turbo") is False
assert model_forces_max_completion_tokens("anthropic/claude-3-opus") is False
def test_fake_prefix_not_matched(self):
# "o-series-but-not-really" doesn't start with o1/o3/o4.
assert model_forces_max_completion_tokens("omni-chat") is False
# "ox" isn't an o-series model, and "olive" / "opus" shouldn't collide.
assert model_forces_max_completion_tokens("ox-large") is False
assert model_forces_max_completion_tokens("opus-3") is False
def test_gpt_5_substring_in_middle_not_matched(self):
# Only a prefix should match — "local-gpt-5-clone" is a different model.
assert model_forces_max_completion_tokens("local-gpt-5-clone") is False
-185
View File
@@ -1,185 +0,0 @@
"""Tests for the TUI gateway → ``gateway.hooks`` bridge.
Every call into ``tui_gateway.server._emit`` should mirror the event onto the
process-wide ``HookRegistry`` under the ``tui:`` namespace so in-process
plugins can subscribe via :func:`gateway.hooks.get_default_registry`.
The mirror runs as a side-effect after ``write_json`` and is wrapped in a
broad try/except so a buggy subscriber can never break the main JSON-RPC
dispatch path.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from gateway.hooks import (
HookRegistry,
_reset_default_registry_for_tests,
get_default_registry,
install_as_default,
)
from tui_gateway import server
@pytest.fixture(autouse=True)
def _reset_registry_and_module_cache():
"""Reset the default registry and the TUI module-level cache before each test.
Without this the cache from a previous test (or a previous run within the
same process) would shadow our fresh install_as_default call and the
mirrored event would land on the wrong registry.
"""
_reset_default_registry_for_tests()
# Force the deferred-import cache in the TUI module to re-resolve.
server._hook_registry = None
# Also reset the forwarder-start sentinel so each test's first emit
# re-evaluates "is a dashboard reachable?" against the fixture's
# state instead of remembering a previous test's outcome.
server._forwarder_started = False
yield
_reset_default_registry_for_tests()
server._hook_registry = None
server._forwarder_started = False
class _StubTransport:
"""Captures write_json calls so the test doesn't actually touch stdout."""
def __init__(self):
self.written: list[dict] = []
def write(self, obj):
self.written.append(obj)
return True
def test_emit_mirrors_to_default_registry():
transport = _StubTransport()
captured: list = []
reg = HookRegistry()
install_as_default(reg)
reg.register("tui:tool.start", lambda e, c: captured.append((e, c)))
with patch.object(server, "_stdio_transport", transport):
server._emit("tool.start", "sid-123", {"name": "search_files"})
# The JSON-RPC event was written as before.
assert len(transport.written) == 1
assert transport.written[0]["params"]["type"] == "tool.start"
# The hook bus saw a tui:-prefixed mirror.
assert captured == [
(
"tui:tool.start",
{"session_id": "sid-123", "payload": {"name": "search_files"}},
)
]
def test_emit_with_no_payload_yields_empty_payload_dict():
transport = _StubTransport()
captured: list = []
reg = HookRegistry()
install_as_default(reg)
reg.register("tui:session.info", lambda _e, c: captured.append(c))
with patch.object(server, "_stdio_transport", transport):
server._emit("session.info", "sid-1", None)
assert captured == [{"session_id": "sid-1", "payload": {}}]
def test_emit_subscriber_exception_does_not_break_dispatch():
transport = _StubTransport()
reg = HookRegistry()
install_as_default(reg)
def broken(_e, _c):
raise RuntimeError("subscriber blew up")
reg.register("tui:tool.start", broken)
# If _publish_tui_hook propagated, this would raise.
with patch.object(server, "_stdio_transport", transport):
server._emit("tool.start", "sid-1", {"name": "x"})
# JSON-RPC event still landed on stdout — host pipeline intact.
assert len(transport.written) == 1
assert transport.written[0]["params"]["type"] == "tool.start"
def test_wildcard_subscriber_sees_all_tui_events():
transport = _StubTransport()
seen_types: list = []
reg = HookRegistry()
install_as_default(reg)
reg.register("tui:*", lambda e, _c: seen_types.append(e))
with patch.object(server, "_stdio_transport", transport):
server._emit("tool.start", "s", {})
server._emit("message.delta", "s", {"text": "hi"})
server._emit("session.info", "s", {})
assert seen_types == [
"tui:tool.start",
"tui:message.delta",
"tui:session.info",
]
def test_emit_does_not_blow_up_when_no_subscribers():
transport = _StubTransport()
# No registry installed beyond the lazy default — and no handlers.
with patch.object(server, "_stdio_transport", transport):
server._emit("tool.start", "s", {"name": "x"})
# Dispatch worked, no subscribers fired (default registry is empty).
assert len(transport.written) == 1
def test_hook_registry_resolved_lazily_via_get_default_registry():
"""The TUI module caches whatever ``get_default_registry`` returns at first
use. Re-set the default before the first ``_emit`` and confirm the cache
picks up the new instance, not a stale or never-installed one."""
transport = _StubTransport()
captured: list = []
custom = HookRegistry()
install_as_default(custom)
custom.register("tui:tool.start", lambda _e, c: captured.append(c))
# Sanity: server._hook_registry starts unset thanks to the fixture.
assert server._hook_registry is None
with patch.object(server, "_stdio_transport", transport):
server._emit("tool.start", "s", {"x": 1})
# The cache now points at the installed registry.
assert server._hook_registry is custom
assert captured == [{"session_id": "s", "payload": {"x": 1}}]
# Subsequent emits keep using the cached reference even if the default
# is swapped — the contract is "resolve once, cache thereafter."
new_reg = HookRegistry()
install_as_default(new_reg)
second: list = []
new_reg.register("tui:tool.start", lambda _e, c: second.append(c))
custom.register("tui:tool.start", lambda _e, c: captured.append({"second": c}))
with patch.object(server, "_stdio_transport", transport):
server._emit("tool.start", "s", {"x": 2})
# The cached registry (``custom``) saw the new event, the freshly-installed
# ``new_reg`` did not.
assert second == []
# ``custom`` has two handlers registered now (the original lambda still
# fires on every event, plus the second one that wraps payload in
# ``{"second": ...}``). Both fire on the second ``_emit`` call.
assert captured == [
{"session_id": "s", "payload": {"x": 1}},
{"session_id": "s", "payload": {"x": 2}},
{"second": {"session_id": "s", "payload": {"x": 2}}},
]
assert get_default_registry() is new_reg
+72 -4
View File
@@ -2397,7 +2397,7 @@ def test_config_set_model_waits_for_lazy_agent_before_switch(monkeypatch):
target["agent"] = agent
agent_ready.set()
def fake_apply(sid, target, raw):
def fake_apply(sid, target, raw, **kwargs):
calls.append(("apply", sid, target.get("agent"), raw))
if target.get("agent") is not agent:
raise AssertionError("model switch ran before lazy agent was ready")
@@ -2424,7 +2424,7 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
server._sessions["sid"] = _session()
seen = {}
def _fake_apply(sid, session, raw):
def _fake_apply(sid, session, raw, **_kwargs):
seen["args"] = (sid, session["session_key"], raw)
return {"value": "new/model", "warning": "catalog unreachable"}
@@ -2442,6 +2442,74 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
assert seen["args"] == ("sid", "session-key", "new/model")
def test_config_set_model_requires_confirmation_for_expensive_model(monkeypatch):
class _Agent:
provider = "openrouter"
model = "old/model"
base_url = ""
api_key = "sk-or"
switched = False
def switch_model(self, **_kwargs):
self.switched = True
result = types.SimpleNamespace(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="openrouter",
api_key="sk-or",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
warning_message="",
model_info=types.SimpleNamespace(
has_cost_data=lambda: True,
cost_input=25.0,
cost_output=125.0,
),
)
agent = _Agent()
server._sessions["sid"] = _session(agent=agent)
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model", lambda **_kwargs: result
)
monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "openai/gpt-5.5-pro --provider openrouter",
},
}
)
assert resp["result"]["confirm_required"] is True
assert "did you mean to select openai/gpt-5.5?" in resp["result"]["confirm_message"]
assert agent.switched is False
confirmed = server.handle_request(
{
"id": "2",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "openai/gpt-5.5-pro --provider openrouter",
"confirm_expensive_model": True,
},
}
)
assert confirmed["result"]["confirm_required"] is False
assert confirmed["result"]["value"] == "openai/gpt-5.5-pro"
assert agent.switched is True
def test_config_set_model_global_persists(monkeypatch):
class _Agent:
provider = "openrouter"
@@ -3944,7 +4012,7 @@ def test_config_set_model_rejects_while_running(monkeypatch):
"""/model via config.set must reject during an in-flight turn."""
seen = {"called": False}
def _fake_apply(sid, session, raw):
def _fake_apply(sid, session, raw, **_kwargs):
seen["called"] = True
return {"value": raw, "warning": ""}
@@ -3978,7 +4046,7 @@ def test_config_set_model_allowed_when_idle(monkeypatch):
"""Regression guard: idle sessions can still switch models."""
seen = {"called": False}
def _fake_apply(sid, session, raw):
def _fake_apply(sid, session, raw, **_kwargs):
seen["called"] = True
return {"value": "newmodel", "warning": ""}
+32 -2
View File
@@ -338,7 +338,7 @@ class TestCaptureResponse:
from tools.computer_use.backend import CaptureResult
from tools.computer_use import tool as cu_tool
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
class FakeBackend:
def start(self): pass
@@ -372,11 +372,41 @@ class TestCaptureResponse:
assert any(p.get("type") == "image_url" for p in out["content"])
assert any(p.get("type") == "text" for p in out["content"])
def test_capture_tiny_image_returns_text_json(self):
"""Providers can reject <8px images, so placeholders must be omitted."""
from tools.computer_use.backend import CaptureResult, UIElement
from tools.computer_use import tool as cu_tool
tiny_png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAC0lEQVR4nGNgQAcAABIAAXfx+gAAAAAASUVORK5CYII="
cap = CaptureResult(
mode="som",
width=0,
height=0,
png_b64=tiny_png,
elements=[
UIElement(index=1, role="AXButton", label="Continue", bounds=(10, 20, 30, 30)),
],
app="Safari",
window_title="Example",
png_bytes_len=68,
)
with patch.object(cu_tool, "_should_route_through_aux_vision",
return_value=False):
out = cu_tool._capture_response(cap)
parsed = json.loads(out)
assert parsed["width"] == 2
assert parsed["height"] == 2
assert "screenshot omitted" in parsed["summary"]
assert parsed["elements"][0]["label"] == "Continue"
def test_capture_som_with_elements_formats_index(self):
from tools.computer_use.backend import CaptureResult, UIElement
from tools.computer_use import tool as cu_tool
fake_png = "iVBORw0KGgo="
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
class FakeBackend:
def start(self): pass
@@ -33,10 +33,10 @@ import pytest
# Fixtures / helpers
# ---------------------------------------------------------------------------
# 1×1 PNG (transparent) — minimal bytes that decode cleanly.
# 8×8 PNG (transparent) — minimal provider-acceptable bytes that decode cleanly.
_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42m"
"NkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nG"
"NgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
)
# 1×1 JPEG — used to verify mime detection works for either stream type.
@@ -172,6 +172,31 @@ def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text):
)
def test_dockerfile_preinstalls_matrix_dependencies(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
if "uv sync" in step and "--no-install-project" in step
]
assert sync_steps, "Dockerfile must install Python dependencies with uv sync"
assert any("--extra matrix" in step for step in sync_steps), (
"Published Docker images must preload the [matrix] extra so the "
"Matrix gateway has mautrix[encryption]/python-olm available at "
"runtime instead of relying on first-boot lazy installation into "
"the container venv (#30399)."
)
def test_dockerfile_installs_matrix_native_build_dependencies(dockerfile_text):
instructions = _instruction_text(dockerfile_text)
for package in ("libolm-dev", "cmake", "g++", "make"):
assert package in instructions, (
"Docker image must include native build dependencies needed by "
f"python-olm when preinstalling the [matrix] extra (#30399): {package}"
)
def test_dockerfile_preinstalls_hindsight_memory_dependency(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
+9 -2
View File
@@ -321,12 +321,19 @@ class TestStdioPgroupReaping:
psutil = pytest.importorskip("psutil")
# Grandchild: sleep forever, write its pid then wait.
# Grandchild: sleep forever, write its pid then wait. The pid file
# is written to a temp path and os.replace()d into place so the
# polling reader below can never observe a created-but-empty file
# (CI flake: int('') ValueError when the reader won the race between
# open('w') creating the file and write() filling it).
grandchild_pid_file = tmp_path / "grandchild.pid"
grandchild_script = tmp_path / "grandchild.py"
grandchild_script.write_text(
"import os, sys, time\n"
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\n"
f"tmp = {str(grandchild_pid_file)!r} + '.tmp'\n"
"with open(tmp, 'w') as f:\n"
" f.write(str(os.getpid()))\n"
f"os.replace(tmp, {str(grandchild_pid_file)!r})\n"
"while True:\n"
" time.sleep(0.5)\n"
)
+82 -1
View File
@@ -1,6 +1,8 @@
"""Tests for tools/skills_hub.py — source adapters, lock file, taps, dedup logic."""
import json
import time
from typing import List, Optional
from unittest.mock import patch, MagicMock
import httpx
@@ -14,13 +16,15 @@ from tools.skills_hub import (
UrlSource,
WellKnownSkillSource,
OptionalSkillSource,
SkillMeta,
SkillSource,
SkillBundle,
SkillMeta,
HubLockFile,
TapsManager,
bundle_content_hash,
check_for_skill_updates,
create_source_router,
parallel_search_sources,
unified_search,
append_audit_log,
_skill_meta_to_dict,
@@ -2201,3 +2205,80 @@ class TestInstallPathSafety:
assert not (skills_dir / "bad-skill" / "leak.txt").exists()
assert secret.read_text() == "data exfiltration payload\n"
# ---------------------------------------------------------------------------
# parallel_search_sources — overall_timeout must be honoured even when a
# source blocks for far longer than the budget (regression: the executor used
# `with ... as pool`, whose __exit__ calls shutdown(wait=True) and blocked the
# caller on the slow worker, making overall_timeout a no-op).
# ---------------------------------------------------------------------------
class _FakeSource(SkillSource):
def __init__(self, sid: str, sleep: float = 0.0, results=None):
self._sid = sid
self._sleep = sleep
self._results = results or []
def source_id(self) -> str:
return self._sid
def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
if self._sleep:
time.sleep(self._sleep)
return list(self._results)
def fetch(self, identifier: str) -> Optional[SkillBundle]:
return None
def inspect(self, identifier: str) -> Optional[SkillMeta]:
return None
class TestParallelSearchSourcesTimeout:
def _meta(self, sid: str) -> SkillMeta:
return SkillMeta(
name=f"{sid}-skill",
description="x",
source=sid,
identifier=f"{sid}/x",
trust_level="community",
)
def test_slow_source_does_not_block_caller(self):
"""A source sleeping well past overall_timeout must not stall the
return. Before the fix the executor's `with` block waited on the slow
worker (~5s); now the call returns promptly and reports the source as
timed out."""
fast = _FakeSource("fast", sleep=0.0, results=[self._meta("fast")])
slow = _FakeSource("slow", sleep=5.0, results=[self._meta("slow")])
start = time.monotonic()
all_results, source_counts, timed_out_ids = parallel_search_sources(
[fast, slow], query="q", overall_timeout=0.3,
)
elapsed = time.monotonic() - start
# Must return long before the slow source's 5s sleep finishes.
assert elapsed < 2.0, f"call blocked for {elapsed:.2f}s (timeout not honoured)"
assert "slow" in timed_out_ids
# Fast source still delivered its result and is not flagged timed out.
assert source_counts.get("fast") == 1
assert "fast" not in timed_out_ids
assert any(r.source == "fast" for r in all_results)
def test_all_fast_sources_complete_without_timeout(self):
"""Happy path: when every source finishes within budget, none are
flagged and all results are collected."""
a = _FakeSource("a", results=[self._meta("a")])
b = _FakeSource("b", results=[self._meta("b")])
all_results, source_counts, timed_out_ids = parallel_search_sources(
[a, b], query="q", overall_timeout=5.0,
)
assert timed_out_ids == []
assert source_counts.get("a") == 1
assert source_counts.get("b") == 1
assert len(all_results) == 2

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