Compare commits

...
Author SHA1 Message Date
teknium1 14e3bb1f27 docs(skills): tighten dynamic-workflow per donovan-yohan review
Address all 5 review points against actual delegate_task behavior:
- child toolsets are subject to delegate restrictions (leaf strips
  delegate_task/clarify/memory/send_message/execute_code), not 'full'
- durable work has lighter options than kanban (cron one-shot,
  managed background terminal) for simpler cases
- unique per-run /tmp/wf_<name>_<uuid> dir + freshness/count check so
  a stale interrupted run isn't read as success
- note that one delegate_task batch is capped by
  delegation.max_concurrent_children; large fan-out needs bounded waves
- delegate_task exposes no per-task model/profile field (per-task keys
  are goal/context/toolsets/role); model/profile-scoped runs go via
  delegation config, cron, kanban, or separate process
2026-06-07 23:45:18 -07:00
teknium1 ba936039be feat(skills): add dynamic-workflow orchestration skill
Adapts Claude Code's research-preview dynamic workflows (plan-in-code
fan-out, hundreds of subagents per session) to Hermes invariants.

The ported mechanic is plan/loop/intermediate-state-out-of-context, not
more subagents. Documents the two real orchestration layers and the hard
capability boundary between them:
- Layer A (execute_code): deterministic fan-out, SANDBOX_ALLOWED_TOOLS
  only, cannot call delegate_task
- Layer B (delegate_task batch): LLM-judgment fan-out

Plus the synchronous trap (delegate_task is turn-scoped, cancelled on new
message; durable/resumable = kanban swarm) and the genuinely-new piece:
the adversarial-convergence verification recipe (N independent attempts
with varied framings + M refuters, keep only located claims that survive
refutation, iterate to convergence).

