asRpcError now always wraps a non-string name in a fresh Error. In-place
assignment was a silent no-op on sealed objects in sloppy mode. Catch
only host.request / requestProfile so routing TypeErrors keep their stack.
JSON-RPC/IPC can reject with a plain object whose name is a number.
React 19 then crashes on (error.name || '').trim, which takes down the
Routines pane instead of showing the cron.manage failure. requestForBot
now wraps those values in an Error with a string name, including
cross-realm Error-like objects from the plugin test vm.
The SDK's focusedSessionOwner store fails closed to null whenever the
focused session has no unique bot owner (a normal chat, ambiguous owner
hints) - the common case while the user browses the Bots pane.
resolveRoutineOwner treated that null as an error and returned null
before consulting the roster selection, so the Routines pane pinned
every agent on 'Cronjobs are unavailable until this agent appears in
the roster.'
Drop the fail-closed null gate and fall through to the existing
selection ladder (focusedBot || selectedBot || ...). An authoritative
focused owner still wins through its exact roster row and still fails
closed when that row is absent; a null owner with no matching selection
also still fails closed. Regression tests prove red pre-fix, green
post-fix.
Fixes the two live E2E blockers @ctaylor86 found on PR #77189 (macOS 26.3.1,
OpenSSL 3.6.3):
- retry the PKCS#12 export with -legacy when security import rejects the
OpenSSL 3 default format ('MAC verification failed during PKCS12 import')
- trust the self-signed root for the codeSign policy (security
add-trusted-cert -r trustRoot -p codeSign) — an imported-but-untrusted cert
is invisible to find-identity -v and unusable by codesign
- gate success on find-identity -v -p codesigning (postcondition), and use
the same -v probe for idempotency so an untrusted leftover cert is repaired
instead of reported as done
Tests rewritten as stateful fakes (valid only after import+trust), plus new
coverage for the -legacy retry, trust failure, postcondition gate, and the
untrusted-cert repair path; sabotage-verified (reverting to the name-in-output
probe fails 4 tests). Docs: manual fallback now includes the Trust step.
Adds a one-shot `hermes desktop --setup-tcc-identity` command that creates a
self-signed code-signing certificate in the login keychain (openssl +
security import), grants codesign access to it, writes
desktop.macos_signing_identity to config.yaml, and re-signs the packaged app
with a certificate-anchored Designated Requirement.
macOS persists permission grants (Full Disk Access, Accessibility, Files and
Folders, microphone) against the app's code-signing identity, not its path.
The default identifier-pinned ad-hoc signature is stable across rebuilds, but
a certificate-anchored identity is the strongest guarantee — the same
mechanism yabai/skhd rely on. Previously users had to create the certificate
manually in Keychain Access; this command automates the whole flow and is
idempotent (re-run after updates).
Docs: desktop.md TCC section now leads with the command, keeps manual steps.
Tests: 4 new — fresh cert creation path, idempotent reuse, non-macOS no-op,
cmd_gui early-exit before build.
Four fixes to the tool-search deferral layer, split from PR #92693 (the
availability-cache staleness fix ships separately):
1. The parallel batch planner now peels the tool_call bridge wrapper and
decides admission on the underlying tool — supports_parallel_tool_calls
works again when deferral is active. Unparseable wrappers stay
sequential barriers; bridged calls get exactly the admission the same
call gets direct. tool_search/tool_describe lookups batch concurrently.
2. _short_desc no longer truncates listing lines at 'e.g.', hostnames, or
version strings — a sentence terminator must be followed by whitespace.
3. BM25 indexes the source label (e.g. 'linear' for mcp-linear), so
service-name queries reach tools whose own name omits the service; the
dead 'mcp' prefix token is stripped.
4. Substring-fallback docstring corrected (token misses, not zero-IDF).
Salvaged from #92693 by @alt-glitch with authorship preserved.
- atlassian: /v1/sse was deprecated by Atlassian after June 30 2026 —
installs OAuth'd fine then failed every handshake with 404 (#91538).
Now points at /v1/mcp/authv2, the endpoint Atlassian's docs recommend
for custom clients (live-probed: OAuth 2.1 + DCR intact).
- grafana (on top of @cedricziel's #93183 entry): defensive
default_excluded for ask_assistant (opaque Assistant delegation
meta-tool, bills usage) and agento11y_* (Grafana's own agent-obs
product suite) — both from Grafana's published tool tables, both
no-ops until the Cloud surface serves them; billing note added to
post_install.
Vendor-hosted remote MCP at https://mcp.grafana.com/mcp over Streamable
HTTP with native OAuth 2.1 + Dynamic Client Registration -- the same
transport/auth shape as the existing datadog and sentry catalog entries,
handled by Hermes's MCP client + mcp_oauth_manager.
Exposes Prometheus/Loki/Tempo/Pyroscope queries, dashboards, datasources,
alerting, incidents, and Grafana Assistant investigations, user-scoped to
the operator's Grafana RBAC. This is the hosted Grafana Cloud MCP, not the
self-run OSS mcp-grafana binary; post_install points self-hosted users to
the OSS server.
Source: https://grafana.com/docs/grafana-cloud/ai-tools/mcp-servers/cloud-mcp/
On Windows, the pre-update concurrent-instance gate aborted with exit 2
whenever ANY other process held the venv hermes.exe shim — including the
gateway itself, which _pause_windows_gateways_for_update() stops moments
later and the post-update restart phase brings back. Users with a running
gateway were forced into a manual taskkill dance before every update.
The gate now filters gateway runtimes out of the abort list and proceeds
when nothing else is concurrent. Classification delegates to
_is_pausable_gateway -> gateway.status.looks_like_gateway_command_line
(the canonical shlex-tokenized, profile-selector-aware matcher shared by
the Desktop preflight exemption and the venv-holder guard fallback), so
the gate's exemption and the pause machinery cannot drift apart. Anything
not positively identified as a gateway — REPLs, dashboard, Desktop
backend children, gateway MANAGEMENT commands like 'gateway status',
unreadable cmdlines — still aborts exactly as before, and the abort
message now lists only the PIDs that are actually the user's problem.
Surgical reapply of PR #37039 by @damadorPL onto current main (the gate
moved from hermes_cli/main.py to hermes_cli/update_cmd.py in the main.py
decomposition); his substring classifier was replaced with the canonical
matcher, which also fixes the 'hermes gateway status' misclassification
flagged in review.
Co-authored-by: Hermes <hermes@nousresearch.com>
Electron safeStorage parks a per-app key ('Hermes Key') in the macOS login
keychain; on machines with a locked/missing/corrupted default keychain that
turned every Hermes Desktop launch into a blocking 'Keychain Not Found' /
password dialog. Keychain-backed encryption is now an explicit opt-in:
- electron/secret-storage-policy.ts: standalone policy seam (default OFF,
strict === true coercion, one-shot migration flag) + unit tests
- default path never calls any safeStorage API (including
isEncryptionAvailable, which itself touches the keychain)
- one-shot legacy migration decrypts existing safeStorage blobs to plain
0600 files at first launch; undecryptable blobs are kept but read as
absent afterward (classify 'drop') so a dead keychain prompts at most once
- Settings -> Gateway toggle (all 5 locales) re-encodes every stored secret
store in place when flipped (v1 connection.json, v2 connections.json,
native-oauth-tokens.json)
- e2e: at-rest spec now covers both postures (opted-in unchanged contract,
default saves without secure storage, owner-only bits, restart round-trip)
- docs: multi-connection-desktop + desktop-native-signin updated
Port of @jeff-mettel's fix onto the post-#91378/#92902 fleet-restart
shape. The current-profile restart was gated on `launchctl list <label>`
exiting 0 - a booted-out job (plist present, definition deregistered:
crashed helper, manual bootout, failed prior update) fails that check,
so the branch silently skipped: no restart, no message, KeepAlive unable
to revive a definition launchd no longer knows, update printing
'Update complete!' with the gateway down. `launchctl list` is also
session-scoped and unreliable as a loaded/unloaded classifier.
- _restart_launchd_gateway_after_update() (his extraction, adapted):
plist-exists is the ONLY gate; launchd_restart() owns the
bootout/bootstrap/kickstart ladder for every plist-present state;
every failure path is loud and names the manual recovery command.
The gate-error 'except: pass' (the second silent variant) now counts
the label failed and tells the operator.
- Success still requires the #92902 supervision verify (fresh
supervised PID), composing his fix with the returned-is-not-supervised
guard.
- His regression suite adapted to the (restarted, failed) contract; the
old 'unregistered -> left alone' pinning test FLIPPED - it pinned the
bug.
A/B: his suite + the flipped test red on merge-base product code
(silent skip live), green at head. No macOS CI lane exists; field
evidence is #74973's reproductions plus the launchctl print output
shapes pinned in the suite.
* feat(cron): durable failure incidents with signature dedup and ack
Introduce a durable cron incident store (cron_incidents in the shared
cron/executions.db) that groups "same job + same error signature" across
runs, so a known recurring failure stops re-pinging the operator every run
once it has been acknowledged.
- cron/incidents.py: lazily-created incident table (detected -> alerted ->
reviewed -> closed lifecycle; closed is per-signature terminal), sha256
signature dedup over job_id + normalized error, redacted/truncated error
storage, failure-type classification, and ack/list/get/count helpers.
- cron/scheduler.py: record an incident on the failure delivery path and
suppress the per-run failure ping when the exact signature is acked (both
the normal failure path and the processing-raised retry path). Best-effort:
an incident-store error never breaks the cron run or delivery. Streak nudge,
alert-once markers, and delivery-error behavior are untouched.
- hermes_cli: add `hermes cron incidents [--state ...]` and
`hermes cron incidents ack <id>`.
- tests/cron/test_cron_incidents.py: dedup, lifecycle, redaction,
classification, lazy-schema, scheduler gating, and CLI coverage.
Non-goals deferred to later slices: Discord buttons/review view, HMAC action
tokens, owner-agent review launch, approval-gated fixes, incident playbooks.
* refactor(cron): tighten incident lifecycle, wire alerted state and suppressed_acked outcome
Follow-ups on top of the salvaged #94692:
- Drop the dead 'reviewed' state and the SQLite CHECK (state validity
lives in INCIDENT_STATES so future slices can add states without a
table rebuild); lifecycle is detected -> alerted -> closed.
- Actually mark incidents 'alerted' after a failure ping reaches
delivery, on both the normal and exception delivery paths.
- Record ack-suppressed runs with a distinct 'suppressed_acked'
delivery outcome (registered in cron_health monitoring) instead of
the ambiguous generic 'suppressed'.
- Drift-skip alerts explicitly bypass the ack gate (they carry the
remediation command and alert once via drift_alerted already).
- Docs: failure-incidents section in the cron guide.
- Tests for the alerted transition + never-resurrect-closed.
---------
Co-authored-by: Laura López Real <113060513+laulopezreal@users.noreply.github.com>
* refactor(prompt): remove the Nous Subscription block from the system prompt (~1.2K tokens/call)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
Merge follow-up on top of the salvaged commits: fold the npm>=10 reduced
hidden-lockfile comparison (intersection of non-null fields) and the
annotation-field exclusions into a single entries_differ() helper, keeping
the workspace-closure scoping intact.
_tui_need_npm_install compared every field of the root package-lock.json
against node_modules/.package-lock.json. npm>=10/11 writes a reduced hidden
lockfile that omits declarative fields (version/dependencies/dev) and adds
extraneous, so nearly every package looked 'changed'; workspace link entries
("link": true, paths outside node_modules/) are never materialized by the
partial --workspace install. Both made the check return True forever, so
hermes --tui re-ran npm install (and dirtied package-lock.json) on every
launch (#84617).
Compare only the keys both sides record with non-null values (resolved,
integrity, ...), ignore workspace link entries and non-node_modules paths in
the missing-entry check, and treat extraneous as an npm runtime annotation.
Real skew (lockfile bumped while node_modules is behind) is still detected.
_tui_need_npm_install() compared ALL fields between
package-lock.json and .package-lock.json, treating npm's
intentionally-stripped metadata (version, license, engines,
dependencies, etc.) as real skew. This caused 'Installing
TUI dependencies…' + rebuild on every launch.
Two changes:
1. Compare only the *intersection* of fields between the
two lockfiles — a field present in the root lock but
absent in npm's hidden actualized lock is a normal npm
artefact, not a real dependency change.
2. Add , , ,
to _NPM_LOCK_RUNTIME_KEYS — these boolean annotations
are written non-deterministically between the two locks
and never indicate a real dependency skew.
Real version/dependency changes still change /
, which are present in both locks and caught by
the intersection comparison.
Fixes: 347 false-positive lockfile mismatches → 0.
On Termux the launch install also selects ui-tui's child packages/*
workspaces (include_child_workspaces=True), so npm installs each child's
devDependencies. The freshness closure only followed devDependencies for
the ui-tui workspace itself, so a devDependency unique to a selected child
was dropped from the closure and a genuine missing package slipped past
_tui_need_npm_install.
Derive the closure from every workspace the install path selects, following
devDependencies for each. _npm_lock_workspace_closure now accepts the set of
selected workspace keys (dev-included roots); _tui_selected_workspace_keys
mirrors _make_tui_argv (ui-tui, plus child packages/* on Termux). Adds a
child-workspace-devDependency regression test (installs on Termux, ignored
off Termux) plus a closure-level dev-scope test.
_tui_need_npm_install compared the full multi-workspace root
package-lock.json against the hidden .package-lock.json, but the launch
install is scoped with npm install --workspace ui-tui and only writes the
ui-tui dependency closure. Every dep belonging solely to another workspace
(apps/desktop, web, ...) was therefore reported as missing, so the check
returned True and printed "Installing TUI dependencies..." on every launch.
Restrict the comparison to the ui-tui workspace's dependency closure,
computed from the root lock's packages map (following npm's node-resolution
walk and workspace symlinks). Standalone / own-lockfile layouts and any
case where the workspace can't be located fall back to the full comparison,
so drift on a genuine ui-tui dependency is still detected.
Fixes#66978
Sidebar and legacy session-list helpers tagged the registry connection but
not the active profile, so Electron routed those reads to the wrong backend
after a profile or remote switch.
Keep hermesApi connection-only: stamp profileScoped on the list helpers
instead of every REST call.
Co-authored-by: noah <loahnisk@gmail.com>
Managed SSH maps a Desktop profile label onto a different remote name.
The sidebar filter lives in recents_profile, so rewriting only ?profile=
left those reads on the remote default and the Sessions list came back empty.
Co-authored-by: noah <loahnisk@gmail.com>
The function _persist_live_session_system_prompt runs on the RPC
dispatcher thread. A model switch calls it. On that thread the
_SESSION_CWD contextvar is not set. Because of this,
resolve_agent_cwd() falls back to the process TERMINAL_CWD value.
The desktop pins TERMINAL_CWD to the home directory. The rebuilt
prompt then records the home directory as the working directory.
The wrong prompt persists to the session database. Later turns
restore the stored bytes without change, because the turn prologue
rebuilds the prompt only when the cache is empty. The wrong line
never heals. The terminal tool is not affected, because it reads
the per-session cwd record.
The desktop composer sends a model switch before the first turn
when its model differs from the config default. Because of this,
a new project session can show the wrong working directory for
its full life.
Fix: bind the session context around the rebuild, in the same way
the function already binds the profile home for issue #50233. The
test drives the real function from a bare thread and asserts the
persisted prompt carries the session cwd.
Adds a 'web_extract shows a stale page' section to the bundled
hermes-agent skill's troubleshooting reference: the 20-minute TTL
symptom, the automatic never-cache carveouts (localhost/private/LAN,
website_blocklist, rescue/failed responses), the web.cache_exempt_hosts
recipe for staging/tunnel sites, and the TTL/disable fallbacks.
Sites under active development but tested over the public internet
(Vercel previews, ngrok tunnels, staging domains) are public DNS, so
the local-dev never-cache rule can't catch them. web.cache_exempt_hosts
lists hosts whose pages are always fetched live: exact, "*.wildcard",
or domain-suffix matching (label-boundary aware — mysite.dev covers
preview.mysite.dev but never evilmysite.dev). Checked on both store
and lookup, so adding an exemption takes effect immediately even for
entries cached before the config change.
Dev servers, hot-reload builds, and chat-GUI artifact previews live on
localhost/private addresses and change on every save — a 20-minute
cached copy would show a stale build exactly when freshness is the
point of fetching. The extract cache now declines loopback, private,
link-local, *.local, *.localhost, and single-label LAN hostnames on
both put and get. Hostname heuristics only (no DNS) — this is a
freshness carveout, not a security boundary; SSRF enforcement is
unchanged in tools/url_safety.py.
Public URLs keep the full TTL.
Review fixes for #94618 (all three blockers reproduced by the reviewer
through the real web_extract_tool):
1. Cache lookup moved AFTER provider resolution and strict-selection
validation, and gated per-URL on the website blocklist policy — a
blocklist-blocked or misconfigured-backend call now behaves exactly
as it would without a cache instead of serving cached content.
2. Rescue-served extract batches are never cached (mirrors the search
memo's exclusion), keeping one-shot rescue one-shot.
3. Cache entries now get dedicated per-(url, format, provider) files
instead of sharing the URL-keyed truncate-store file — html and
markdown (or two backends') copies of one URL no longer overwrite
each other, and switching extract backends within the TTL never
serves the old backend's rendering.
Also from review: per-process index tmp filename (cross-process writers
can no longer truncate each other mid-write) and held flight locks are
never evicted from the bounded lock table (eviction could have allowed
a duplicate paid request).
New regression tests for formats/provider keying; E2E harness extended
with policy-block, strict-selection, rescue-two-call, and dual-format
scenarios — 6/6 pass; original 13/13 still pass.
Repeat searches (same normalized query + provider) within a 20-minute
TTL are served from an in-process memo, and concurrent identical
queries are single-flighted so a parallel subagent fan-out pays for
one vendor request instead of N. Requested limits bucket up to
10/20/50/100 so near-identical requests share an entry; callers get
their requested count sliced from the bucket.
Repeat extracts of the same URL are served from the existing
cache/web full-text store (previously written for read_file paging
but never read back), via a small JSON sidecar index. Disk-backed, so
CLI, gateway, cron, and subagents share it. Cached extracts re-run
the normal truncate pipeline, so per-call char_limit still works.
Both caches sit after every safety gate (secret-URL, SSRF, policy,
provider resolution) and directly around the paid vendor call — hits
skip only the network request. Only successful responses cache;
rescue-served responses are never cached (one-shot rescue must stay
one-shot). Config: web.cache_enabled (default on),
web.cache_ttl_minutes (default 20, clamped 1-1440).
Idea credit: query coalescing + num-bucketing pattern observed in
Apodex FrontierAgent (Apache-2.0).
Closes the two config-UI halves of the exclude-mode review (GottZ findings
5-7 on #94513):
- hermes mcp configure: on an exclude-mode server, unchecking a tool now
APPENDS a literal exclude and re-checking drops it — glob patterns are
preserved so future vendor tools keep getting filtered. Previously one
uncheck converted the whole config to a frozen include list (globs
silently deleted, new vendor tools invisible). Re-checked tools still
shadowed by a kept glob get an explicit warning instead of a silent
no-op.
- hermes tools MCP checklist: same exclude-mode write-back, plus display
now matches excludes via matches_name_filter (fnmatch) — glob excludes
previously rendered as if nothing were excluded.
- klaviyo manifest: post_install no longer tells users to append a param
the URL already pins; now documents how to get the FULL surface.
Live-verified through the real cmd_mcp_configure path: exclude-mode server
with ['*_secret_*', 'docs'], uncheck beta + re-check docs ->
exclude becomes ['*_secret_*', 'beta'], no include written.
171 tests green across test_mcp_catalog/test_mcp_config/test_mcp_tool.
The probe-fail path ignored prior state entirely: with no manifest
default_enabled it wrote include=None, which pops the whole tools
block. For exclude-mode manifests default_enabled is necessarily unset,
and for the 30+ OAuth entries the entry rewrite precedes first auth —
so the common reinstall-while-unreachable case removed the curated
excludes and enabled every tool on next connect. The fallback order is
now: prior include > prior exclude > manifest default > no filter.
The "Probing '<name>' for available tools..." line printed before the
exclude-mode short-circuit, which deliberately never probes (the test
suite asserts _probe_tools must not run there). Move the announcement
next to the actual probe call so install output matches what happened.
_normalize_name_filter([]) returns an empty set, which is falsy, so
_should_register fell through to "no filter" and registered every tool
— the exact opposite of what _apply_tool_selection wrote when the user
unchecked everything in the install checklist ("contributes nothing
until reconfigured"). Whitelist mode is now keyed on the include key
holding a valid filter shape (str/list/tuple/set) rather than on set
truthiness, at both the live-discovery and cached-manifest sites.
Invalid include values keep the old warn-and-ignore behaviour.
The test wrote the evil manifest and imported install_entry but never
called it and asserted nothing, so the security gate in
_save_mcp_server/validate_mcp_server_entry was left uncovered. Now it
calls install_entry, expects CatalogError, and verifies the entry was
not persisted. Also drops the hardcoded ~/.hermes/ path from the
fixture args (AGENTS.md tests rule); the egress + exfil-hint shape
still trips both patterns.
Review blockers (independent reviewer on #94513):
1. Reinstalling an exclude-mode catalog entry wiped the user's edited
tools.exclude, replacing it with manifest defaults. install_entry now
reads the prior exclude (like it already did for include) and re-writes
it verbatim on reinstall. Regression test added + sabotage-verified
(fails on old behavior); include-priority test added too.
2. aws-knowledge: exclude aws___retrieve_skill — vendor SKILL.md loader is
a vendor skill layer (live tools/list confirmed the tool exists).
3. betterstack: exclude list rewritten to cover the snake_case wire names
(vendor's own header examples show remove_dashboard) via globs alongside
the doc display-labels; caveat documented in the manifest — server is
OAuth-gated so pre-auth enumeration is impossible.
4. railway: exclude railway-agent (opaque server-side agent delegation,
acts outside Hermes's per-tool approval loop).
5. twelve-data: exclude oauth plumbing pseudo-tools + quota probe.
6. betterstack post_install no longer claims a fully-checked checklist —
exclude-mode bypasses the checklist; text now describes the applied
exclude list.
Live E2E: fresh temp HERMES_HOME — install applies manifest excludes,
user edit survives reinstall. 33/33 catalog tests green.
The cloudflare entry's 3,320-endpoint surface is ~43% product families a
personal/dev account never touches (Zero Trust org-fleet suite, Magic
Transit/WAN, Cloudforce One, Radar analytics, API Shield, legacy
migration surfaces). Ship a 34-pattern curated exclude list in the
manifest: 3,320 -> 1,905 tools kept, and everything Cloudflare adds
later stays enabled by default.
Mechanism, two small extensions:
- tools/mcp_tool.py: tools.include/exclude entries containing glob
metacharacters now match via fnmatch (plain names stay exact-match),
so a product family is one pattern instead of hundreds of stale
literals.
- hermes_cli/mcp_catalog.py: manifests may declare
tools.default_excluded (mutually exclusive with default_enabled);
install writes it to tools.exclude and skips the probe/checklist —
a 3,320-row curses checklist is not a UX. Prior user include
selections still win on reinstall.
Verified by replaying the real filter functions over the live-probed
3,320-tool list: 1,415 excluded, zero overmatch against a per-product
target audit; DNS/Workers/R2/D1/tunnels/Access/AI kept.