Self-contained: inlines the load-bearing fan-out hygiene rather than
hard-depending on local-only skills; references the shipped kanban swarm
subsystem for the durable path.
2026-06-02 00:31:25 -07:00
Teknium 2c0d648397 fix(cron): sanitize invisible unicode in vetted skill content instead of hard-blocking (#37245)
A stray zero-width space (U+200B), BOM, or bidi control in loaded skill
markdown permanently killed any cron that loaded it. The skills-attached
assembled-prompt scan hard-blocked on any invisible-unicode char, even
though skill bodies are already install-time vetted by skills_guard.py and
the chars commonly appear in copy-pasted unicode docs / code examples.

The skills path now strips invisibles (logging the codepoints) and runs the
cleaned prompt. The raw user-prompt path (_scan_cron_prompt) keeps the hard
block — that is the actual #3968 injection surface, where a small directive
prompt with a ZWSP is a smoking gun, not prose. Stripping does not let a real
injection slip through: the directive still matches after sanitization.

_scan_cron_skill_assembled now returns (cleaned_prompt, error).
2026-06-02 00:29:44 -07:00
Teknium 3c1d066a8a feat(dashboard): Channels page — set up every gateway messaging channel from the browser (#37211)
The /api/messaging/platforms endpoints (catalog, configure, test) shipped
with the desktop app but never got a dashboard UI; the recent admin-panel
PRs covered MCP/webhooks/hooks/system but skipped messaging channels. This
adds the missing page so all 20+ channels (Telegram, Discord, Slack, Matrix,
Mattermost, WhatsApp, Signal, BlueBubbles, Email, SMS, DingTalk, Feishu,
WeCom, WeChat, QQ Bot, Yuanbao, plugin platforms, etc.) can be configured,
enabled/disabled, tested, and connected entirely from the browser.

- web/src/pages/ChannelsPage.tsx: per-platform list with live status, enable
  Switch, Test, and a Configure modal that renders each platform's exact
  setup fields (secrets masked, required validated, redacted display).
- web/src/lib/api.ts: MessagingPlatform types + get/update/test client fns.
- web/src/App.tsx: /channels route + nav tab (Radio icon, after MCP).
- docs: Channels section + REST endpoints + screenshot.

Frontend-only — reuses the existing env-write + config-enable backend, which
auto-enables a platform once its required env vars are present and the
gateway restarts. No core changes, no new tool schema.
2026-06-01 23:41:35 -07:00
Spider-Versandalaamohanad169-ship-it 15cb4e2279 fix(docker): install python3-venv so ensurepip fallback works (closes #36813) (#36905)
Co-authored-by: alaamohanad169-ship-it <alaamohanad169-ship-it@users.noreply.github.com>
2026-06-02 16:39:32 +10:00
Teknium 0269eca7e1 test(minimax): assert M3 stale-cache guard contract, not a brittle 1M literal (#37220)
test_stale_m3_cache_dropped_and_reresolves_to_1m hardcoded
assert ctx == 1_000_000. The test re-resolves M3 through the live models.dev
registry (the seeded stale entry is dropped, so nothing short-circuits the
lookup), and models.dev now reports MiniMax-M3 at 512,000 — a change-detector
failure unrelated to any code change.

The guard's actual contract is: a stale <=204,800 catch-all value for an M3
slug must be DROPPED and re-resolved to M3's real (large) context. Both
sources satisfy that (hardcoded catalog 1,000,000; models.dev 512,000), so
assert the invariant (ctx > 204,800, stale value gone) instead of a literal
that external data can move. Renamed accordingly.

47/47 in test_minimax_provider.py pass.
2026-06-01 23:35:23 -07:00
Evi Nova 81dd43a8eb fix(docker): preserve Docker -w workdir in main-wrapper (#35472) (#36259)
Save the original working directory before init scripts cd to
/opt/data, then restore it before exec'ing the user command, so
the container starts in the Docker -w directory instead of /opt/data.

Adds regression test verifying cwd save/restore ordering in
main-wrapper.sh.
2026-06-02 16:13:44 +10:00
Teknium 272c2f30aa fix(kanban): kanban_create inherits the spawning worker's task workspace (#37182)
When a dispatcher-spawned worker (HERMES_KANBAN_TASK set) calls
kanban_create without an explicit workspace, the new child now inherits
the worker's own running-task workspace_kind/workspace_path instead of
defaulting to scratch. A worker editing a dir:/worktree project that
spawns a follow-up child keeps it in that project.

Orchestrators (kanban toolset, no HERMES_KANBAN_TASK) and CLI/dashboard
callers still default to scratch. An explicit workspace arg always wins.
2026-06-01 21:26:29 -07:00
Teknium bd8e2ec1a6 feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)
* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats

Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
  /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
  with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
  list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
  uptime/process, stdlib fallback

All gated by dashboard auth; secrets never returned.

* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats

- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
  env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
  shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats

* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle

Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.

* docs(dashboard): document the comprehensive admin pass + fresh screenshots

Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.

* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops

Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
  /api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
  (backgrounded, tailed via action status)

Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.

* feat(dashboard): curator + portal + diagnostics UI, tests

- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
  Skill curator card (status + pause/resume + run now), and three new
  Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
  coverage for the new GET endpoints (31 tests total)

* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots

Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.

* feat(dashboard): session stats/export/prune + skills hub search endpoints

Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
  POST /api/sessions/prune. /stats is registered before /{session_id} so the
  literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
  returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)

* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit

Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
  prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
  install by identifier with a live install log, and 'Update all' (mirrors
  hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
  updateCronJob, searchSkillsHub + types

Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.

* test(dashboard): cover session stats/rename/export/prune + skills hub search

Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.

* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit

Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
2026-06-02 00:16:11 -04:00
Ben Barclay 40ae170647 ci(docker): use registry-backed build cache for arm64 (#37129)
The arm64 PR build ran fully uncached because the previous gha cache
backend's short-lived Azure SAS token expired mid-build on slow
cold-cache arm64 runs and crashed before the smoke test. Uncached arm64
PR builds were ~45% slower than amd64 (median 553s vs 382s), making the
arm64 job the one most often cancelled on supersede — surfacing as a red
X in PR checks and reading as 'the arm64 build keeps failing'.

Switch arm64 to a registry-backed cache on ghcr.io
(type=registry, ref ghcr.io/nousresearch/hermes-agent:buildcache-arm64).
Its credential is the job-lifetime GITHUB_TOKEN, not a time-boxed SAS
token, so the cold-build-outlives-token failure mode cannot recur.

- PR builds: cache-from only (read-only) — warm layers, no write races,
  no cache-ref pollution from rapid PR pushes.
- main/release builds: cache-from + cache-to (mode=max) to populate the
  cache for subsequent PR/main builds and let the digest push reuse the
  smoke-test build's layers.
- Add packages: write permission and a ghcr.io login for the cache.

amd64 keeps its gha cache: it builds fast enough to stay inside the SAS
token's lifetime, so it never hit this failure mode.
2026-06-02 14:03:40 +10:00
whyhkzkandBen 1495f0cc38 fix(file-safety): extend sandbox-mirror guard to cover inner-container path (#32049) (#32407)
* fix(file-safety): extend sandbox-mirror guard to cover inner-container path (#32049)

Brian's shape-based guard (#32213) catches paths that still carry the
full sandboxes/<backend>/<task>/home/.hermes/… prefix on the host side.
The inner-container case is not covered: when file tools execute inside
Docker the bind-mount strips that prefix, so the guard receives plain
/root/.hermes/… and passes through. The root:root ownership on the
divergent SOUL.md in #32049 confirms this is the primary failure mode.

Add a ContextVar (_CONTAINER_HERMES_MIRROR) set by DockerEnvironment
when persistent=True. classify_container_mirror_target / get_container_
mirror_warning detect any write whose resolved path falls under that
prefix, using the same warning format and cross_profile=True bypass
contract as the existing guards. Chain the new guard in
_check_cross_profile_path after the two existing detectors.

* fix(file-safety): derive Docker mirror guard from task

---------

Co-authored-by: Ben <ben@nousresearch.com>
2026-06-02 14:03:37 +10:00
Stephen Chin a5aecf26fa feat(kanban): gate notifier watcher on dispatch_in_gateway
Non-dispatch gateways no longer open per-board kanban DBs for notifier
polling. Mirrors the existing dispatcher gate (config
kanban.dispatch_in_gateway, default True; env override
HERMES_KANBAN_DISPATCH_IN_GATEWAY) so multi-gateway setups collapse to a
single process holding kanban.db file descriptors.

Salvaged from PR #31964 by @steveonjava; tests and docs trimmed during
salvage.
2026-06-01 20:30:24 -07:00
xxxigm c35ede789f refactor(cli): normalize note and avoid blank lines in prepend helper
Adopt the cleaner handling from PR #37080: coerce/strip the note and
skip the extra newlines when the underlying message (or text part) is
empty, while keeping the safer fail-open behavior for unknown shapes.
2026-06-01 20:30:08 -07:00
xxxigm a26a12ad07 test(cli): cover _prepend_note_to_message str/list handling
Regression coverage for the multimodal-message TypeError: note folding into
text parts, image-only insertion, empty-note passthrough, and unknown-shape
fail-open.
2026-06-01 20:30:08 -07:00
xxxigm 043350dfd3 fix(cli): prepend queued notes safely to multimodal messages
Sending an image to a vision model turns the user message into a list of
OpenAI-style content parts. When a /model or /reload-skills note was queued
for the same turn, the CLI did `note + "\n\n" + agent_message`, crashing the
agent thread with:

    TypeError: can only concatenate str (not "list") to str

Repro: `/model gpt-5.5 --provider openai-codex`, then paste+send an image.

Add _prepend_note_to_message(), which folds the note into the first text
part of a content-parts list (or inserts a leading text part for image-only
messages) and keeps the plain-string path unchanged. Used for both the
model-switch and skills-reload notes.
2026-06-01 20:30:08 -07:00
Teknium 21f55af769 fix(model-picker): stop routing OpenAI selection to OpenRouter (#37175)
The /model picker emitted a standalone slug=openai row (gated on
OPENAI_API_KEY). Selecting it ran resolve_provider_full("openai"),
which resolved the legacy providers.py alias openai->openrouter BEFORE
checking the user's own providers.openai config — silently switching
users onto OpenRouter (HTTP 401 when they have no OR key).

- model_switch.list_authenticated_providers: skip vendor names that are
  aliases to an aggregator (isolates openai->openrouter; copilot/kimi/etc.
  are real providers and unaffected). Kills the phantom picker row.
- providers.resolve_provider_full: user-config providers.<name> now wins
  over the built-in alias table, so providers.openai (api.openai.com)
  beats the alias.
- model_switch PATH A: user-config providers resolve credentials via
  their own endpoint instead of the name-based runtime resolver that
  doesn't know user-config slugs; plus a fail-loud guard for explicit
  unauthed-aggregator hops.

Verified E2E with the reporter's config (no OR key): selecting OpenAI +
gpt-4o-mini now resolves to api.openai.com instead of openrouter.ai.
2026-06-01 20:27:41 -07:00
Teknium 72e82f88c0 fix(kanban): decompose children inherit root workspace instead of forcing scratch (#37172)
decompose_triage_task hardcoded every fan-out child to workspace_kind
'scratch', ignoring the root task's workspace. A code-gen task created
with a dir:/worktree: workspace would fan out into throwaway scratch tmp
dirs (GC'd on archive), so generated code never landed in the project.

Children now inherit the root's workspace_kind + workspace_path. A child
dict may still override with its own workspace_kind/workspace_path; the
path only carries over when kinds match. Scratch roots are unchanged.
2026-06-01 20:26:57 -07:00
teknium1andGlucksberg fa3b06b035 refactor(telegram): generalize observed-media caching into a reusable primitive
Collapse the per-type observed-media dispatch into one platform-agnostic
cache_media_bytes() helper in gateway/platforms/base.py. Any adapter can now
hand it raw attachment bytes + a filename/MIME hint; it classifies against the
shared MIME registries, routes to the right cache_*_from_bytes helper,
sandbox-translates the path, and returns a CachedMedia with a ready
context_note(). Telegram's observed-group path shrinks to: size-gate, download,
call the helper, annotate. Also dedupes the addressed-media type ladder into
_media_message_type().

Net: contributor's Telegram-only +595 LOC becomes a +210/-32 production change,
with the reusable primitive available to Discord/Slack/Signal/etc.

Co-authored-by: Glucksberg <markuscontasul@gmail.com>
2026-06-01 20:18:41 -07:00
Glucksberg f768e75ecf fix(telegram): cache observed group media 2026-06-01 20:18:41 -07:00
teknium1 34468ed0d4 fix: normalize terminalBackground default and drop unrelated lockfile churn
Follow-up to the salvaged terminalBackground commit:
- align the CSS-var fallback and type doc to the runtime default (#000000)
- revert web/package-lock.json to main (the original commit stripped peer
  flags as an npm-version artifact, unrelated to the feature)
2026-06-01 20:13:56 -07:00
davidgut1982 fc995634cc feat(dashboard): add terminalBackground field to DashboardTheme
Wires the xterm.js terminal pane background color into the theme
system. Previously hardcoded as #0d2626; now reads from
DashboardTheme.terminalBackground with #000000 as default.

Users can override via ~/.hermes/dashboard-themes/*.yaml:
  terminalBackground: "#1a0a2e"
2026-06-01 20:13:56 -07:00
Stephen Schoettler f24b7ed9d9 fix: make Honcho startup fail open 2026-06-01 20:13:42 -07:00
Teknium 59510d7b44 feat(skills): fix browse cap, add source links + copy buttons + category cleanup (#37143)
Skills discovery surfaced ~136 of 88k skills in the CLI and gave community
skills no clickable source on the docs page. Three coupled fixes:

CLI browse:
- hermes skills browse capped at 50 because the per-source limit dict had no
  'hermes-index' key — when the centralized index is available the router
  skips external APIs and serves only the index, so the default-50 fallthrough
  silently truncated the whole hub. Add hermes-index: 5000. Browse now loads
  5367 (269 pages) instead of 136.
- Add an Identifier column + install/inspect hint to the browse table so users
  can act on what they see without a second 'search'.
- Route the TUI browse_skills() helper through parallel_search_sources so it
  inherits the same index-aware source-skip (was double-counting); expose
  identifier in its output.

Docs Skills Hub page:
- Synthesize a sourceUrl for every community skill (github tree URL, clawhub /
  skills.sh / lobehub / browse.sh detail pages), preferring the adapter's
  explicit extra.detail_url/source_url/repo_url. Expanded cards now show
  'View source' for community skills (was nothing) and keep 'View full
  documentation' for built-in/optional. 99% coverage.
- Add a Copy button on the install command.
- Add a loading state instead of flashing '0 skills / No skills found' while
  the 45MB catalog fetches.

Category cleanup:
- _guess_category fell back to tags[0] verbatim, producing ~430 junk one-off
  categories (version strings, brand names: '0.10.7 Dev', 'Doramagic Crystal').
  Now only curated buckets are accepted; unknowns fold into 'Other'. Widen the
  tag->category map so common community tags route to real buckets. 430 -> 173
  categories, top 20 all meaningful.

Tests: tests/website/test_extract_skills.py covers _source_url synthesis +
precedence and _guess_category curation (13 tests). All 27 skills-hub CLI
tests still pass. Docusaurus build verified; expanded cards confirmed in
browser for both community (View source) and built-in (View full docs).
2026-06-01 19:52:28 -07:00
Zyrixtrex 0cd5867bbb fix(whatsapp): honor dm_policy and group_policy open at the gateway 2026-06-01 19:51:21 -07:00
kyssta-exe d4b533de4e fix: batch of small robustness/correctness fixes from @kyssta-exe
Salvages 8 distinct fixes from a batch of PRs by @kyssta-exe, reapplied
onto current main (original branches were stale) with a few refinements.

- cron(jobs.py): load_jobs() validates top-level JSON shape — a bare
  list auto-repairs into the {"jobs": [...]} dict; scalars/null raise a
  clear RuntimeError instead of an uncaught AttributeError that took
  down the whole cron subsystem (#37065, closes #36867).
- web(web_server.py): close the per-action log file handle after Popen
  so the parent stops leaking one fd per spawned action (#36843).
- web(web_server.py): DELETE /api/env returns 400 for invalid key names
  instead of a misleading 500, mirroring PUT /api/env (#36840).
- gateway(gateway.py): read /proc/<pid>/cmdline inside a with-block so
  the fd is released immediately instead of relying on GC (#36804).
- web-tools(web_tools.py): include "xai" in check_web_api_key() so a
  configured X.AI web backend reports as available (#36802).
- compression(conversation_compression.py): mark the feasibility check
  done only after it completes, and default the gate to "not checked"
  if the attribute is missing (#36803).
- completion(completion.py): replace `ls` with directory globbing in the
  generated bash/zsh/fish profile listers — handles names with spaces
  and skips non-directory entries (#36806).
- terminal-tool(terminal_tool.py): drop a duplicate `import threading`
  (#36808).
- claw(claw.py): the migrate recommendation now points at the real
  `hermes gateway stop` command instead of the non-existent
  `hermes stop` (#36795, #36796, closes #36771).
- tests: guard against a leaked HERMES_CRON_SESSION breaking gateway
  approval tests — add it to the hermetic conftest unset list (root
  cause, protects every test) and pop it in the affected test's
  setup_method (#36796).

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-01 19:51:03 -07:00
64f7f36713 fix(mcp): make non-MCP HTTP endpoint fast-fail robust and non-retryable
Reworks the content-type preflight so a misconfigured HTTP MCP url (a web-app
root serving HTML) fails in <1s instead of hanging the full 60s connect_timeout
— and does so non-retryably, which neither original PR achieved.

- Allow-list detection (application/json, text/event-stream) instead of a
  text/html-only denylist — catches text/plain, application/xml, etc.
- New NonMcpEndpointError(ConnectionError); run() catches it in the same
  top-level fast-fail block as InvalidMcpUrlError, so it returns before the
  reconnect-backoff loop (truly non-retryable) and the probe runs once, not
  on every reconnect.
- Probe runs on its own httpx client OUTSIDE the SDK anyio task group, so the
  error propagates as itself rather than wrapped in an ExceptionGroup (the
  trap that made the in-SDK event-hook approach a no-op).
- Forwards ssl_verify + client_cert + headers; HEAD->GET fallback on 405/501;
  best-effort pass-through on missing content type, non-2xx, and network
  errors; skips SSE transport. CancelledError is never swallowed.
- Replaces the malformed test file (which never imported the real method and
  failed CI) with 21 tests driving the actual _preflight_content_type against
  a real local HTTP server, plus full run() integration verifying <1s
  non-retryable failure.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: uzunkuyruk <egitimviscara@gmail.com>
2026-06-01 19:49:50 -07:00
liuhao1024 c914e4a371 fix(mcp): fail fast on HTML content-type instead of waiting full connect_timeout
A misconfigured MCP server URL that returns text/html (e.g. pointing at
a web app root instead of an MCP endpoint) causes the MCP SDK to block
for the full connect_timeout (default 60 s) before surfacing
CancelledError.

Add a lightweight HEAD pre-flight check that detects text/html responses
in ≤5 s and raises ConnectionError with an actionable message. Non-HTML
responses, missing headers, and network errors pass through silently so
the normal MCP handshake proceeds unaffected.

Fixes #36052
2026-06-01 19:49:50 -07:00
brooklyn!andCopilot Autofix powered by AI fabca0bdd8 feat(tui): single /model command + unified Sessions overlay (#37112)
* feat(tui): single /model command + unified Sessions overlay

Collapse the redundant `/provider` alias so `/model` is the only name
everywhere (it already drove the same 2-step ModelPicker in the TUI).

Merge the separate `/resume` (cold history browser) and `/sessions` (live
switcher) surfaces into one Sessions overlay reached by `/resume`,
`/sessions`, `/session`, and `/switch`. It pins a "+ new" row at the top
(always visible), lists live sessions with status, and lists resumable
history below — dispatching session.activate for live rows vs resume for
cold ones, with close/delete in place. Fixes `/session` opening an empty
live-only switcher and the hidden new-session affordance.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(tui): address Copilot review on the Sessions overlay

- Track the armed history-delete by session id instead of row index so the
  1.5s live-status poll re-indexing rows can't redirect the second `d` to a
  different session.
- Re-add the busy-session guard to immediate `/resume <id>` and `/sessions new`
  actions (browsing the bare overlay stays allowed) so resuming/switching can't
  corrupt an in-flight turn's streaming/busy state.

* fix(tui): guard cold-resume (not live-switch/new) from the Sessions overlay

Copilot flagged that overlay actions bypassed the busy guard. Only cold
resume actually closes the current session, so only it is guarded — both
from the slash path and now from the overlay (appActions.resumeById).
Switching between live sessions and starting a `+ new` live session keep
the current session running in the background, so they stay unguarded:
that concurrency is the orchestrator's whole purpose. Also dropped the
over-broad guard on `/sessions new` for the same reason.

* fix(tui): address Copilot review (history dedup + desktop /provider)

- The 1.5s poll now re-derives the resumable list from the RAW session.list
  results (rawHistoryRef) against the current live set, so a session hidden
  while live reappears in history once it closes — instead of being lost
  until a full reload. Delete also prunes the raw ref.
- Drop the dead `/provider` entry from the desktop PICKER_OWNED_COMMANDS now
  that the alias is gone, so the desktop client no longer advertises it.

* fix(tui): surface session.list errors + keep selection stable across polls

- A garbled session.list response now surfaces an error and preserves the
  last good raw history, instead of silently blanking the resumable section.
- The 1.5s poll re-anchors the selection to the same row by session id
  (live or history) when the live list grows/shrinks, so the highlight no
  longer drifts to a different row mid-interaction.

* fix(tui): degrade session.list independently + cover overlay helpers

- Fetch active_list and session.list via Promise.allSettled so a failing
  session.list no longer rejects the whole load: live sessions still render
  and only the resumable history degrades (with an error).
- Add unit tests for the new helpers (sessionRowKindAt row ordering,
  resumableHistory dedupe, sessionsCountLabel, relativeSessionAge).

* test(tui-gateway): assert /provider alias is gone, /model remains

The CI test_complete_slash_includes_provider_alias asserted the removed
`/provider` alias still autocompleted. Flip it to lock in the removal:
`/pro` no longer offers `provider`, and `/mod` still completes `model`.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 22:28:36 -04:00
Zyrixtrex f7a3509b25 fix(gateway): honor WECOM_ALLOWED_USERS in env-only WeCom DM allowlist 2026-06-01 19:20:36 -07:00
brooklyn! 7d51cd7516 Merge pull request #37115 from NousResearch/bb/tui-statusbar-responsive
fix(tui): prioritize status/model over cwd in the status bar on narrow terminals
2026-06-01 21:10:18 -05:00
Brooklyn Nicholson 13a2350c8d fix(tui): pass indicatorStyle into FaceTicker so render matches reservation
FaceTicker now takes the indicator style as a prop (same value used by
busyIndicatorWidth) instead of reading the store independently, so the
rendered busy indicator and its reserved width can't desync on /indicator
changes.
2026-06-01 21:02:32 -05:00
Jeffrey Quesnelle f600352e43 Merge pull request #37123 from NousResearch/installer-optional-commit-pin
feat(installer): make commit pinning opt-in, default to branch-follow
2026-06-01 22:01:57 -04:00
Julien Talbot 8104b20269 fix(xai): route video models by modality 2026-06-01 19:00:30 -07:00
eee32cdd52 fix(gateway): fall back to in-process heartbeat when s6 sleep is missing (#36208) (#37120)
Inside an s6 container, `gateway run` redirects to the supervised
gateway and then keeps the CMD process alive as a no-op heartbeat so
/init doesn't start stage-3 shutdown. That heartbeat is
`os.execvp("sleep", ["sleep", "infinity"])`, which does a PATH lookup
for the `sleep` binary. When PATH was empty/truncated/clobbered at that
point — e.g. after user customizations rewrote PATH, or on a minimal
image without `sleep` on PATH — the exec raised FileNotFoundError,
killing the CMD process and causing /init to tear down every service:
the container failed to start (issue #36208, a regression in the s6
image from 2026.5.28).

Wrap the exec in try/except OSError: on success it still replaces the
process with the cheap `sleep` heartbeat (no resident Python
interpreter, and the existing process-tree/recursion contract is
preserved); on failure it falls back to `_block_until_terminated()` —
a SIGTERM handler (clean 128+signum exit on `docker stop`) plus a
signal.pause() loop, which needs no external binary and so can't fail
on PATH state. A threading.Event().wait() fallback covers platforms
without signal.pause().

Keeping execvp as the primary path (rather than replacing it outright)
preserves the `sleep infinity` heartbeat that the docker integration
tests assert (test_gateway_run_supervised.py) and avoids leaving a
full Python interpreter resident for the container's lifetime.

Verified end-to-end on a built image: with execvp forced to fail,
_block_until_terminated() blocks cleanly instead of raising
FileNotFoundError; normal boot still runs the cheap `sleep infinity`
heartbeat; the 6 test_gateway_run_supervised.py integration tests pass.

Salvages the two community fixes for this issue — the fallback design
from #36221 (@Pluviobyte) and the signal.pause() heartbeat from #36267
(@karmeleon) — and adds regression tests for both the normal and
sleep-missing paths.

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: karmeleon <karmeleon@users.noreply.github.com>

Closes #36208.
2026-06-02 11:59:27 +10:00
Brooklyn Nicholson 899e8b9067 fix(tui): keep fmtCwdBranch default, cap cwd at the status-bar call site
Reverts the shared fmtCwdBranch default (28 → 40) so it isn't an API/
behavior change for other callers, and instead passes max=28 explicitly
from the status-bar caller where the tighter cap is intended.
2026-06-01 20:55:14 -05:00
teknium1 abe0e19c0a refactor(bluebubbles): simplify mention-gating helpers
Collapse the three mention-parsing helpers into one _compile_mention_patterns
that handles list/string/None inputs, and inline the require_mention bool
coercion to match the signal/dingtalk convention. Same behavior, 16 fewer
lines, no per-instance state in the staticmethod.
2026-06-01 18:52:05 -07:00
Trevin Chow d967e74427 chore: add contributor attribution mapping 2026-06-01 18:52:05 -07:00
Trevin Chow 05022066ea feat(bluebubbles): support group mention gating 2026-06-01 18:52:05 -07:00
Brooklyn Nicholson e25b2a6e18 fix(tui): address Copilot review on status-bar tail disclosure
- Render SpawnHud last in the tail so its un-budgeted (dynamic) width can
  only truncate itself, never push budgeted segments past leftWidth.
- Precompute kaomoji/emoji frame widths once at module load instead of
  rescanning FACES/EMOJI_FRAMES on every status render.
- Correct the tail-priority comment to match the actual fits() order
  (bar, duration, compressions, voice, session count, bg, cost).
2026-06-01 20:49:51 -05:00
Brooklyn Nicholson 9cb7d40d8d fix(tui): derive busy/duration reservation width from fmtDuration
fmtDuration renders a space between units (e.g. `59m 59s`), so the flat
6-col reservation under-counted and could let the elapsed-time tail shove
the model off-screen / break the whole-segment budget. Reserve the bounded
clock width from fmtDuration itself (MAX_DURATION_WIDTH) in both the busy
indicator reservation and the tail duration budget.
2026-06-01 20:42:04 -05:00
brooklyn!andCopilot Autofix powered by AI 85b65e29f0 feat(desktop): session hygiene, archive, media streaming + connecting overlay (#37099)
* feat(desktop): session hygiene, archive, media streaming + connecting overlay

Address a batch of desktop feedback:

- Stop leaking empty "Untitled" sessions: the TUI gateway pre-created a DB
  row on every session.create (i.e. every launch/draft). Persist the row
  lazily on first prompt instead, and hide message-less rows in the sidebar.
- Archive/hide sessions: new `archived` column + set_session_archived, web
  API (`?archived=` + PATCH archived), Ctrl/⌘-click and a context-menu item
  in the sidebar, and an "Archived Chats" settings panel to restore/delete.
- Videos load via a streaming `hermes-media://` protocol instead of capped,
  in-memory data URLs (16 MB limit) — bypasses the cap and supports seeking.
- Background-process completions route to the session that launched them:
  the completion event now carries session_key and each poller only consumes
  its own.
- Sidebar: "Group by workspace" toggle is always visible; each workspace
  group gets a "+" to start a session in that directory; "New agent"/"Agents"
  relabeled to "New session"/"Sessions".
- New gateway connecting overlay (ascii decode → fade out) replacing the bare
  skeleton/"starting gateway" state.

* fix(desktop): bail connecting overlay on boot error

The shownRef latch kept the connecting overlay mounted behind
BootFailureOverlay after a hard boot failure. Return null on boot.error
so the failure recovery surface fully owns the screen.

* fix(desktop): address Copilot review

- /api/sessions: validate `archived` (400 on unknown) and return `archived`
  as a JSON boolean instead of SQLite's 0/1.
- PATCH /api/sessions/{id}: 400 (not a misleading 404) when the body has no
  updatable fields; stop conflating a no-op with "not found".
- hermes-media protocol: drop `bypassCSP` — streaming only needs
  secure/standard/stream/supportFetchAPI.
- Sidebar workspace header: split the toggle and the "+" into sibling buttons
  so we no longer nest interactive elements inside a <button>.

* fix(desktop): address Copilot re-review

- hermes-media protocol: restrict streaming to an audio/video extension
  allowlist (415 otherwise) so it can't be used to read arbitrary local files.
- Connecting overlay: use z-[1200] instead of the non-standard z-1200 utility.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 20:41:34 -05:00
Ben Barclay ddc22866a3 chore(release): add whyhkzk to AUTHOR_MAP for PR #32407 (#37121) 2026-06-02 11:41:22 +10:00
emozilla 1d9aacbd00 feat(installer): make commit pinning opt-in, default to branch-follow
The bootstrap installer's build.rs unconditionally baked a commit pin via
`git rev-parse HEAD`, forcing every dev build to clone an exact SHA at
install time. That SHA had to be pushed to origin or the fresh-box clone
would fail.

Make the commit pin opt-in: by default build.rs bakes ONLY the detected
branch, so the installer follows that branch's HEAD at install time. Set
HERMES_BUILD_PIN_COMMIT (SHA, tag, or branch name) to bake an immutable
commit pin for reproducible/release builds; it is resolved to a SHA via
`git rev-parse --verify <ref>^{commit}` and fails loud on an unresolvable
ref. Runtime resolution already supported branch-only pins, so no changes
needed in bootstrap.rs / install_script.rs / install.ps1.
2026-06-01 21:35:46 -04:00
Brooklyn Nicholson 2f171743b7 fix(tui): pin status/model, whole-segment tail disclosure, smaller cwd
The previous reservation set the left box width but everything still
shared one flex row, so the lower-priority tail + cwd could still shrink
`ready`/model down to fragments ("re"). Pin the essentials (indicator +
model + context) in a non-shrinking group, and render the tail segments
(bar, duration, compressions, voice, session count, bg, cost) only when
the whole segment fits in the leftover space — in priority order — so
nothing truncates mid-segment and the low-value tail drops first.

Also shrink the cwd/branch label (max 40 → 28) so it stops dominating the
bar on roomy-but-not-huge terminals.
2026-06-01 20:32:27 -05:00
162c7856ca fix(file-safety): add sandbox-mirror soft guard for writes to per-task .hermes mirrors (#32213)
#32049 reports that under terminal.backend: docker, write_file / patch
calls to authoritative profile state (SOUL.md, memories, etc.) land on
the sandbox-local mirror at
``<HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...``
— a path the host Hermes process never reads. The tool reports success,
the user sees no behavior change, and on disk two divergent copies of
SOUL.md (or any other profile file) accumulate.

The existing classify_cross_profile_target guard does not catch this:
its parts[2] check sees "sandboxes" and returns None, and the path is
in-profile from the inner-mirror perspective so even a fixed version
would not fire.

Add a parallel sandbox-mirror classifier in agent/file_safety:

  * classify_sandbox_mirror_target() detects the
    ``…/sandboxes/<backend>/<task>/home/.hermes/…`` shape via path parts.
    Detection is path-shape only — backend-agnostic, does not require
    the file to exist, and works regardless of which HERMES_HOME resolves.
  * get_sandbox_mirror_warning() returns a model-facing warning that
    names the mirror root and the inner authoritative path the agent
    likely meant.

Wire both detectors through tools/file_tools._check_cross_profile_path
so the existing write_file and v4a patch call sites pick up the new
guard with no API change. The bypass kwarg (``cross_profile=True``)
remains shared between the two guards — same "I know what I'm doing"
escape valve after explicit user direction.

This is the defense-in-depth piece of the proposal in #32049 ("any
…/sandboxes/<backend>/…/home/…hermes/… path as sandbox-mirror"). It
catches the host-side speculation case where the agent writes a literal
sandbox-mirror path. The inner-container case (where the bind mount
strips the ``sandboxes/`` prefix from the agent's path view) is out of
scope for this surgical change — that requires either a dispatch-layer
host-side check before the container handoff, or the host-side
``profile_state`` / ``soul`` tool the issue also proposes.

Soft guard, NOT a security boundary — matches the existing
classify_cross_profile_target contract.

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-06-02 11:29:24 +10:00
Brooklyn Nicholson 1d7a1c00b4 fix(tui): make busy status-bar reservation /indicator-style aware
The left-content reservation used a flat constant for the busy face,
but its width varies by /indicator style: kaomoji is a wide glyph plus
a rotating verb, while unicode is a bare 1-col braille spinner with no
verb. Reserve the real width via busyIndicatorWidth(style, hasDuration)
so the model stays on-screen across styles without over-reserving the
unbounded elapsed-time tail.
2026-06-01 20:28:43 -05:00
Brooklyn Nicholson e59b815c04 fix(tui): prioritize status/model over cwd in the status bar on narrow terminals
The status rule reserved only 8 cols for the left segments, so the
cwd + git-branch label on the right could grow until the loading
indicator, model, and context read-out were crushed to almost nothing
(sometimes collapsing to a single illegible line) on small screens.

Reverse the priority: `statusRuleWidths` now reserves the display width
of the must-keep left content (status indicator + model + context) so
the cwd/branch segment truncates first. Add `statusBarSegments(cols)`
progressive disclosure — as the terminal narrows the low-priority tail
sheds in order (cost → bg → voice → compressions → duration → context
bar), and below the bar breakpoint the context read-out collapses to a
bare token count. Status and model are always guaranteed room.

Default `minLeftContent = 0` keeps `statusRuleWidths` byte-identical for
existing callers.
2026-06-01 20:26:41 -05:00
Ben Barclay 4f7fe9bcff fix(dashboard): surface Docker update guidance instead of generic failure (#34347) (#37085)
The dashboard Update button's backend guard (#36263) already returns a
structured {ok:false, error:"docker_update_unsupported", message,
update_command} envelope (HTTP 200) when running in a Docker install,
instead of surfacing a raw SystemExit. But the frontend ignored that
envelope: runAction() only branched on a thrown error, so the 200 fell
through to the action-status poll, which reported a generic
"Action failed (exit 1)" toast and never showed the actual guidance.

Now runAction() inspects the update response and, on the
docker_update_unsupported case, surfaces the backend's guidance message
plus the recommended re-pull command directly (success-styled, since it's
actionable guidance — not a crash) without starting the poll.

Closes #34347.
2026-06-02 10:36:10 +10:00
firefly 3a8d643d37 chore(release): map caojiguang@gmail.com in AUTHOR_MAP
The fix commit preserves @caojiguang's authorship (from #31853); the
release-notes AUTHOR_MAP gate requires their email to map to a GitHub
username.
2026-06-01 17:31:40 -07:00
firefly 765790a216 test(weixin): regression suite for _api_post/_api_get timeout migration 2026-06-01 17:31:40 -07:00
Cao Jiguang 566669013f fix(weixin): replace aiohttp ClientTimeout with asyncio.wait_for in _api_post/_api_get
Cron delivery to WeChat fails with 'Timeout context manager should
be used inside a task' because _api_post and _api_get use aiohttp's
ClientTimeout directly.  When the cron scheduler calls send() via
asyncio.run_coroutine_threadsafe(), aiohttp cannot find a running
task and raises RuntimeError.

_upload_media, _download_bytes, and _download_remote_media already
use asyncio.wait_for() to avoid this.  Apply the same pattern to
_api_post and _api_get — the two remaining iLink API helpers that
still use the raw ClientTimeout approach.

This fixes cron delivery errors seen on the WeChat platform adapter
when meyo-external cron jobs attempt to deliver output to WeChat.
2026-06-01 17:31:40 -07:00
firefly a1f76ba7e9 fix(gateway): recover extract-stripped tool responses on all platforms (#29346)
The extract pipeline (extract_media/extract_images/extract_local_files +
directive strips) can reduce a non-empty tool-using response to empty
text_content with no deliverable attachment. The 'if text_content' send
guard then silently skips delivery: a 'response ready' log with no
'Sending response', no error, and the answer never reaches the user.

- A2: snapshot the pre-extract response; when extraction yields empty text
  and no image/local/media attachment, deliver the recovered original from
  the post-extract_media body (so a spaced MEDIA path can't leak). Applies
  on ALL platforms (supersedes the Discord-only #33842 and the unsafe
  raw-fallback #29499).
- A3: loud delivery invariant - a non-empty response that produces nothing
  deliverable logs response_delivery_dropped at ERROR; every recovery logs
  response_delivery_recovered. No silent drop survives.
- Factor a _strip_media_directives helper for the [[...]] strips; MEDIA
  stripping stays owned by extract_media, whose grammar handles spaced and
  quoted paths.
- Salvaged + de-scoped the #33842 test harness to all platforms; added
  unrecoverable-drop and no-leak regression tests.
2026-06-01 17:31:32 -07:00
firefly 8bf498c21d fix(gateway): scope final-delivery flags to turn-final segment (#29346)
A streamed preamble ("Let me search...") finalized at a tool boundary
routed through _try_fresh_final, which unconditionally set
_final_response_sent=True even though it is a NON-final segment. The
gateway then reads that flag as "final delivered" and suppresses the
genuine final answer produced on the next API call, so the user silently
gets nothing. Only reproduces with fresh_final_after_seconds > 0.

- _try_fresh_final / _send_or_edit take is_turn_final; the segment-break
  call site passes is_turn_final=got_done so only the turn-final answer
  marks final-delivered.
- _reset_segment_state clears the final-delivery flags at every tool
  boundary as defense-in-depth against any future premature setter.
- Failing-first regression + happy-path no-duplicate test.
2026-06-01 17:31:32 -07:00
Teknium 92273e4f57 docs: add 25 new community user stories to the collage (#37048)
Sourced from X/Twitter, blogs (Medium/Substack/dev.to), and YouTube since the
last refresh. Deduped against the existing 237 entries by id, url, and author.
237 -> 262 stories.

Highlights: 24/7 Mac Mini agent at $21/mo (@witcheer), automated TikTok
slideshow factory (@cyrilXBT), per-client isolated profiles as an AI-ops
business (@IBuzovskyi), PM briefing 20->8min (@aakashgupta), Railway+Telegram
deploy gotchas (Tessa Kriesel), compounding-cost field report (chintanonweb),
18-agent Kanban fleet (Tonbi), and several daily-automation setups.
2026-06-01 17:01:18 -07:00
kshitijk4poor 0fdab53ef0 feat(cli): ranked fuzzy search in the curses model picker
Wires the salvaged search helpers into the shared curses menu driver and
turns on type-to-filter for the CLI model pickers (the 100+ model lists
that previously required scrolling).

- Search lives in the shared `_run_curses_menu` driver behind a
  `searchable` flag + `search_labels`, so both `curses_radiolist` and
  `curses_single_select` get it without per-menu duplication. `/` opens
  the filter, BACKSPACE edits, Ctrl+U clears, ESC clears the filter then
  cancels. Returned values are always original item indices.
- `_filter_indices` RANKS matches (best-first) via a Python port of the
  TS scorer in ui-tui/src/lib/fuzzy.ts and web/src/lib/fuzzy.ts. The port
  is byte-identical in score: same per-char bonuses, prefix (+8) and
  exact (+20) bonuses, camelCase/word-boundary detection (matching on the
  lowercased target, boundary on the original case), and the -len*0.01
  length tiebreak — so the CLI, TUI, and WebUI rank results identically.
  A cross-language parity test pins the exact scores.
- `_prompt_model_selection` (the canonical picker across the model flows)
  and the custom-provider model list pass `searchable=True`.
- Split `_decode_menu_key` out of `read_menu_key` so the search loop can
  peek the raw key (catch `/`) before nav decoding.
- ESC during active search now clears the query (restores the full list)
  so a no-match filter can't strand the user; printable-key capture is
  restricted to ASCII to avoid Latin-1 mojibake.
- Update two setup-menu tests whose mock signatures predate the new
  `searchable` kwarg; add ranked-scorer + parity + state-machine tests.
2026-06-01 16:58:58 -07:00
Harish Kukreja 53f598e7a2 feat(cli): add fuzzy search helpers for curses pickers
Pure, refactor-independent helpers for type-to-filter search in the
curses single-/radio-select menus: subsequence matching, filtered-index
mapping, cursor reconciliation, scroll clamping, and an active-search
key handler, plus unit tests.

Salvaged from #22758 (the curses event loop was since refactored into a
shared driver on main, so the integration is rebuilt in a follow-up
commit; these pure helpers and their tests carry over unchanged).
2026-06-01 16:58:58 -07:00
kshitijk4poor 7527e7aeac feat: fuzzy search for the model picker (WebUI + TUI)
Adds fuzzy subsequence matching with quality ranking to the model
pickers, replacing the WebUI's exact-substring filter and giving the
TUI a search where it previously had none.

- New fuzzy scorer (ui-tui/src/lib/fuzzy.ts + an identical copy at
  web/src/lib/fuzzy.ts, since the two are separate TS packages with no
  shared module). Matches a query as an ordered subsequence (so `g4o`
  matches `gpt-4o`), scores by quality (exact > prefix > word-boundary >
  contiguous > scattered) and returns matched character positions for
  highlighting. Multi-token AND semantics (`clad snnt` -> claude-sonnet).
  15 vitest tests cover the algorithm.

- WebUI ModelPickerDialog: ranked fuzzy filter on providers + models;
  matched characters in model rows are highlighted via <mark>.

- TUI modelPicker: type-to-filter on the provider and model stages with
  live ranking. Backspace edits the filter, Ctrl+U clears it, Esc clears
  a non-empty filter before navigating back. Persist-global / disconnect
  shortcuts moved from g/d to Ctrl+G / Ctrl+D so letters feed the filter.

Closes #30849
2026-06-01 16:58:58 -07:00
Tekniumandsbw2025 c45593ceae docs: expand quickstart Skills section (#37047)
* fix(file_tools): block agent writes to ~/.hermes/config.yaml to prevent silent approval bypass

* fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes

Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but
the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml
already tripped the project-config pattern, while `sed -i` and direct edits
slipped through with auto-approve. An unpaired write_file deny is theater per
SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the
mtime-keyed config cache reloads it mid-session.

config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist
live there), so it warrants real pairing, not a half-door. Add a
_HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into
_SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for
both config.yaml and .env. Pins 9 regression tests including no-regression
guards (reads pass, /tmp writes pass).

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>

* chore(release): map Subway2023 for PR #14639 salvage

* docs: expand quickstart Skills section

The Skills section was two bare commands with no framing — it never said
what a skill is, how skills load, or what the install slug means. Expanded
to explain the concept, the bundled catalog, install/browse/use flow, and
slash-command activation. Removed the inaccurate /skills chat-command hint
(skills become individual /<name> commands; hermes skills is the CLI verb).

---------

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
2026-06-01 16:56:50 -07:00
firefly 128da68823 test(tools): characterize tool-surface TERMINAL_CWD contract (#29265)
Port PR #29365's tool-surface contract test: terminal/file/execute_code
already honor TERMINAL_CWD (out of scope for the resolver cluster). Pinning
the behavior makes the supersession of #29365 airtight and guards against a
future refactor silently regressing the workspace contract.
2026-06-01 16:55:04 -07:00
firefly ac0cce5f3f test(agent): pin whitespace-strip and OSError-propagation in runtime_cwd
Cover the two new hardening behaviors that were unpinned: whitespace-only
TERMINAL_CWD falling through to getcwd/None, and OSError from the getcwd
fallback arm propagating to the build_environment_hints try/except guard.
2026-06-01 16:55:04 -07:00
firefly 75f478750c docs(test): correct None-semantics comment in test_runtime_cwd (discovery not skipped) 2026-06-01 16:55:04 -07:00
firefly eadfeef60e docs(agent): correct resolve_context_cwd comment (None → caller getcwd fallback, not skip) 2026-06-01 16:55:04 -07:00
firefly f90777a6b8 refactor(prompt): route context-file cwd through runtime_cwd resolver 2026-06-01 16:55:04 -07:00
firefly c79b80a8a5 test(prompt): place cwd regression tests in TestEnvironmentHints (drop redundant docker case) 2026-06-01 16:55:04 -07:00
firefly 16047655b5 fix(prompt): show configured working directory in system prompt (closes #24882, #24969, #27383, #29265) 2026-06-01 16:55:04 -07:00
firefly 2564760d7a test(agent): pin context_cwd isdir-skip asymmetry and tilde expansion 2026-06-01 16:55:04 -07:00
firefly 4bc7296042 feat(agent): add runtime_cwd resolver (single source of truth for working dir) 2026-06-01 16:55:04 -07:00
teknium1 f1237aa95b chore(release): map maxcz79 author email for AUTHOR_MAP 2026-06-01 16:36:43 -07:00
maxcz79 32032e1e2d fix(simplex): avoid reconnecting healthy idle websocket
Do not treat lack of application-level SimpleX events as a stale WebSocket. The websockets client already uses protocol ping/pong for connection liveness, so quiet but healthy connections should not be closed by the health monitor.
2026-06-01 16:36:43 -07:00
Tekniumandsbw2025 e946f49ab5 fix(models): add gemini-3.5-flash to Gemini OAuth + API-key pickers (#37046)
* fix(file_tools): block agent writes to ~/.hermes/config.yaml to prevent silent approval bypass

* fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes

Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but
the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml
already tripped the project-config pattern, while `sed -i` and direct edits
slipped through with auto-approve. An unpaired write_file deny is theater per
SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the
mtime-keyed config cache reloads it mid-session.

config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist
live there), so it warrants real pairing, not a half-door. Add a
_HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into
_SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for
both config.yaml and .env. Pins 9 regression tests including no-regression
guards (reads pass, /tmp writes pass).

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>

* chore(release): map Subway2023 for PR #14639 salvage

* fix(models): add gemini-3.5-flash to Gemini OAuth + API-key pickers

#34581 swapped gemini-3-flash-preview -> gemini-3.5-flash in the
OpenRouter and Nous lists but missed the curated Gemini catalogs, so
the Google OAuth (google-gemini-cli) picker still offered the retired
gemini-3-flash-preview slug and gemini-3.5-flash was unselectable.

Per Google's docs gemini-3-flash-preview was renamed to gemini-3.5-flash
and is served via Cloud Code Assist, so this completes the rename for:
- google-gemini-cli (OAuth/Code Assist) picker
- gemini (API-key) picker
- gemini provider default_aux_model

copilot keeps gemini-3-flash-preview (separate backend, own slug).

---------

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
2026-06-01 16:31:13 -07:00
Teknium 1ffa22ee6b fix(minimax): drop stale ≤204,800 cache entries for MiniMax-M3 (#36726)
M3 is 1M context, but pre-catalog builds resolved it via the generic
'minimax' catch-all (204,800) and persisted that to the context-length
cache. Step 1 of get_model_context_length returned the cached value
directly before reaching the 'minimax-m3' (1M) catalog entry, so users
who first probed M3 on an older build were stuck at 204K forever (e.g.
/new in the Telegram gateway showing 'Context: 204K tokens (detected)').

Mirror the existing Kimi/Codex stale-cache guards: when a cached entry
for a minimax-m3 slug is <= 204,800, drop it and re-resolve. M2.x slugs
(correctly 204,800) are untouched since they don't match the M3 name.
2026-06-01 14:59:07 -07:00
BenandClaude Opus 4.8 b9646276fd fix(utils): guard os.fchmod for Windows in atomic_json_write
os.fchmod is Unix-only; the Windows os module has no fchmod (only
chmod). Passing mode= (e.g. 0o600 when saving the Hindsight config
during `hermes memory setup`) crashed on Windows with:

    AttributeError: module 'os' has no attribute 'fchmod'

Guard the fchmod fast-path with hasattr(os, "fchmod"). Skipping it on
Windows is safe: mkstemp already creates the temp file as 0o600, and
the existing post-replace os.chmod(real_path, mode) — already wrapped
in try/except — applies the final mode durably (as far as Windows
honors it).

Adds regression tests: one simulating a Windows os module without
fchmod (must not raise), and one asserting the durable 0o600 mode on
POSIX.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:57:10 -07:00
kshitij a5371b3e68 chore: add benfrank241 to AUTHOR_MAP (#36898)
Maps ben.bartholomew@vectorize.io -> benfrank241 so the contributor
attribution audit passes when their commit lands via #36824.
2026-06-01 16:47:07 +00:00
teknium1 ef3a650f05 chore(release): map Subway2023 for PR #14639 salvage 2026-06-01 03:29:48 -07:00
teknium1andsbw2025 4e9d886d9d fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes
Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but
the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml
already tripped the project-config pattern, while `sed -i` and direct edits
slipped through with auto-approve. An unpaired write_file deny is theater per
SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the
mtime-keyed config cache reloads it mid-session.

config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist
live there), so it warrants real pairing, not a half-door. Add a
_HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into
_SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for
both config.yaml and .env. Pins 9 regression tests including no-regression
guards (reads pass, /tmp writes pass).

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
2026-06-01 03:29:48 -07:00
sbw2025 8f2931e3ee fix(file_tools): block agent writes to ~/.hermes/config.yaml to prevent silent approval bypass 2026-06-01 03:29:48 -07:00
173 changed files with 11924 additions and 1192 deletions
+40 -11
View File
@@ -26,6 +26,10 @@ on:
permissions:
contents: read
# Needed so the arm64 job can push/pull its registry-backed build cache
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
# job for why registry cache replaced the gha cache on that arch.
packages: write
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
@@ -196,11 +200,34 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for smoke testing. PR arm64
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
# outlive GitHub's short-lived Azure cache SAS token, then fail while
# reading or writing cache blobs before the smoke test can run.
- name: Build image (arm64, smoke test, uncached PR)
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
# the whole job — unlike the gha cache backend's short-lived Azure SAS
# token, which expired mid-build on slow cold-cache arm64 runs and
# crashed the build before the smoke test (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build once, load into the local daemon for smoke testing.
#
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
# they pull warm layers pushed by the most recent main build but never
# write, so rapid PR pushes don't race on cache writes or pollute the
# cache ref. This restores warm-cache speed to arm64 PR builds (which
# were running fully uncached and were ~45% slower than amd64, making
# them the job most often cancelled on supersede).
#
# Registry cache (type=registry on ghcr.io) is used instead of the gha
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- name: Build image (arm64, smoke test, cache read-only PR)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -211,9 +238,11 @@ jobs:
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
# Main/release builds still use the per-arch gha cache so the digest
# push below can reuse layers from this smoke-test build.
# Main/release builds read AND write the registry cache so the digest
# push below reuses layers from this smoke-test build, and so the next
# PR/main build starts warm.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
@@ -225,8 +254,8 @@ jobs:
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Smoke test image
uses: ./.github/actions/hermes-smoke-test
@@ -253,8 +282,8 @@ jobs:
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
+1 -1
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 libffi-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
+8 -5
View File
@@ -308,11 +308,14 @@ def compress_context(
# The check itself sets ``agent._compression_warning`` so the
# status-callback replay machinery still emits the warning to the user
# the first time it would matter.
if not getattr(agent, "_compression_feasibility_checked", True):
try:
check_compression_model_feasibility(agent)
finally:
agent._compression_feasibility_checked = True
if not getattr(agent, "_compression_feasibility_checked", False):
# Mark as checked only after the probe completes. If the check
# raises (e.g. a fatal aux-context ValueError that aborts the
# session), leaving the flag unset is harmless; a non-fatal
# transient failure is swallowed inside the function so the flag
# is set normally on the next successful pass.
check_compression_model_feasibility(agent)
agent._compression_feasibility_checked = True
_pre_msg_count = len(messages)
logger.info(
+187
View File
@@ -451,3 +451,190 @@ def get_cross_profile_warning(path: str) -> Optional[str]:
f"``cross_profile=True``. (Defense-in-depth — not a security "
f"boundary; the terminal tool can still bypass.)"
)
# ---------------------------------------------------------------------------
# Sandbox-mirror write guard (#32049)
#
# Non-local terminal backends (Docker, Daytona, etc.) bind a sandbox-local
# directory to the container's ``$HOME``. The on-disk layout looks like
#
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
#
# When the agent (running host-side) speculates that authoritative profile
# state lives at one of those sandbox-mirror paths, the write lands on the
# mirror — never read by the host process — while the host file is left
# untouched. The agent reports success, the user sees no change, and on
# disk two divergent copies accumulate. See #32049 for evidence.
#
# This guard is path-shape-only: it detects the
# ``…/sandboxes/<backend>/<task>/home/.hermes/…`` segment and warns
# regardless of which Hermes profile is active. It does NOT cover the
# inner-container case where the bind mount strips the ``sandboxes/`` prefix
# (the agent's view inside the container is plain ``/root/.hermes/...``);
# that case needs a separate dispatch-layer or host-side ``profile_state``
# tool.
# ---------------------------------------------------------------------------
def _find_sandbox_mirror_segments(parts: tuple) -> Optional[int]:
"""Return the index of the inner ``.hermes`` part in a sandbox-mirror path.
Matches ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and returns the
index where the inner Hermes-state portion starts. Returns ``None`` for
paths that do not contain the sandbox-mirror shape.
"""
for i, part in enumerate(parts):
if part != "sandboxes":
continue
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>
if i + 5 >= len(parts):
continue
if parts[i + 3] == "home" and parts[i + 4] == ".hermes":
return i + 4
return None
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
"""Classify a write target as a sandbox-mirror of authoritative Hermes state.
Returns ``None`` when the path does not match the sandbox-mirror shape.
Otherwise returns a dict with:
* ``target_path``: the resolved path string
* ``mirror_root``: the ``…/sandboxes/<backend>/<task>/home/.hermes``
prefix (so callers can show users which sandbox owns the mirror)
* ``inner_path``: the portion under the mirror's ``.hermes`` (what the
agent likely meant to address on the host)
Detection is path-shape-only — does not require any Hermes resolver to
succeed, so it works correctly even when called from contexts where
HERMES_HOME resolution would be ambiguous.
"""
try:
target = Path(os.path.expanduser(str(path))).resolve()
except (OSError, RuntimeError):
return None
parts = target.parts
inner_idx = _find_sandbox_mirror_segments(parts)
if inner_idx is None:
return None
mirror_root = str(Path(*parts[: inner_idx + 1]))
inner_path = str(Path(*parts[inner_idx + 1 :])) if inner_idx + 1 < len(parts) else ""
return {
"target_path": str(target),
"mirror_root": mirror_root,
"inner_path": inner_path,
}
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
"""Return a model-facing warning when ``path`` lands in a sandbox mirror.
Returns ``None`` when the path is not a sandbox-mirror target. Caller
is expected to surface the warning to the agent as a tool-result
error. The bypass kwarg (``cross_profile=True``) is shared with the
cross-profile guard: both are soft "I know what I'm doing" overrides
a user can authorise.
Defense-in-depth, NOT a security boundary: the terminal tool runs as
the same OS user and can write the mirror path directly. The guard
exists to surface the misclassification before the silent-success +
divergent-copy footgun in #32049 fires.
"""
info = classify_sandbox_mirror_target(path)
if info is None:
return None
return (
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
f"sits under {info['mirror_root']!r}, which is a per-task mirror "
f"created by a non-local terminal backend (docker/daytona/etc.). "
f"Writes here land on a copy that the host Hermes process never "
f"reads — the authoritative file is likely {info['inner_path']!r} "
f"under the real HERMES_HOME. Use the host-side tool for "
f"authoritative state (e.g. ``memory`` for memories), or address "
f"the host path directly. To bypass this guard after explicit "
f"user direction, retry the call with ``cross_profile=True``. "
f"(Defense-in-depth — not a security boundary; the terminal tool "
f"can still bypass.)"
)
# ---------------------------------------------------------------------------
# Container-context mirror guard (inner-container case — #32049 follow-up)
#
# Brian's shape-based detector (#32213) catches paths that still carry the
# full ``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix on the host.
# But when file tools execute *inside* the container the bind-mount strips
# that prefix: the agent sees plain ``/root/.hermes/…``. The root:root
# ownership on the divergent SOUL.md in #32049 confirms this is the primary
# failure mode.
#
# Fix: file_tools passes the active Docker mirror prefix when the terminal
# backend is docker + persistent. This catches the very first file-tool call,
# before a DockerEnvironment object necessarily exists.
# ---------------------------------------------------------------------------
def classify_container_mirror_target(
path: str,
mirror_prefix: str | None = None,
) -> Optional[dict]:
"""Classify a write target as a container-side sandbox mirror.
``mirror_prefix`` must be supplied by the caller after it has established
that file tools are executing in a container whose home is a sandbox
mirror. Returns ``None`` when no such context is active or the path is not
under the mirror prefix. Otherwise returns:
* ``target_path``: resolved path string
* ``mirror_root``: the declared container mirror prefix
* ``inner_path``: portion under the mirror root (what the agent
likely meant to address in the host HERMES_HOME)
"""
if not mirror_prefix:
return None
try:
target = Path(os.path.expanduser(str(path))).resolve()
mirror = Path(os.path.expanduser(mirror_prefix)).resolve()
inner = target.relative_to(mirror)
except (OSError, RuntimeError, ValueError):
return None
return {
"target_path": str(target),
"mirror_root": str(mirror),
"inner_path": inner.as_posix(),
}
def get_container_mirror_warning(
path: str,
mirror_prefix: str | None = None,
) -> Optional[str]:
"""Return a model-facing warning when *path* lands in the container's
sandbox mirror of authoritative Hermes state.
The caller supplies ``mirror_prefix`` only when the current file-tool
backend is known to execute inside a Docker sandbox. Same contract as
``get_cross_profile_warning``: soft guard, returns ``None`` for
non-mirror paths, caller surfaces as a tool-result error. Bypass via
``cross_profile=True`` after explicit user direction.
"""
info = classify_container_mirror_target(path, mirror_prefix)
if info is None:
return None
return (
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
f"sits under {info['mirror_root']!r}, which is the container's "
f"bind-mounted home — a per-task mirror that the host Hermes "
f"process never reads. The authoritative file is "
f"{info['inner_path']!r} under the real HERMES_HOME. Use the "
f"host-side tool for authoritative state (e.g. ``memory`` for "
f"memories), or address the host path directly. To bypass after "
f"explicit user direction, retry with ``cross_profile=True``. "
f"(Defense-in-depth — not a security boundary; the terminal tool "
f"can still bypass.)"
)
+25
View File
@@ -1128,6 +1128,18 @@ def _model_name_suggests_kimi(model: str) -> bool:
return lower.startswith("kimi") or "moonshot" in lower
def _model_name_suggests_minimax_m3(model: str) -> bool:
"""Return True if the model name looks like MiniMax M3.
Catches ``MiniMax-M3``, ``minimax/minimax-m3``, and similar variants
across surfaces (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
Used as a guard against stale cache entries seeded by pre-catalog builds
that resolved M3 via the generic ``minimax`` catch-all (204,800) before
the ``minimax-m3`` (1M) entry existed in DEFAULT_CONTEXT_LENGTHS.
"""
return "minimax-m3" in model.lower()
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@@ -1539,6 +1551,19 @@ def get_model_context_length(
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale ≤204,800 cache entries for MiniMax-M3. Pre-catalog
# builds resolved M3 via the generic ``minimax`` catch-all (204,800)
# and persisted it before the ``minimax-m3`` (1M) entry existed; that
# stale value would otherwise stick forever here at step 1. M3 is 1M,
# so any sub-256K cached value for an M3 slug is a leftover — drop it
# and fall through to the hardcoded default.
elif cached <= 204_800 and _model_name_suggests_minimax_m3(model):
logger.info(
"Dropping stale MiniMax-M3 cache entry %s@%s -> %s (pre-catalog value); "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Nous Portal: the portal /v1/models endpoint is authoritative.
# Bypass the persistent cache so step 5b can always reconcile
# against it — this corrects pre-fix entries seeded from the
+2 -1
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
extract_skill_conditions,
extract_skill_description,
@@ -802,7 +803,7 @@ def build_environment_hints() -> str:
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
try:
host_lines.append(f"Current working directory: {os.getcwd()}")
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
except OSError:
pass
+33
View File
@@ -0,0 +1,33 @@
"""Single source of truth for the agent working directory.
`TERMINAL_CWD` is the runtime carrier for the configured working directory
(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
gateway/cron startup). The local-CLI backend deliberately leaves it unset and
relies on the launch dir. Reading it in one place keeps the system prompt, the
tool surfaces, and context-file discovery agreeing on where the agent lives.
The #29531 per-session extension point is this function: a future PR adds a
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
`set_session_vars` seam — by design, not a reopening hazard.
"""
import os
from pathlib import Path
def resolve_agent_cwd() -> Path:
raw = os.environ.get("TERMINAL_CWD", "").strip()
if raw:
p = Path(raw).expanduser()
if p.is_dir():
return p
return Path(os.getcwd())
def resolve_context_cwd() -> Path | None:
# None means "no configured cwd": build_context_files_prompt then falls back
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
raw = os.environ.get("TERMINAL_CWD", "").strip()
return Path(raw).expanduser() if raw else None
+6 -7
View File
@@ -24,7 +24,6 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
@@ -41,6 +40,7 @@ from agent.prompt_builder import (
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
)
from agent.runtime_cwd import resolve_context_cwd
def _ra():
@@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
context_parts.append(system_message)
if not agent.skip_context_files:
# Use TERMINAL_CWD for context file discovery when set (gateway
# mode). The gateway process runs from the hermes-agent install
# dir, so os.getcwd() would pick up the repo's AGENTS.md and
# other dev files — inflating token usage by ~10k for no benefit.
_context_cwd = os.getenv("TERMINAL_CWD") or None
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
# CLI), None lets build_context_files_prompt fall back to the launch
# dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt(
cwd=_context_cwd, skip_soul=_soul_loaded)
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
if context_files_prompt:
context_parts.append(context_files_prompt)
+71 -31
View File
@@ -8,18 +8,24 @@ fn main() {
// `option_env!()` macro to default the install-script reference.
// Precedence (matches install.ps1's own arg precedence): commit > branch.
//
// Resolution order:
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
// Useful for CI builds that want to pin to a tagged release SHA
// rather than whatever the checkout's HEAD happens to be.
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
// the repo this build.rs lives in. Default for `cargo tauri build`
// from a dev machine — pins the produced .exe to your current
// checkout state.
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
// installer will fetch HEAD-of-main at runtime. Used when the
// build is happening outside a git checkout (e.g. cargo install
// from a packaged crate, unlikely for this binary but defensive).
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
// so the produced installer follows that branch's HEAD at install time
// (tolerant of fast-forwards/new commits, and never references a SHA the
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
// immutable commit pin for reproducible/release installers.
//
// Commit pin resolution:
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
// or branch name; resolved to an immutable SHA via `git rev-parse`
// when possible, else used verbatim if it already looks like a SHA.
// - Otherwise: NO commit pin (branch-follow is the default).
//
// Branch pin resolution:
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
// lives in — the current branch. (None on a detached HEAD.)
// 3. Last-resort fallback handled below: if neither commit nor branch
// resolves, warn — the binary needs a runtime arg or dev-repo env.
//
// Build script reruns on git HEAD change so a new commit triggers
// a rebuild without `cargo clean`.
@@ -30,11 +36,20 @@ fn main() {
if let Some(c) = &commit {
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
println!(
"cargo:warning=hermes-bootstrap: pinning to commit {}",
short(c)
);
}
if let Some(b) = &branch {
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
match &commit {
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
None => println!(
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
),
}
}
if commit.is_none() && branch.is_none() {
// Fail loudly rather than silently produce a binary that errors
@@ -46,8 +61,11 @@ fn main() {
);
}
// Rerun build.rs when HEAD moves so successive builds pick up new
// commits without needing `cargo clean`. .git/HEAD changes on every
// Rerun build.rs when HEAD moves. With branch-follow as the default the
// baked commit no longer changes per-commit, but a branch *switch* changes
// the detected branch name, so we still re-trigger. When an explicit
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
// HEAD move can also change that resolution. .git/HEAD changes on every
// commit / branch switch / rebase.
let git_dir = locate_git_dir();
if let Some(gd) = &git_dir {
@@ -83,24 +101,46 @@ fn main() {
}
fn resolve_commit_pin() -> Option<String> {
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
// None and the installer follows the branch HEAD at install time.
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
let requested = requested.trim();
if requested.is_empty() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
// Resolve the request (which may be a SHA, tag, or branch name) to an
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
// dereferences tags to the commit they point at.
if let Ok(out) = Command::new("git")
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
.output()
{
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let s = s.trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
}
}
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
// literal value only if it already looks like a SHA; otherwise fail loud
// rather than bake an unresolvable ref into the binary.
if is_sha(requested) {
return Some(requested.to_string());
}
panic!(
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
(git rev-parse failed and it is not a valid SHA)"
);
}
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
fn is_sha(s: &str) -> bool {
let len = s.len();
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn resolve_branch_pin() -> Option<String> {
+63
View File
@@ -8,6 +8,8 @@ const {
ipcMain,
nativeImage,
nativeTheme,
net: electronNet,
protocol,
safeStorage,
session,
shell,
@@ -364,6 +366,66 @@ app.setAboutPanelOptions({
copyright: 'Copyright © 2026 Nous Research'
})
// Custom scheme for streaming local media (video/audio) into the renderer.
// Reading large media through `readFileDataUrl` failed: it base64-loads the
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
// so any non-trivial video silently refused to load. Streaming via a protocol
// handler removes the size cap and gives the <video> element seekable,
// range-aware playback. Must be registered before the app is ready.
const MEDIA_PROTOCOL = 'hermes-media'
// Only audio/video may be streamed. Without this the handler would read any
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
const STREAMABLE_MEDIA_EXTS = new Set([
'.avi',
'.flac',
'.m4a',
'.mkv',
'.mov',
'.mp3',
'.mp4',
'.ogg',
'.opus',
'.wav',
'.webm'
])
protocol.registerSchemesAsPrivileged([
{
scheme: MEDIA_PROTOCOL,
privileges: {
secure: true,
standard: true,
stream: true,
supportFetchAPI: true
}
}
])
function registerMediaProtocol() {
protocol.handle(MEDIA_PROTOCOL, async request => {
let resolvedPath
try {
const url = new URL(request.url)
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
} catch {
return new Response('Media not found', { status: 404 })
}
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
return new Response('Unsupported media type', { status: 415 })
}
// Delegate to Electron's net stack on a file:// URL — it resolves the
// content-type and honors Range requests so seeking works. Forward the
// renderer's headers (notably Range) and skip custom-protocol re-entry.
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
bypassCustomProtocolHandlers: true,
headers: request.headers
})
})
}
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
@@ -3654,6 +3716,7 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(null)
}
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
createWindow()
+1 -1
View File
@@ -97,7 +97,7 @@ function ChatHeader({
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
+66 -21
View File
@@ -67,7 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
const SIDEBAR_NAV: SidebarNavItem[] = [
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
{
id: 'new-session',
label: 'New session',
icon: props => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
@@ -149,6 +154,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onLoadMoreSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
}
export function ChatSidebar({
@@ -156,7 +163,9 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onResumeSession,
onDeleteSession
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const agentsGrouped = useStore($sidebarAgentsGrouped)
@@ -328,6 +337,7 @@ export function ChatSidebar({
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
@@ -361,9 +371,9 @@ export function ChatSidebar({
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<Button
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
@@ -372,15 +382,17 @@ export function ChatSidebar({
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
}
label="Agents"
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorder={handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
@@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps {
workingSessionIdSet: Set<string>
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
onNewSessionInWorkspace?: (path: null | string) => void
pinned: boolean
rootClassName?: string
contentClassName?: string
@@ -496,7 +510,9 @@ function SidebarSessionsSection({
workingSessionIdSet,
onResumeSession,
onDeleteSession,
onArchiveSession,
onTogglePin,
onNewSessionInWorkspace,
pinned,
rootClassName,
contentClassName,
@@ -518,6 +534,7 @@ function SidebarSessionsSection({
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id),
@@ -551,9 +568,19 @@ function SidebarSessionsSection({
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
<SortableSidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
) : (
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
<SidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
)
)
@@ -568,6 +595,7 @@ function SidebarSessionsSection({
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
@@ -610,6 +638,7 @@ function SidebarSessionsSection({
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
@@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
function SidebarWorkspaceGroup({
group,
renderRows,
onNewSession,
reorderable = false,
dragging = false,
dragHandleProps,
@@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<button
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{onNewSession && (
<button
aria-label={`New session in ${group.label}`}
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={() => onNewSession(group.path)}
title={`New session in ${group.label}`}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
)}
{reorderable && (
<span
{...dragHandleProps}
@@ -663,7 +706,7 @@ function SidebarWorkspaceGroup({
/>
</span>
)}
</button>
</div>
{open && (
<>
{renderRows(visibleSessions)}
@@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({
interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
@@ -702,6 +746,7 @@ interface SortableSessionRowProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -26,6 +26,7 @@ interface SessionActions {
title: string
pinned?: boolean
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
}
@@ -40,7 +41,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
@@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }
setRenameOpen(true)
}
},
{
disabled: !onArchive,
icon: 'archive',
label: 'Archive',
onSelect: () => {
triggerHaptic('selection')
onArchive?.()
}
},
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -45,6 +46,7 @@ export function SidebarSessionRow({
isPinned,
isSelected,
isWorking,
onArchive,
onDelete,
onPin,
onResume,
@@ -61,7 +63,14 @@ export function SidebarSessionRow({
const handleLabel = `Reorder ${title}`
return (
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<SessionContextMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
sessionId={session.id}
title={title}
>
<div
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
@@ -88,6 +97,15 @@ export function SidebarSessionRow({
return
}
if (event.metaKey || event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onArchive()
return
}
onResume()
}}
type="button"
@@ -127,7 +145,14 @@ export function SidebarSessionRow({
{age}
</span>
)}
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<SessionActionsMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
sessionId={session.id}
title={title}
>
<Button
aria-label={`Actions for ${title}`}
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
@@ -12,6 +12,7 @@ interface SessionRowCommonProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -20,6 +21,7 @@ interface SessionRowCommonProps {
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
onArchiveSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
@@ -35,6 +37,7 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
onArchiveSession,
onDeleteSession,
onResumeSession,
onTogglePin,
@@ -72,6 +75,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id)
@@ -113,7 +113,7 @@ interface SectionSearchEntry {
}
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{
+36 -2
View File
@@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
@@ -33,6 +34,8 @@ import {
$selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages,
@@ -122,6 +125,7 @@ export function DesktopController() {
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
const terminalTakeoverActive = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
@@ -192,7 +196,10 @@ export function DesktopController() {
try {
const limit = $sessionsLimit.get()
const result = await listSessions(limit)
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// clutter the sidebar.
const result = await listSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
@@ -324,6 +331,7 @@ export function DesktopController() {
})
const {
archiveSession,
branchCurrentSession,
createBackendSessionForSend,
openSettings,
@@ -392,6 +400,29 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions]
)
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
const target = path?.trim()
if (!target) {
return
}
// The next message creates the backend session in $currentCwd, so seed
// it (and the branch) from the workspace the user clicked the + on.
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
setCurrentCwd(info.cwd || target)
setCurrentBranch(info.branch || '')
})
.catch(() => undefined)
},
[requestGateway, startFreshSessionDraft]
)
const handleSkinCommand = useSkinCommand()
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
@@ -461,9 +492,11 @@ export function DesktopController() {
const sidebar = (
<ChatSidebar
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
@@ -485,6 +518,7 @@ export function DesktopController() {
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
{settingsOpen && (
@@ -575,10 +609,10 @@ export function DesktopController() {
titlebarTools={titlebarToolGroups.flat.right}
>
<Pane
disabled={terminalTakeoverActive}
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
disabled={terminalTakeoverActive}
resizable
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages } from '@/hermes'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
@@ -751,7 +751,39 @@ export function useSessionActions({
]
)
const archiveSession = useCallback(
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
if (wasSelected) {
startFreshSessionDraft(true)
}
try {
await setSessionArchived(storedSessionId, true)
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
}
$pinnedSessionIds.set(previousPinned)
notifyError(err, 'Archive failed')
}
},
[selectedStoredSessionId, startFreshSessionDraft]
)
return {
archiveSession,
branchCurrentSession,
closeSettings,
createBackendSessionForSend,
+5 -1
View File
@@ -311,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
export const SEARCH_PLACEHOLDER: Record<
'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
string
> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...',
tools: 'Search skills and tools...'
}
+12 -1
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -19,6 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { SessionsSettings } from './sessions-settings'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
@@ -27,6 +28,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'mcp',
'sessions',
'tools',
'about'
]
@@ -40,6 +42,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
gateway: '',
keys: '',
mcp: '',
sessions: '',
tools: ''
})
@@ -149,6 +152,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
label="MCP"
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label="Archived Chats"
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
@@ -200,6 +209,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
<KeysSettings query={queries.keys} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : activeView === 'sessions' ? (
<SessionsSettings query={queries.sessions} />
) : (
<ToolsSettings query={queries.tools} />
)}
@@ -0,0 +1,168 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
const ARCHIVED_FETCH_LIMIT = 200
function workspaceLabel(cwd: null | string | undefined): string {
const path = cwd?.trim()
if (!path) {
return ''
}
return (
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
.pop() ?? path
)
}
export function SessionsSettings({ query }: SearchProps) {
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, 'Could not load archived sessions')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const unarchive = useCallback(async (session: SessionInfo) => {
setBusyId(session.id)
try {
await setSessionArchived(session.id, false)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
triggerHaptic('selection')
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
} catch (err) {
notifyError(err, 'Unarchive failed')
} finally {
setBusyId(null)
}
}, [])
const remove = useCallback(async (session: SessionInfo) => {
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
return
}
setBusyId(session.id)
try {
await deleteSession(session.id)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
notifyError(err, 'Delete failed')
} finally {
setBusyId(null)
}
}, [])
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) {
return sessions
}
return sessions.filter(session =>
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
)
}, [query, sessions])
if (loading) {
return <LoadingState label="Loading archived sessions…" />
}
return (
<SettingsContent>
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
title="Archived sessions"
/>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/-click a chat in the sidebar to
archive it.
</p>
{filtered.length === 0 ? (
<EmptyState
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
title="Nothing archived"
/>
) : (
<div className="divide-y divide-border/30">
{filtered.map(session => {
const label = workspaceLabel(session.cwd)
const busy = busyId === session.id
return (
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="outline"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
</Button>
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
title="Delete permanently"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
key={session.id}
title={sessionTitle(session)}
/>
)
})}
</div>
)}
</SettingsContent>
)
}
+2 -2
View File
@@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
@@ -19,9 +19,9 @@ import {
filePathFromMediaPath,
mediaExternalUrl,
mediaKind,
mediaMime,
mediaName,
mediaPathFromMarkdownHref
mediaPathFromMarkdownHref,
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
@@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
const blob = await fetch(dataUrl).then(response => response.blob())
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
// Stream audio/video through the custom protocol: data URLs are capped and
// load the whole file into memory, which broke playback for larger videos.
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return mediaStreamUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
@@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {
// render, which churns Streamdown's outer memo + propagates new prop
// identities into every Block. The plugin set really only varies on
// `isStreaming`, so memoize on that.
const plugins = useMemo(
() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
[isStreaming]
)
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
const components = useMemo(
() =>
@@ -0,0 +1,183 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import { $gatewayState } from '@/store/session'
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
// the render level means no timer logic (even a stale HMR one) can ever
// scramble "CONN".
const PREFIX = 'CONN'
const TAIL = 'ECTING'
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
// nousnet-web download-button decode effect).
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
const TICK_MS = 45
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
const TEXT_OUT_MS = 360
const POST_TEXT_HOLD_MS = 300
const OVERLAY_OUT_MS = 520
// Preview-only: how long to "connect" for, and the pause before replaying.
const PREVIEW_CONNECT_MS = 2600
const PREVIEW_REPLAY_MS = 1100
type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
// only flashes. Load with `?connecting=1` to force a looping preview.
function forcedPreview(): boolean {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false
}
try {
return new URLSearchParams(window.location.search).get('connecting') === '1'
} catch {
return false
}
}
function scrambledTail(resolvedCount: number): string {
return Array.from(TAIL, (ch, i) =>
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
).join('')
}
export function GatewayConnectingOverlay() {
const gatewayState = useStore($gatewayState)
const boot = useStore($desktopBoot)
const [previewing] = useState(forcedPreview)
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
const connecting = gatewayState !== 'open' && !boot.error
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.
const shownRef = useRef(false)
if (previewing || connecting) {
shownRef.current = true
}
// Decode loop — only while live (freeze the resolved word during the exit).
useEffect(() => {
if (phase !== 'live' || (!previewing && !connecting)) {
return
}
let resolved = 0
let hold = 0
const id = window.setInterval(() => {
if (resolved >= TAIL.length) {
hold += 1
if (hold > 16) {
resolved = 0
hold = 0
}
setTail(TAIL)
return
}
resolved += 0.5
setTail(scrambledTail(Math.floor(resolved)))
}, TICK_MS)
return () => window.clearInterval(id)
}, [phase, previewing, connecting])
// Kick off the exit when connected: real connect, or a faked timer in preview.
useEffect(() => {
if (phase !== 'live') {
return
}
if (previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('text-out')
}, PREVIEW_CONNECT_MS)
return () => window.clearTimeout(id)
}
if (gatewayState === 'open' && shownRef.current) {
setTail(TAIL)
setPhase('text-out')
}
}, [phase, previewing, gatewayState])
// Advance the exit choreography: text-out -> overlay-out -> gone.
useEffect(() => {
if (phase === 'text-out') {
const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
return () => window.clearTimeout(id)
}
if (phase === 'overlay-out') {
const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
return () => window.clearTimeout(id)
}
// Preview replays so we can keep watching the transition.
if (phase === 'gone' && previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('live')
}, PREVIEW_REPLAY_MS)
return () => window.clearTimeout(id)
}
}, [phase, previewing])
// Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
if (boot.error && !previewing) {
return null
}
// Real connect: once the fade finishes, get out of the way for good.
if (phase === 'gone' && !previewing) {
return null
}
// Never showed (e.g. gateway already up on a warm reload) — stay out.
if (!previewing && !connecting && !shownRef.current) {
return null
}
const leaving = phase !== 'live'
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
return (
<div
className={cn(
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
<span
className={cn(
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
)}
>
{PREFIX}
{tail}
<span
aria-hidden="true"
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
style={{ animation: 'gco-cursor 1s step-end infinite' }}
/>
</span>
</div>
)
}
+14 -2
View File
@@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient {
}
}
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
})
return {
@@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
}
}
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return window.hermesDesktop.api<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
@@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
const PICKER_OWNED_COMMANDS = new Set(['/model'])
const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
+4
View File
@@ -2,6 +2,8 @@ import {
IconActivity as Activity,
IconAlertCircle as AlertCircle,
IconAlertTriangle as AlertTriangle,
IconArchive as Archive,
IconArchiveOff as ArchiveOff,
IconArrowUp as ArrowUp,
IconArrowUpRight as ArrowUpRight,
IconAt as AtSign,
@@ -98,6 +100,8 @@ export {
Activity,
AlertCircle,
AlertTriangle,
Archive,
ArchiveOff,
ArrowUp,
ArrowUpRight,
AtSign,
+7
View File
@@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
}
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
// file with Range support. Used for audio/video so playback bypasses the data
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
export function mediaStreamUrl(path: string): string {
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
}
export function mediaPathFromMarkdownHref(href?: string): string | null {
if (!href?.startsWith('#media:')) {
return null
+1
View File
@@ -240,6 +240,7 @@ export interface SessionCreateResponse {
}
export interface SessionInfo {
archived?: boolean
cwd?: null | string
ended_at: null | number
id: string
+42 -3
View File
@@ -2116,6 +2116,41 @@ def _cprint(text: str):
pass
def _prepend_note_to_message(message, note: str):
"""Prepend a one-shot system-style note to a user message.
``message`` is normally a plain string, but when the user attaches an image
to a vision-capable model it becomes a list of OpenAI-style content parts
(text + ``image_url`` blocks). Naively doing ``note + "\\n\\n" + message``
then raises ``TypeError: can only concatenate str (not "list") to str``
e.g. running ``/model ...`` (which queues a model-switch note) and then
sending a pasted image in the same turn.
Returns the message with ``note`` prepended:
* ``str`` ``f"{note}\\n\\n{message}"`` (just ``note`` when empty)
* ``list`` note folded into the first text part, or inserted as a new
leading ``{"type": "text"}`` part when there is no text part.
Unknown shapes are returned unchanged (fail-open).
"""
note = str(note or "").strip()
if not note:
return message
if isinstance(message, str):
return f"{note}\n\n{message}" if message else note
if isinstance(message, list):
parts = list(message)
for i, part in enumerate(parts):
if isinstance(part, dict) and part.get("type") == "text":
merged = dict(part)
text = merged.get("text", "")
merged["text"] = f"{note}\n\n{text}" if text else note
parts[i] = merged
return parts
# No text part (image-only) — insert the note as a leading text block.
return [{"type": "text", "text": note}, *parts]
return message
# ---------------------------------------------------------------------------
# File-drop / local attachment detection — extracted as pure helpers for tests.
# ---------------------------------------------------------------------------
@@ -12135,17 +12170,21 @@ class HermesCLI:
reset_current_session_key = None # type: ignore[assignment]
_approval_session_token = None
agent_message = _voice_prefix + message if _voice_prefix else message
# Prepend pending model switch note so the model knows about the switch
# Prepend pending notes via _prepend_note_to_message, which
# handles both plain-string and multimodal content-parts list
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
# TypeError when an image was attached (agent_message is a list)
# and a /model or /reload-skills note was queued for the turn.
_msn = getattr(self, '_pending_model_switch_note', None)
if _msn:
agent_message = _msn + "\n\n" + agent_message
agent_message = _prepend_note_to_message(agent_message, _msn)
self._pending_model_switch_note = None
# Prepend pending /reload-skills note so the model sees which
# skills were added/removed before handling this turn. Same
# one-shot queue pattern as the model-switch note above.
_srn = getattr(self, '_pending_skills_reload_note', None)
if _srn:
agent_message = _srn + "\n\n" + agent_message
agent_message = _prepend_note_to_message(agent_message, _srn)
self._pending_skills_reload_note = None
try:
result = self.agent.run_conversation(
+27 -8
View File
@@ -428,22 +428,18 @@ def load_jobs() -> List[Dict[str, Any]]:
ensure_dirs()
if not JOBS_FILE.exists():
return []
_strict_retry = False # track whether we used the strict=False fallback
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("jobs", [])
except json.JSONDecodeError:
# Retry with strict=False to handle bare control chars in string values
_strict_retry = True
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.loads(f.read(), strict=False)
jobs = data.get("jobs", [])
if jobs:
# Auto-repair: rewrite with proper escaping
save_jobs(jobs)
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
return jobs
except Exception as e:
logger.error("Failed to auto-repair jobs.json: %s", e)
raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e
@@ -451,6 +447,29 @@ def load_jobs() -> List[Dict[str, Any]]:
logger.error("IOError reading jobs.json: %s", e)
raise RuntimeError(f"Failed to read cron database: {e}") from e
# Validate the top-level JSON shape: accept a dict (expected) or a bare
# list (auto-repair). Anything else (str/number/null) is corruption that
# would otherwise raise an uncaught AttributeError on ``.get()`` and take
# down the whole cron subsystem.
if isinstance(data, dict):
jobs = data.get("jobs", [])
if _strict_retry and jobs:
# Hit control-character corruption — rewrite with proper escaping.
save_jobs(jobs)
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
return jobs
if isinstance(data, list):
# Bare array — likely saved/edited outside save_jobs(). Wrap it back
# into the expected {"jobs": [...]} structure.
if data:
save_jobs(data)
logger.warning("Auto-repaired jobs.json (bare list wrapped as dict)")
return data
raise RuntimeError(
f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}"
)
def save_jobs(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage."""
+13 -5
View File
@@ -1182,14 +1182,22 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
markdown often security docs / runbooks that *describe* attack
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
pattern set is used: only unambiguous prompt-injection directives
and invisible unicode block, command-shape patterns are dropped
to avoid false-positives. Skill bodies are vetted at install time
by ``skills_guard.py``.
block; command-shape patterns are dropped and invisible unicode is
sanitized (stripped + logged) rather than blocked, to avoid
false-positives that permanently kill a job. Skill bodies are
vetted at install time by ``skills_guard.py``.
"""
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
scanner = _scan_cron_skill_assembled if has_skills else _scan_cron_prompt
scan_error = scanner(assembled)
if has_skills:
# Skill content is install-time vetted by skills_guard.py. Invisible
# unicode is sanitized (not blocked) so a stray zero-width space in a
# skill code example can't permanently kill the job; the cleaned
# prompt is what actually runs.
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
assembled = cleaned
else:
scan_error = _scan_cron_prompt(assembled)
if scan_error:
job_label = job.get("name") or job.get("id") or "<unknown>"
logger.warning(
+10
View File
@@ -27,10 +27,20 @@ drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
# don't try to write to /root.
export HOME=/opt/data
# Save the Docker -w (or default) working directory before init
# scripts cd to /opt/data, so the container starts in the
# directory the user requested.
_hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
cd /opt/data
# shellcheck disable=SC1091
. /opt/hermes/.venv/bin/activate
# Restore the original working directory before handing off to
# the user's command so `hermes chat` starts in the Docker -w
# directory, not /opt/data.
cd "$_hermes_orig_cwd"
if [ $# -eq 0 ]; then
drop hermes
fi
+39
View File
@@ -0,0 +1,39 @@
# Multi-gateway deployment
Hermes supports multiple gateway processes running concurrently — one per profile
(default, writer, admin, coder, researcher). Each gateway opens its own connection
to platform APIs and delivers messages for its profile's subscribers.
## Single-dispatcher posture
Only one gateway owns the kanban dispatcher. The owning gateway keeps
`kanban.dispatch_in_gateway: true` (the default); every other gateway sets it
to `false`.
**Why this matters:** a gateway with `dispatch_in_gateway: true` opens per-board
SQLite connections for both the dispatcher and the notifier watcher. Multiple
gateways doing this concurrently multiplies the open file descriptors on each
`kanban.db` and amplifies WAL `-shm` reader contention. Gating both paths on the
same flag means exactly one process touches the kanban DBs.
## Configuration
On the dispatch-owning gateway (typically the `default` profile), no change is
needed. On every other profile gateway, add to `~/.hermes/config.yaml`:
```yaml
kanban:
dispatch_in_gateway: false
```
Or set the env var: `HERMES_KANBAN_DISPATCH_IN_GATEWAY=false`
## What each gateway does
| Gateway role | dispatch_in_gateway | Opens per-board DBs? | Runs dispatcher + notifier? |
|---|---|---|---|
| default (dispatch owner) | true (default) | yes | yes |
| writer, admin, coder, etc. | false | no | no |
Non-dispatch gateways still deliver messages for their own platform adapters
(Telegram, Discord, etc.) — they just don't poll kanban boards.
+16
View File
@@ -1722,6 +1722,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
})
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
if bluebubbles_require_mention is not None:
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
)
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
if bluebubbles_mention_patterns:
try:
parsed_patterns = json.loads(bluebubbles_mention_patterns)
except Exception:
parsed_patterns = [
part.strip()
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
if part.strip()
]
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
+160 -12
View File
@@ -1265,6 +1265,107 @@ def cleanup_document_cache(max_age_hours: int = 24) -> int:
return removed
# ---------------------------------------------------------------------------
# Unified media caching
#
# One entry point for "I have raw attachment bytes from a platform — cache them
# and tell me what I got." Classifies by extension/MIME against the shared
# registries above, routes to the right cache_*_from_bytes helper, and returns
# a small result the caller can store and/or describe in a transcript. Used by
# both the addressed-message path and the observed-group-context path, on any
# platform — not Telegram-specific.
# ---------------------------------------------------------------------------
@dataclass
class CachedMedia:
"""Result of caching one attachment's bytes."""
path: str # absolute cache path, agent-visible (sandbox-translated)
media_type: str # MIME type recorded on the MessageEvent
kind: str # "image" | "video" | "audio" | "document"
display_name: str # human-readable name for transcript notes
def context_note(self) -> str:
"""One-line transcript annotation pointing the agent at the file."""
return f"[{self.kind} '{self.display_name}' saved at: {self.path}]"
def _resolve_media_ext(filename: str, mime_type: str) -> str:
"""Best-effort file extension from filename, then MIME fallback."""
if filename:
ext = os.path.splitext(filename)[1].lower()
if ext:
return ext
mime = (mime_type or "").lower()
if not mime:
return ""
for table in (
SUPPORTED_IMAGE_DOCUMENT_TYPES,
SUPPORTED_VIDEO_TYPES,
SUPPORTED_DOCUMENT_TYPES,
):
for ext, m in table.items():
if m == mime:
return ext
return ""
def cache_media_bytes(
data: bytes,
*,
filename: str = "",
mime_type: str = "",
default_kind: Optional[str] = None,
) -> Optional[CachedMedia]:
"""Classify and cache raw attachment bytes; return a CachedMedia or None.
``default_kind`` ("image"/"video"/"audio"/"document") biases classification
when the extension/MIME are ambiguous e.g. a Telegram native photo whose
file has no usable name. Unsupported document types return None so the
caller can record an "unsupported" note. Images that fail validation
(``cache_image_from_bytes`` raises ValueError) also return None.
"""
from tools.credential_files import to_agent_visible_cache_path
ext = _resolve_media_ext(filename, mime_type)
mime = (mime_type or "").lower()
display = re.sub(r"[^\w.\- ]", "_", filename) if filename else (ext.lstrip(".") or "file")
is_image = (
mime.startswith("image/")
or ext in SUPPORTED_IMAGE_DOCUMENT_TYPES
or default_kind == "image"
)
is_video = mime.startswith("video/") or ext in SUPPORTED_VIDEO_TYPES or default_kind == "video"
is_audio = mime.startswith("audio/") or default_kind == "audio"
if is_image:
img_ext = ext if ext in SUPPORTED_IMAGE_DOCUMENT_TYPES else ".jpg"
try:
path = cache_image_from_bytes(data, ext=img_ext)
except ValueError:
return None
out_mime = mime if mime.startswith("image/") else SUPPORTED_IMAGE_DOCUMENT_TYPES.get(img_ext, "image/jpeg")
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "image", display)
if is_video:
vid_ext = ext if ext in SUPPORTED_VIDEO_TYPES else ".mp4"
path = cache_video_from_bytes(data, ext=vid_ext)
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_VIDEO_TYPES.get(vid_ext, "video/mp4"), "video", display)
if is_audio:
aud_ext = ext if ext in {".ogg", ".mp3", ".wav", ".m4a", ".opus", ".flac"} else ".ogg"
path = cache_audio_from_bytes(data, ext=aud_ext)
out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}"
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display)
if ext not in SUPPORTED_DOCUMENT_TYPES:
return None
path = cache_document_from_bytes(data, filename or f"document{ext}")
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_DOCUMENT_TYPES[ext], "document", display or f"document{ext}")
class MessageType(Enum):
"""Types of incoming messages."""
TEXT = "text"
@@ -1644,6 +1745,22 @@ def resolve_channel_skills(
return None
def _strip_media_directives(text: str) -> str:
"""Strip internal delivery directives ([[audio_as_voice]], [[as_document]],
MEDIA:<path>) so they never render as visible text.
Backstop only: run ``extract_media`` first. MEDIA cleanup uses the shared
``MEDIA_TAG_CLEANUP_RE`` (only tags whose path has a known deliverable
extension are removed; an unknown-extension tag is intentionally left so the
bare-path detector downstream can still pick it up, per #34517). [[...]] is
exact.
"""
if not text:
return text
text = text.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
return MEDIA_TAG_CLEANUP_RE.sub("", text)
class BasePlatformAdapter(ABC):
"""
Base class for platform adapters.
@@ -1734,8 +1851,8 @@ class BasePlatformAdapter(ABC):
def enforces_own_access_policy(self) -> bool:
"""Whether this adapter gates inbound access before dispatch.
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
config-driven access surface ``dm_policy`` / ``group_policy`` /
Some adapters (WeCom, Weixin, Yuanbao, QQBot, WhatsApp) implement a
documented config-driven access surface ``dm_policy`` / ``group_policy`` /
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` and
enforce it at intake: a message is dropped inside the adapter and never
reaches the gateway unless it already passed that policy.
@@ -3884,21 +4001,20 @@ class BasePlatformAdapter(ABC):
# where Telegram's sendPhoto recompression destroys legibility.
force_document_attachments = "[[as_document]]" in response
# Pre-extract snapshot for the #29346 recovery/invariant below.
_response_pre_extract = response
# Extract MEDIA:<path> tags (from TTS tool) before other processing
media_files, response = self.extract_media(response)
media_files = self.filter_media_delivery_paths(media_files)
# Extract image URLs and send them as native platform attachments
images, text_content = self.extract_images(response)
# Strip any remaining internal directives from message body (fixes #1561)
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
text_content = text_content.replace("[[as_document]]", "").strip()
# Strip only MEDIA: tags whose path has a deliverable extension
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
# extension is intentionally left in the body so extract_local_files
# below can still pick up the bare path — otherwise the file would
# be silently dropped (issue #34517).
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
# Strip any remaining internal directives from message body (fixes #1561).
# _strip_media_directives shares MEDIA_TAG_CLEANUP_RE, so a MEDIA: tag
# with an unknown extension is intentionally left in the body for
# extract_local_files below to pick up rather than silently dropped (#34517).
text_content = _strip_media_directives(text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
@@ -3912,7 +4028,25 @@ class BasePlatformAdapter(ABC):
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
# A2 (#29346): extraction can reduce a non-empty response to
# empty text with no attachment, and the `if text_content` guard
# below then drops it silently. Recover on every platform (#33842
# was Discord-only); the guard avoids duplicating an attachment.
if not (text_content or images or local_files or media_files):
# Recover from the post-extract_media `response`, not the raw
# snapshot: extract_media already stripped MEDIA (incl. spaced
# paths) with its full grammar, so no fragment can leak.
_recovered = _strip_media_directives(response).strip()
if _recovered:
logger.warning(
"[%s] response_delivery_recovered: extract pipeline "
"reduced a non-empty response (%d chars) to empty with "
"no attachment; delivering recovered original to %s",
self.name, len(_response_pre_extract), event.source.chat_id,
)
text_content = _recovered
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
@@ -4110,6 +4244,20 @@ class BasePlatformAdapter(ABC):
except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
# A3 (#29346): if a non-empty response produced nothing
# deliverable, fail loudly rather than dropping it in silence.
_anything_delivered = (
delivery_attempted or _tts_caption_delivered
or images or local_files or media_files
)
if not _anything_delivered and _response_pre_extract.strip():
logger.error(
"[%s] response_delivery_dropped: non-empty response "
"(%d chars) produced no delivered message or attachment "
"for %s (empty after extract, recovery yielded nothing).",
self.name, len(_response_pre_extract), event.source.chat_id,
)
# Determine overall success for the processing hook
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
await self._run_processing_hook(
+81
View File
@@ -44,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
MAX_TEXT_LENGTH = 4000
# BlueBubbles/iMessage does not expose a stable bot mention identity like
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
# group mention gating without custom aliases, use conservative Hermes wake
# words so `require_mention: true` is a one-line enablement path.
DEFAULT_MENTION_PATTERNS = [
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
r"(?<![\w@])@?hermes\b[,:\-]?",
]
# Tapback reaction codes (BlueBubbles associatedMessageType values)
_TAPBACK_ADDED = {
2000: "love", 2001: "like", 2002: "dislike",
@@ -127,6 +136,15 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not str(self.webhook_path).startswith("/"):
self.webhook_path = f"/{self.webhook_path}"
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
_require_mention = extra.get("require_mention")
if _require_mention is None:
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
self._mention_patterns = self._compile_mention_patterns(
extra["mention_patterns"]
if "mention_patterns" in extra
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
)
self.client: Optional[httpx.AsyncClient] = None
self._runner = None
self._private_api_enabled: Optional[bool] = None
@@ -141,6 +159,62 @@ class BlueBubblesAdapter(BasePlatformAdapter):
sep = "&" if "?" in path else "?"
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
@staticmethod
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
"""Compile group-mention wake words from config/env.
``raw`` is a list (from config or env JSON), a string (raw env var:
JSON list, or comma/newline-separated), or None (use Hermes defaults).
"""
if raw is None:
patterns = list(DEFAULT_MENTION_PATTERNS)
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip()
for line in text.splitlines()
for part in line.split(",")
]
elif isinstance(raw, list):
patterns = raw
else:
patterns = [raw]
compiled: List["re.Pattern"] = []
for pattern in patterns:
text = str(pattern).strip()
if not text:
continue
try:
compiled.append(re.compile(text, re.IGNORECASE))
except re.error as exc:
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
return compiled
def _message_matches_mention_patterns(self, text: str) -> bool:
if not text or not self._mention_patterns:
return False
return any(pattern.search(text) for pattern in self._mention_patterns)
def _clean_mention_text(self, text: str) -> str:
"""Strip a leading BlueBubbles wake word before dispatch.
Custom mention patterns are regular expressions, so stripping only a
leading match avoids deleting ordinary words later in the prompt.
"""
if not text:
return text
for pattern in self._mention_patterns:
match = pattern.match(text.lstrip())
if match:
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
return cleaned or text
return text
async def _api_get(self, path: str) -> Dict[str, Any]:
assert self.client is not None
res = await self.client.get(self._api_url(path))
@@ -921,6 +995,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
session_chat_id = chat_guid or chat_identifier
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
if is_group and self.require_mention:
if not self._message_matches_mention_patterns(text):
logger.debug(
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
)
return web.Response(text="ok")
text = self._clean_mention_text(text)
source = self.build_source(
chat_id=session_chat_id,
chat_name=chat_identifier or sender,
+109 -32
View File
@@ -4918,13 +4918,109 @@ class TelegramAdapter(BasePlatformAdapter):
channel_prompt=channel_prompt,
)
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
def _media_message_type(self, msg: Message) -> MessageType:
"""Classify a Telegram media message into a MessageType."""
if msg.sticker:
return MessageType.STICKER
if msg.photo:
return MessageType.PHOTO
if msg.video:
return MessageType.VIDEO
if msg.audio:
return MessageType.AUDIO
if msg.voice:
return MessageType.VOICE
return MessageType.DOCUMENT
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
"""Cache an unmentioned group attachment and annotate the observed text.
Passive group traffic, so downloads are bounded by the same
``_max_doc_bytes`` limit as the addressed document path. Oversized or
unsupported attachments are noted in the transcript without downloading.
"""
from gateway.platforms.base import cache_media_bytes
source, filename, mime, kind = self._observed_media_source(msg)
if source is None:
return
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
file_size = getattr(source, "file_size", None)
try:
size = int(file_size or 0)
except (TypeError, ValueError):
size = 0
if not (0 < size <= max_bytes):
limit_mb = max_bytes // (1024 * 1024)
event.text = self._append_observed_note(
event.text,
f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]",
)
logger.info("[Telegram] Observed group attachment skipped (size=%s)", file_size)
return
try:
file_obj = await source.get_file()
data = bytes(await file_obj.download_as_bytearray())
if not filename:
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
except Exception as exc:
logger.warning("[Telegram] Failed to cache observed group media: %s", exc, exc_info=True)
return
if cached is None:
event.text = self._append_observed_note(
event.text, "[Observed Telegram attachment: unsupported type, not cached.]"
)
return
event.media_urls = [cached.path]
event.media_types = [cached.media_type]
if cached.kind == "image":
event.message_type = MessageType.PHOTO
elif cached.kind == "video":
event.message_type = MessageType.VIDEO
event.text = self._append_observed_note(event.text, cached.context_note())
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
def _observed_media_source(self, msg: Message):
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
if msg.photo:
return msg.photo[-1], "", "", "image"
if msg.video:
return msg.video, "", "video/mp4", "video"
if msg.voice:
return msg.voice, "voice.ogg", "audio/ogg", "audio"
if msg.audio:
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
if msg.document:
doc = msg.document
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
return None, "", "", None
@staticmethod
def _append_observed_note(existing: Optional[str], note: str) -> str:
if not note:
return existing or ""
if not existing:
return note
return f"{existing}\n\n{note}"
def _observe_unmentioned_group_message(
self,
message: Message,
msg_type: MessageType,
update_id: Optional[int] = None,
event: Optional[MessageEvent] = None,
) -> None:
"""Append skipped group chatter to the target session without dispatching."""
store = getattr(self, "_session_store", None)
if not store:
return
try:
event = self._build_message_event(message, msg_type, update_id=update_id)
event = event or self._build_message_event(message, msg_type, update_id=update_id)
shared_source = self._telegram_group_observe_shared_source(event.source)
session_entry = store.get_or_create_session(shared_source)
entry = {
@@ -5285,39 +5381,20 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._should_process_message(update.message):
if self._should_observe_unmentioned_group_message(update.message):
_m = update.message
if _m.sticker:
_observe_type = MessageType.STICKER
elif _m.photo:
_observe_type = MessageType.PHOTO
elif _m.video:
_observe_type = MessageType.VIDEO
elif _m.audio:
_observe_type = MessageType.AUDIO
elif _m.voice:
_observe_type = MessageType.VOICE
else:
_observe_type = MessageType.DOCUMENT
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
_observe_type = self._media_message_type(_m)
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
if _m.caption:
_event.text = self._clean_bot_trigger_text(_m.caption)
await self._cache_observed_media(_m, _event)
self._observe_unmentioned_group_message(
_m, _event.message_type, update_id=update.update_id, event=_event
)
return
msg = update.message
# Determine media type
if msg.sticker:
msg_type = MessageType.STICKER
elif msg.photo:
msg_type = MessageType.PHOTO
elif msg.video:
msg_type = MessageType.VIDEO
elif msg.audio:
msg_type = MessageType.AUDIO
elif msg.voice:
msg_type = MessageType.VOICE
elif msg.document:
msg_type = MessageType.DOCUMENT
else:
msg_type = MessageType.DOCUMENT
msg_type = self._media_message_type(msg)
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
# Add caption as text
+9
View File
@@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
{"error": f"Unknown route: {route_name}"}, status=404
)
# Disabled routes are kept in the subscriptions file (so the dashboard
# can re-enable them) but reject incoming events. Default-enabled:
# only an explicit ``enabled: false`` turns a route off, matching the
# mcp_servers ``enabled`` semantics.
if route_config.get("enabled", True) is False:
return web.json_response(
{"error": f"Route disabled: {route_name}"}, status=403
)
# ── Auth-before-body ─────────────────────────────────────
# Check Content-Length before reading the full payload.
content_length = request.content_length or 0
+9 -1
View File
@@ -161,7 +161,15 @@ class WeComAdapter(BasePlatformAdapter):
).strip() or DEFAULT_WS_URL
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
# (dm_policy=allowlist via env, no config extra) runs with an empty
# allowlist and drops every authorized DM at intake.
self._allow_from = _coerce_list(
extra.get("allow_from")
or extra.get("allowFrom")
or os.getenv("WECOM_ALLOWED_USERS", "")
)
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
+20 -12
View File
@@ -378,12 +378,16 @@ async def _api_post(
) -> Dict[str, Any]:
body = _json_dumps({**payload, "base_info": _base_info()})
url = f"{base_url.rstrip('/')}/{endpoint}"
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do() -> Dict[str, Any]:
async with session.post(url, data=body, headers=_headers(token, body)) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _api_get(
@@ -398,12 +402,16 @@ async def _api_get(
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
}
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
async with session.get(url, headers=headers, timeout=timeout) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do() -> Dict[str, Any]:
async with session.get(url, headers=headers) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _get_updates(
+5
View File
@@ -379,6 +379,11 @@ class WhatsAppAdapter(BasePlatformAdapter):
return True
return False
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
def _is_dm_allowed(self, sender_id: str) -> bool:
"""Check whether a DM from the given sender should be processed."""
if self._dm_policy == "disabled":
+26 -2
View File
@@ -5121,6 +5121,30 @@ class GatewayRunner:
cross boards, so delivery semantics are unchanged this is
purely a fan-out of the single-DB poll.
"""
# Gate: only the dispatch-owning gateway opens kanban DBs for notifier polling.
# Non-dispatch gateways have no subscriptions to deliver — all kanban state lives
# in the dispatch owner's per-board DBs. This prevents N-gateway -shm contention.
# TODO: gate per-board when per-board dispatcher_owner tracking lands.
try:
from hermes_cli.config import load_config as _load_config
except Exception:
logger.warning("kanban notifier: config loader unavailable; disabled")
return
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
if env_override in {"0", "false", "no", "off"}:
logger.info("kanban notifier: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
return
try:
cfg = _load_config()
except Exception as exc:
logger.warning("kanban notifier: cannot load config (%s); disabled", exc)
return
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
if not kanban_cfg.get("dispatch_in_gateway", True):
logger.info(
"kanban notifier: disabled via config kanban.dispatch_in_gateway=false"
)
return
from gateway.config import Platform as _Platform
try:
from hermes_cli import kanban_db as _kb
@@ -6820,8 +6844,8 @@ class GatewayRunner:
"""Whether the adapter for *platform* gates access at intake itself.
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
+23 -4
View File
@@ -261,6 +261,12 @@ class GatewayStreamConsumer:
self._last_sent_text = ""
self._fallback_final_send = False
self._fallback_prefix = ""
# #29346: a tool/segment boundary means what we delivered was an interim
# preamble, not the final answer — clear the flags so a premature setter
# can't fool the gateway. Safe: got_done returns before any reset, and
# run.py reads these only after the consumer task exits.
self._final_response_sent = False
self._final_content_delivered = False
# Native draft streaming: bump the draft_id so the next text segment
# animates as a fresh preview below the tool-progress bubbles, not
# over the prior segment's already-finalized draft. This is how
@@ -549,6 +555,9 @@ class GatewayStreamConsumer:
current_update_visible = await self._send_or_edit(
display_text,
finalize=(got_done or got_segment_break),
# A segment-break finalize closes a preamble, not the
# turn-final answer — only got_done marks delivered (#29346).
is_turn_final=got_done,
)
self._last_edit_time = time.monotonic()
@@ -1058,12 +1067,17 @@ class GatewayStreamConsumer:
age = time.monotonic() - self._message_created_ts
return age >= threshold
async def _try_fresh_final(self, text: str) -> bool:
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
"""Send ``text`` as a brand-new message (best-effort delete the old
preview) so the platform's visible timestamp reflects completion
time. Returns True on successful delivery, False on any failure so
the caller falls back to the normal edit path.
``is_turn_final`` is False when finalizing an interim segment at a tool
boundary (a preamble) rather than the turn-final answer; the
final-delivery flag is then left unset so the gateway still delivers the
real answer from the next API call (#29346).
Ported from openclaw/openclaw#72038.
"""
old_message_id = self._message_id
@@ -1108,10 +1122,13 @@ class GatewayStreamConsumer:
self._message_created_ts = None
self._already_sent = True
self._last_sent_text = text
self._final_response_sent = True
if is_turn_final:
self._final_response_sent = True
return True
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
async def _send_or_edit(
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
) -> bool:
"""Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited),
@@ -1205,7 +1222,9 @@ class GatewayStreamConsumer:
if (
finalize
and self._should_send_fresh_final()
and await self._try_fresh_final(text)
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
):
return True
# Edit existing message
+1
View File
@@ -6165,6 +6165,7 @@ def _prompt_model_selection(
selected=default_idx,
cancel_returns=-1,
description=description,
searchable=True,
)
if idx < 0:
return None
+1 -1
View File
@@ -177,7 +177,7 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
"conflicts (Telegram, Discord, and Slack only allow one active "
"session per token)."
)
print_info("Recommendation: stop the gateway first with 'hermes stop'.")
print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.")
print()
if not auto_yes and not prompt_yes_no("Continue anyway?", default=False):
print_info("Migration cancelled. Stop the gateway and try again.")
+1 -1
View File
@@ -124,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
args_hint="[model] [--provider name] [--global] [--refresh]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
+7 -3
View File
@@ -105,7 +105,9 @@ _hermes_profiles() {{
local profiles_dir="$HOME/.hermes/profiles"
local profiles="default"
if [ -d "$profiles_dir" ]; then
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
for f in "$profiles_dir"/*/; do
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
done
fi
echo "$profiles"
}}
@@ -206,7 +208,7 @@ _hermes_profiles() {{
local -a profiles
profiles=(default)
if [[ -d "$HOME/.hermes/profiles" ]]; then
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
profiles+=($HOME/.hermes/profiles/*(N/:t))
fi
_describe 'profile' profiles
}}
@@ -260,7 +262,9 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
"function __hermes_profiles",
" echo default",
" if test -d $HOME/.hermes/profiles",
" ls $HOME/.hermes/profiles 2>/dev/null",
" for d in $HOME/.hermes/profiles/*/",
" basename $d",
" end",
" end",
"end",
"",
+349 -27
View File
@@ -5,11 +5,242 @@ Provides a curses multi-select with keyboard navigation, plus a
text-based numbered fallback for terminals without curses support.
"""
import sys
from dataclasses import dataclass
from typing import Callable, List, Optional, Set
from hermes_cli.colors import Colors, color
def _query_matches(label: str, query: str) -> bool:
"""Return True when every query token is a case-insensitive subsequence."""
normalized = label.lower()
tokens = query.lower().split()
if not tokens:
return True
for token in tokens:
pos = 0
for ch in token:
pos = normalized.find(ch, pos)
if pos < 0:
return False
pos += 1
return True
_WORD_BOUNDARY = frozenset("-_/. ")
def _is_boundary(target: str, index: int) -> bool:
"""True if position ``index`` in ``target`` starts a word.
Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a
separator char, or a lower->upper camelCase transition.
"""
if index == 0:
return True
prev = target[index - 1]
if prev in _WORD_BOUNDARY:
return True
# camelCase / lower->upper transition (e.g. the `O` in `gptO`).
cur = target[index]
return prev == prev.lower() and cur != cur.lower() and cur == cur.upper()
def _token_score(orig: str, lower: str, token: str) -> float | None:
"""Score one token against a target. None if the token isn't a subsequence.
A faithful port of ``fuzzyScore`` in ui-tui/src/lib/fuzzy.ts and
web/src/lib/fuzzy.ts so all three surfaces rank model ids identically:
contiguous runs, word-boundary / first-char starts, prefix matches, and
exact matches all score higher than scattered subsequence hits.
``lower`` is ``orig`` lowercased; matching is done against ``lower`` while
boundary detection uses ``orig`` (so the camelCase rule works), exactly as
in the TS scorer.
"""
score = 0.0
prev = -1
search_from = 0
positions: list[int] = []
for ch in token:
idx = lower.find(ch, search_from)
if idx < 0:
return None
positions.append(idx)
score += 1
if prev >= 0 and idx == prev + 1:
score += 5
elif prev >= 0:
score -= min(idx - prev - 1, 3)
if _is_boundary(orig, idx):
score += 3
if idx == 0:
score += 5
prev = idx
search_from = idx + 1
# Prefix bonus: the token matched a contiguous prefix of the target.
if positions and positions[0] == 0 and positions[-1] == len(positions) - 1:
score += 8
# Exact full match dominates everything else.
if lower == token:
score += 20
# Slightly prefer shorter targets when scores are otherwise close.
score -= len(lower) * 0.01
return score
def _fuzzy_score(label: str, query: str) -> float | None:
"""Aggregate score for a multi-token query (AND). None if any token fails.
Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated
token must match; per-token scores are summed.
"""
lower = label.lower()
tokens = query.lower().split()
if not tokens:
return 0.0
total = 0.0
for token in tokens:
token_score = _token_score(label, lower, token)
if token_score is None:
return None
total += token_score
return total
def _filter_indices(items: List[str], query: str) -> List[int]:
"""Return item indices matching *query*, ranked best-first.
An empty query keeps every item in original order. Otherwise items are
filtered to fuzzy matches and sorted by score descending, ties broken by
original index so equal-scoring rows keep their catalog order.
"""
q = query.strip()
if not q:
return list(range(len(items)))
scored = []
for i, label in enumerate(items):
score = _fuzzy_score(label, q)
if score is not None:
scored.append((i, score))
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return [i for i, _ in scored]
@dataclass
class _SearchState:
"""Mutable search state shared by curses picker loops."""
active: bool = False
query: str = ""
def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]:
"""Return ``(cursor, cursor_pos)`` inside the filtered index list."""
if not filtered:
return cursor, 0
if cursor not in filtered:
cursor = filtered[0]
return cursor, filtered.index(cursor)
def _move_filtered_cursor(
filtered: List[int], cursor: int, cursor_pos: int, delta: int
) -> int:
"""Move through the filtered index list, wrapping like the legacy menus."""
if not filtered:
return cursor
return filtered[(cursor_pos + delta) % len(filtered)]
def _scroll_for_cursor(
scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int
) -> int:
"""Clamp scroll offset so the cursor remains visible."""
visible_rows = max(1, visible_rows)
if cursor_pos < scroll_offset:
scroll_offset = cursor_pos
elif cursor_pos >= scroll_offset + visible_rows:
scroll_offset = cursor_pos - visible_rows + 1
return max(0, min(scroll_offset, max(0, total_rows - visible_rows)))
def _handle_active_search_key(
curses_mod, key: int, search: _SearchState
) -> tuple[bool, bool, bool]:
"""Handle a key while the search prompt is active.
Returns ``(handled, confirm, changed)``. Active search consumes query
editing keys, but leaves navigation keys for the menu loop to handle.
"""
if not search.active:
return False, False, False
if key == 27:
# Esc stops search AND clears the query, restoring the full list (so a
# no-match filter can't strand the user on an empty list). Signals
# `changed` when there was a query so the driver resets scroll/cursor.
had_query = bool(search.query)
search.active = False
search.query = ""
return True, False, had_query
if key in (curses_mod.KEY_BACKSPACE, 127, 8):
search.query = search.query[:-1]
return True, False, True
if key == 21: # Ctrl+U
search.query = ""
return True, False, True
if key in (curses_mod.KEY_ENTER, 10, 13):
return True, True, False
if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255
search.query += chr(key)
return True, False, True
return False, False, False
def flush_stdin() -> None:
"""Flush any stray bytes from the stdin input buffer.
@@ -58,9 +289,16 @@ def read_menu_key(stdscr) -> str:
the escape path; ``q`` also cancels. Unknown sequences map to
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
"""
import curses
return _decode_menu_key(stdscr, stdscr.getch())
key = stdscr.getch()
def _decode_menu_key(stdscr, key: int) -> str:
"""Normalize an already-read keypress to a menu action.
Split out from ``read_menu_key`` so search-aware loops can peek the raw
key (e.g. to catch ``/``) before falling back to nav decoding.
"""
import curses
if key in (curses.KEY_UP, ord("k")):
return NAV_UP
@@ -121,6 +359,8 @@ def _run_curses_menu(
extra_color_pairs=False,
fallback,
cancel_value,
searchable=False,
search_labels=None,
):
"""Shared curses single-/multi-select event loop.
@@ -135,9 +375,12 @@ def _run_curses_menu(
Callbacks / params:
draw_header(stdscr, max_y, max_x) -> int
Draw the title/hint/description rows. Returns the first screen row
index where the scrollable item list should start.
index where the scrollable item list should start. When search is
active it receives the live ``_SearchState`` via the optional
``search`` keyword (drawn by the menu so the hint line can show it).
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
Draw one item row.
Draw one item row. ``idx`` is always the ORIGINAL item index, so
per-menu rendering is unchanged whether or not a filter is active.
on_action(action, cursor) -> value
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
loop; return anything else to resolve the menu with that value.
@@ -151,6 +394,10 @@ def _run_curses_menu(
fallback() -> value
Called when curses errors out on a real TTY (curses unavailable).
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
searchable: when true, ``/`` opens a type-to-filter prompt over
``search_labels``. Returned values are always ORIGINAL item indices.
search_labels: per-item text used for filtering (required when
``searchable`` is true; length must equal ``item_count``).
"""
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
# so return the cancel value directly — matching the pre-refactor guard in
@@ -158,6 +405,8 @@ def _run_curses_menu(
if not sys.stdin.isatty():
return cancel_value
use_search = searchable and search_labels is not None and len(search_labels) == item_count
try:
import curses
result_holder = [_KEEP]
@@ -175,22 +424,46 @@ def _run_curses_menu(
)
cursor = initial_cursor
scroll_offset = 0
search = _SearchState()
# Non-None labels for filtering; empty when search is disabled so
# _filter_indices stays a cheap identity range.
labels: List[str] = (
search_labels if (use_search and search_labels is not None) else []
)
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
items_start = draw_header(stdscr, max_y, max_x)
filtered = (
_filter_indices(labels, search.query)
if use_search
else list(range(item_count))
)
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
visible_rows = max_y - items_start - reserve_bottom
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
# draw_header accepts an optional `search` kwarg when the menu
# wants to render the live filter; tolerate headers that don't.
try:
items_start = draw_header(stdscr, max_y, max_x, search=search)
except TypeError:
items_start = draw_header(stdscr, max_y, max_x)
for draw_i, i in enumerate(
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
visible_rows = max(1, max_y - items_start - reserve_bottom)
scroll_offset = _scroll_for_cursor(
scroll_offset, cursor_pos, visible_rows, len(filtered)
)
if use_search and search.query and not filtered:
try:
stdscr.addnstr(items_start, 0, " No matches", max_x - 1, curses.A_DIM)
except curses.error:
pass
for draw_i, filtered_pos in enumerate(
range(scroll_offset, min(len(filtered), scroll_offset + visible_rows))
):
i = filtered[filtered_pos]
y = draw_i + items_start
if y >= max_y - reserve_bottom:
break
@@ -200,13 +473,46 @@ def _run_curses_menu(
draw_footer(stdscr, max_y, max_x)
stdscr.refresh()
action = read_menu_key(stdscr)
if use_search:
key = stdscr.getch()
if search.active:
# Active search consumes query-editing keys; nav keys
# fall through to be decoded below.
handled, confirm, changed = _handle_active_search_key(
curses, key, search
)
if changed:
scroll_offset = 0
cursor, cursor_pos = _reconcile_cursor(
_filter_indices(search_labels, search.query), cursor
)
if confirm:
if filtered:
outcome = on_action(NAV_SELECT, cursor)
if outcome is not _KEEP:
result_holder[0] = outcome
return
continue
if handled:
continue
action = _decode_menu_key(stdscr, key)
elif key == ord("/"):
search.active = True
continue
else:
action = _decode_menu_key(stdscr, key)
else:
action = read_menu_key(stdscr)
if action == NAV_UP:
cursor = (cursor - 1) % item_count
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1)
elif action == NAV_DOWN:
cursor = (cursor + 1) % item_count
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1)
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
if action == NAV_SELECT and use_search and not filtered:
continue
outcome = on_action(action, cursor)
if outcome is not _KEEP:
result_holder[0] = outcome
@@ -320,6 +626,7 @@ def curses_radiolist(
*,
cancel_returns: int | None = None,
description: str | None = None,
searchable: bool = False,
) -> int:
"""Curses single-select radio list. Returns the selected index.
@@ -331,6 +638,9 @@ def curses_radiolist(
description: Optional multi-line text shown between the title and
the item list. Useful for context that should survive the
curses screen clear.
searchable: When true, ``/`` opens a type-to-filter prompt. The
returned value is always the original item index, not a filtered
row position.
"""
if cancel_returns is None:
cancel_returns = selected
@@ -339,7 +649,7 @@ def curses_radiolist(
if description:
desc_lines = description.splitlines()
def _draw_header(stdscr, max_y, max_x):
def _draw_header(stdscr, max_y, max_x, search=None):
import curses
row = 0
try:
@@ -356,11 +666,13 @@ def curses_radiolist(
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
if searchable and search is not None and search.active:
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
elif searchable:
hint = " \u2191\u2193 navigate ENTER/SPACE select / search ESC cancel"
else:
hint = " \u2191\u2193 navigate ENTER/SPACE select ESC cancel"
stdscr.addnstr(row, 0, hint, max_x - 1, curses.A_DIM)
row += 1
except curses.error:
pass
@@ -396,6 +708,8 @@ def curses_radiolist(
reserve_bottom=1,
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
searchable=searchable,
search_labels=list(items) if searchable else None,
)
@@ -431,27 +745,33 @@ def curses_single_select(
default_index: int = 0,
*,
cancel_label: str = "Cancel",
searchable: bool = False,
) -> int | None:
"""Curses single-select menu. Returns selected index or None on cancel.
Works inside prompt_toolkit because curses.wrapper() restores the terminal
safely, unlike simple_term_menu which conflicts with /dev/tty.
When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the
returned value is always the original item index (or None for cancel).
"""
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
def _draw_header(stdscr, max_y, max_x):
def _draw_header(stdscr, max_y, max_x, search=None):
import curses
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate ENTER confirm ESC/q cancel",
max_x - 1, curses.A_DIM,
)
if searchable and search is not None and search.active:
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
elif searchable:
hint = " ↑↓ navigate ENTER confirm / search ESC/q cancel"
else:
hint = " ↑↓ navigate ENTER confirm ESC/q cancel"
stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM)
except curses.error:
pass
return 3
@@ -488,6 +808,8 @@ def curses_single_select(
reserve_bottom=1,
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
cancel_value=None,
searchable=searchable,
search_labels=list(all_items) if searchable else None,
)
+54 -12
View File
@@ -453,11 +453,8 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
if pid == my_pid or pid in exclude_pids:
continue
try:
cmdline = (
open(f"/proc/{pid}/cmdline", "rb")
.read()
.decode("utf-8", errors="replace")
)
with open(f"/proc/{pid}/cmdline", "rb") as _f:
cmdline = _f.read().decode("utf-8", errors="replace")
cmdline = cmdline.replace("\x00", " ")
cmdline_lc = cmdline.lower()
if any(p in cmdline_lc for p in patterns) and (
@@ -5877,15 +5874,60 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool:
file=sys.stderr,
flush=True,
)
# Block until the container is signalled. The supervised gateway's
# lifetime is independent of this process — s6-supervise restarts
# it on crash, and we don't want the container to exit when the
# gateway flaps. `sleep infinity` matches the static main-hermes
# service's pattern (see docker/s6-rc.d/main-hermes/run): the CMD
# process is a no-op heartbeat that keeps /init alive until
# Keep the CMD process alive as a no-op heartbeat. The supervised
# gateway's lifetime is independent of this process — s6-supervise
# restarts it on crash, and we don't want the container to exit when
# the gateway flaps. The CMD process keeps /init alive until
# `docker stop` sends SIGTERM, at which point /init runs stage 3
# shutdown (which tears down the supervised gateway cleanly).
os.execvp("sleep", ["sleep", "infinity"])
#
# Prefer `sleep infinity` (matches the static main-hermes service's
# pattern in docker/s6-rc.d/main-hermes/run, and frees the Python
# interpreter — the heartbeat is a tiny `sleep` process, not a
# resident interpreter). But `os.execvp` does a PATH lookup for the
# `sleep` binary and historically crashed the whole container with
# FileNotFoundError when PATH was empty/truncated/clobbered at this
# point — e.g. after user customizations rewrote PATH, or on minimal
# images without `sleep` on PATH (issue #36208). Fall back to an
# in-process block (no external binary, can't fail on PATH) so the
# container keeps running instead of dying during boot.
try:
os.execvp("sleep", ["sleep", "infinity"])
except OSError:
# execvp only returns by raising; on success it replaces this
# process. ENOENT (no `sleep` on PATH) and any other exec error
# land here.
print(
"→ `sleep` is unavailable; keeping the s6 CMD process alive "
"in-process until the container is stopped.",
file=sys.stderr,
flush=True,
)
_block_until_terminated()
return True # unreachable on the execvp success path
def _block_until_terminated() -> None:
"""Keep the s6 CMD process alive until the container is stopped.
Fallback heartbeat for when ``os.execvp("sleep", ...)`` can't run
(``sleep`` missing from PATH issue #36208). Installs a SIGTERM
handler that exits with the conventional 128+signum code so
``docker stop`` produces a clean, expected exit, then blocks on
``signal.pause()``. Falls back to ``threading.Event().wait()`` on
platforms without ``signal.pause()`` (e.g. Windows) although this
path only runs inside the s6 Linux container image, the fallback
keeps the helper safe to import and unit-test anywhere.
"""
signal.signal(signal.SIGTERM, lambda signum, _frame: sys.exit(128 + signum))
pause = getattr(signal, "pause", None)
if pause is not None:
while True:
pause()
else: # pragma: no cover - non-Unix fallback, not exercised in the s6 image
import threading
threading.Event().wait()
def _gateway_command_inner(args):
+25 -3
View File
@@ -4353,13 +4353,21 @@ def decompose_triage_task(
child_ids: list[str] = []
with write_txn(conn):
root_row = conn.execute(
"SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,)
"SELECT id, status, tenant, workspace_kind, workspace_path "
"FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if root_row is None:
return None
if root_row["status"] != "triage":
return None
tenant = root_row["tenant"]
# Children inherit the root's workspace by default so a fan-out
# of a code-gen task lands in the parent's project dir/worktree
# rather than throwaway scratch tmp dirs. A child dict can still
# override with its own 'workspace_kind' / 'workspace_path'.
root_ws_kind = root_row["workspace_kind"] or "scratch"
root_ws_path = root_row["workspace_path"]
# Create children. Status is 'todo' regardless of parents — we
# link them under the root AFTER creation so the dispatcher
@@ -4370,16 +4378,30 @@ def decompose_triage_task(
title = child["title"].strip()
body = child.get("body")
assignee = _canonical_assignee(child.get("assignee"))
# Per-child override wins; otherwise inherit the root's
# workspace. A child that sets workspace_kind without a path
# falls back to the root path only when kinds match (so a
# child can't accidentally point a 'dir' at the root's
# worktree path or vice versa).
child_ws_kind = child.get("workspace_kind") or root_ws_kind
if child.get("workspace_path"):
child_ws_path = child.get("workspace_path")
elif child_ws_kind == root_ws_kind:
child_ws_path = root_ws_path
else:
child_ws_path = None
conn.execute(
"INSERT INTO tasks "
"(id, title, body, assignee, status, workspace_kind, "
" tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
" workspace_path, tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)",
(
new_id,
title,
body if isinstance(body, str) else None,
assignee,
child_ws_kind,
child_ws_path,
tenant,
now,
(author or "decomposer"),
+1
View File
@@ -4575,6 +4575,7 @@ def _model_flow_named_custom(config, provider_info):
menu_items,
selected=default_idx,
cancel_returns=-1,
searchable=True,
)
print()
if idx < 0 or idx >= len(models):
+115 -19
View File
@@ -700,6 +700,48 @@ def switch_model(
target_provider = pdef.id
# Guard against silent aggregator hops. A vendor name like bare
# "openai" is an alias that resolves to an aggregator ("openrouter").
# If the user explicitly asked for that vendor but the aggregator it
# routes to has no credentials, do NOT silently switch them onto an
# unauthed endpoint (the classic HTTP 401 "Missing Authentication
# header"). Point them at the real direct provider instead.
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
_explicit_norm = explicit_provider.strip().lower()
_alias_target = _PROVIDER_ALIAS_TABLE.get(_explicit_norm)
if (
_alias_target
and _alias_target == target_provider
and target_provider != _explicit_norm
and target_provider in _AGG_PROVIDERS
):
_authed = get_authenticated_provider_slugs(
current_provider=current_provider,
user_providers=user_providers,
custom_providers=custom_providers,
)
if target_provider not in _authed:
_suggestions = [
s for s in _authed
if s.startswith(_explicit_norm) and s != _explicit_norm
]
_hint = (
f" Did you mean: {', '.join(_suggestions)}?"
if _suggestions else ""
)
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=pdef.name,
is_global=is_global,
error_message=(
f"Provider '{_explicit_norm}' is an alias that routes "
f"through {get_label(target_provider)}, which "
f"has no credentials configured.{_hint}"
),
)
# If no model specified, try auto-detect from endpoint
if not new_model:
if pdef.base_url:
@@ -854,25 +896,62 @@ def switch_model(
api_mode = ""
if provider_changed or explicit_provider:
try:
runtime = resolve_runtime_provider(
requested=target_provider,
target_model=new_model,
)
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception as e:
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=(
f"Could not resolve credentials for provider "
f"'{provider_label}': {e}"
),
)
import os
# User-config providers (providers.<name> in config.yaml) carry their
# own base_url + transport + key reference. resolve_runtime_provider()
# resolves by provider NAME and doesn't know user-config slugs (e.g. a
# block named "openai"), so it would re-resolve from scratch and fail
# or hop to an aggregator. Use the pdef's endpoint directly instead.
_user_pdef = None
if explicit_provider and user_providers:
from hermes_cli.providers import resolve_user_provider as _ruser
_user_pdef = _ruser(explicit_provider.strip().lower(), user_providers)
if _user_pdef is None:
_user_pdef = _ruser(target_provider, user_providers)
if _user_pdef is not None and _user_pdef.base_url:
_ucfg = (user_providers or {}).get(explicit_provider.strip().lower()) \
or (user_providers or {}).get(target_provider) or {}
_ukey = str(_ucfg.get("api_key", "") or "").strip()
if _ukey.startswith("${") and _ukey.endswith("}"):
_ukey = os.environ.get(_ukey[2:-1], "").strip()
if not _ukey:
_kenv = str(_ucfg.get("key_env", "") or "").strip()
if _kenv:
_ukey = os.environ.get(_kenv, "").strip()
try:
runtime = resolve_runtime_provider(
requested=target_provider,
explicit_api_key=_ukey or None,
explicit_base_url=_user_pdef.base_url,
target_model=new_model,
)
api_key = runtime.get("api_key", "") or _ukey
base_url = runtime.get("base_url", "") or _user_pdef.base_url
api_mode = runtime.get("api_mode", "")
except Exception:
api_key = _ukey
base_url = _user_pdef.base_url
api_mode = ""
else:
try:
runtime = resolve_runtime_provider(
requested=target_provider,
target_model=new_model,
)
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception as e:
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=(
f"Could not resolve credentials for provider "
f"'{provider_label}': {e}"
),
)
else:
try:
runtime = resolve_runtime_provider(
@@ -1195,7 +1274,24 @@ def list_authenticated_providers(
curated["lmstudio"] = live
# --- 1. Check Hermes-mapped providers ---
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
# Skip vendor names that are merely aliases routing through an
# aggregator (e.g. bare "openai" → "openrouter"). These are NOT
# directly-routable providers: emitting them as their own picker
# row produces a phantom entry that, when selected, resolves via
# resolve_provider_full() to the aggregator (OpenRouter) — silently
# switching a user off their real provider onto an endpoint they
# may have no key for (HTTP 401). The user's real provider (e.g.
# openai-api, or a providers.openai config row) covers this vendor.
_alias_target = _PROVIDER_ALIAS_TABLE.get(hermes_id)
if (
_alias_target
and _alias_target != hermes_id
and _alias_target in _AGG_PROVIDERS
):
continue
# Skip aliases that map to the same models.dev provider (e.g.
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
# The first one with valid credentials wins (#10526).
+2 -2
View File
@@ -235,13 +235,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.5-flash",
"gemini-3.1-flash-lite-preview",
],
"google-gemini-cli": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.5-flash",
],
"zai": [
"glm-5.1",
+15 -1
View File
@@ -677,6 +677,20 @@ def resolve_provider_full(
ProviderDef if found, else None.
"""
canonical = normalize_provider(name)
raw = name.strip().lower()
# 0. User-defined config providers win over the built-in alias table.
# A user who declares ``providers.<name>`` in config.yaml has stated
# explicit intent for that name — it must not be hijacked by a legacy
# vendor alias (e.g. bare "openai" → "openrouter"). Resolve the raw
# name against user config FIRST so a configured ``providers.openai``
# (pointing at api.openai.com) beats the alias that would otherwise
# silently route to OpenRouter. Only the raw (pre-alias) name is tried
# here; canonical/alias resolution still happens below.
if user_providers:
user_pdef = resolve_user_provider(raw, user_providers)
if user_pdef is not None:
return user_pdef
# 1. Built-in (models.dev + overlays)
pdef = get_provider(canonical)
@@ -690,7 +704,7 @@ def resolve_provider_full(
if user_pdef is not None:
return user_pdef
# Try original name (in case alias didn't match)
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
user_pdef = resolve_user_provider(raw, user_providers)
if user_pdef is not None:
return user_pdef
+35 -18
View File
@@ -335,7 +335,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Collect results from all (or filtered) sources in parallel.
# Per-source limits are generous — parallelism + 30s timeout cap prevents hangs.
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
# NOTE: when the centralized index is available, parallel_search_sources
# skips the external API sources and serves everything from "hermes-index".
# That source MUST therefore carry a high limit, or browse silently caps
# the entire hub at the default (50) — it shipped that way and surfaced
# ~136 of 88k skills. The external-source limits below only apply when the
# index is unavailable (offline / first run before the cache populates).
_PER_SOURCE_LIMIT = {
"hermes-index": 5000,
"official": 200, "skills-sh": 200, "well-known": 50,
"github": 200, "clawhub": 500, "claude-marketplace": 100,
"lobehub": 500, "browse-sh": 500,
@@ -396,18 +403,22 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Build table
table = Table(show_header=True, header_style="bold")
table.add_column("#", style="dim", width=4, justify="right")
table.add_column("Name", style="bold cyan", max_width=25)
table.add_column("Description", max_width=50)
table.add_column("Name", style="bold cyan", max_width=22)
table.add_column("Description", max_width=44)
table.add_column("Source", style="dim", width=12)
table.add_column("Trust", width=10)
# The identifier is what you pass to `hermes skills install`. Browse used
# to omit it entirely, so users couldn't act on what they saw without a
# second `search`. overflow="fold" keeps long slugs copy-pasteable.
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
for i, r in enumerate(page_items, start=start + 1):
trust_style = {"builtin": "bright_cyan", "trusted": "green",
"community": "yellow"}.get(r.trust_level, "dim")
trust_label = "★ official" if r.source == "official" else r.trust_level
desc = r.description[:50]
if len(r.description) > 50:
desc = r.description[:44]
if len(r.description) > 44:
desc += "..."
table.add_row(
@@ -416,6 +427,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
desc,
r.source,
f"[{trust_style}]{trust_label}[/]",
r.identifier,
)
c.print(table)
@@ -439,7 +451,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
f"— run again for cached results[/]")
c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
"'hermes skills install <identifier>' to install, "
"'hermes skills search <query>' to search deeper[/]\n")
def do_install(identifier: str, category: str = "", force: bool = False,
@@ -725,24 +739,27 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
"""
from tools.skills_hub import GitHubAuth, create_source_router
from tools.skills_hub import (
GitHubAuth, create_source_router, parallel_search_sources,
)
page_size = max(1, min(page_size, 100))
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
# "hermes-index" must carry a high limit: when the index is available the
# router skips external API sources and serves everything from it, so a
# low cap here silently truncates the whole hub (see do_browse note).
_PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100,
"well-known": 25, "github": 100, "clawhub": 50,
"claude-marketplace": 50, "lobehub": 50, "browse-sh": 500}
auth = GitHubAuth()
sources = create_source_router(auth)
all_results: list = []
for src in sources:
sid = src.source_id()
if source != "all" and sid != source and sid != "official":
continue
try:
limit = _PER_SOURCE_LIMIT.get(sid, 50)
all_results.extend(src.search("", limit=limit))
except Exception:
continue
# Delegate to the shared parallel walker so this inherits the index-aware
# source-skip logic — querying hermes-index AND the external APIs at once
# would double-count every skill.
all_results, _counts, _timed_out = parallel_search_sources(
sources, query="", per_source_limits=_PER_SOURCE_LIMIT,
source_filter=source, overall_timeout=30,
)
if not all_results:
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
seen: dict = {}
@@ -759,7 +776,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
page_items = deduped[start : min(start + page_size, total)]
return {
"items": [{"name": r.name, "description": r.description, "source": r.source,
"trust": r.trust_level} for r in page_items],
"trust": r.trust_level, "identifier": r.identifier} for r in page_items],
"page": page,
"total_pages": total_pages,
"total": total,
+700 -39
View File
@@ -753,6 +753,225 @@ async def get_status():
}
@app.get("/api/system/stats")
async def get_system_stats():
"""Host + process system stats for the System page.
OS / Python / host identity from stdlib; CPU / memory / disk / uptime from
psutil when available, with graceful degradation when it isn't. Read-only
and non-sensitive (no env values, no paths beyond the hermes home root).
"""
import platform as _platform
info: Dict[str, Any] = {
"os": _platform.system(),
"os_release": _platform.release(),
"os_version": _platform.version(),
"platform": _platform.platform(),
"arch": _platform.machine(),
"hostname": _platform.node(),
"python_version": _platform.python_version(),
"python_impl": _platform.python_implementation(),
"hermes_version": __version__,
"cpu_count": os.cpu_count(),
}
# psutil enriches the picture when present; everything below is optional.
try:
import psutil # type: ignore
vm = psutil.virtual_memory()
info["memory"] = {
"total": vm.total,
"available": vm.available,
"used": vm.used,
"percent": vm.percent,
}
try:
du = psutil.disk_usage(str(get_hermes_home()))
info["disk"] = {
"total": du.total,
"used": du.used,
"free": du.free,
"percent": du.percent,
}
except Exception:
pass
try:
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
la = getattr(psutil, "getloadavg", None)
if la:
info["load_avg"] = list(la())
except Exception:
pass
try:
boot = psutil.boot_time()
info["uptime_seconds"] = int(time.time() - boot)
except Exception:
pass
try:
proc = psutil.Process()
info["process"] = {
"pid": proc.pid,
"rss": proc.memory_info().rss,
"create_time": int(proc.create_time()),
"num_threads": proc.num_threads(),
}
except Exception:
pass
info["psutil"] = True
except Exception:
info["psutil"] = False
# stdlib-only fallbacks for load average + uptime where the kernel
# exposes them.
try:
info["load_avg"] = list(os.getloadavg())
except (OSError, AttributeError):
pass
return info
# ---------------------------------------------------------------------------
# Curator endpoints — background skill-maintenance status + controls.
#
# The curator periodically reviews skills (archive stale, prune, pin). The
# dashboard surfaces its state and the pause/resume/run-now controls that
# `hermes curator` exposes.
# ---------------------------------------------------------------------------
@app.get("/api/curator")
async def get_curator_status():
try:
from agent import curator
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
try:
state = curator.load_state()
except Exception:
state = {}
return {
"enabled": _safe_call(curator, "is_enabled", True),
"paused": _safe_call(curator, "is_paused", False),
"interval_hours": _safe_call(curator, "get_interval_hours", None),
"last_run_at": state.get("last_run_at"),
"min_idle_hours": _safe_call(curator, "get_min_idle_hours", None),
"stale_after_days": _safe_call(curator, "get_stale_after_days", None),
"archive_after_days": _safe_call(curator, "get_archive_after_days", None),
}
class CuratorPause(BaseModel):
paused: bool
@app.put("/api/curator/paused")
async def set_curator_paused(body: CuratorPause):
from agent import curator
curator.set_paused(bool(body.paused))
return {"ok": True, "paused": bool(body.paused)}
@app.post("/api/curator/run")
async def run_curator():
"""Trigger a curator review now (backgrounded; tail via action status)."""
try:
proc = _spawn_hermes_action(["curator", "run"], "curator-run")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}")
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
def _safe_call(mod, fn_name: str, default):
try:
fn = getattr(mod, fn_name, None)
return fn() if callable(fn) else default
except Exception:
return default
# ---------------------------------------------------------------------------
# Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only).
# ---------------------------------------------------------------------------
@app.get("/api/portal")
async def get_portal_status():
cfg = load_config() or {}
auth: Dict[str, Any] = {}
try:
from hermes_cli.auth import get_nous_auth_status
auth = get_nous_auth_status() or {}
except Exception:
auth = {}
features = []
try:
from hermes_cli.nous_subscription import get_nous_subscription_features
feats = get_nous_subscription_features(cfg)
if feats is not None:
for feat in feats.items():
if getattr(feat, "managed_by_nous", False):
state = "via Nous Portal"
elif getattr(feat, "active", False) and getattr(feat, "current_provider", None):
state = feat.current_provider
elif getattr(feat, "active", False):
state = "active"
else:
state = "not configured"
features.append({"label": getattr(feat, "label", ""), "state": state})
except Exception:
_log.exception("portal features failed")
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
return {
"logged_in": bool(auth.get("logged_in")),
"portal_url": auth.get("portal_base_url"),
"inference_url": auth.get("inference_base_url"),
"provider": str((model_cfg or {}).get("provider") or ""),
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
"features": features,
}
# ---------------------------------------------------------------------------
# Diagnostics: prompt-size, support dump, debug upload, config migrate.
# All produce text output, so they spawn background actions tailed via
# /api/actions/<name>/status.
# ---------------------------------------------------------------------------
@app.post("/api/ops/prompt-size")
async def run_prompt_size():
try:
proc = _spawn_hermes_action(["prompt-size"], "prompt-size")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "prompt-size"}
@app.post("/api/ops/dump")
async def run_dump():
try:
proc = _spawn_hermes_action(["dump"], "dump")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "dump"}
@app.post("/api/ops/config-migrate")
async def run_config_migrate():
try:
proc = _spawn_hermes_action(["config", "migrate"], "config-migrate")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
# ---------------------------------------------------------------------------
# Gateway + update actions (invoked from the Status page).
#
@@ -779,6 +998,10 @@ _ACTION_LOG_FILES: Dict[str, str] = {
"skills-install": "action-skills-install.log",
"skills-uninstall": "action-skills-uninstall.log",
"skills-update": "action-skills-update.log",
"curator-run": "action-curator-run.log",
"prompt-size": "action-prompt-size.log",
"dump": "action-dump.log",
"config-migrate": "action-config-migrate.log",
}
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
@@ -838,6 +1061,10 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
# The child inherits its own duplicated fd for stdout/stderr, so the
# parent's handle can be released immediately — otherwise we leak one
# fd per spawned action.
log_file.close()
_ACTION_RESULTS.pop(name, None)
_ACTION_PROCS[name] = proc
return proc
@@ -1131,22 +1358,51 @@ async def get_action_status(name: str, lines: int = 200):
@app.get("/api/sessions")
async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
async def get_sessions(
limit: int = 20,
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
):
"""List sessions.
``archived`` controls how soft-archived sessions are treated:
``exclude`` (default) hides them, ``only`` returns just the archived ones
(used by the desktop "Archived sessions" settings panel), and ``include``
returns both.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
status_code=400,
detail="archived must be one of: exclude, only, include",
)
try:
from hermes_state import SessionDB
db = SessionDB()
try:
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
sessions = db.list_sessions_rich(
limit=limit, offset=offset, min_message_count=min_message_count
limit=limit,
offset=offset,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
)
total = db.session_count(
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
)
total = db.session_count(min_message_count=min_message_count)
now = time.time()
for s in sessions:
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
# SQLite stores the flag as 0/1; expose a real JSON boolean.
s["archived"] = bool(s.get("archived"))
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
finally:
db.close()
@@ -1779,6 +2035,11 @@ async def remove_env_var(body: EnvVarDelete):
return {"ok": True, "key": body.key}
except HTTPException:
raise
except ValueError as exc:
# remove_env_value raises ValueError for invalid key names. Surface
# the message to the SPA so the user understands why the delete was
# refused instead of seeing an opaque 500. Mirrors PUT /api/env.
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
_log.exception("DELETE /api/env failed")
raise HTTPException(status_code=500, detail="Internal server error")
@@ -3652,6 +3913,38 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
archived = db.session_count(archived_only=True)
messages = db.message_count()
by_source: Dict[str, int] = {}
try:
for s in db.list_sessions_rich(limit=10000, include_archived=True):
src = str(s.get("source") or "cli")
by_source[src] = by_source.get(src, 0) + 1
except Exception:
pass
return {
"total": total,
"active_store": active_store,
"archived": archived,
"messages": messages,
"by_source": by_source,
}
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@@ -3707,25 +4000,82 @@ async def delete_session_endpoint(session_id: str):
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = None
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Rename a session (or clear its title when ``title`` is empty/null)."""
"""Update a session: rename (or clear its title) and/or archive it.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session. Either field may be omitted.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
try:
updated = db.set_session_title(sid, body.title or "")
except ValueError as e:
# Title too long, invalid characters, or already in use.
raise HTTPException(status_code=400, detail=str(e))
if not updated:
if body.title is None and body.archived is None:
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title' and/or 'archived'.",
)
if body.title is not None:
try:
db.set_session_title(sid, body.title or "")
except ValueError as e:
# Title too long, invalid characters, or already in use.
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
return result
finally:
db.close()
@app.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str):
"""Export a single session (metadata + messages) as JSON."""
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True, "title": db.get_session_title(sid) or ""}
data = db.export_session(sid)
if data is None:
raise HTTPException(status_code=404, detail="Session not found")
return data
finally:
db.close()
class SessionPrune(BaseModel):
older_than_days: int = 90
source: Optional[str] = None
@app.post("/api/sessions/prune")
async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
from hermes_state import SessionDB
db = SessionDB()
try:
sessions_dir = get_hermes_home() / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
sessions_dir=sessions_dir if sessions_dir.exists() else None,
)
return {"ok": True, "removed": removed}
finally:
db.close()
@@ -4120,6 +4470,129 @@ async def test_mcp_server(name: str):
}
class MCPEnabledToggle(BaseModel):
enabled: bool
@app.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
"""Enable or disable an MCP server (takes effect on next session/gateway).
Toggles the ``enabled`` key on the server's config.yaml entry — the same
flag the agent reads at startup. Disabled servers stay in config so they
can be re-enabled without re-entering their settings.
"""
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
@app.get("/api/mcp/catalog")
async def list_mcp_catalog():
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
Each entry reports whether it's already installed and enabled so the UI
can show install / enabled state inline. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` read.
"""
try:
from hermes_cli import mcp_catalog
except Exception as exc:
_log.exception("mcp_catalog import failed")
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
entries = []
try:
for entry in mcp_catalog.list_catalog():
auth = entry.auth
entries.append({
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": entry.transport.type,
"auth_type": getattr(auth, "type", "none"),
# Env vars the user must supply (names + prompts only, never values).
"required_env": [
{"name": e.name, "prompt": e.prompt, "required": e.required}
for e in getattr(auth, "env", []) or []
],
"needs_install": entry.install is not None,
"installed": mcp_catalog.is_installed(entry.name),
"enabled": mcp_catalog.is_enabled(entry.name),
})
except Exception:
_log.exception("list_mcp_catalog failed")
diagnostics = []
try:
diagnostics = [
{"name": n, "kind": k, "message": m}
for (n, k, m) in mcp_catalog.catalog_diagnostics()
]
except Exception:
pass
return {"entries": entries, "diagnostics": diagnostics}
class MCPCatalogInstall(BaseModel):
name: str
# env: KEY=VALUE map for catalog entries that declare required env vars.
env: Dict[str, str] = {}
enable: bool = True
@app.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
via the standard env path so the agent can read them at session start.
Entries that need a git bootstrap (``needs_install``) are installed via
the CLI action path because the clone can take time.
"""
from hermes_cli import mcp_catalog
name = (body.name or "").strip()
entry = mcp_catalog.get_entry(name)
if entry is None:
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
# Persist any supplied env vars first (catalog entries declare which names
# they need; we only write the ones the user provided).
if body.env:
for k, v in body.env.items():
if v:
save_env_value(k, v)
# Git-bootstrap entries can take a while to clone — run via the background
# action path so the request returns immediately and the UI can tail logs.
if entry.install is not None:
try:
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
# No git step — install synchronously via the catalog API.
try:
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
except Exception as exc:
_log.exception("install_mcp_catalog_entry failed")
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True, "name": name, "background": False}
# Register the mcp-install action log so /api/actions/mcp-install/status works.
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
# ---------------------------------------------------------------------------
# Pairing endpoints — approve / revoke / list messaging pairing codes.
#
@@ -4231,6 +4704,8 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
"url": f"{base_url}/webhooks/{name}",
# Secret is masked on read; full value only returned on create.
"secret_set": bool(route.get("secret")),
# Default-enabled; only an explicit enabled:false turns a route off.
"enabled": route.get("enabled", True) is not False,
}
@@ -4315,6 +4790,30 @@ async def delete_webhook(name: str):
return {"ok": True}
class WebhookEnabledToggle(BaseModel):
enabled: bool
@app.put("/api/webhooks/{name}/enabled")
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
"""Enable or disable a webhook route.
Disabled routes stay in the subscriptions file (so they can be
re-enabled) but the gateway rejects incoming events with 403. The
gateway hot-reloads the subscriptions file, so this takes effect on the
next event without a restart.
"""
import hermes_cli.webhook as wh
key = (name or "").strip().lower()
subs = wh._load_subscriptions()
if key not in subs:
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
subs[key]["enabled"] = bool(body.enabled)
wh._save_subscriptions(subs)
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
# ---------------------------------------------------------------------------
# Gateway lifecycle endpoints — start / stop.
#
@@ -4636,38 +5135,160 @@ async def run_import(body: ImportRequest):
@app.get("/api/ops/hooks")
async def list_hooks():
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
"""List configured shell hooks from config.yaml with consent + health.
Reports each hook's allowlist (consent) status and whether the script is
currently executable, plus the set of valid hook events so the create
form can offer them.
"""
from hermes_cli.config import load_config as _load_config
from agent import shell_hooks
try:
from hermes_cli.plugins import VALID_HOOKS
valid_events = sorted(VALID_HOOKS)
except Exception:
valid_events = []
specs = []
try:
specs = shell_hooks.iter_configured_hooks(_load_config())
except Exception:
_log.exception("iter_configured_hooks failed")
out = []
for spec in specs:
entry = None
try:
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
except Exception:
pass
executable = False
try:
executable = shell_hooks.script_is_executable(spec.command)
except Exception:
pass
out.append({
"event": spec.event,
"matcher": spec.matcher,
"command": spec.command,
"timeout": spec.timeout,
"allowed": entry is not None,
"approved_at": (entry or {}).get("approved_at"),
"executable": executable,
})
return {"hooks": out, "valid_events": valid_events}
class HookCreate(BaseModel):
event: str
command: str
matcher: Optional[str] = None
timeout: Optional[int] = None
# approve: write the consent allowlist entry too (the operator using the
# authenticated dashboard is giving consent). Without it the hook is
# configured but won't fire until approved.
approve: bool = True
@app.post("/api/ops/hooks")
async def create_hook(body: HookCreate):
"""Add a shell hook to config.yaml (and optionally approve it).
Shell hooks run arbitrary commands, so this is a privileged action: it
writes to the ``hooks:`` config block and, when ``approve`` is set, records
consent in the allowlist so the hook actually fires. Takes effect on the
next session / gateway restart.
"""
from agent import shell_hooks
event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")
try:
from hermes_cli.plugins import VALID_HOOKS
if event not in VALID_HOOKS:
raise HTTPException(
status_code=400,
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
)
except HTTPException:
raise
except Exception:
pass
cfg = load_config()
hooks_cfg = cfg.get("hooks")
out = []
if isinstance(hooks_cfg, dict):
for event, entries in hooks_cfg.items():
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
out.append({
"event": event,
"matcher": entry.get("matcher"),
"command": entry.get("command"),
"timeout": entry.get("timeout"),
})
# Consent allowlist status (which commands have been approved for run).
allowlist: List[str] = []
if not isinstance(hooks_cfg, dict):
hooks_cfg = {}
cfg["hooks"] = hooks_cfg
entries = hooks_cfg.get(event)
if not isinstance(entries, list):
entries = []
hooks_cfg[event] = entries
new_entry: Dict[str, Any] = {"command": command}
if body.matcher:
new_entry["matcher"] = body.matcher
if body.timeout is not None:
new_entry["timeout"] = int(body.timeout)
entries.append(new_entry)
save_config(cfg)
approved = False
if body.approve:
try:
shell_hooks._record_approval(event, command)
approved = True
except Exception:
_log.exception("hook consent record failed")
return {"ok": True, "event": event, "command": command, "approved": approved}
class HookDelete(BaseModel):
event: str
command: str
@app.delete("/api/ops/hooks")
async def delete_hook(body: HookDelete):
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
from agent import shell_hooks
event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")
cfg = load_config()
hooks_cfg = cfg.get("hooks")
removed = False
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
before = len(hooks_cfg[event])
hooks_cfg[event] = [
e for e in hooks_cfg[event]
if not (isinstance(e, dict) and e.get("command") == command)
]
removed = len(hooks_cfg[event]) < before
if not hooks_cfg[event]:
del hooks_cfg[event]
if not hooks_cfg:
cfg.pop("hooks", None)
save_config(cfg)
# Revoke consent regardless so a re-add re-prompts.
try:
allow_path = get_hermes_home() / "shell-hooks-allowlist.json"
if allow_path.exists():
data = json.loads(allow_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
allowlist = list(data.keys())
elif isinstance(data, list):
allowlist = [str(x) for x in data]
shell_hooks.revoke(command)
except Exception:
_log.exception("Failed to read shell-hooks allowlist")
for h in out:
h["allowed"] = h.get("command") in allowlist
return {"hooks": out, "allowlist": allowlist}
pass
if not removed:
raise HTTPException(status_code=404, detail="No matching hook found")
return {"ok": True}
@app.get("/api/ops/checkpoints")
@@ -4766,6 +5387,46 @@ async def update_skills_hub():
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
@app.get("/api/skills/hub/search")
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
"""Search the skill hub across all configured sources.
Network-bound (parallel source search); runs in a thread so the FastAPI
loop isn't blocked. Returns structured results the UI installs by
identifier via POST /api/skills/hub/install.
"""
query = (q or "").strip()
if not query:
return {"results": []}
def _run():
from tools.skills_hub import create_source_router, unified_search
sources = create_source_router()
metas = unified_search(
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
)
return [
{
"name": m.name,
"description": m.description,
"source": m.source,
"identifier": m.identifier,
"trust_level": m.trust_level,
"repo": m.repo,
"tags": list(m.tags or []),
}
for m in metas
]
try:
results = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub search failed")
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
return {"results": results}
# ---------------------------------------------------------------------------
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
# ---------------------------------------------------------------------------
+34 -1
View File
@@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_platform TEXT,
handoff_error TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@@ -1430,6 +1431,22 @@ class SessionDB:
row = cursor.fetchone()
return row["title"] if row else None
def set_session_archived(self, session_id: str, archived: bool) -> bool:
"""Archive or unarchive a session.
Archived sessions are hidden from the default session list but keep all
their messages this is a soft hide, not a delete. Returns True when a
row was updated.
"""
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET archived = ? WHERE id = ?",
(1 if archived else 0, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:
@@ -1549,6 +1566,8 @@ class SessionDB:
min_message_count: int = 0,
project_compression_tips: bool = True,
order_by_last_active: bool = False,
include_archived: bool = False,
archived_only: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -1604,6 +1623,10 @@ class SessionDB:
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
if order_by_last_active:
@@ -3027,7 +3050,13 @@ class SessionDB:
# Utility
# =========================================================================
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
def session_count(
self,
source: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
) -> int:
"""Count sessions, optionally filtered by source."""
where_clauses = []
params = []
@@ -3038,6 +3067,10 @@ class SessionDB:
if min_message_count > 0:
where_clauses.append("message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("archived = 1")
elif not include_archived:
where_clauses.append("archived = 0")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
+142 -52
View File
@@ -228,6 +228,9 @@ class HonchoMemoryProvider(MemoryProvider):
self._session_initialized = False
self._lazy_init_kwargs: Optional[dict] = None
self._lazy_init_session_id: Optional[str] = None
self._init_thread: Optional[threading.Thread] = None
self._init_lock = threading.Lock()
self._init_error = ""
# Port #4053: cron guard — when True, plugin is fully inactive
self._cron_skipped = False
@@ -326,22 +329,24 @@ class HonchoMemoryProvider(MemoryProvider):
# aiPeer comes from honcho.json (host block or root) only.
# SOUL.md is persona content, not identity config.
# ----- Port #1957: lazy session init for tools-only mode -----
self._lazy_init_kwargs = dict(kwargs)
self._lazy_init_session_id = session_id
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
# Network-backed session creation can block on Honcho service or DB
# outages. Startup must fail open for context/hybrid modes, where
# Honcho is initialized only to enrich prompts. Tools-only mode has
# an explicit contract: init_on_session_start=False stays lazy until
# the first tool call, while init_on_session_start=True remains an
# eager, ready-on-return initialization path.
if self._recall_mode == "tools":
if cfg.init_on_session_start:
# Eager init even in tools mode (opt-in)
self._do_session_init(cfg, session_id, **kwargs)
self._ensure_session()
return
# Defer actual session creation until first tool call
self._lazy_init_kwargs = kwargs
self._lazy_init_session_id = session_id
# Still need a client reference for _ensure_session
self._config = cfg
logger.debug("Honcho tools-only mode — deferring session init until first tool call")
return
# ----- Eager init (context or hybrid mode) -----
self._do_session_init(cfg, session_id, **kwargs)
self._start_session_init_background(wait_timeout=0.1)
except ImportError:
logger.debug("honcho-ai package not installed — plugin inactive")
@@ -349,6 +354,66 @@ class HonchoMemoryProvider(MemoryProvider):
logger.warning("Honcho init failed: %s", e)
self._manager = None
def _resolve_session_key(self, cfg, session_id: str, **kwargs) -> str:
"""Resolve the Honcho session key without touching the network."""
session_title = kwargs.get("session_title")
gateway_session_key = kwargs.get("gateway_session_key")
return (
cfg.resolve_session_name(
session_title=session_title,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
or session_id
or "hermes-default"
)
def _start_session_init_background(self, *, wait_timeout: float = 0.0) -> None:
"""Start Honcho session initialization in a daemon thread.
This keeps Hermes CLI/gateway startup responsive when Honcho is down,
slow, or its database is unhealthy. The thread may still take the SDK
timeout path, but it cannot block agent construction or first prompt
assembly. ``wait_timeout`` lets fast/mock initializations finish before
returning while still failing open for slow backends.
"""
if self._cron_skipped or self._session_initialized:
return
if not self._config or self._lazy_init_kwargs is None:
return
with self._init_lock:
if self._cron_skipped or self._session_initialized:
return
if self._init_thread and self._init_thread.is_alive():
return
if not self._config or self._lazy_init_kwargs is None:
return
cfg = self._config
init_kwargs = dict(self._lazy_init_kwargs)
init_session_id = self._lazy_init_session_id or "hermes-default"
def _run() -> None:
try:
self._do_session_init(cfg, init_session_id, **init_kwargs)
self._lazy_init_kwargs = None
self._lazy_init_session_id = None
self._init_error = ""
except Exception as e:
self._init_error = str(e)
self._manager = None
logger.warning("Honcho background session init failed: %s", e)
self._init_thread = threading.Thread(
target=_run,
daemon=True,
name="honcho-session-init",
)
self._init_thread.start()
if wait_timeout > 0:
self._init_thread.join(timeout=wait_timeout)
def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
"""Shared session initialization logic for both eager and lazy paths."""
from plugins.memory.honcho.client import get_honcho_client
@@ -364,22 +429,15 @@ class HonchoMemoryProvider(MemoryProvider):
)
# ----- B3: resolve_session_name -----
session_title = kwargs.get("session_title")
gateway_session_key = kwargs.get("gateway_session_key")
self._session_key = (
cfg.resolve_session_name(
session_title=session_title,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
or session_id
or "hermes-default"
)
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
logger.debug("Honcho session key resolved: %s", self._session_key)
# Create session eagerly
# Create the remote session before running startup-only migration and
# prewarm work. Do not mark the provider ready until this method's
# synchronous setup has finished; background startup sets _manager before
# get_or_create()/migration/prewarm are complete, and lifecycle hooks must
# not treat that partially initialized state as usable.
session = self._manager.get_or_create(self._session_key)
self._session_initialized = True
# ----- B6: Memory file migration (one-time, for new sessions) -----
# Skip under per-session strategy: every Hermes run creates a fresh
@@ -434,12 +492,15 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
prewarm_thread = threading.Thread(
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
)
self._prefetch_thread.start()
prewarm_thread.start()
self._prefetch_thread = prewarm_thread
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
self._session_initialized = True
def _ensure_session(self) -> bool:
"""Lazily initialize the Honcho session (for tools-only mode).
@@ -449,7 +510,9 @@ class HonchoMemoryProvider(MemoryProvider):
return True
if self._cron_skipped:
return False
if not self._config or not self._lazy_init_kwargs:
if self._init_thread and self._init_thread.is_alive():
return False
if not self._config or self._lazy_init_kwargs is None:
return False
try:
@@ -463,9 +526,26 @@ class HonchoMemoryProvider(MemoryProvider):
self._lazy_init_session_id = None
return self._manager is not None
except Exception as e:
self._manager = None
self._session_initialized = False
logger.warning("Honcho lazy session init failed: %s", e)
return False
def _session_ready(self) -> bool:
"""Return whether a manager/session key can be used safely.
Background initialization sets ``_manager`` before the blocking
get-or-create call completes, so ``_session_initialized`` guards real
async startup. Tests and legacy direct construction may inject a ready
manager/session key without setting that flag; allow that only when no
init thread is currently in flight.
"""
if not self._manager or not self._session_key:
return False
if self._session_initialized:
return True
return not (self._init_thread and self._init_thread.is_alive())
def _format_first_turn_context(self, ctx: dict) -> str:
"""Format the prefetch context dict into a readable system prompt block."""
parts = []
@@ -505,14 +585,8 @@ class HonchoMemoryProvider(MemoryProvider):
if self._cron_skipped:
return ""
if not self._manager or not self._session_key:
# tools-only mode without session yet still returns a minimal block
if self._recall_mode == "tools" and self._config:
return (
"# Honcho Memory\n"
"Active (tools-only mode). Use honcho_profile, honcho_search, "
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
)
return ""
if not self._config:
return ""
# ----- B1: adapt text based on recall_mode -----
if self._recall_mode == "context":
@@ -563,6 +637,10 @@ class HonchoMemoryProvider(MemoryProvider):
if self._recall_mode == "tools":
return ""
if not self._session_ready():
self._start_session_init_background()
return ""
# B5: injection_frequency — if "first-turn" and past first turn, return empty.
# _turn_count is 1-indexed (first user message = 1), so > 1 means "past first".
if self._injection_frequency == "first-turn" and self._turn_count > 1:
@@ -575,18 +653,17 @@ class HonchoMemoryProvider(MemoryProvider):
parts = []
# ----- Layer 1: Base context (representation + card) -----
# On first call, fetch synchronously so turn 1 isn't empty.
# After that, serve from cache and refresh in background on cadence.
# First fetch is asynchronous: a slow Honcho backend must not block the
# first response. Serve empty context now and consume the background
# result on a later turn.
with self._base_context_lock:
if self._base_context_cache is None:
# First call — synchronous fetch
self._base_context_cache = ""
self._last_context_turn = self._turn_count
try:
ctx = self._manager.get_prefetch_context(self._session_key)
self._base_context_cache = self._format_first_turn_context(ctx) if ctx else ""
self._last_context_turn = self._turn_count
self._manager.prefetch_context(self._session_key, query or None)
except Exception as e:
logger.debug("Honcho base context fetch failed: %s", e)
self._base_context_cache = ""
logger.debug("Honcho base context prefetch failed: %s", e)
base_context = self._base_context_cache
# Check if background context prefetch has a fresher result
@@ -641,10 +718,11 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
first_turn_thread = threading.Thread(
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
)
self._prefetch_thread.start()
first_turn_thread.start()
self._prefetch_thread = first_turn_thread
self._prefetch_thread.join(timeout=_first_turn_timeout)
if self._prefetch_thread.is_alive():
logger.debug(
@@ -709,13 +787,14 @@ class HonchoMemoryProvider(MemoryProvider):
"""
if self._cron_skipped:
return
if not self._manager or not self._session_key or not query:
return
# B1: tools-only mode — no prefetch
if self._recall_mode == "tools":
return
if not self._session_ready() or not query:
self._start_session_init_background()
return
# Trivial prompts don't warrant either a context refresh or a dialectic call.
if self._is_trivial_prompt(query):
return
@@ -769,10 +848,11 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
prefetch_thread = threading.Thread(
target=_run, daemon=True, name="honcho-prefetch"
)
self._prefetch_thread.start()
prefetch_thread.start()
self._prefetch_thread = prefetch_thread
# ----- Dialectic depth: multi-pass .chat() with cold/warm prompts -----
@@ -1126,7 +1206,10 @@ class HonchoMemoryProvider(MemoryProvider):
"""
if self._cron_skipped:
return
if not self._manager or not self._session_key:
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
self._start_session_init_background()
return
msg_limit = self._config.message_max_chars if self._config else 25000
@@ -1169,7 +1252,10 @@ class HonchoMemoryProvider(MemoryProvider):
return
if self._cron_skipped:
return
if not self._manager or not self._session_key:
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
self._start_session_init_background()
return
def _write():
@@ -1187,6 +1273,8 @@ class HonchoMemoryProvider(MemoryProvider):
return
if not self._manager:
return
if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
return
# Wait for pending sync
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=10.0)
@@ -1213,6 +1301,8 @@ class HonchoMemoryProvider(MemoryProvider):
# Port #1957: ensure session is initialized for tools-only mode
if not self._session_initialized:
if self._init_thread and self._init_thread.is_alive():
return tool_error("Honcho session is still initializing; try again shortly.")
if not self._ensure_session():
return tool_error("Honcho session could not be initialized.")
@@ -1313,7 +1403,7 @@ class HonchoMemoryProvider(MemoryProvider):
if t and t.is_alive():
t.join(timeout=5.0)
# Flush any remaining messages
if self._manager:
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
try:
self._manager.flush_all()
except Exception:
+1 -1
View File
@@ -56,7 +56,7 @@ gemini = GeminiProfile(
env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
base_url="https://generativelanguage.googleapis.com/v1beta",
auth_type="api_key",
default_aux_model="gemini-3-flash-preview",
default_aux_model="gemini-3.5-flash",
)
google_gemini_cli = GeminiProfile(
+27 -17
View File
@@ -269,7 +269,13 @@ class SimplexAdapter(BasePlatformAdapter):
# ------------------------------------------------------------------
async def _health_monitor(self) -> None:
"""Force reconnect if the WebSocket has been idle too long."""
"""Observe WebSocket idleness without reconnecting healthy quiet links.
simplex-chat can legitimately stay application-silent for long periods
when no messages arrive. The websockets client already sends protocol
pings (see _ws_listener ping_interval/ping_timeout), so treating lack of
chat events as a stale connection causes needless reconnect churn.
"""
while self._running:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
if not self._running:
@@ -277,15 +283,7 @@ class SimplexAdapter(BasePlatformAdapter):
elapsed = time.time() - self._last_ws_activity
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
logger.warning(
"SimpleX: WS idle for %.0fs, forcing reconnect", elapsed
)
self._last_ws_activity = time.time()
if self._ws:
try:
await self._ws.close()
except Exception:
pass
logger.debug("SimpleX: WS application-idle for %.0fs", elapsed)
# ------------------------------------------------------------------
# Inbound event handling
@@ -293,7 +291,12 @@ class SimplexAdapter(BasePlatformAdapter):
async def _handle_event(self, event: dict) -> None:
"""Dispatch a daemon event to the appropriate handler."""
resp_type = event.get("type") or event.get("resp", {}).get("type", "")
# simplex-chat WebSocket messages are usually shaped as:
# {"corrId": "...", "resp": {"type": "newChatItems", ...}}
# Older/examples may put the response fields at top-level. Normalize
# both forms before dispatching, otherwise inbound chatItems are lost.
resp = event.get("resp") if isinstance(event.get("resp"), dict) else event
resp_type = event.get("type") or resp.get("type", "")
# Filter responses to our own commands (echoes)
corr_id = event.get("corrId", "")
@@ -302,10 +305,10 @@ class SimplexAdapter(BasePlatformAdapter):
return
if resp_type == "newChatItem":
await self._handle_new_chat_item(event)
await self._handle_new_chat_item(resp)
elif resp_type == "newChatItems":
# Batch variant — process each item
items = event.get("chatItems") or []
items = resp.get("chatItems") or []
for item_wrapper in items:
await self._handle_new_chat_item(item_wrapper)
# Ignore all other event types (delivery receipts, contact updates, etc.)
@@ -347,7 +350,9 @@ class SimplexAdapter(BasePlatformAdapter):
or contact_info.get("localDisplayName")
or contact_id
)
chat_id = contact_id
# Replies must be routed by SimpleX CLI display name, while
# authorization should still use the stable numeric contactId.
chat_id = contact_name or contact_id
chat_name = contact_name
if not chat_id:
@@ -364,7 +369,7 @@ class SimplexAdapter(BasePlatformAdapter):
or sender_id
)
else:
sender_id = chat_id
sender_id = contact_id if not is_group else chat_id
sender_name = chat_name
# Extract text
@@ -508,7 +513,11 @@ class SimplexAdapter(BasePlatformAdapter):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {content}"
else:
cmd_str = f"@[{chat_id}] {content}"
# SimpleX CLI addresses direct contacts by display name, e.g.
# `@Alice hello`. `@[Alice]` is interpreted literally as a contact
# named "[Alice]" and `@[4]` as "[4]", so do not wrap direct
# chat IDs / display names in brackets.
cmd_str = f"@{chat_id} {content}"
payload = {
"corrId": corr_id,
@@ -643,7 +652,8 @@ async def _standalone_send(
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {message}"
else:
cmd_str = f"@[{chat_id}] {message}"
# Direct contacts are addressed by display name without brackets.
cmd_str = f"@{chat_id} {message}"
payload = {
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
+76 -13
View File
@@ -21,9 +21,12 @@ delivers it.
from __future__ import annotations
import asyncio
import base64
import logging
import mimetypes
import os
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import httpx
@@ -42,7 +45,9 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
DEFAULT_MODEL = "grok-imagine-video"
DEFAULT_TEXT_TO_VIDEO_MODEL = "grok-imagine-video"
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5-preview"
DEFAULT_MODEL = DEFAULT_TEXT_TO_VIDEO_MODEL
DEFAULT_DURATION = 8
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_RESOLUTION = "720p"
@@ -58,10 +63,18 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"grok-imagine-video": {
"display": "Grok Imagine Video",
"speed": "~60-240s",
"strengths": "Text-to-video + image-to-video; up to 7 reference images for style/character.",
"price": "see https://docs.x.ai/docs/models",
"strengths": "Text-to-video; legacy image-to-video fallback.",
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
"modalities": ["text", "image"],
},
"grok-imagine-video-1.5-preview": {
"display": "Grok Imagine Video 1.5 Preview",
"speed": "~60-240s",
"strengths": "Latest xAI image-to-video model.",
"price": "see https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview",
"modalities": ["image"],
"aliases": ["grok-imagine-video-1.5-2026-05-30"],
},
}
@@ -111,10 +124,31 @@ def _xai_headers(api_key: str) -> Dict[str, str]:
}
def _image_ref_to_xai_url(value: str) -> str:
"""Return a URL/data URI accepted by xAI for image inputs."""
ref = (value or "").strip()
if not ref:
return ""
lower = ref.lower()
if lower.startswith(("http://", "https://", "data:image/")):
return ref
path = Path(ref).expanduser()
if not path.is_file():
return ref
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
if not mime.startswith("image/"):
return ref
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _normalize_reference_images(reference_image_urls: Optional[List[str]]):
refs = []
for url in reference_image_urls or []:
normalized = (url or "").strip()
normalized = _image_ref_to_xai_url(url)
if normalized:
refs.append({"url": normalized})
return refs or None
@@ -131,6 +165,28 @@ def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int:
return value
def _resolve_model_for_modality(
model: Optional[str],
*,
modality: str,
explicit_model: bool,
) -> str:
"""Select xAI's text/video model without treating config as a prompt override.
``grok-imagine-video-1.5-preview`` currently rejects text-only video
generation, but it is the desired image-to-video backend. Explicit tool
``model=`` still wins for users who intentionally request another model.
"""
requested = (model or "").strip()
if explicit_model and requested:
return requested
if modality == "image":
return DEFAULT_IMAGE_TO_VIDEO_MODEL
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL:
return DEFAULT_TEXT_TO_VIDEO_MODEL
return requested or DEFAULT_TEXT_TO_VIDEO_MODEL
async def _submit(
client: httpx.AsyncClient,
payload: Dict[str, Any],
@@ -192,7 +248,7 @@ async def _poll(
class XAIVideoGenProvider(VideoGenProvider):
"""xAI grok-imagine-video backend (text-to-video + image-to-video)."""
"""xAI Grok Imagine video backend (text-to-video + image-to-video)."""
@property
def name(self) -> str:
@@ -222,7 +278,7 @@ class XAIVideoGenProvider(VideoGenProvider):
return {
"name": "xAI Grok Imagine",
"badge": "paid",
"tag": "grok-imagine-video text-to-video & image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
"tag": "grok-imagine-video for text-to-video; grok-imagine-video-1.5-preview for image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"post_setup": "xai_grok",
}
@@ -260,6 +316,7 @@ class XAIVideoGenProvider(VideoGenProvider):
return loop.run_until_complete(self._generate_async(
prompt=prompt,
model=model,
explicit_model=bool(kwargs.get("_model_override_explicit")),
image_url=image_url,
reference_image_urls=reference_image_urls,
duration=duration,
@@ -284,6 +341,7 @@ class XAIVideoGenProvider(VideoGenProvider):
*,
prompt: str,
model: Optional[str],
explicit_model: bool,
image_url: Optional[str],
reference_image_urls: Optional[List[str]],
duration: Optional[int],
@@ -303,10 +361,15 @@ class XAIVideoGenProvider(VideoGenProvider):
)
prompt = (prompt or "").strip()
image_url_norm = (image_url or "").strip() or None
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
modality_used = "image" if image_url_norm else "text"
resolved_model = _resolve_model_for_modality(
model,
modality=modality_used,
explicit_model=explicit_model,
)
if not prompt:
return error_response(
@@ -340,7 +403,7 @@ class XAIVideoGenProvider(VideoGenProvider):
normalized_resolution = DEFAULT_RESOLUTION
payload: Dict[str, Any] = {
"model": model or DEFAULT_MODEL,
"model": resolved_model,
"prompt": prompt,
"duration": clamped_duration,
"aspect_ratio": normalized_aspect_ratio,
@@ -366,7 +429,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
error_type="api_error",
provider="xai",
model=model or DEFAULT_MODEL,
model=resolved_model,
prompt=prompt,
)
@@ -388,7 +451,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error="xAI video generation completed without a video URL",
error_type="empty_response",
provider="xai",
model=body.get("model") or model or DEFAULT_MODEL,
model=body.get("model") or resolved_model,
prompt=prompt,
)
extra: Dict[str, Any] = {
@@ -399,7 +462,7 @@ class XAIVideoGenProvider(VideoGenProvider):
extra["usage"] = body["usage"]
return success_response(
video=url,
model=body.get("model") or model or DEFAULT_MODEL,
model=body.get("model") or resolved_model,
prompt=prompt,
modality=modality_used,
aspect_ratio=normalized_aspect_ratio,
@@ -413,7 +476,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
error_type="timeout",
provider="xai",
model=model or DEFAULT_MODEL,
model=resolved_model,
prompt=prompt,
)
@@ -426,7 +489,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=message,
error_type=f"xai_{status}",
provider="xai",
model=model or DEFAULT_MODEL,
model=resolved_model,
prompt=prompt,
)
+1 -1
View File
@@ -1,6 +1,6 @@
name: xai
version: 1.0.0
description: "xAI Grok-Imagine video generation backend. Supports text-to-video, image-to-video, reference-image-guided generation, video edit, and video extend via the xAI async videos API."
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, and reference-image-guided generation via the xAI async videos API."
author: NousResearch
kind: backend
requires_env:
+8
View File
@@ -45,9 +45,13 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"ben.bartholomew@vectorize.io": "benfrank241",
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
"subw3@mail2.sysu.edu.cn": "Subway2023",
"trevin@trevinchow.com": "tmchow",
"zhipengli@thebrainly.ai": "a1245582339",
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
"david.gutowsky@gmail.com": "davidgut1982",
"drpelagik@gmail.com": "SeaXen",
"lengr@users.noreply.github.com": "LengR",
"17255546+CharZhou@users.noreply.github.com": "CharZhou",
@@ -61,6 +65,7 @@ AUTHOR_MAP = {
"524706+Twanislas@users.noreply.github.com": "Twanislas",
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
"kchuang1015@users.noreply.github.com": "kchuang1015",
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
"45688690+fujinice@users.noreply.github.com": "fujinice",
"276689385+carltonawong@users.noreply.github.com": "carltonawong",
"195255660+EvilHumphrey@users.noreply.github.com": "EvilHumphrey",
@@ -83,6 +88,7 @@ AUTHOR_MAP = {
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
"tillfalko@gmail.com": "tillfalko",
"hi@fesalfayed.com": "fesalfayed",
"marek.les@seznam.cz": "maxcz79",
# teknium (multiple emails)
"teknium1@gmail.com": "teknium1",
"kenyon1977@gmail.com": "kenyonxu",
@@ -1198,6 +1204,7 @@ AUTHOR_MAP = {
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
"agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity)
"jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback)
"caojiguang@gmail.com": "caojiguang", # PR #35117 carries #31853 (weixin _api_post/_api_get wait_for)
# pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
"ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix)
# Kanban bug-fix batch salvage (May 2026)
@@ -1416,6 +1423,7 @@ AUTHOR_MAP = {
"me@simontaggart.com": "SiTaggart", # PR #35583 (docker_forward_env empty-secret .env fallback)
"2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files)
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
}
@@ -0,0 +1,209 @@
---
name: dynamic-workflow
description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims).
version: 1.0.0
author: Hermes Agent + Teknium
license: MIT
metadata:
hermes:
tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research]
category: autonomous-ai-agents
related_skills: []
when_to_use:
- A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record)
- You want orchestration codified as a re-runnable script, not improvised turn-by-turn
- Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer
- Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked
when_not_to_use:
- Small bounded task (<~10 units) — just call the tool directly or do it inline
- Tight serial dependency (B needs A's output) — orchestration overhead is wasted
- You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead
---
# Dynamic Workflow — plan-in-code fan-out with verification
This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of
parallel subagents in one session). The mechanic worth copying is NOT "more
subagents" — it is **moving the plan, the loop, and the intermediate results
OUT of the context window and INTO a script.** Normally the agent IS the
orchestrator: every intermediate result piles into context, which is exactly
what caps you at a handful of agents. A workflow keeps only the *final verified
answer* in context; the script holds everything else.
> This skill is self-contained, but it builds on standard fan-out hygiene —
> chunk inputs to ~50-70KB per child, route structured output to files (not the
> `summary` field, which truncates under load), use delimiter-separated lines
> over JSON wrappers, and remember that a "stalled" child often completed its
> write anyway (check the filesystem before retrying). If your install has a
> `delegate-task-output-patterns` skill, load it for the detailed thresholds;
> the rules above are the load-bearing subset.
## The two orchestration-script layers (pick the right one — they are NOT interchangeable)
Hermes has no JS runtime. The "orchestration script" is one of two layers, and
the split is enforced by a real capability boundary, not a style preference:
| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch |
|---|---|---|
| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item |
| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent |
| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) |
| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` |
| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) |
| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive |
**Rule of thumb:** do the deterministic part in Layer A first (inline, in a
script), then fan out ONLY the irreducibly-LLM step via Layer B. This is
Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale.
Mixing them: a Layer-A script can write a manifest file, and you (the parent)
then read that manifest and issue a single Layer-B `delegate_task` batch.
## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints)
`delegate_task` runs **synchronously inside the parent turn**. If the user sends
a new message, hits /stop, or /new, every in-flight child is **cancelled and its
work discarded** (status `interrupted`). It does NOT run in the background, and
it does NOT survive the turn. There is no cache-resume of a half-finished fan-out.
So a "workflow" in Hermes is one of:
1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed
within a single turn. Good for minutes-long fan-out (dozens of units). The
user waits. This is what you build 90% of the time.
2. **Durable workflow (hours/days, survives interruption):** use the **kanban
swarm** (the SQLite-backed multi-agent kernel that ships with Hermes —
`hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a
`kanban-multiagent` skill, load it for the workflow). It
writes a task graph (root → parallel workers → verifier → synthesizer) into
the SQLite kanban kernel with a JSON blackboard. State persists across turns
and restarts. This is the ONLY path that matches Claude Code's "runs into
hours and days, resumes where it left off." Reach for it when the foreground
path would time out or when the user must be able to walk away.
Never promise "background, resumable, hundreds of agents over days" from a plain
`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban
swarm is the right fit. For simpler durable/out-of-turn cases there are lighter
options too: a `cronjob` one-shot or scheduled job, or a managed
`terminal(background=True, notify_on_complete=True)` process — both survive the
turn without standing up a full task graph.
## Workflow recipe (foreground)
1. **Decompose into independent units.** What is the unit — a file? an endpoint?
a source? a record? Each unit must be answerable WITHOUT the others' output
(else it's serial, not fan-out — see when_not_to_use).
2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the
manifest: list the files, extract the candidate sites, fetch the raw sources,
compute anything regex/parse can compute. Write a manifest to a **unique
per-run** directory — `/tmp/wf_<name>_<uuid>/manifest.jsonl` (one unit per
line), never a bare `/tmp/wf_<name>/` that a prior interrupted run could have
left stale outputs in. This is the "plan in code." Print the unit count and
the run dir, and stop.
3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each
child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR
~50-70KB of corpus. Look at the LARGEST unit, not the average. One
`delegate_task(tasks=[...])` call is bounded by
`delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds
of tasks internally. For larger fan-out, issue bounded waves yourself (loop:
one batch, collect, next batch) or have the user raise the config
intentionally.
4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]`
array, one task per chunk. Each task: reads its slice from the manifest,
emits delimiter-separated lines to `/tmp/wf_<name>_<uuid>/out_<i>.csv`, prints a
status word, stops. Do NOT depend on the `summary` field for content.
5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the
file count and freshness (each was written this run) so a stale or missing
output from an interrupted child isn't silently read as success — then merge
and present. The cross-cutting "whole picture" step stays on the parent — only
the per-unit work fanned out.
## The novel mechanic worth building: adversarial convergence
This is the part Hermes did NOT already have and the real reason to bother.
Claude Code's quality claim ("independent agents try to refute each other's
findings; only surviving claims surface; iterate until they converge") maps
cleanly onto `delegate_task` batch mode:
### Recipe: N independent attempts + M refuters
For a finding-quality task (security audit, "is this code path actually
vulnerable?", "does this migration preserve behavior?", a high-stakes plan):
1. **Independent attempts (round 1).** Fan out the SAME question to N children
(N=2-4) with DIFFERENT framings/angles in each `context`, so they don't
collapse to the same reasoning. Each writes its claims to
`/tmp/wf_<name>/attempt_<i>.md` as a list of discrete, individually-checkable
claims (one claim per line — atomicity is what makes refutation possible).
2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single
numbered list. Identical claims from independent attempts = higher prior;
note the agreement count per claim.
3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the
claim list and is told "your job is to BREAK these claims — for each, find the
counter-evidence (the auth check that DOES exist, the test that DOES cover it,
the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`."
Give refuters the codebase/sources, not the original attempts' reasoning.
4. **Keep only survivors.** A claim surfaces to the user only if it survived
refutation (no refuter produced valid counter-evidence). Filtered claims are
dropped, with a one-line note of why if the user asked for completeness.
5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find
adjacent issues), feed them back through one more refutation round. Stop when
a round produces no new surviving claims — that's convergence. Cap at 3 rounds
to bound cost.
This gives you the "more trustworthy than a single pass" property without a
runtime — it's just two `delegate_task` batches and a merge, structured so
disagreement is visible and unsupported claims die before they reach the user.
### Why atomic claims matter
A refuter cannot break "the auth layer has problems." It CAN break "endpoint
`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force
attempts to emit specific, located, individually-falsifiable claims or the
refutation round is theater.
## Cost discipline (this is the thing that bites)
A workflow can consume dramatically more tokens than a normal turn — that is
inherent, not a bug. Two real multipliers:
- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls.
`delegation.max_concurrent_children` only bounds *concurrency*, not *total*.
- **Hermes aux/subagent model defaults to main-model-first.** Children inherit
the parent's (often expensive reasoning) model. `delegate_task` does NOT expose
a per-task `model` or `profile` field — its per-task keys are
`{goal, context, toolsets, role}`. To run the fan-out cheaper you either route
delegation globally via `delegation` config (model/provider applied to all
children), or — for genuinely model/profile-scoped work — use cron, the kanban
swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out
is still Layer A: do the deterministic part in a script with no per-item LLM at
all.
Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove
the recipe end-to-end, report the token cost, THEN offer to run it at full scale.
Never silently fan out hundreds of children — surface the cost first and let the
user say go.
## Pitfalls
- **Writing `delegate_task` inside an `execute_code` script.** It's not in
`SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic
tools only. Fan out LLM judgment from the parent turn, not from inside a script.
- **Promising background/resumable from `delegate_task`.** It's synchronous and
turn-scoped. Durable = kanban swarm.
- **Trusting `summary` fields for content.** Route structured output to files
(Pattern 2 in delegate-task-output-patterns).
- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive
refutation by default and pollute the output. Force located, specific claims.
- **Same framing in all "independent" attempts.** They collapse to one answer and
the cross-check is worthless. Vary the angle in each child's context.
- **Fanning out a serial task.** If unit B needs unit A's output, parallelism
produces wrong/empty results. Re-check independence before fanning out.
## Verification before you call it done
- Did the deterministic pre-pass actually run, and does the manifest line-count
match the expected unit count? (`wc -l /tmp/wf_<name>/manifest.jsonl`)
- Did every fan-out child write its output file? (`ls /tmp/wf_<name>/out_*.csv`) —
remember stalled children often completed anyway (Pattern 6).
- For the verify recipe: can you point to the refuter counter-evidence for every
DROPPED claim, and confirm every SURFACED claim went through refutation?
- Did you report token cost on the scoped run before offering full scale?
@@ -0,0 +1,106 @@
"""Tests for the container-context sandbox-mirror guard (#32049 follow-up).
Brian's shape-based guard (#32213) catches paths that carry the full
``/sandboxes/<backend>/<task>/home/.hermes/`` prefix. This covers the
complementary inner-container case: when file tools execute inside Docker,
the bind-mount strips that prefix and the guard sees plain ``/root/.hermes/``.
The root:root ownership on the divergent SOUL.md in #32049 confirms this
is the primary failure mode.
"""
from __future__ import annotations
import pytest
class TestClassifyContainerMirrorTarget:
def test_returns_none_without_context(self):
"""No Docker context — /root/.hermes/… must not be flagged."""
from agent.file_safety import classify_container_mirror_target
assert classify_container_mirror_target("/root/.hermes/profiles/group1/SOUL.md") is None
def test_catches_soul_md_with_context(self):
"""Primary failure mode from #32049: agent writes SOUL.md via container path."""
from agent.file_safety import classify_container_mirror_target
result = classify_container_mirror_target(
"/root/.hermes/profiles/group1/SOUL.md",
mirror_prefix="/root/.hermes",
)
assert result is not None
assert result["mirror_root"].replace("\\", "/").endswith("root/.hermes")
assert result["inner_path"] == "profiles/group1/SOUL.md"
@pytest.mark.parametrize("inner", [
"SOUL.md",
"memories/MEMORY.md",
])
def test_catches_authoritative_profile_files(self, inner):
from agent.file_safety import classify_container_mirror_target
result = classify_container_mirror_target(
f"/root/.hermes/{inner}",
mirror_prefix="/root/.hermes",
)
assert result is not None
assert result["inner_path"] == inner
def test_non_hermes_path_not_flagged(self):
"""/root/workspace/… is not .hermes state and must not be blocked."""
from agent.file_safety import classify_container_mirror_target
assert (
classify_container_mirror_target(
"/root/workspace/main.py",
mirror_prefix="/root/.hermes",
)
is None
)
class TestGetContainerMirrorWarning:
def test_warning_names_inner_path_and_bypass(self):
from agent.file_safety import get_container_mirror_warning
warn = get_container_mirror_warning(
"/root/.hermes/profiles/group1/SOUL.md",
mirror_prefix="/root/.hermes",
)
assert warn is not None
assert "profiles/group1/SOUL.md" in warn
assert "cross_profile=True" in warn
class TestOrthogonality:
"""Container-context guard catches what the shape-based guard (#32213) misses."""
def test_inner_container_path_caught_by_context_guard(self):
"""No sandboxes/ segment — shape guard passes, context guard blocks."""
from agent.file_safety import classify_container_mirror_target
path = "/root/.hermes/profiles/group1/SOUL.md"
assert classify_container_mirror_target(path) is None # no context
assert classify_container_mirror_target(path, mirror_prefix="/root/.hermes") is not None
class TestFileToolIntegration:
"""file_tools must catch the mirror path before creating DockerEnvironment."""
def test_guard_uses_current_docker_config_before_env_exists(self, monkeypatch):
import tools.file_tools as file_tools
monkeypatch.setattr(
file_tools,
"_get_container_mirror_prefix_for_task",
lambda task_id: "/root/.hermes",
)
warning = file_tools._check_cross_profile_path(
"/root/.hermes/profiles/group1/SOUL.md",
task_id="new-task",
)
assert warning is not None
assert "Sandbox-mirror write blocked" in warning
assert "profiles/group1/SOUL.md" in warning
@@ -0,0 +1,224 @@
"""Tests for the sandbox-mirror write guard in agent/file_safety.
The guard fires when a tool tries to write into the per-task mirror
directory created by a non-local terminal backend (Docker, Daytona, etc.).
Those paths look like ``/sandboxes/<backend>/<task>/home/.hermes/`` and
they accumulate divergent copies of authoritative profile state (SOUL.md,
config.yaml, memories/*.md) because the host Hermes process never reads
them. Soft guard defense in depth, NOT a security boundary.
Reference: #32049 — under ``terminal.backend: docker``, the agent's
``write_file`` / ``patch`` calls landed on the sandbox mirror of SOUL.md
while the host process kept loading the untouched authoritative file.
The agent reported success; the rule never took effect.
"""
from __future__ import annotations
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# classify_sandbox_mirror_target — pure path-shape detection
# ---------------------------------------------------------------------------
class TestClassifySandboxMirrorTarget:
def test_docker_mirror_soul_md_classified(self, tmp_path):
"""The exact path shape reported in #32049."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("# mirror copy\n")
result = classify_sandbox_mirror_target(str(target))
assert result is not None
assert result["target_path"] == str(target.resolve())
assert result["mirror_root"].endswith(
"sandboxes/docker/default/home/.hermes"
)
assert result["inner_path"] == "profiles/group1/SOUL.md"
@pytest.mark.parametrize(
"backend,inner",
[
("docker", "profiles/coder/memories/MEMORY.md"),
("daytona", "profiles/default/cron/jobs.json"),
("podman", ".env"),
],
)
def test_other_backends_and_inner_files_match(self, tmp_path, backend, inner):
"""The detector is backend-agnostic — sandbox-mirror shape is what matters."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "sandboxes" / backend / "task-42" / "home" / ".hermes"
/ Path(inner)
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("x")
result = classify_sandbox_mirror_target(str(target))
assert result is not None
assert result["inner_path"] == inner
assert backend in result["mirror_root"]
def test_path_outside_sandbox_returns_none(self, tmp_path):
"""A plain Hermes path is not a mirror."""
from agent.file_safety import classify_sandbox_mirror_target
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
target.parent.mkdir(parents=True)
target.write_text("# real SOUL\n")
assert classify_sandbox_mirror_target(str(target)) is None
def test_sandboxes_segment_without_home_hermes_returns_none(self, tmp_path):
"""A ``sandboxes/`` directory unrelated to Hermes-state mirroring (e.g.
the sandbox workspace itself) is not flagged."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "sandboxes" / "docker" / "task-42" / "workspace" / "main.py"
)
target.parent.mkdir(parents=True)
target.write_text("print('hi')\n")
assert classify_sandbox_mirror_target(str(target)) is None
def test_sandboxes_segment_with_home_but_no_hermes_returns_none(self, tmp_path):
"""``sandboxes/<backend>/<task>/home/anything-not-hermes`` is not a mirror."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "sandboxes" / "docker" / "task-42" / "home" / ".bashrc"
)
target.parent.mkdir(parents=True)
target.write_text("alias ll='ls -la'\n")
assert classify_sandbox_mirror_target(str(target)) is None
def test_truncated_sandbox_path_returns_none(self, tmp_path):
"""``…/sandboxes/<backend>/<task>`` without ``home/.hermes/<thing>`` is not a mirror."""
from agent.file_safety import classify_sandbox_mirror_target
target = tmp_path / "sandboxes" / "docker" / "task-42"
target.mkdir(parents=True)
assert classify_sandbox_mirror_target(str(target)) is None
def test_non_existent_path_still_classifies_by_shape(self, tmp_path):
"""Detection is path-shape only — it must not require the file to exist
(the agent is about to CREATE the mirror file, that's the bug)."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
# Parent directory exists so .resolve() doesn't strip the tail
# under strict mode, but the file itself does NOT exist.
target.parent.mkdir(parents=True)
assert not target.exists()
result = classify_sandbox_mirror_target(str(target))
assert result is not None
assert result["inner_path"] == "profiles/group1/SOUL.md"
# ---------------------------------------------------------------------------
# get_sandbox_mirror_warning — the model-facing string
# ---------------------------------------------------------------------------
class TestGetSandboxMirrorWarning:
def test_non_mirror_returns_none(self, tmp_path):
from agent.file_safety import get_sandbox_mirror_warning
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
target.parent.mkdir(parents=True)
target.write_text("# real SOUL\n")
assert get_sandbox_mirror_warning(str(target)) is None
def test_mirror_warning_names_mirror_root_and_inner_path(self, tmp_path):
from agent.file_safety import get_sandbox_mirror_warning
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("# mirror copy\n")
warn = get_sandbox_mirror_warning(str(target))
assert warn is not None
# Must name the mirror root so the user can locate the sandbox.
assert "sandboxes/docker/default/home/.hermes" in warn
# Must hint at what the agent likely meant.
assert "profiles/group1/SOUL.md" in warn
# Must name the bypass kwarg shared with the cross-profile guard.
assert "cross_profile=True" in warn
def test_warning_is_defense_in_depth_not_boundary(self, tmp_path):
from agent.file_safety import get_sandbox_mirror_warning
target = (
tmp_path
/ "sandboxes" / "docker" / "t" / "home" / ".hermes"
/ "profiles" / "g" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("x")
warn = get_sandbox_mirror_warning(str(target))
# Must self-document as defense-in-depth so future reviewers
# don't promote it to a hard block (matches the existing
# cross-profile guard's contract).
assert "not a security boundary" in warn.lower()
# ---------------------------------------------------------------------------
# Independence from cross-profile classifier
# ---------------------------------------------------------------------------
class TestSandboxMirrorIsOrthogonalToCrossProfile:
"""The sandbox-mirror guard must fire even when the inner path is
in-profile from the host's view — the bug is the mirror, not the
profile mismatch."""
def test_same_profile_mirror_still_flagged(self, tmp_path, monkeypatch):
import agent.file_safety as fs
monkeypatch.setattr(fs, "_hermes_root_path", lambda: tmp_path)
monkeypatch.setattr(fs, "_hermes_home_path", lambda: tmp_path / "profiles" / "group1")
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("x")
# cross-profile classifier: active profile == target's inner-mirror
# profile name; on the existing detector the path's parts[2] is
# ``sandboxes``, not a scoped area, so it returns None.
assert fs.classify_cross_profile_target(str(target)) is None
# sandbox-mirror classifier: fires unconditionally on the shape.
assert fs.classify_sandbox_mirror_target(str(target)) is not None
+72 -2
View File
@@ -4,8 +4,9 @@ from unittest.mock import patch
class TestMinimaxContextLengths:
"""Verify context length entries match official docs (204,800 for all models).
"""Verify context length entries match official docs.
M2.x series is 204,800; M3 is 1M (max output 512K).
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
@@ -15,11 +16,80 @@ class TestMinimaxContextLengths:
def test_minimax_models_resolve_via_prefix(self):
from agent.model_metadata import get_model_context_length
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
# M2.x models resolve to 204,800 via the "minimax" catch-all
for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
ctx = get_model_context_length(model, "")
assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
def test_minimax_m3_resolves_to_1m(self):
from agent.model_metadata import get_model_context_length
# M3 must beat the generic "minimax" catch-all (204,800) and resolve to
# a 1M-class context. The exact value depends on the source: our
# hardcoded catalog says 1,000,000; the OpenRouter catalog reports
# 1,048,576 (1024²). Either is correct — assert "≥ 1M, not 204,800".
for model in ("MiniMax-M3", "minimax/minimax-m3", "minimax-m3"):
ctx = get_model_context_length(model, "")
assert ctx >= 1_000_000, f"{model} expected 1M-class, got {ctx}"
class TestMinimaxM3StaleCacheGuard:
"""Pre-catalog builds resolved M3 via the generic 'minimax' catch-all
(204,800) and persisted it before the 'minimax-m3' (1M) catalog entry
existed. The step-1 cache guard must drop that stale value and re-resolve
to 1M, while leaving correct M2.x entries (204,800) untouched.
"""
def test_suggests_minimax_m3(self):
from agent.model_metadata import _model_name_suggests_minimax_m3
assert _model_name_suggests_minimax_m3("MiniMax-M3")
assert _model_name_suggests_minimax_m3("minimax/minimax-m3")
assert not _model_name_suggests_minimax_m3("MiniMax-M2.7")
assert not _model_name_suggests_minimax_m3("MiniMax-M2.5")
def test_stale_m3_cache_dropped_and_reresolves(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import importlib
import agent.model_metadata as mm
importlib.reload(mm)
base = "https://api.minimaxi.com/anthropic"
mm.save_context_length("MiniMax-M3", base, 204_800)
ctx = mm.get_model_context_length(
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
)
# Invariant: the stale 204,800 catch-all value must be DROPPED and
# re-resolved to M3's real, larger context. The exact value depends on
# the resolution source (hardcoded catalog = 1,000,000; the models.dev
# registry currently reports 512,000) — both are large-context values
# well above the generic "minimax" catch-all. Assert the contract
# ("> 204,800, stale value gone"), not a brittle literal.
assert ctx > 204_800, f"stale M3 cache not dropped/re-resolved, got {ctx}"
def test_correct_m3_cache_preserved(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import importlib
import agent.model_metadata as mm
importlib.reload(mm)
base = "https://api.minimaxi.com/anthropic"
mm.save_context_length("MiniMax-M3", base, 1_000_000)
ctx = mm.get_model_context_length(
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
)
assert ctx == 1_000_000
def test_m2_cache_not_clobbered(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import importlib
import agent.model_metadata as mm
importlib.reload(mm)
base = "https://api.minimaxi.com/anthropic"
# 204,800 is the CORRECT value for M2.x — guard must not touch it.
for slug in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1"):
mm.save_context_length(slug, base, 204_800)
ctx = mm.get_model_context_length(
slug, base_url=base, api_key="", provider="minimax-cn"
)
assert ctx == 204_800, f"{slug} should stay 204800, got {ctx}"
class TestMinimaxThinkingSupport:
+23 -1
View File
@@ -927,6 +927,29 @@ class TestEnvironmentHints:
assert "Terminal backend: docker" in result
assert "inside" in result.lower()
def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path):
"""THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd()
(the daemon launch dir). Regression for #24882/#24969/#27383/#29265."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
configured = tmp_path / "workspace"
configured.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(configured))
monkeypatch.chdir(tmp_path)
_pb._clear_backend_probe_cache()
assert f"Current working directory: {configured}" in _pb.build_environment_hints()
def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
"""The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
_pb._clear_backend_probe_cache()
assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints()
def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch):
"""When the probe succeeds, its output must appear in the hint block."""
import agent.prompt_builder as _pb
@@ -1247,4 +1270,3 @@ class TestOpenAIModelExecutionGuidance:
# =========================================================================
+79
View File
@@ -0,0 +1,79 @@
"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
import os
from pathlib import Path
import pytest
import agent.runtime_cwd as rt
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
def _raise_oserror(*args, **kwargs):
raise OSError("cwd gone")
class TestResolveAgentCwd:
def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
monkeypatch.chdir(os.path.expanduser("~"))
assert resolve_agent_cwd() == tmp_path
def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
# The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
assert resolve_agent_cwd() == tmp_path
def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
monkeypatch.chdir(tmp_path)
assert resolve_agent_cwd() == tmp_path
def test_expands_leading_tilde(self, monkeypatch):
monkeypatch.setenv("TERMINAL_CWD", "~")
assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
# " ".strip() → "" → falsy, so the launch dir wins (not a " " path).
monkeypatch.setenv("TERMINAL_CWD", " ")
monkeypatch.chdir(tmp_path)
assert resolve_agent_cwd() == tmp_path
def test_propagates_oserror_from_getcwd(self, monkeypatch):
# The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
# The resolver must NOT swallow it — build_environment_hints owns the
# try/except OSError guard at the call site (prompt_builder.py:805).
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
with pytest.raises(OSError):
resolve_agent_cwd()
class TestResolveContextCwd:
def test_returns_dir_when_set(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert resolve_context_cwd() == tmp_path
def test_returns_none_when_unset(self, monkeypatch):
# Unset → None; the caller (build_context_files_prompt) then getcwds —
# the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
monkeypatch.delenv("TERMINAL_CWD", raising=False)
assert resolve_context_cwd() is None
def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path):
# Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir
# guard, so a missing dir is returned (not None) — discovery just finds nothing.
missing = tmp_path / "gone"
monkeypatch.setenv("TERMINAL_CWD", str(missing))
assert resolve_context_cwd() == missing
def test_expands_leading_tilde(self, monkeypatch):
monkeypatch.setenv("TERMINAL_CWD", "~")
assert resolve_context_cwd() == Path(os.path.expanduser("~"))
def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
# " ".strip() → "" → None, so the caller getcwds for discovery rather
# than building Path(" ") and resolving garbage under the launch dir.
monkeypatch.setenv("TERMINAL_CWD", " ")
assert resolve_context_cwd() is None
+57
View File
@@ -0,0 +1,57 @@
"""Tests for agent/system_prompt.py — context-file cwd wiring."""
from types import SimpleNamespace
from unittest.mock import patch
from agent.system_prompt import build_system_prompt_parts
def _make_agent(**overrides):
base = dict(
load_soul_identity=False,
skip_context_files=False,
valid_tool_names=[],
_task_completion_guidance=False,
_tool_use_enforcement=False,
_environment_probe=False,
_kanban_worker_guidance="",
_memory_store=None,
_memory_manager=None,
model="",
provider="",
platform="",
pass_session_id=False,
session_id="",
)
base.update(overrides)
return SimpleNamespace(**base)
def _captured_context_cwd(agent):
"""The cwd build_system_prompt_parts hands to build_context_files_prompt."""
captured = {}
def fake_context_files(cwd=None, skip_soul=False):
captured["cwd"] = cwd
return ""
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
):
build_system_prompt_parts(agent)
return captured["cwd"]
class TestContextFileCwd:
def test_none_when_terminal_cwd_unset(self, monkeypatch):
# Unset → None, so discovery falls back to the launch dir inside
# build_context_files_prompt (the local-CLI #19242 contract).
monkeypatch.delenv("TERMINAL_CWD", raising=False)
assert _captured_context_cwd(_make_agent()) is None
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert _captured_context_cwd(_make_agent()) == tmp_path
+80
View File
@@ -0,0 +1,80 @@
"""Tests for cli._prepend_note_to_message.
Regression coverage for the TypeError raised when a queued /model or
/reload-skills note was prepended to a multimodal (image-attached) message:
``can only concatenate str (not "list") to str``.
"""
from cli import _prepend_note_to_message
def test_string_message_gets_note_prepended():
assert _prepend_note_to_message("hello", "NOTE") == "NOTE\n\nhello"
def test_empty_note_returns_message_unchanged():
assert _prepend_note_to_message("hello", "") == "hello"
assert _prepend_note_to_message("hello", " ") == "hello"
parts = [{"type": "text", "text": "hi"}]
assert _prepend_note_to_message(parts, "") == parts
def test_note_is_stripped():
assert _prepend_note_to_message("hello", " NOTE ") == "NOTE\n\nhello"
def test_empty_string_message_yields_just_note():
# No trailing blank lines when the user message is empty.
assert _prepend_note_to_message("", "NOTE") == "NOTE"
def test_empty_text_part_yields_just_note():
message = [
{"type": "text", "text": ""},
{"type": "image_url", "image_url": {"url": "x"}},
]
result = _prepend_note_to_message(message, "NOTE")
assert result[0]["text"] == "NOTE"
assert result[1]["type"] == "image_url"
def test_list_message_folds_note_into_first_text_part():
message = [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:..."}},
]
result = _prepend_note_to_message(message, "NOTE")
assert result[0]["type"] == "text"
assert result[0]["text"] == "NOTE\n\ndescribe this"
# Image part is preserved untouched.
assert result[1] == {"type": "image_url", "image_url": {"url": "data:..."}}
# Original message is not mutated.
assert message[0]["text"] == "describe this"
def test_image_only_list_gets_leading_text_part():
message = [{"type": "image_url", "image_url": {"url": "data:..."}}]
result = _prepend_note_to_message(message, "NOTE")
assert result[0] == {"type": "text", "text": "NOTE"}
assert result[1]["type"] == "image_url"
def test_list_message_does_not_raise_typeerror():
# The exact #repro shape: multimodal list + queued note must not raise
# "can only concatenate str (not 'list') to str".
message = [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "x"}},
]
result = _prepend_note_to_message(
message, "Model switched to gpt-5.5 (provider: openai-codex)."
)
assert isinstance(result, list)
assert result[0]["text"].startswith("Model switched to gpt-5.5")
def test_unknown_shape_returned_unchanged():
assert _prepend_note_to_message(123, "NOTE") == 123
assert _prepend_note_to_message(None, "NOTE") is None
+1
View File
@@ -182,6 +182,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_SESSION_SOURCE",
"HERMES_SESSION_KEY",
"HERMES_GATEWAY_SESSION",
"HERMES_CRON_SESSION",
"_HERMES_GATEWAY",
"HERMES_PLATFORM",
"HERMES_MODEL",
+10 -3
View File
@@ -206,7 +206,11 @@ class TestBuildJobPromptScansSkillContent:
assert prompt is not None
assert "cat ~/.hermes/.env" in prompt
def test_skill_with_invisible_unicode_raises(self, cron_env):
def test_skill_with_invisible_unicode_sanitized_not_blocked(self, cron_env):
"""A stray zero-width space in a vetted skill body is stripped, not
blocked. The job builds normally with the invisible char removed.
Regression: the free-surgeon-gpt55 cron was permanently dead because
a single U+200B in loaded skill content tripped a hard block."""
hermes_home, scheduler = cron_env
# Zero-width space smuggled into the skill body.
_plant_skill(hermes_home, "zwsp-skill", "clean looking\u200bskill content")
@@ -218,8 +222,11 @@ class TestBuildJobPromptScansSkillContent:
"skills": ["zwsp-skill"],
}
with pytest.raises(scheduler.CronPromptInjectionBlocked):
scheduler._build_job_prompt(job)
# Must NOT raise — the invisible char is sanitized out and the job runs.
prompt = scheduler._build_job_prompt(job)
assert prompt is not None
assert "\u200b" not in prompt
assert "clean lookingskill content" in prompt
def test_no_skills_still_scans_user_prompt(self, cron_env):
"""Defense-in-depth: even without skills, assembled-prompt scanning
+132
View File
@@ -1,4 +1,7 @@
"""Tests for the BlueBubbles iMessage gateway adapter."""
import asyncio
import json
import pytest
from gateway.config import Platform, PlatformConfig
@@ -25,6 +28,8 @@ class TestBlueBubblesConfigLoading:
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999")
monkeypatch.setenv("BLUEBUBBLES_REQUIRE_MENTION", "true")
monkeypatch.setenv("BLUEBUBBLES_MENTION_PATTERNS", r'["(?i)^amos\\b"]')
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
@@ -35,6 +40,8 @@ class TestBlueBubblesConfigLoading:
assert bc.extra["server_url"] == "http://localhost:1234"
assert bc.extra["password"] == "secret"
assert bc.extra["webhook_port"] == 9999
assert bc.extra["require_mention"] is True
assert bc.extra["mention_patterns"] == ["(?i)^amos\\b"]
def test_home_channel_set_from_env(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
@@ -130,6 +137,131 @@ class TestBlueBubblesHelpers:
adapter = _make_adapter(monkeypatch, server_url="localhost:1234")
assert adapter.server_url == "http://localhost:1234"
def test_default_mention_patterns_match_hermes_variants(self, monkeypatch):
adapter = _make_adapter(monkeypatch, require_mention=True)
assert adapter.require_mention is True
assert adapter._message_matches_mention_patterns("Hermes, summarize this")
assert adapter._message_matches_mention_patterns("@Hermes agent help")
assert not adapter._message_matches_mention_patterns("casual family chatter")
assert not adapter._message_matches_mention_patterns("antihermes should not match")
def test_custom_mention_patterns_override_defaults(self, monkeypatch):
adapter = _make_adapter(
monkeypatch,
require_mention=True,
mention_patterns=[r"(?<![\w@])@?amos\b[,:\-]?"],
)
assert adapter._message_matches_mention_patterns("Amos what is next?")
assert not adapter._message_matches_mention_patterns("Hermes what is next?")
def test_clean_mention_text_strips_leading_wake_word(self, monkeypatch):
adapter = _make_adapter(monkeypatch, require_mention=True)
assert adapter._clean_mention_text("Hermes, summarize this") == "summarize this"
assert adapter._clean_mention_text("Hermes agent: summarize this") == "summarize this"
assert adapter._clean_mention_text("please ask Hermes about this") == "please ask Hermes about this"
class _FakeBlueBubblesRequest:
def __init__(self, payload, password="secret"):
self.query = {"password": password}
self.headers = {}
self._body = json.dumps(payload).encode("utf-8")
async def read(self):
return self._body
class TestBlueBubblesMentionGating:
@pytest.mark.asyncio
async def test_group_message_without_mention_is_acknowledged_and_skipped(self, monkeypatch):
adapter = _make_adapter(
monkeypatch,
require_mention=True,
send_read_receipts=False,
)
handled = []
async def fake_handle_message(event):
handled.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
"type": "new-message",
"data": {
"guid": "msg-1",
"text": "casual family chatter",
"handle": {"address": "+15555550100"},
"isFromMe": False,
"isGroup": True,
"chats": [{"guid": "iMessage;+;group-chat"}],
},
}))
await asyncio.sleep(0)
assert response.status == 200
assert handled == []
@pytest.mark.asyncio
async def test_group_message_with_default_mention_is_dispatched_cleaned(self, monkeypatch):
adapter = _make_adapter(
monkeypatch,
require_mention=True,
send_read_receipts=False,
)
handled = []
async def fake_handle_message(event):
handled.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
"type": "new-message",
"data": {
"guid": "msg-2",
"text": "Hermes, summarize this",
"handle": {"address": "+15555550100"},
"isFromMe": False,
"isGroup": True,
"chats": [{"guid": "iMessage;+;group-chat"}],
},
}))
await asyncio.sleep(0)
assert response.status == 200
assert [event.text for event in handled] == ["summarize this"]
@pytest.mark.asyncio
async def test_dm_message_does_not_require_mention(self, monkeypatch):
adapter = _make_adapter(
monkeypatch,
require_mention=True,
send_read_receipts=False,
)
handled = []
async def fake_handle_message(event):
handled.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
"type": "new-message",
"data": {
"guid": "msg-3",
"text": "hello from a dm",
"handle": {"address": "user@example.com"},
"isFromMe": False,
"chatGuid": "iMessage;-;user@example.com",
"chatIdentifier": "user@example.com",
},
}))
await asyncio.sleep(0)
assert response.status == 200
assert [event.text for event in handled] == ["hello from a dm"]
class TestBlueBubblesWebhookParsing:
def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch):
@@ -1,8 +1,9 @@
"""Tests for config-driven platform access policies at the gateway layer.
Background (#34515): WeCom, Weixin, Yuanbao, and QQBot expose a documented
config-driven access surface (``dm_policy`` / ``group_policy`` / ``allow_from``
/ ``group_allow_from`` in ``PlatformConfig.extra``) and enforce it at intake
Background (#34515): WeCom, Weixin, Yuanbao, QQBot, and WhatsApp expose a
documented config-driven access surface (``dm_policy`` / ``group_policy`` /
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra``) and enforce
it at intake
a message is dropped inside the adapter and never reaches the gateway unless it
already passed that policy.
@@ -34,6 +35,7 @@ _OWN_POLICY_PLATFORMS = [
Platform.WEIXIN,
Platform.YUANBAO,
Platform.QQBOT,
Platform.WHATSAPP,
]
@@ -44,6 +46,7 @@ def _clear_auth_env(monkeypatch) -> None:
"YUANBAO_ALLOWED_USERS",
"QQ_ALLOWED_USERS",
"QQ_GROUP_ALLOWED_USERS",
"WHATSAPP_ALLOWED_USERS",
"TELEGRAM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
@@ -51,6 +54,7 @@ def _clear_auth_env(monkeypatch) -> None:
"WEIXIN_ALLOW_ALL_USERS",
"YUANBAO_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS",
"WHATSAPP_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
@@ -103,10 +107,11 @@ def test_base_adapter_defaults_to_not_owning_access_policy():
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
("gateway.platforms.qqbot.adapter", "QQAdapter"),
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
],
)
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
"""The four config-policy adapters override the flag to True."""
"""The config-policy adapters override the flag to True."""
import importlib
module = importlib.import_module(module_path)
+61
View File
@@ -155,3 +155,64 @@ class TestSupportedDocumentTypes:
)
def test_expected_extensions_present(self, ext):
assert ext in SUPPORTED_DOCUMENT_TYPES
# ---------------------------------------------------------------------------
# TestCacheMediaBytes — the unified, platform-agnostic caching primitive
# ---------------------------------------------------------------------------
# 1x1 transparent PNG (passes cache_image_from_bytes validation)
_PNG_1PX = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6360000002000154a24f5f0000000049454e44ae426082"
)
class TestCacheMediaBytes:
def test_pdf_routes_to_document(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"%PDF-1.4 body", filename="report.pdf", mime_type="application/pdf")
assert result is not None
assert result.kind == "document"
assert result.media_type == "application/pdf"
assert "report.pdf" in result.display_name
assert os.path.exists(result.path)
assert "report.pdf" in result.context_note()
def test_png_routes_to_image(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(_PNG_1PX, filename="photo.png", mime_type="image/png")
assert result is not None
assert result.kind == "image"
assert result.media_type == "image/png"
assert os.path.exists(result.path)
def test_native_photo_without_filename_uses_default_kind(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(_PNG_1PX, filename="", mime_type="", default_kind="image")
assert result is not None
assert result.kind == "image"
def test_mp4_routes_to_video(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"\x00\x00\x00\x18ftypmp42", filename="clip.mp4", mime_type="video/mp4")
assert result is not None
assert result.kind == "video"
assert result.media_type == "video/mp4"
def test_mime_only_resolves_extension(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"col1,col2\n1,2", filename="", mime_type="text/csv")
assert result is not None
assert result.kind == "document"
assert result.media_type == "text/csv"
def test_unsupported_document_returns_none(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"MZ", filename="malware.exe", mime_type="application/x-msdownload")
assert result is None
def test_invalid_image_returns_none(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"<html>not an image</html>", filename="x.png", mime_type="image/png")
assert result is None
@@ -0,0 +1,74 @@
"""Tests for the dispatch_in_gateway gate on _kanban_notifier_watcher.
- Non-dispatch gateways (dispatch_in_gateway=false) exit before opening any DB.
- HERMES_KANBAN_DISPATCH_IN_GATEWAY env var disables without loading config.
- Dispatch-owning gateways (dispatch_in_gateway=true) proceed past the gate.
"""
import asyncio
from unittest.mock import MagicMock, patch
from gateway.config import Platform
from gateway.run import GatewayRunner
def _make_runner(with_adapter=False):
runner = GatewayRunner.__new__(GatewayRunner)
runner._running = True
runner.adapters = {Platform.TELEGRAM: MagicMock()} if with_adapter else {}
runner._kanban_sub_fail_counts = {}
return runner
def _fake_config(dispatch_in_gateway):
return {"kanban": {"dispatch_in_gateway": dispatch_in_gateway}}
def test_notifier_watcher_skips_when_dispatch_disabled():
"""dispatch_in_gateway=false returns before opening any board DB."""
runner = _make_runner()
with patch("hermes_cli.config.load_config", return_value=_fake_config(False)):
with patch("hermes_cli.kanban_db.connect") as mock_connect:
asyncio.run(runner._kanban_notifier_watcher())
mock_connect.assert_not_called()
def test_notifier_watcher_env_override_disables(monkeypatch):
"""HERMES_KANBAN_DISPATCH_IN_GATEWAY=false skips config load entirely."""
runner = _make_runner()
monkeypatch.setenv("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "false")
with patch("hermes_cli.config.load_config") as mock_load_config:
with patch("hermes_cli.kanban_db.connect") as mock_connect:
asyncio.run(runner._kanban_notifier_watcher())
mock_load_config.assert_not_called()
mock_connect.assert_not_called()
def test_notifier_watcher_runs_when_dispatch_enabled():
"""dispatch_in_gateway=true proceeds past the gate to the board fan-out."""
runner = _make_runner(with_adapter=True)
past_gate = []
sleep_calls = []
async def fake_sleep(delay):
sleep_calls.append(delay)
# Stop after the initial delay + first per-interval sleep so the loop
# body runs exactly once.
if len(sleep_calls) >= 2:
runner._running = False
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
import hermes_cli.kanban_db as _kb
with patch("hermes_cli.config.load_config", return_value=_fake_config(True)):
with patch.object(
_kb, "list_boards",
side_effect=lambda *a, **kw: past_gate.append(True) or [],
):
with patch("asyncio.sleep", side_effect=fake_sleep):
with patch("asyncio.to_thread", side_effect=fake_to_thread):
asyncio.run(runner._kanban_notifier_watcher())
assert past_gate, "list_boards should be called when dispatch_in_gateway=true"
+46 -13
View File
@@ -7,6 +7,7 @@ sibling platform-plugin tests on the same xdist worker.
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
@@ -214,7 +215,7 @@ async def test_send_dm():
result = await adapter.send("contact-42", "Hello, SimpleX!")
mock_ws.send.assert_called_once()
payload = json.loads(mock_ws.send.call_args[0][0])
assert payload["cmd"] == "@[contact-42] Hello, SimpleX!"
assert payload["cmd"] == "@contact-42 Hello, SimpleX!"
assert payload["corrId"].startswith(_CORR_PREFIX)
assert result.success is True
@@ -301,23 +302,55 @@ async def test_standalone_send_missing_websockets(monkeypatch):
@pytest.mark.asyncio
async def test_standalone_send_missing_url(monkeypatch):
async def test_standalone_send_defaults_to_local_daemon(monkeypatch):
monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
pconfig = MagicMock()
pconfig.extra = {}
# We expect the URL fallback (extra+env both empty) to be empty string,
# producing an error. We also need websockets to be importable for the
# url-check branch to be reached, so skip when it's not.
try:
import websockets.client # noqa: F401
except ImportError:
pytest.skip("websockets not installed")
sent_payloads = []
class DummyWs:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def send(self, payload):
sent_payloads.append(json.loads(payload))
def fake_connect(url, **kwargs):
assert url == "ws://127.0.0.1:5225"
assert kwargs["open_timeout"] == 10
assert kwargs["close_timeout"] == 5
return DummyWs()
import websockets
monkeypatch.setattr(websockets, "connect", fake_connect)
result = await _standalone_send(pconfig, "contact-42", "hi")
assert isinstance(result, dict)
# Either error about URL or a connection attempt failure — both are valid
# signals that the standalone path requires configuration.
assert "error" in result
assert result == {"success": True, "platform": "simplex", "chat_id": "contact-42"}
assert sent_payloads[0]["cmd"] == "@contact-42 hi"
@pytest.mark.asyncio
async def test_health_monitor_does_not_reconnect_quiet_healthy_ws(monkeypatch):
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
adapter._running = True
adapter._last_ws_activity = 0
adapter._ws = AsyncMock()
monkeypatch.setattr(_simplex, "HEALTH_CHECK_INTERVAL", 0.01)
monkeypatch.setattr(_simplex, "HEALTH_CHECK_STALE_THRESHOLD", 0.01)
task = asyncio.create_task(adapter._health_monitor())
await asyncio.sleep(0.03)
adapter._running = False
await asyncio.wait_for(task, timeout=1)
adapter._ws.close.assert_not_called()
# ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ time instead of first-token time.
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -173,6 +174,179 @@ class TestFreshFinalForLongLivedPreviews:
assert consumer._should_send_fresh_final() is False
class TestSegmentBreakDoesNotMarkFinalSent:
"""Regression for #29346 — silent response loss after tool calls.
When ``fresh_final_after_seconds > 0`` and a streamed *preamble* ("Let me
search") has aged past the threshold, finalizing it at a tool boundary
used to route through ``_try_fresh_final``, which unconditionally set
``_final_response_sent = True`` even though this is a NON-final segment.
The gateway (run.py:18128) then reads that flag as "final delivered" and
suppresses the genuine final answer (which arrives on a later API call and
does not re-stream), so the user gets nothing.
The fix scopes the final-delivery flags to the turn-final segment and
clears them at every tool/segment boundary, so a preamble can never mark
the turn as delivered.
"""
@staticmethod
def _delivered_texts(adapter) -> list[str]:
"""Every text the adapter actually put on screen (sends + edits)."""
texts = [c.kwargs.get("content", "") for c in adapter.send.call_args_list]
texts += [c.kwargs.get("content", "") for c in adapter.edit_message.call_args_list]
return texts
@pytest.mark.asyncio
async def test_preamble_fresh_final_at_tool_boundary_does_not_mark_final(self):
"""Real-aging reproduction (exercises the actual _should_send_fresh_final
age gate, not a monkeypatch): a preamble ages past the threshold, then a
tool boundary finalizes it via fresh-final. The genuine final answer is
produced on a later API call and is NOT streamed through this consumer
(the #29346 repro), so the consumer must NOT believe the final was sent."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter=adapter,
chat_id="chat",
config=StreamConsumerConfig(
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=0.001, # tiny → real aging fires
),
)
consumer.on_delta("Let me search the web for that.")
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05) # preamble sent + aged well past 0.001s
consumer.on_delta(None) # tool boundary → segment-break fresh-final
await asyncio.sleep(0.05)
consumer.finish()
await task
# Fresh-final actually engaged (preamble preview + a fresh resend), yet
# the turn is NOT marked delivered — no genuine final ever streamed.
assert adapter.send.call_count >= 2
assert consumer.final_response_sent is False
assert consumer.final_content_delivered is False
@pytest.mark.asyncio
async def test_final_answer_after_preamble_is_delivered_exactly_once(self):
"""P0 user-visible contract: when the real final answer DOES stream in
after the preamble + tool boundary, the user gets it exactly once AND
the consumer marks it delivered (so the gateway correctly suppresses a
redundant send)."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter=adapter,
chat_id="chat",
config=StreamConsumerConfig(
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=0.001,
),
)
consumer.on_delta("Let me search the web for that.")
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05)
consumer.on_delta(None) # tool boundary
consumer.on_delta("The answer is 42.") # genuine final answer streams
await asyncio.sleep(0.05)
consumer.finish()
await task
# The real final answer was delivered → suppression must engage.
assert consumer.final_response_sent is True
# And it reached the user exactly once (no duplicate fresh send).
final_sends = [
c for c in adapter.send.call_args_list
if "answer is 42" in c.kwargs.get("content", "")
]
assert len(final_sends) <= 1
assert any("answer is 42" in t for t in self._delivered_texts(adapter))
@pytest.mark.asyncio
async def test_genuine_final_answer_without_tools_marks_delivered(self):
"""P1 happy path: a single answer streamed straight to completion (no
tool boundary) still sets final_response_sent so the gateway suppresses
the redundant final send."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter=adapter,
chat_id="chat",
config=StreamConsumerConfig(
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=60.0,
),
)
consumer.on_delta("Here is the full answer.")
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05)
consumer.finish()
await task
assert consumer.final_response_sent is True
assert any("Here is the full answer." in t for t in self._delivered_texts(adapter))
@pytest.mark.asyncio
async def test_no_edit_adapter_delivers_final_after_preamble(self):
"""No-edit adapters (Signal/SMS/webhook → __no_edit__) accumulate and
deliver rather than fresh-final. A preamble before a tool call must not
swallow the genuine final answer it must reach the user."""
adapter = _make_adapter()
adapter.send.return_value = SimpleNamespace(success=True, message_id=None)
consumer = GatewayStreamConsumer(
adapter=adapter,
chat_id="chat",
config=StreamConsumerConfig(
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=0.001,
),
)
consumer.on_delta("Let me search the web for that.")
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05)
consumer.on_delta(None) # tool boundary
consumer.on_delta("The answer is 42.") # genuine final answer
await asyncio.sleep(0.05)
consumer.finish()
await task
# The final answer reached the user, not swallowed by the preamble.
assert any(
"answer is 42" in c.kwargs.get("content", "")
for c in adapter.send.call_args_list
)
@pytest.mark.asyncio
async def test_multi_tool_call_turn_delivers_final_once(self):
"""Two tool boundaries before the final answer: flags stay clear across
both boundaries and the genuine final is delivered exactly once and
marked sent."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter=adapter,
chat_id="chat",
config=StreamConsumerConfig(
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=0.001,
),
)
consumer.on_delta("Let me check a couple of things.")
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05)
consumer.on_delta(None) # tool boundary 1
consumer.on_delta("Now cross-referencing.")
await asyncio.sleep(0.05)
consumer.on_delta(None) # tool boundary 2
consumer.on_delta("The answer is 42.") # genuine final answer
await asyncio.sleep(0.05)
consumer.finish()
await task
assert consumer.final_response_sent is True
final_sends = [
c for c in adapter.send.call_args_list
if "answer is 42" in c.kwargs.get("content", "")
]
assert len(final_sends) <= 1
assert any("answer is 42" in t for t in self._delivered_texts(adapter))
class TestStreamConsumerConfigFreshFinalField:
"""The dataclass field must exist and default to 0 (disabled)."""
+158 -1
View File
@@ -1,7 +1,7 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
from gateway.config import Platform, PlatformConfig, load_gateway_config
from gateway.platforms.base import MessageType
@@ -1005,3 +1005,160 @@ def test_triggered_voice_message_uses_shared_session_in_observe_mode():
assert "[Alice Example|111]" in event.text
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Observed-media caching (unmentioned group attachments)
# ---------------------------------------------------------------------------
def _group_photo_message(*, chat_id=-100, caption="Veja esta foto", file_size=1024):
file_obj = SimpleNamespace(
file_path="photos/observed.png",
download_as_bytearray=AsyncMock(return_value=bytearray(b"\x89PNG\r\n\x1a\n observed")),
)
photo = SimpleNamespace(file_size=file_size, get_file=AsyncMock(return_value=file_obj))
return SimpleNamespace(
message_id=52, text=None, caption=caption, entities=[], caption_entities=[],
message_thread_id=None, is_topic_message=False,
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False),
from_user=SimpleNamespace(id=111, full_name="Alice Example", first_name="Alice"),
reply_to_message=None, date=None, location=None, venue=None,
sticker=None, photo=[photo], video=None, audio=None, voice=None, document=None,
)
def _group_document_message(*, chat_id=-100, caption="Este arquivo", document=None):
file_obj = SimpleNamespace(
file_path="documents/report.pdf",
download_as_bytearray=AsyncMock(return_value=bytearray(b"%PDF observed bytes")),
)
document = document or SimpleNamespace(
file_name="RESULTADO BIOLOGICO - PROTOCOLO 103- URBAN.pdf",
mime_type="application/pdf", file_size=1024,
get_file=AsyncMock(return_value=file_obj),
)
return SimpleNamespace(
message_id=53, text=None, caption=caption, entities=[], caption_entities=[],
message_thread_id=None, is_topic_message=False,
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False),
from_user=SimpleNamespace(id=111, full_name="Alice Example", first_name="Alice"),
reply_to_message=None, date=None, location=None, venue=None,
sticker=None, photo=None, video=None, audio=None, voice=None, document=document,
)
def test_unmentioned_photo_observed_with_cached_path(monkeypatch, tmp_path):
async def _run():
adapter = _make_adapter(
require_mention=True, allowed_chats=["-100"],
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
cached_path = tmp_path / "img_abc_observed.png"
monkeypatch.setattr(
"gateway.platforms.base.cache_image_from_bytes",
lambda _data, ext=".jpg": str(cached_path),
)
update = SimpleNamespace(update_id=3003, message=_group_photo_message(), effective_message=None)
await adapter._handle_media_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert len(store.messages) == 1
_, message, _ = store.messages[0]
assert message["observed"] is True
assert "Veja esta foto" in message["content"]
assert "image" in message["content"]
assert str(cached_path) in message["content"]
assert store.sources[0].user_id is None
asyncio.run(_run())
def test_unmentioned_document_observed_with_cached_path(monkeypatch, tmp_path):
async def _run():
adapter = _make_adapter(
require_mention=True, allowed_chats=["-100"],
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
cached_path = tmp_path / "doc_abc_report.pdf"
monkeypatch.setattr(
"gateway.platforms.base.cache_document_from_bytes",
lambda _data, _filename: str(cached_path),
)
update = SimpleNamespace(update_id=3004, message=_group_document_message(), effective_message=None)
await adapter._handle_media_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert len(store.messages) == 1
_, message, _ = store.messages[0]
assert message["observed"] is True
assert "Este arquivo" in message["content"]
assert str(cached_path) in message["content"]
asyncio.run(_run())
def test_unmentioned_large_document_observed_without_download(monkeypatch):
async def _run():
adapter = _make_adapter(
require_mention=True, allowed_chats=["-100"],
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
)
adapter._max_doc_bytes = 100
store = _FakeSessionStore()
adapter._session_store = store
cache_doc = Mock(return_value="/tmp/huge.pdf")
monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc)
document = SimpleNamespace(
file_name="huge.pdf", mime_type="application/pdf",
file_size=101, get_file=AsyncMock(),
)
update = SimpleNamespace(
update_id=3005, message=_group_document_message(document=document), effective_message=None,
)
await adapter._handle_media_message(update, SimpleNamespace())
cache_doc.assert_not_called()
document.get_file.assert_not_called()
_, message, _ = store.messages[0]
assert "too large" in message["content"]
assert "/tmp/huge.pdf" not in message["content"]
asyncio.run(_run())
def test_unmentioned_unsupported_document_observed_without_caching(monkeypatch):
async def _run():
adapter = _make_adapter(
require_mention=True, allowed_chats=["-100"],
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
cache_doc = Mock(return_value="/tmp/malware.exe")
monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc)
file_obj = SimpleNamespace(
file_path="documents/malware.exe",
download_as_bytearray=AsyncMock(return_value=bytearray(b"MZ")),
)
document = SimpleNamespace(
file_name="malware.exe", mime_type="application/x-msdownload",
file_size=2, get_file=AsyncMock(return_value=file_obj),
)
update = SimpleNamespace(
update_id=3006, message=_group_document_message(document=document), effective_message=None,
)
await adapter._handle_media_message(update, SimpleNamespace())
cache_doc.assert_not_called()
_, message, _ = store.messages[0]
assert "unsupported" in message["content"].lower()
asyncio.run(_run())
@@ -0,0 +1,258 @@
"""Regression tests for tool-using response silent drop (issue #29346).
When the agent returns a non-empty response that the extract pipeline
(extract_media / extract_images / extract_local_files / inline directive
strips) happens to reduce to an empty string, the ``if text_content:`` guard
in ``BasePlatformAdapter._process_message_background`` previously bypassed
the send entirely. The symptom was a ``response ready`` log followed by
silence no ``Sending response`` line, no error and the final answer
never reaching the channel.
The fix (A2/A3 of the silent-response-loss plan) preserves the pre-extract
response and, when no native attachment was produced to deliver in its
place, sanitizes the original text and sends it as a fallback on ALL
platforms (a ``response_delivery_recovered`` WARNING marks the recovery so
the silent-drop pattern is observable). When even the sanitized recovery
yields nothing deliverable, a ``response_delivery_dropped`` ERROR fires so a
genuinely-lost response is never silent.
Salvaged and de-scoped from the superseded Discord-only PR #33842.
"""
import asyncio
import logging
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
SendResult,
)
from gateway.session import SessionSource, build_session_key
class _DummyAdapter(BasePlatformAdapter):
"""Minimal BasePlatformAdapter for dispatch tests on any platform."""
def __init__(self, platform: Platform):
super().__init__(PlatformConfig(enabled=True, token="fake-token"), platform)
self.sent: list[dict] = []
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
return None
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
self.sent.append({"chat_id": chat_id, "content": content})
return SendResult(success=True, message_id="msg-1")
async def send_typing(self, chat_id: str, metadata=None) -> None:
return None
async def get_chat_info(self, chat_id: str):
return {"id": chat_id}
def _make_event(platform: Platform, chat_id: str = "111", message_id: str = "m1") -> MessageEvent:
return MessageEvent(
text="hello",
source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"),
message_id=message_id,
)
async def _hold_typing(_chat_id, interval=2.0, metadata=None, stop_event=None):
if stop_event is not None:
await stop_event.wait()
else:
await asyncio.Event().wait()
def _strip_everything(adapter, monkeypatch):
"""Force the extract pipeline to reduce text_content to "" with no
attachments the exact failure mode that made the drop invisible."""
monkeypatch.setattr(
type(adapter), "extract_media", staticmethod(lambda content: ([], content))
)
monkeypatch.setattr(
type(adapter), "extract_images", staticmethod(lambda content: ([], ""))
)
monkeypatch.setattr(
type(adapter), "extract_local_files", staticmethod(lambda content: ([], ""))
)
@pytest.mark.parametrize("platform", [Platform.DISCORD, Platform.TELEGRAM])
class TestExtractStripRecoveryAllPlatforms:
"""A non-empty response stripped to empty must be recovered on EVERY
platform (the fix de-scopes the recovery from Discord-only)."""
@pytest.mark.asyncio
async def test_response_reduced_to_empty_is_recovered_and_sent(
self, platform, monkeypatch, caplog
):
adapter = _DummyAdapter(platform)
adapter._keep_typing = _hold_typing
tool_response = (
"Based on my search, the cheapest TPE-PAR flight on Dec 14 is $632 "
"via Saudia. Here are the top options sorted by price... "
) * 5
assert len(tool_response) > 500
async def handler(_event):
return tool_response
adapter.set_message_handler(handler)
_strip_everything(adapter, monkeypatch)
event = _make_event(platform)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.base"):
await adapter._process_message_background(
event, build_session_key(event.source)
)
# The response WAS delivered, not silently dropped.
assert len(adapter.sent) == 1, f"expected 1 send, got {adapter.sent}"
assert adapter.sent[0]["content"] == tool_response.strip()
# And the recovery is observable via the stable event key.
assert any(
"response_delivery_recovered" in r.getMessage()
for r in caplog.records
), [r.getMessage() for r in caplog.records]
@pytest.mark.asyncio
async def test_directives_stripped_from_fallback_text(self, platform, monkeypatch):
adapter = _DummyAdapter(platform)
adapter._keep_typing = _hold_typing
raw = (
"[[audio_as_voice]]\n[[as_document]]\nMEDIA: /tmp/nope.ogg\n"
"The real answer the user should see."
)
async def handler(_event):
return raw
adapter.set_message_handler(handler)
_strip_everything(adapter, monkeypatch)
event = _make_event(platform)
await adapter._process_message_background(event, build_session_key(event.source))
assert len(adapter.sent) == 1
delivered = adapter.sent[0]["content"]
assert "[[audio_as_voice]]" not in delivered
assert "[[as_document]]" not in delivered
assert "MEDIA:" not in delivered
assert "The real answer the user should see." in delivered
@pytest.mark.asyncio
async def test_no_fallback_when_attachment_produced(self, platform, monkeypatch):
"""When an image attachment IS extracted, the empty text_content is
intentional recovery must NOT re-send the original markdown and
duplicate the attachment's content."""
adapter = _DummyAdapter(platform)
adapter._keep_typing = _hold_typing
async def handler(_event):
return "![chart](https://example.com/chart.png)"
adapter.set_message_handler(handler)
monkeypatch.setattr(
type(adapter), "extract_media", staticmethod(lambda content: ([], content))
)
monkeypatch.setattr(
type(adapter), "extract_images",
staticmethod(lambda content: ([("https://example.com/chart.png", "chart")], "")),
)
monkeypatch.setattr(
type(adapter), "extract_local_files", staticmethod(lambda content: ([], ""))
)
adapter.send_multiple_images = lambda *a, **kw: asyncio.sleep(0, result=None)
event = _make_event(platform)
await adapter._process_message_background(event, build_session_key(event.source))
assert adapter.sent == [], f"expected no text echo, got {adapter.sent}"
class TestRecoveryDoesNotLeakMediaFragments:
"""The A2 recovery must not leak fragments of a MEDIA: path to the user.
extract_media's real regex matches paths WITH SPACES; if the recovery
sanitizes the raw pre-extract snapshot with a weaker MEDIA regex (one that
stops at the first space), a spaced path whose file gets filtered out leaks
a fragment like 'vacation photo.png'. The recovery must instead use the
post-extract_media `response`, which the strong regex already cleaned.
"""
@pytest.mark.asyncio
async def test_spaced_media_path_does_not_leak_fragment(self, monkeypatch, caplog):
adapter = _DummyAdapter(Platform.DISCORD)
adapter._keep_typing = _hold_typing
async def handler(_event):
# Spaced path with a valid extension — matched in full by the real
# extract_media regex, then removed from the body.
return "MEDIA: /tmp/nope_dir_zzz/my vacation photo.png"
adapter.set_message_handler(handler)
# Use the REAL extract_media (so the strong regex cleans `response`),
# but force the path to be filtered out (unsafe/nonexistent) so we hit
# the empty-text + no-attachment recovery branch deterministically.
monkeypatch.setattr(
type(adapter), "filter_media_delivery_paths", staticmethod(lambda m: [])
)
event = _make_event(Platform.DISCORD)
with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"):
await adapter._process_message_background(
event, build_session_key(event.source)
)
# No fragment of the media path may reach the user.
leaked = [
s for s in adapter.sent
if "vacation" in s["content"] or "photo" in s["content"] or "MEDIA" in s["content"]
]
assert leaked == [], f"media-path fragment leaked to user: {leaked}"
# The genuinely-undeliverable response is logged loudly, not silent.
assert any(
"response_delivery_dropped" in r.getMessage()
for r in caplog.records if r.levelno == logging.ERROR
), [r.getMessage() for r in caplog.records]
class TestUnrecoverableDropIsLoud:
"""A non-empty response that produces NOTHING deliverable (sanitizes to
empty, no attachment) must log a response_delivery_dropped ERROR rather
than vanishing silently."""
@pytest.mark.asyncio
async def test_directive_only_response_logs_dropped(self, monkeypatch, caplog):
adapter = _DummyAdapter(Platform.DISCORD)
adapter._keep_typing = _hold_typing
async def handler(_event):
return "[[audio_as_voice]]\nMEDIA: /tmp/missing.ogg" # only directives
adapter.set_message_handler(handler)
# Extraction strips to empty AND the media path filtered out (no file).
_strip_everything(adapter, monkeypatch)
event = _make_event(Platform.DISCORD)
with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"):
await adapter._process_message_background(
event, build_session_key(event.source)
)
assert adapter.sent == []
assert any(
"response_delivery_dropped" in r.getMessage()
for r in caplog.records if r.levelno == logging.ERROR
), [r.getMessage() for r in caplog.records]
+33
View File
@@ -285,6 +285,39 @@ class TestPolicyHelpers:
assert adapter._is_dm_allowed("user-1") is True
assert adapter._is_dm_allowed("user-2") is False
def test_dm_allowlist_honors_env_only_allowed_users(self, monkeypatch):
"""Env-only setup (WECOM_DM_POLICY + WECOM_ALLOWED_USERS, no config
``extra``) must populate the DM allowlist. Otherwise ``dm_policy:
allowlist`` runs with an empty allowlist and drops every listed user
at intake the documented env vars become no-ops."""
from gateway.platforms.wecom import WeComAdapter
monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
monkeypatch.setenv("WECOM_ALLOWED_USERS", "user-1, user-2")
adapter = WeComAdapter(PlatformConfig(enabled=True))
assert adapter._dm_policy == "allowlist"
assert adapter._allow_from == ["user-1", "user-2"]
assert adapter._is_dm_allowed("user-1") is True
assert adapter._is_dm_allowed("user-2") is True
assert adapter._is_dm_allowed("stranger") is False
def test_dm_allowlist_extra_takes_precedence_over_env(self, monkeypatch):
"""Config ``extra`` wins over the env fallback, so an explicit
allowlist is never silently widened by a stray WECOM_ALLOWED_USERS."""
from gateway.platforms.wecom import WeComAdapter
monkeypatch.setenv("WECOM_ALLOWED_USERS", "env-user")
adapter = WeComAdapter(
PlatformConfig(enabled=True, extra={"dm_policy": "allowlist", "allow_from": ["cfg-user"]})
)
assert adapter._allow_from == ["cfg-user"]
assert adapter._is_dm_allowed("cfg-user") is True
assert adapter._is_dm_allowed("env-user") is False
def test_group_allowlist_and_per_group_sender_allowlist(self):
from gateway.platforms.wecom import WeComAdapter
+145
View File
@@ -968,3 +968,148 @@ class TestWeixinTextDebounce:
asyncio.run(_drive())
assert dispatched == ["one\ntwo\nthree"]
class _StubResponse:
def __init__(self, *, status=200, body="{}", delay=0.0):
self.status = status
self.ok = 200 <= status < 300
self._body = body
self._delay = delay
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def text(self):
if self._delay:
await asyncio.sleep(self._delay)
return self._body
class _StubSession:
"""Records request kwargs and returns a configurable async-CM response.
Unlike aiohttp.ClientSession it installs no TimerContext, so it cannot
reproduce aiohttp's cross-loop crash directly; these tests instead pin the
observable contract of the asyncio.wait_for migration.
"""
def __init__(self, response):
self._response = response
self.post_calls = []
self.get_calls = []
def post(self, url, **kwargs):
self.post_calls.append((url, kwargs))
return self._response
def get(self, url, **kwargs):
self.get_calls.append((url, kwargs))
return self._response
class TestWeixinApiTimeout:
def test_api_post_does_not_pass_aiohttp_timeout_kwarg(self):
session = _StubSession(_StubResponse(body='{"ret": 0}'))
result = asyncio.run(
weixin._api_post(
session,
base_url="https://weixin.example.com",
endpoint="ep",
payload={"k": "v"},
token="tok",
timeout_ms=5000,
)
)
assert result == {"ret": 0}
# The fix enforces the timeout via asyncio.wait_for, so ClientTimeout is
# gone and `timeout` is no longer forwarded to session.post().
[(_url, kwargs)] = session.post_calls
assert "timeout" not in kwargs
def test_api_get_does_not_pass_aiohttp_timeout_kwarg(self):
session = _StubSession(_StubResponse(body='{"ret": 0}'))
result = asyncio.run(
weixin._api_get(
session,
base_url="https://weixin.example.com",
endpoint="ep",
timeout_ms=5000,
)
)
assert result == {"ret": 0}
[(_url, kwargs)] = session.get_calls
assert "timeout" not in kwargs
def test_api_post_raises_timeout_when_response_is_slow(self):
# 1 ms budget against a 1 s response: wait_for must cancel and raise.
session = _StubSession(_StubResponse(delay=1.0))
with pytest.raises(asyncio.TimeoutError):
asyncio.run(
weixin._api_post(
session,
base_url="https://weixin.example.com",
endpoint="ep",
payload={"k": "v"},
token="tok",
timeout_ms=1,
)
)
def test_api_get_raises_timeout_when_response_is_slow(self):
session = _StubSession(_StubResponse(delay=1.0))
with pytest.raises(asyncio.TimeoutError):
asyncio.run(
weixin._api_get(
session,
base_url="https://weixin.example.com",
endpoint="ep",
timeout_ms=1,
)
)
def test_api_post_raises_runtime_error_on_non_ok_status(self):
# The non-2xx branch now lives inside the wait_for-wrapped inner coro;
# confirm it still raises with the HTTP status and truncated body.
session = _StubSession(_StubResponse(status=500, body="boom"))
with pytest.raises(RuntimeError, match="iLink POST ep HTTP 500: boom"):
asyncio.run(
weixin._api_post(
session,
base_url="https://weixin.example.com",
endpoint="ep",
payload={"k": "v"},
token="tok",
timeout_ms=5000,
)
)
def test_api_get_raises_runtime_error_on_non_ok_status(self):
session = _StubSession(_StubResponse(status=500, body="boom"))
with pytest.raises(RuntimeError, match="iLink GET ep HTTP 500: boom"):
asyncio.run(
weixin._api_get(
session,
base_url="https://weixin.example.com",
endpoint="ep",
timeout_ms=5000,
)
)
def test_get_updates_returns_empty_sentinel_on_timeout(self):
# wait_for raises asyncio.TimeoutError, which _get_updates swallows into
# an empty long-poll batch rather than propagating.
session = _StubSession(_StubResponse(delay=1.0))
result = asyncio.run(
weixin._get_updates(
session,
base_url="https://weixin.example.com",
token="tok",
sync_buf="buf-123",
timeout_ms=1,
)
)
assert result == {"ret": 0, "msgs": [], "get_updates_buf": "buf-123"}
@@ -1,6 +1,7 @@
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
import json
import os
from pathlib import Path
from unittest.mock import patch
@@ -132,6 +133,38 @@ class TestAtomicJsonWrite:
assert result["emoji"] == "🎉"
assert result["japanese"] == "日本語"
def test_mode_does_not_crash_without_fchmod(self, tmp_path):
"""Regression: os.fchmod is Unix-only and absent on Windows. Passing a
mode must not raise AttributeError when fchmod is unavailable.
Simulates the Windows os module by removing fchmod from the namespace.
Previously this crashed in `hermes memory setup` while saving the
Hindsight config with mode=0o600 (GitHub: Windows setup traceback).
"""
import utils
target = tmp_path / "secret.json"
no_fchmod = {k: getattr(os, k) for k in dir(os) if k != "fchmod"}
fake_os = type("FakeOs", (), no_fchmod)
assert not hasattr(fake_os, "fchmod")
with patch.object(utils, "os", fake_os):
atomic_json_write(target, {"api_key": "secret"}, mode=0o600)
assert json.loads(target.read_text(encoding="utf-8")) == {"api_key": "secret"}
def test_mode_applied_when_supported(self, tmp_path):
import stat as stat_mod
target = tmp_path / "secret.json"
atomic_json_write(target, {"api_key": "secret"}, mode=0o600)
# os.chmod's effect is platform-dependent (Windows only honors the
# write bit), so only assert the durable mode on POSIX.
if hasattr(os, "fchmod"):
actual = stat_mod.S_IMODE(target.stat().st_mode)
assert actual == 0o600
def test_concurrent_writes_dont_corrupt(self, tmp_path):
"""Multiple rapid writes should each produce valid JSON."""
import threading
@@ -0,0 +1,127 @@
"""Tests for the ranked fuzzy scorer used by the searchable curses pickers."""
from hermes_cli.curses_ui import (
_SearchState,
_filter_indices,
_fuzzy_score,
_handle_active_search_key,
_is_boundary,
_token_score,
)
class _FakeCurses:
KEY_BACKSPACE = 263
KEY_DOWN = 258
KEY_ENTER = 343
def test_fuzzy_score_matches_subsequence():
assert _fuzzy_score("gpt-4o", "g4o") is not None
assert _fuzzy_score("gpt-4o", "4o") is not None
assert _fuzzy_score("gpt-4o", "o4g") is None
assert _fuzzy_score("gpt-4o", "xyz") is None
def test_scorer_matches_typescript_reference():
"""Score parity with ui-tui/web fuzzy.ts. These exact values are produced
by the TS fuzzyScoreMulti for the same inputs (verified via a cross-language
harness); keep the Python port byte-identical so all three surfaces rank
consistently. If you change the scoring constants, update the TS copies too.
"""
cases = {
("gpt-4o", "g4o"): 15.94,
("gpt-4o", "gpt"): 28.94,
("claude-sonnet-4", "sonnet"): 33.85,
("claude-sonnet-4", "clad snnt"): 30.70,
("GptO", "gpto"): 57.96, # camelCase boundary on the original-case 'O'
}
for (label, query), expected in cases.items():
score = _fuzzy_score(label, query)
assert score is not None
assert round(score, 2) == expected, f"{label!r}/{query!r}: {score} != {expected}"
def test_is_boundary_camelcase_and_separators():
assert _is_boundary("gpt-4o", 0) is True # start
assert _is_boundary("gpt-4o", 4) is True # after '-'
assert _is_boundary("gpt-4o", 2) is False # mid-word
assert _is_boundary("GptO", 3) is True # lower->upper transition
def test_token_score_takes_orig_and_lower():
# Exact match (lower == token) earns the +20 bonus over a prefix.
exact = _token_score("sonnet", "sonnet", "sonnet")
prefix = _token_score("sonnet-x", "sonnet-x", "sonnet")
assert exact is not None and prefix is not None
assert exact > prefix
def test_esc_clears_query_and_signals_changed():
# Esc during active search clears the filter (restores full list) and
# signals `changed` so the driver resets scroll/cursor.
search = _SearchState(active=True, query="gpt")
handled, confirm, changed = _handle_active_search_key(_FakeCurses, 27, search)
assert (handled, confirm, changed) == (True, False, True)
assert search.active is False
assert search.query == ""
# Esc with no query: still stops search, but nothing changed.
search2 = _SearchState(active=True, query="")
assert _handle_active_search_key(_FakeCurses, 27, search2) == (True, False, False)
def test_high_byte_keys_ignored():
# Bytes 128-255 must NOT append Latin-1 mojibake to the query.
search = _SearchState(active=True, query="ab")
handled, _, changed = _handle_active_search_key(_FakeCurses, 200, search)
assert (handled, changed) == (False, False)
assert search.query == "ab"
def test_fuzzy_score_empty_query_is_zero():
assert _fuzzy_score("anything", "") == 0
assert _fuzzy_score("anything", " ") == 0
def test_fuzzy_score_prefix_beats_scattered():
prefix = _fuzzy_score("gpt-4o-mini", "gpt")
scattered = _fuzzy_score("a-g-p-t", "gpt")
assert prefix is not None and scattered is not None
assert prefix > scattered
def test_fuzzy_score_exact_and_shorter_rank_higher():
exact = _fuzzy_score("sonnet", "sonnet")
longer = _fuzzy_score("sonnet-extended", "sonnet")
assert exact is not None and longer is not None
# Same prefix match, but the shorter id wins on the length tiebreak.
assert exact > longer
def test_filter_indices_ranks_best_first():
models = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4", "claude-haiku", "o1-preview"]
# g4o matches both gpt-4o variants; the shorter exact-ish one ranks first.
ranked = _filter_indices(models, "g4o")
assert [models[i] for i in ranked] == ["gpt-4o", "gpt-4o-mini"]
# son4 surfaces the sonnet model.
assert [models[i] for i in _filter_indices(models, "son4")] == ["claude-sonnet-4"]
# Multi-token AND.
assert [models[i] for i in _filter_indices(models, "clad snnt")] == ["claude-sonnet-4"]
# No match drops everything.
assert _filter_indices(models, "zzz") == []
def test_filter_indices_blank_query_preserves_order():
models = ["b", "a", "c"]
assert _filter_indices(models, "") == [0, 1, 2]
assert _filter_indices(models, " ") == [0, 1, 2]
def test_filter_indices_stable_for_equal_scores():
# Identical labels score identically; original order is the tiebreak.
items = ["ab", "ab", "ab"]
assert _filter_indices(items, "ab") == [0, 1, 2]
+68
View File
@@ -0,0 +1,68 @@
from hermes_cli.curses_ui import (
_SearchState,
_filter_indices,
_handle_active_search_key,
_move_filtered_cursor,
_reconcile_cursor,
)
class _FakeCurses:
KEY_BACKSPACE = 263
KEY_DOWN = 258
KEY_ENTER = 343
def test_filter_indices_keeps_all_items_for_blank_query():
assert _filter_indices(["Anthropic", "OpenAI"], "") == [0, 1]
assert _filter_indices(["Anthropic", "OpenAI"], " ") == [0, 1]
def test_filter_indices_matches_subsequences():
items = ["claude-opus-4-7", "gpt-5.4-codex", "deepseek-v4"]
assert _filter_indices(items, "co47") == [0]
assert _filter_indices(items, "gpt5") == [1]
def test_filter_indices_requires_all_tokens():
items = ["OpenAI Codex", "OpenAI Chat Completions", "Anthropic Claude"]
assert _filter_indices(items, "open cod") == [0]
def test_reconcile_cursor_moves_to_first_visible_match():
assert _reconcile_cursor([2, 4], 0) == (2, 0)
assert _reconcile_cursor([2, 4], 4) == (4, 1)
def test_move_filtered_cursor_wraps_within_matches():
filtered = [2, 4, 7]
assert _move_filtered_cursor(filtered, 2, 0, -1) == 7
assert _move_filtered_cursor(filtered, 7, 2, 1) == 2
def test_active_search_allows_navigation_keys_to_reach_menu_loop():
search = _SearchState(active=True, query="opus")
assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_DOWN, search) == (
False,
False,
False,
)
assert search.active is True
assert search.query == "opus"
def test_active_search_consumes_query_editing_and_confirm_keys():
search = _SearchState(active=True, query="op")
assert _handle_active_search_key(_FakeCurses, ord("u"), search) == (True, False, True)
assert search.query == "opu"
assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_ENTER, search) == (
True,
True,
False,
)
@@ -74,6 +74,35 @@ class TestMcpEndpoints:
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
assert r.status_code == 400
def test_enable_disable_toggle(self):
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
srv = [
s for s in self.client.get("/api/mcp/servers").json()["servers"]
if s["name"] == "tog"
][0]
assert srv["enabled"] is False
# Toggling a missing server is a 404.
assert self.client.put(
"/api/mcp/servers/nope/enabled", json={"enabled": True}
).status_code == 404
def test_catalog_lists_entries(self):
r = self.client.get("/api/mcp/catalog")
assert r.status_code == 200
body = r.json()
assert "entries" in body and "diagnostics" in body
# The shipped optional-mcps/ catalog has at least one entry; each must
# carry the install/enabled status fields the UI relies on.
for e in body["entries"]:
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
def test_catalog_install_unknown_404(self):
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
assert r.status_code == 404
class TestCredentialPoolEndpoints:
@pytest.fixture(autouse=True)
@@ -190,6 +219,40 @@ class TestOpsEndpoints:
save_config(cfg)
data = self.client.get("/api/ops/hooks").json()
assert data["hooks"][0]["command"] == "/bin/echo hi"
assert "valid_events" in data and len(data["valid_events"]) >= 1
def test_hook_create_and_delete(self):
# Create with consent approval.
r = self.client.post(
"/api/ops/hooks",
json={
"event": "pre_tool_call",
"command": "/bin/echo created",
"matcher": "terminal",
"timeout": 7,
"approve": True,
},
)
assert r.status_code == 200 and r.json()["approved"] is True
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
created = [h for h in hooks if h["command"] == "/bin/echo created"]
assert created and created[0]["allowed"] is True
# Unknown event rejected.
assert self.client.post(
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
).status_code == 400
# Delete it.
r = self.client.request(
"DELETE",
"/api/ops/hooks",
json={"event": "pre_tool_call", "command": "/bin/echo created"},
)
assert r.status_code == 200
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
def test_checkpoints_list_empty(self):
data = self.client.get("/api/ops/checkpoints").json()
@@ -200,6 +263,131 @@ class TestOpsEndpoints:
assert r.status_code == 404
class TestSystemStatsEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_stats_shape(self):
r = self.client.get("/api/system/stats")
assert r.status_code == 200
s = r.json()
# Identity fields always present (stdlib-sourced).
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
assert key in s and s[key]
# psutil flag tells the UI whether the richer metrics are populated.
assert "psutil" in s
class TestCuratorEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_status_and_pause_toggle(self):
r = self.client.get("/api/curator")
assert r.status_code == 200
body = r.json()
assert {"enabled", "paused", "interval_hours"} <= set(body)
# Pause then resume; the read reflects the write.
r = self.client.put("/api/curator/paused", json={"paused": True})
assert r.status_code == 200 and r.json()["paused"] is True
assert self.client.get("/api/curator").json()["paused"] is True
r = self.client.put("/api/curator/paused", json={"paused": False})
assert r.status_code == 200 and r.json()["paused"] is False
class TestPortalEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_status_shape(self):
r = self.client.get("/api/portal")
assert r.status_code == 200
body = r.json()
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
assert isinstance(body["features"], list)
class TestSessionManagementEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
from hermes_state import SessionDB
db = SessionDB()
db.create_session(session_id="sess-x", source="cli")
db.close()
def test_stats_not_shadowed_by_session_id_route(self):
# /api/sessions/stats must resolve to the stats handler, not be captured
# as {session_id}="stats" by the parameterized route registered after it.
r = self.client.get("/api/sessions/stats")
assert r.status_code == 200
body = r.json()
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
assert body["total"] >= 1
def test_rename(self):
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
assert r.status_code == 200 and r.json()["title"] == "Renamed"
def test_export(self):
r = self.client.get("/api/sessions/sess-x/export")
assert r.status_code == 200 and "messages" in r.json()
assert self.client.get("/api/sessions/nope/export").status_code == 404
def test_prune_validation(self):
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
assert r.status_code == 200 and "removed" in r.json()
assert self.client.post(
"/api/sessions/prune", json={"older_than_days": 0}
).status_code == 400
class TestSkillsHubSearchEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_empty_query_returns_empty(self):
# Empty query short-circuits (no network) and returns no results.
r = self.client.get("/api/skills/hub/search?q=")
assert r.status_code == 200 and r.json() == {"results": []}
class TestWebhookToggleEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
# Enable the webhook platform so a subscription can be created.
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("platforms", {})["webhook"] = {
"enabled": True,
"extra": {"host": "0.0.0.0", "port": 8644},
}
save_config(cfg)
def test_create_toggle_disable(self):
r = self.client.post(
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
)
assert r.status_code == 200 and r.json()["enabled"] is True
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
subs = self.client.get("/api/webhooks").json()["subscriptions"]
assert subs[0]["enabled"] is False
assert self.client.put(
"/api/webhooks/nope/enabled", json={"enabled": True}
).status_code == 404
class TestAdminEndpointsAuthGate:
"""Every admin endpoint must sit behind the dashboard session-token gate."""
@@ -221,6 +409,9 @@ class TestAdminEndpointsAuthGate:
"/api/memory",
"/api/ops/hooks",
"/api/ops/checkpoints",
"/api/curator",
"/api/portal",
"/api/system/stats",
],
)
def test_gated(self, path):
+116 -27
View File
@@ -361,28 +361,6 @@ def _stub_s6(monkeypatch: pytest.MonkeyPatch, *, on_s6: bool) -> _CallRecorder:
return rec
class _ExecvpCalled(BaseException):
"""Sentinel raised by the os.execvp stub so tests can assert on it
without actually replacing the test runner process. Inherits from
BaseException so it bypasses generic ``except Exception`` blocks in
the code under test (just like a real exec would)."""
def __init__(self, argv: list[str]) -> None:
self.argv = argv
def _stub_execvp(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
"""Replace os.execvp with a recorder that raises _ExecvpCalled."""
calls: list[list[str]] = []
def fake_execvp(file: str, args: list[str]) -> None: # noqa: ANN401
calls.append([file, *args])
raise _ExecvpCalled([file, *args])
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
return calls
def test_redirect_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
"""Host runs (non-s6) must not redirect. Returns False; caller
continues to the foreground gateway code path unchanged."""
@@ -407,14 +385,31 @@ def test_redirect_fires_inside_s6_container(
1. Dispatch `start` to the service manager.
2. Print the loud breadcrumb to stderr.
3. exec `sleep infinity` to keep the CMD alive without binding
container lifetime to gateway PID lifetime.
3. exec `sleep infinity` to keep the CMD alive (the cheap heartbeat;
no resident Python interpreter) without binding container
lifetime to gateway PID lifetime.
"""
from hermes_cli import gateway as gw
rec = _stub_s6(monkeypatch, on_s6=True)
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
execvp_calls = _stub_execvp(monkeypatch)
class _ExecvpCalled(BaseException):
def __init__(self, argv: list[str]) -> None:
self.argv = argv
execvp_calls: list[list[str]] = []
def fake_execvp(file: str, args: list[str]) -> None:
execvp_calls.append([file, *args])
raise _ExecvpCalled([file, *args])
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
# If the fallback ran, the normal sleep path was wrongly skipped.
monkeypatch.setattr(
"hermes_cli.gateway._block_until_terminated",
lambda: pytest.fail("fallback should not run when sleep is available"),
)
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
@@ -428,11 +423,90 @@ def test_redirect_fires_inside_s6_container(
assert "s6 supervision" in err
assert "--no-supervise" in err
assert "HERMES_GATEWAY_NO_SUPERVISE" in err
# 3. exec'd `sleep infinity`.
# 3. exec'd `sleep infinity` (the preferred cheap heartbeat).
assert execvp_calls == [["sleep", "sleep", "infinity"]]
assert excinfo.value.argv == ["sleep", "sleep", "infinity"]
def test_redirect_falls_back_when_sleep_missing(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
"""Regression guard for issue #36208: when ``os.execvp("sleep", ...)``
raises (no `sleep` on a clobbered/empty PATH, or a minimal image
without it), the redirect must NOT crash the container it falls
back to the in-process ``_block_until_terminated`` heartbeat so the
container keeps running.
"""
from hermes_cli import gateway as gw
rec = _stub_s6(monkeypatch, on_s6=True)
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
def missing_sleep(file: str, args: list[str]) -> None:
raise FileNotFoundError(2, "No such file or directory", file)
monkeypatch.setattr("hermes_cli.gateway.os.execvp", missing_sleep)
block_calls: list[bool] = []
monkeypatch.setattr(
"hermes_cli.gateway._block_until_terminated",
lambda: block_calls.append(True),
)
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
# Must not raise FileNotFoundError — that was the #36208 crash.
result = gw._maybe_redirect_run_to_s6_supervision(_Args())
assert result is True
assert rec.calls == [("start", "gateway-default")]
# Fell back to the in-process heartbeat instead of crashing.
assert block_calls == [True]
err = capsys.readouterr().err
assert "`sleep` is unavailable" in err
def test_block_until_terminated_installs_sigterm_handler_and_blocks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``_block_until_terminated`` must register a SIGTERM handler (so
`docker stop` exits cleanly) and then block on signal.pause() never
touching an external binary. Regression guard for issue #36208, where
os.execvp("sleep", ...) crashed the container with FileNotFoundError
when PATH lacked a directory containing `sleep`.
"""
import signal as _signal
from hermes_cli import gateway as gw
registered: dict[int, object] = {}
monkeypatch.setattr(
"hermes_cli.gateway.signal.signal",
lambda signum, handler: registered.__setitem__(signum, handler),
)
# Make signal.pause() raise after the first call so the infinite loop
# terminates deterministically instead of hanging the test.
pause_calls = {"n": 0}
def fake_pause() -> None:
pause_calls["n"] += 1
raise KeyboardInterrupt # break out of the `while True: pause()` loop
monkeypatch.setattr("hermes_cli.gateway.signal.pause", fake_pause)
with pytest.raises(KeyboardInterrupt):
gw._block_until_terminated()
# A SIGTERM handler was installed...
assert _signal.SIGTERM in registered
# ...and it exits with the conventional 128+signum code.
handler = registered[_signal.SIGTERM]
with pytest.raises(SystemExit) as exc:
handler(_signal.SIGTERM, None) # type: ignore[operator]
assert exc.value.code == 128 + _signal.SIGTERM
# ...and we actually blocked on pause().
assert pause_calls["n"] == 1
def test_redirect_short_circuits_supervised_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -516,10 +590,25 @@ def test_redirect_no_supervise_env_falsy_values_dont_opt_out(
_stub_s6(monkeypatch, on_s6=True)
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
_stub_execvp(monkeypatch)
# The redirect reaching its `sleep` heartbeat means it did NOT opt
# out. Stub execvp to record + raise (so it doesn't replace the test
# process) rather than actually exec.
class _ExecvpCalled(BaseException):
pass
execvp_calls: list[str] = []
def fake_execvp(file: str, args: list[str]) -> None:
execvp_calls.append(file)
raise _ExecvpCalled
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
for falsy in ("", "0", "false", "no", "off", "garbage"):
execvp_calls.clear()
monkeypatch.setenv("HERMES_GATEWAY_NO_SUPERVISE", falsy)
with pytest.raises(_ExecvpCalled):
gw._maybe_redirect_run_to_s6_supervision(_Args())
assert execvp_calls == ["sleep"], f"redirect should fire for {falsy!r}"
@@ -166,3 +166,65 @@ def test_decompose_records_audit_comment_and_event(kanban_home):
assert any("Decomposed into" in (c.body or "") for c in comments)
assert any(ev.kind == "decomposed" for ev in events)
def test_decompose_children_inherit_dir_workspace(kanban_home):
"""Fan-out children inherit the root's dir workspace, not scratch."""
proj = "/home/teknium/myproject"
with kb.connect() as conn:
tid = kb.create_task(
conn, title="codegen root", assignee="worker",
workspace_kind="dir", workspace_path=proj, triage=True,
)
child_ids = kb.decompose_triage_task(
conn, tid, root_assignee="orchestrator",
children=[{"title": "part A"}, {"title": "part B", "parents": [0]}],
author="decomposer",
)
assert child_ids and len(child_ids) == 2
with kb.connect() as conn:
for cid in child_ids:
t = kb.get_task(conn, cid)
assert t.workspace_kind == "dir"
assert t.workspace_path == proj
def test_decompose_children_stay_scratch_when_root_scratch(kanban_home):
"""No regression: a scratch root still fans out into scratch children."""
with kb.connect() as conn:
tid = kb.create_task(
conn, title="scratch root", assignee="worker",
workspace_kind="scratch", triage=True,
)
child_ids = kb.decompose_triage_task(
conn, tid, root_assignee="orchestrator",
children=[{"title": "s1"}], author="decomposer",
)
with kb.connect() as conn:
t = kb.get_task(conn, child_ids[0])
assert t.workspace_kind == "scratch"
assert t.workspace_path is None
def test_decompose_per_child_workspace_override(kanban_home):
"""An explicit per-child workspace beats inheritance."""
proj = "/home/teknium/myproject"
with kb.connect() as conn:
tid = kb.create_task(
conn, title="root", assignee="worker",
workspace_kind="dir", workspace_path=proj, triage=True,
)
child_ids = kb.decompose_triage_task(
conn, tid, root_assignee="orchestrator",
children=[
{"title": "override", "workspace_kind": "dir",
"workspace_path": "/other/repo"},
{"title": "inherit"},
],
author="decomposer",
)
with kb.connect() as conn:
over = kb.get_task(conn, child_ids[0])
inh = kb.get_task(conn, child_ids[1])
assert over.workspace_path == "/other/repo"
assert inh.workspace_path == proj
@@ -13,7 +13,7 @@ def test_prompt_model_selection_uses_curses_radiolist():
seen = {}
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
seen["title"] = title
seen["items"] = items
return 1 # pick second model
@@ -67,7 +67,7 @@ def test_model_selection_with_pricing_passes_description():
seen = {}
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
seen["description"] = description
return len(items) - 1 # Skip
@@ -254,8 +254,13 @@ def test_openai_native_curated_catalog_is_non_empty():
assert len(_PROVIDER_MODELS["openai"]) >= 4
def test_list_authenticated_providers_openai_built_in_nonzero_total(monkeypatch):
"""Built-in openai row must not report total_models=0 when creds exist."""
def test_list_authenticated_providers_openai_alias_not_emitted_as_phantom(monkeypatch):
"""Bare 'openai' is an alias to the OpenRouter aggregator, NOT a directly-
routable provider. It must NOT be emitted as its own picker row: selecting
such a row resolves via resolve_provider_full() to OpenRouter, silently
switching the user onto an endpoint they may have no key for (HTTP 401).
Real OpenAI access comes via 'openai-api' (direct) or a providers.openai
config entry both of which carry api.openai.com. See model-picker bug."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(
"agent.models_dev.fetch_models_dev",
@@ -271,8 +276,63 @@ def test_list_authenticated_providers_openai_built_in_nonzero_total(monkeypatch)
max_models=50,
)
row = next((p for p in providers if p.get("slug") == "openai"), None)
assert row is not None
assert row["total_models"] > 0
assert row is None, (
"bare 'openai' alias must not appear as a standalone picker row — "
"it routes through OpenRouter and traps users without an OR key"
)
def test_resolve_provider_full_user_config_openai_beats_alias():
"""A providers.openai config entry must win over the built-in
'openai' 'openrouter' alias. Regression for the model-picker bug
where users with provider=openai-api + a providers.openai config block
had their OpenAI selection silently routed to OpenRouter (HTTP 401)."""
from hermes_cli.providers import resolve_provider_full
user_providers = {
"openai": {
"name": "OpenAI-API",
"api": "https://api.openai.com/v1",
"transport": "codex_responses",
"models": {"gpt-5.4-nano": {}},
}
}
pdef = resolve_provider_full("openai", user_providers, [])
assert pdef is not None
# Must resolve to the user's direct endpoint, NOT the OpenRouter aggregator.
assert pdef.id == "openai"
assert pdef.source == "user-config"
assert pdef.base_url == "https://api.openai.com/v1"
assert "openrouter" not in pdef.base_url
def test_switch_model_user_config_openai_does_not_hop_to_openrouter(monkeypatch):
"""End-to-end: selecting a providers.openai config row in the picker must
resolve to api.openai.com, never silently switch to OpenRouter."""
monkeypatch.setenv("CUSTOM_OPENAI_API_KEY", "sk-resolved")
user_providers = {
"openai": {
"name": "OpenAI-API",
"api": "https://api.openai.com/v1",
"api_key": "${CUSTOM_OPENAI_API_KEY}",
"transport": "codex_responses",
"models": {"gpt-5.4-nano": {}, "gpt-4o-mini": {}},
}
}
result = switch_model(
raw_input="gpt-4o-mini",
current_provider="openai-api",
current_model="gpt-5.4-nano",
current_base_url="https://api.openai.com/v1",
current_api_key="sk-test",
explicit_provider="openai",
user_providers=user_providers,
custom_providers=[],
)
assert result.success, result.error_message
assert result.target_provider != "openrouter"
assert "openrouter" not in (result.base_url or "")
assert result.base_url == "https://api.openai.com/v1"
def test_list_authenticated_providers_user_openai_official_url_fallback(monkeypatch):
+72 -2
View File
@@ -187,11 +187,11 @@ class TestWebServerEndpoints:
def __init__(self, *args, **kwargs):
pass
def list_sessions_rich(self, limit, offset, min_message_count=0):
def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):
captured["list"] = min_message_count
return []
def session_count(self, min_message_count=0):
def session_count(self, min_message_count=0, **kwargs):
captured["count"] = min_message_count
return 0
@@ -250,6 +250,76 @@ class TestWebServerEndpoints:
resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
assert resp.status_code == 404
def test_archive_session_via_patch(self):
"""PATCH archived=true soft-hides a session; archived=false restores it."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="arch-me", source="cli")
db.append_message(session_id="arch-me", role="user", content="hi")
finally:
db.close()
resp = self.client.patch("/api/sessions/arch-me", json={"archived": True})
assert resp.status_code == 200
assert resp.json()["archived"] is True
# Hidden from the default list, surfaced by archived=only.
listed = self.client.get("/api/sessions").json()
assert all(s["id"] != "arch-me" for s in listed["sessions"])
only = self.client.get("/api/sessions?archived=only").json()
assert any(s["id"] == "arch-me" for s in only["sessions"])
resp = self.client.patch("/api/sessions/arch-me", json={"archived": False})
assert resp.status_code == 200
restored = self.client.get("/api/sessions").json()
assert any(s["id"] == "arch-me" for s in restored["sessions"])
def test_patch_session_without_fields_is_400(self):
"""An existing session + empty body is a bad request, not a 404."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="no-fields", source="cli")
finally:
db.close()
resp = self.client.patch("/api/sessions/no-fields", json={})
assert resp.status_code == 400
def test_get_sessions_rejects_unknown_archived_value(self):
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400
def test_get_sessions_archived_is_boolean(self):
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="bool-arch", source="cli")
db.append_message(session_id="bool-arch", role="user", content="hi")
finally:
db.close()
row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch")
assert row["archived"] is False
def test_rename_response_omits_archived_when_not_set(self):
"""Title-only PATCH keeps its legacy {ok, title} response shape."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="title-only", source="cli")
finally:
db.close()
resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"})
assert resp.status_code == 200
assert "archived" not in resp.json()
def test_audio_transcription_endpoint(self, monkeypatch):
import tools.transcription_tools as transcription_tools
@@ -25,6 +25,43 @@ def test_xai_provider_registers():
assert provider.default_model() == "grok-imagine-video"
def test_xai_provider_lists_text_and_current_image_video_models():
from plugins.video_gen.xai import XAIVideoGenProvider
models = XAIVideoGenProvider().list_models()
ids = [model["id"] for model in models]
assert ids[0] == "grok-imagine-video"
assert ids[1] == "grok-imagine-video-1.5-preview"
assert models[1]["modalities"] == ["image"]
assert models[1]["aliases"] == ["grok-imagine-video-1.5-2026-05-30"]
def test_xai_routes_default_models_by_modality():
from plugins.video_gen.xai import _resolve_model_for_modality
assert _resolve_model_for_modality(
"grok-imagine-video",
modality="text",
explicit_model=False,
) == "grok-imagine-video"
assert _resolve_model_for_modality(
"grok-imagine-video",
modality="image",
explicit_model=False,
) == "grok-imagine-video-1.5-preview"
assert _resolve_model_for_modality(
"grok-imagine-video-1.5-preview",
modality="text",
explicit_model=False,
) == "grok-imagine-video"
assert _resolve_model_for_modality(
"grok-imagine-video-1.5-preview",
modality="text",
explicit_model=True,
) == "grok-imagine-video-1.5-preview"
def test_xai_capabilities_text_and_image_only():
"""xAI was previously advertised with edit/extend operations. The
simplified surface only exposes text-to-video and image-to-video
@@ -56,7 +56,7 @@ class _FakeAsyncClient:
return _FakeResponse(200, {
"status": "done",
"video": {"url": "https://xai-cdn/out.mp4", "duration": 8},
"model": "grok-imagine-video",
"model": self.posts[-1]["json"]["model"],
})
@@ -113,6 +113,7 @@ class TestXAIPayload:
provider, captured = xai_provider
provider.generate("a dog at sunset")
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video"
assert payload["prompt"] == "a dog at sunset"
assert "image" not in payload
assert "reference_images" not in payload
@@ -121,8 +122,31 @@ class TestXAIPayload:
provider, captured = xai_provider
provider.generate("animate this", image_url="https://example.com/cat.png")
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video-1.5-preview"
assert payload["image"] == {"url": "https://example.com/cat.png"}
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
provider, captured = xai_provider
image_path = tmp_path / "frame.png"
image_path.write_bytes(b"\x89PNG\r\n\x1a\nfake")
provider.generate("animate this", image_url=str(image_path))
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video-1.5-preview"
assert payload["image"]["url"].startswith("data:image/png;base64,")
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
provider, captured = xai_provider
provider.generate(
"animate this",
image_url="https://example.com/cat.png",
model="grok-imagine-video",
_model_override_explicit=True,
)
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video"
def test_reference_images_payload(self, xai_provider):
provider, captured = xai_provider
provider.generate(
@@ -5,6 +5,37 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
MAIN_WRAPPER = REPO_ROOT / "docker" / "main-wrapper.sh"
def test_main_wrapper_preserves_docker_workdir() -> None:
"""The main-wrapper MUST save and restore the original working
directory so the container starts in the Docker ``-w`` directory,
not /opt/data. Regression test for #35472.
"""
text = MAIN_WRAPPER.read_text(encoding="utf-8")
# Must save original cwd before cd /opt/data.
assert "_hermes_orig_cwd" in text, (
"main-wrapper.sh must save the original cwd before cd /opt/data"
)
assert 'HERMES_ORIG_CWD:-$PWD' in text, (
"main-wrapper.sh must capture PWD as the fallback original cwd"
)
# Must cd to /opt/data for init (existing behaviour preserved).
assert "cd /opt/data" in text
# Must restore original cwd before exec'ing the user command.
# The restore cd must appear AFTER venv activation but BEFORE the
# first exec / if-block.
activate_idx = text.index("/opt/hermes/.venv/bin/activate")
restore_idx = text.index('cd "$_hermes_orig_cwd"')
exec_idx = text.index("if [ $# -eq 0 ]")
assert activate_idx < restore_idx < exec_idx, (
"cd $_hermes_orig_cwd must appear after venv activation and "
"before the exec routing block"
)
def test_dashboard_run_resets_home_before_dropping_privileges() -> None:
+40
View File
@@ -3509,3 +3509,43 @@ class TestApplyWalProbe:
assert any("journal_mode=WAL" in sql for sql in conn.executed), (
"set-pragma must fire when probe returns 'delete'"
)
class TestSessionArchive:
"""Soft-archiving hides a session from default listings without deleting it."""
def _seed(self, db, sid, *, archived=False):
db.create_session(session_id=sid, source="cli")
db.append_message(session_id=sid, role="user", content=f"hello from {sid}")
if archived:
db.set_session_archived(sid, True)
def test_set_session_archived_roundtrip(self, db):
self._seed(db, "s1")
assert db.set_session_archived("s1", True) is True
assert db.get_session("s1")["archived"] == 1
assert db.set_session_archived("s1", False) is True
assert db.get_session("s1")["archived"] == 0
def test_set_session_archived_missing_row(self, db):
assert db.set_session_archived("nope", True) is False
def test_archived_excluded_by_default(self, db):
self._seed(db, "live")
self._seed(db, "hidden", archived=True)
ids = [s["id"] for s in db.list_sessions_rich()]
assert ids == ["live"]
assert db.session_count() == 1
def test_archived_only_and_include(self, db):
self._seed(db, "live")
self._seed(db, "hidden", archived=True)
only = [s["id"] for s in db.list_sessions_rich(archived_only=True)]
assert only == ["hidden"]
assert db.session_count(archived_only=True) == 1
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
assert both == {"live", "hidden"}
assert db.session_count(include_archived=True) == 2
+357
View File
@@ -0,0 +1,357 @@
"""Regression tests for Honcho startup fail-open behavior."""
from __future__ import annotations
import json
import threading
import time
from types import SimpleNamespace
from plugins.memory.honcho import HonchoMemoryProvider
class _FakeHonchoConfig(SimpleNamespace):
def resolve_session_name(self, **kwargs):
return "test-session"
def _configured_hybrid_config() -> _FakeHonchoConfig:
return _FakeHonchoConfig(
enabled=True,
api_key=None,
base_url="http://127.0.0.1:8000",
recall_mode="hybrid",
init_on_session_start=False,
dialectic_depth=1,
dialectic_depth_levels=None,
reasoning_heuristic=True,
reasoning_level_cap="high",
context_tokens=None,
message_max_chars=25000,
session_strategy="per-directory",
)
def _configured_tools_config(*, init_on_session_start: bool = False) -> _FakeHonchoConfig:
cfg = _configured_hybrid_config()
cfg.recall_mode = "tools"
cfg.init_on_session_start = init_on_session_start
return cfg
def test_honcho_hybrid_initialize_returns_without_waiting_for_session_init(monkeypatch):
"""Slow Honcho session creation must not block agent startup."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
started = threading.Event()
release = threading.Event()
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def slow_session_init(self, cfg, session_id, **kwargs):
started.set()
release.wait(timeout=5)
self._session_initialized = True
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", slow_session_init)
start = time.perf_counter()
provider.initialize("session-1", platform="cli")
elapsed = time.perf_counter() - start
try:
assert elapsed < 0.5
assert started.wait(timeout=1)
assert provider._session_key == "test-session"
finally:
release.set()
init_thread = getattr(provider, "_init_thread", None)
if init_thread:
init_thread.join(timeout=1)
def test_honcho_background_init_rechecks_state_after_lock_race():
"""Startup should not spawn/crash if init completes while waiting for lock."""
provider = HonchoMemoryProvider()
provider._config = _configured_hybrid_config()
provider._lazy_init_kwargs = {"platform": "cli"}
provider._lazy_init_session_id = "session-1"
class RacingLock:
def __enter__(self):
provider._session_initialized = True
provider._lazy_init_kwargs = None
return self
def __exit__(self, exc_type, exc, tb):
return False
provider._init_lock = RacingLock()
provider._start_session_init_background()
assert provider._init_thread is None
assert provider._session_initialized is True
def test_honcho_prefetch_returns_without_waiting_for_first_context_fetch():
"""First-turn context injection must fail open when Honcho is slow."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
cfg.timeout = 0.1
fetch_started = threading.Event()
class SlowManager:
def get_prefetch_context(self, session_key, user_message=None):
fetch_started.set()
time.sleep(5)
return {"representation": "late"}
def prefetch_context(self, session_key, user_message=None):
fetch_started.set()
def pop_context_result(self, session_key):
return {}
provider._config = cfg
provider._manager = SlowManager()
provider._session_key = "test-session"
provider._session_initialized = True
provider._turn_count = 1
start = time.perf_counter()
result = provider.prefetch("what do you know about me?")
elapsed = time.perf_counter() - start
assert result == ""
assert elapsed < 0.5
assert fetch_started.is_set()
def test_honcho_sync_turn_does_not_start_network_write_before_session_init():
"""Session-end sync must not create a blocking writer before init finishes."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
get_started = threading.Event()
background_started = threading.Event()
release_init = threading.Event()
class SlowManager:
def get_or_create(self, session_key):
get_started.set()
time.sleep(5)
return SimpleNamespace()
def _flush_session(self, session):
pass
provider._config = cfg
provider._manager = SlowManager()
provider._session_key = "test-session"
provider._session_initialized = False
provider._start_session_init_background = background_started.set
provider._init_thread = threading.Thread(
target=lambda: release_init.wait(timeout=5), daemon=True
)
provider._init_thread.start()
try:
provider.sync_turn("hello", "world")
assert provider._sync_thread is None
assert background_started.is_set()
assert not get_started.wait(timeout=0.1)
finally:
release_init.set()
provider._init_thread.join(timeout=1)
def test_honcho_sync_turn_waits_for_full_background_startup(monkeypatch):
"""Manager assignment alone is not readiness while background init continues."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
session_created = threading.Event()
migration_started = threading.Event()
release_migration = threading.Event()
get_calls = []
class StartupManager:
def __init__(self, *args, **kwargs):
pass
def get_or_create(self, session_key):
get_calls.append(session_key)
session_created.set()
return SimpleNamespace(messages=[])
def migrate_memory_files(self, session_key, mem_dir):
migration_started.set()
release_migration.wait(timeout=5)
def prefetch_context(self, session_key, user_message=None):
pass
def _flush_session(self, session):
pass
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object())
monkeypatch.setattr("plugins.memory.honcho.session.HonchoSessionManager", StartupManager)
provider.initialize("session-1", platform="cli")
try:
assert session_created.wait(timeout=1)
assert migration_started.wait(timeout=1)
assert provider._manager is not None
assert provider._session_initialized is False
provider.sync_turn("hello", "world")
assert provider._sync_thread is None
assert get_calls == ["test-session"]
finally:
release_migration.set()
init_thread = getattr(provider, "_init_thread", None)
if init_thread:
init_thread.join(timeout=1)
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=1)
assert provider._session_initialized is True
def test_honcho_system_prompt_advertises_active_while_background_init_runs(monkeypatch):
"""Prompt metadata should not require a completed network session."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
release = threading.Event()
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def slow_session_init(self, cfg, session_id, **kwargs):
release.wait(timeout=5)
self._session_initialized = True
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", slow_session_init)
provider.initialize("session-1", platform="cli")
try:
prompt = provider.system_prompt_block()
assert "Honcho Memory" in prompt
assert "hybrid mode" in prompt
finally:
release.set()
init_thread = getattr(provider, "_init_thread", None)
if init_thread:
init_thread.join(timeout=1)
def test_honcho_tools_eager_init_still_ready_on_return(monkeypatch):
"""tools + initOnSessionStart=true keeps its ready-on-return contract."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def fake_session_init(self, cfg, session_id, **kwargs):
self._manager = SimpleNamespace()
self._session_key = "test-session"
self._session_initialized = True
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", fake_session_init)
provider.initialize("session-1", platform="cli")
assert provider._session_initialized is True
assert provider._manager is not None
assert provider._init_thread is None
def test_honcho_tools_eager_init_failure_does_not_leave_ready_manager(monkeypatch):
"""Failed eager tools startup must not leave hooks seeing a ready session."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def failing_session_init(self, cfg, session_id, **kwargs):
self._manager = SimpleNamespace()
self._session_key = "test-session"
raise RuntimeError("boom")
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", failing_session_init)
provider.initialize("session-1", platform="cli")
assert provider._session_initialized is False
assert provider._manager is None
background_started = threading.Event()
provider._start_session_init_background = background_started.set
provider.sync_turn("hello", "world")
provider.on_memory_write("add", "user", "prefers safe Honcho startup")
assert provider._sync_thread is None
assert not background_started.is_set()
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
assert "could not be initialized" in result["error"]
assert provider._manager is None
def test_honcho_tools_lazy_hooks_do_not_prestart_background_init(monkeypatch):
"""tools lazy mode lets the first tool call own session initialization."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=False)
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
provider.initialize("session-1", platform="cli")
background_started = threading.Event()
provider._start_session_init_background = background_started.set
provider.prefetch("what do you know?")
provider.queue_prefetch("what do you know?")
provider.sync_turn("hello", "world")
provider.on_memory_write("add", "user", "prefers fail-open memory")
assert not background_started.is_set()
assert provider._session_initialized is False
class ToolManager:
def get_peer_card(self, session_key, peer="user"):
return ["ready"]
init_calls = []
def fake_session_init(self, cfg, session_id, **kwargs):
init_calls.append(session_id)
self._manager = ToolManager()
self._session_key = "test-session"
self._session_initialized = True
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", fake_session_init)
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
assert result == {"result": ["ready"]}
assert init_calls == ["session-1"]
assert not background_started.is_set()
+78 -2
View File
@@ -884,6 +884,73 @@ def test_session_title_queues_when_db_row_not_ready(monkeypatch):
server._sessions.pop("sid", None)
def test_notification_event_routing_by_session_key(monkeypatch):
"""Background-process events surface only in the session that owns them."""
mine = _session(session_key="mine")
other = _session(session_key="other")
monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})
# My own event → handle it.
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False
# Global/system event with no owner → handle it.
assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False
assert server._notification_event_belongs_elsewhere(mine, {}) is False
# Owned by another *live* session → defer to that session's poller.
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True
# Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False
def test_session_create_does_not_persist_empty_row(monkeypatch):
"""session.create must NOT eagerly write a DB row.
Every TUI/desktop launch opens a session here just to paint the composer;
eagerly creating a row left an empty "Untitled" session behind for every
launch the user never typed into. The row is created lazily on first prompt.
"""
created = []
class _FakeDB:
def create_session(self, *args, **kwargs):
created.append((args, kwargs))
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
monkeypatch.setattr(
server.threading,
"Timer",
lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),
)
resp = server.handle_request(
{"id": "1", "method": "session.create", "params": {"cols": 80}}
)
sid = resp["result"]["session_id"]
try:
assert resp["result"]["stored_session_id"]
assert created == [], "session.create should not persist an empty DB row"
finally:
server._sessions.pop(sid, None)
def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
"""First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
]
def test_session_title_clears_pending_after_persist(monkeypatch):
class _FakeDB:
def __init__(self):
@@ -1666,12 +1733,21 @@ def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypa
assert resp["result"]["provider"] == "bedrock"
def test_complete_slash_includes_provider_alias():
def test_complete_slash_drops_removed_provider_alias():
# `/provider` was folded into a single `/model` command, so autocomplete
# must no longer offer the dead alias...
resp = server.handle_request(
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
)
assert any(item["text"] == "provider" for item in resp["result"]["items"])
assert not any(item["text"] == "provider" for item in resp["result"]["items"])
# ...while `/model` stays the canonical command.
resp_model = server.handle_request(
{"id": "2", "method": "complete.slash", "params": {"text": "/mod"}}
)
assert any(item["text"] == "model" for item in resp_model["result"]["items"])
def test_complete_slash_returns_plain_string_fields():
+57 -1
View File
@@ -389,6 +389,57 @@ class TestTeePattern:
assert key is None
class TestHermesConfigWriteProtection:
"""Terminal-side pairing for the file_tools write_file/patch deny on
~/.hermes/config.yaml (#14639). config.yaml IS the security policy
(approvals.mode/yolo live there, mtime-keyed cache reloads mid-session),
so a write_file deny without terminal-side coverage is unpaired theater.
These pin every terminal write idiom against the config file."""
def test_redirect_overwrite(self):
dangerous, key, desc = detect_dangerous_command("echo 'approvals:' > ~/.hermes/config.yaml")
assert dangerous is True
assert key is not None
def test_append(self):
dangerous, key, desc = detect_dangerous_command("echo ' mode: off' >> ~/.hermes/config.yaml")
assert dangerous is True
def test_tee(self):
dangerous, key, desc = detect_dangerous_command("echo x | tee ~/.hermes/config.yaml")
assert dangerous is True
def test_cp_over_config(self):
dangerous, key, desc = detect_dangerous_command("cp /tmp/evil.yaml ~/.hermes/config.yaml")
assert dangerous is True
def test_sed_in_place(self):
# The gap the pairing closes: sed -i mutates the file directly,
# bypassing the redirection/tee patterns.
dangerous, key, desc = detect_dangerous_command("sed -i 's/manual/off/' ~/.hermes/config.yaml")
assert dangerous is True
assert "hermes config" in desc.lower() or "in-place" in desc.lower()
def test_sed_in_place_long_flag(self):
dangerous, key, desc = detect_dangerous_command("sed --in-place 's/manual/off/' ~/.hermes/config.yaml")
assert dangerous is True
def test_custom_hermes_home(self):
dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/config.yaml")
assert dangerous is True
def test_read_is_safe(self):
# Reading config is not a write — must not trip.
dangerous, key, desc = detect_dangerous_command("cat ~/.hermes/config.yaml")
assert dangerous is False
def test_normal_yaml_write_safe(self):
# A non-Hermes config.yaml in a project dir is handled by the project
# patterns, but a plain temp write must not false-positive.
dangerous, key, desc = detect_dangerous_command("echo data > /tmp/scratch.txt")
assert dangerous is False
class TestFindExecFullPathRm:
"""Detect find -exec with full-path rm bypasses."""
@@ -1344,11 +1395,16 @@ class TestApprovalTimeoutIsNotConsent:
self._saved_env = {
k: os.environ.get(k)
for k in ("HERMES_GATEWAY_SESSION", "HERMES_YOLO_MODE",
for k in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
"HERMES_YOLO_MODE",
"HERMES_SESSION_KEY", "HERMES_INTERACTIVE")
}
os.environ.pop("HERMES_YOLO_MODE", None)
os.environ.pop("HERMES_INTERACTIVE", None)
# HERMES_CRON_SESSION takes priority over HERMES_GATEWAY_SESSION in
# _is_gateway_approval_context(); a leaked value from a parent cron
# process would force the cron path and break these gateway tests.
os.environ.pop("HERMES_CRON_SESSION", None)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_SESSION_KEY"] = self.SESSION_KEY

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