Every failed auxiliary model_route row read error_class=unknown because the
aux logical scope only carried outcome/model/provider and the projection
hard-coded unknown for any non-success. Every exception escaping call_llm,
including Hermes' own aborts (Ctrl+C / /stop mid-compression, interrupted
aux streams, task cancellation), was recorded as failed.
- classify the error that ended an aux call (and the last one a successful
call recovered from) with agent.error_classifier; HTTP-200 bodies that
carry a provider error object are classified from that object
- Hermes aborts (non-Exception BaseExceptions, InterruptedError) close the
call as cancelled/none, matching the contract
- MoA aggregator streams report the same way
- aux calls that finish under the turn's own live scopes (title generation
runs beside the turn) were never counted: the scope could not be popped
and the turn-close drain closed it as an orphan without a route. The
drain now closes it with the stored real result.
Both upstream repositories return 404 (chris-yyau/hermes-herdr-auto-reconcile,
tylerbrevard/hermes-localsend; the owner accounts still exist), so the
pinned SHAs cannot be fetched and an install from the catalog fails.
The parameter-patcher pass recorded a method's rebinding parameter at its
index counting self, so a call site like self._patches.bind(module, name, fn)
was checked at args[1] (the attribute name) instead of args[0] (the module).
A plugin that routed every core rebind through a class helper passed the
gate with zero findings.
Fleet telemetry labelled every `hermes -z` / `chat -q` / `-Q` run as
entrypoint=interactive because the cli surface mapped straight to
interactive and no caller declared anything else, so 95% of CLI sessions
read as one-turn humans and scripts could not be told apart.
- New closed entrypoint `one_shot` (contract + v3 schema enum). The task
scope start declares it for cli-surface runs carrying the
HERMES_SINGLE_QUERY_SESSION marker the one-shot paths already set (the
signal session source and cache_ttl: auto read). Children Hermes
dispatchers spawn (kanban worker source=kanban, A2A forward source=a2a)
read the existing `background`. Delegated children, other surfaces and
the REPL are unchanged.
- Engagement keeps treating one_shot like interactive (its prior label).
- `hermes -z` exits via os._exit, skipping the atexit hook that closes the
metrics session: its pre-exit cleanup now runs the runtime shutdown, so
task_run.finished and session.count are actually written (before, only
task_run.started survived and the store kept a hot journal).
- Smoke expectations move to one_shot (it drives `chat --query --quiet`).
- relay-shared-metrics.md documents the entrypoint vocabulary.
The external worker now calls GatewayStartupMixin._register_config_hooks (the
helper gateway startup and each multiplexed profile already use) instead of a
copied load_config/register_from_config sequence, and calls it after
set_secret_scope rather than before. Registered outside the scope, a
hooks.outbound target with secret_env hits get_secret with no scope while
multiplexing, which raises UnscopedSecretError; the never-raise guard swallowed
it, so that profile's outbound webhooks were silently dropped again.
Both registries are idempotent per (home, event, ...) so a worker running
several payloads registers each hook once. The kept test gains a secret_env
target on a multiplexed payload to pin the ordering; the broken-config test is
dropped (register_from_config never raises on a malformed block, and the
helper's guard is already covered by the gateway path).
Refs #131764
The restart-safe external worker (python -m cron.scheduler
--external-worker-file) called discover_plugins() under the payload's
home override but never agent.shell_hooks.register_from_config() nor
agent.outbound_webhooks.register_from_config(), so hooks: blocks in the
owning profile's config.yaml silently stopped firing for cron sessions
once gateway fires were handed to the worker instead of running
in-process. Plugin hooks kept working, which made the gap invisible.
Register both right after plugin discovery + secret hydration, under the
same home override, with the gateway's never-raise shape (#102504):
consent resolves non-interactively from the owning profile's allowlist /
HERMES_ACCEPT_HOOKS / hooks_auto_accept, and a broken hooks block logs a
warning instead of taking the cron execution down.
Fixes#131764
(cherry picked from commit d1971c698a4429868e9d43d058032930ec62d7b9)
Register direct workers with a live future and fence dispatcher release and future attachment with per-registration identity tokens. Preserve profile-scoped release keys.
Port the in-memory fix from 251b99332550be4dc5a7ba265109e367842e9081, without timestamp-based durable-state guards.
Fixes#132223. Refs #88688.
(cherry picked from commit dd834c874fef04a7d56658e0d05baa3bee5decba)
Review and simplify pass on the guest fast-search restore.
- tools/tool_backend_helpers.py: _account_info was split out so two gates
could share one account read. Only managed_nous_tools_enabled is left, so
fold it back into its pre-salvage shape. Keep the branched force_fresh
call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
_managed_gateway only fed the removed refusal helper. search() now calls
resolve_free_search_gateway() directly once it has the route answer. The
module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
removed gate. The tier carried on a failed lookup still picks the "needs
a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
lost their only coverage when the gate tests went, so cover them at
their consumer, format_nous_portal_entitlement_message. The fast-search
premise now asserts that the guest case really reads as the anonymous
tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
true for direct keys only. It now names the managed guest route.
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.
This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.
Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
Gate r1 (2ab, optional): nothing failed if session.save left
_LONG_HANDLERS and went back to running its full stored-session read
+ JSON render inline on the RPC reader. Extend the existing pool
assertion to session.save; dropping the entry turns it red.
Gate r1 (quality, Low): every control.error from a classified route,
including the compress and codeless failures, carried `"code": null`
on the wire once session.save started forwarding its 4131 cap code.
Send the key only when the handler's error has a code. The compress
caller still maps every failure to its own 4009, so it is unchanged.
Gate r1 (2c + quality, Low): with no stored row, TUI/Desktop /save json
wrote the legacy {model, session_id, session_start, system_prompt,
messages} shape. It has no `id`, so import_sessions rejected it
("session id is required") while every other /save json file imports.
Use the CLI fallback's canonical keys (id = the live row key,
started_at as an epoch or None); keep system_prompt as an extra key.
No reader of the old keys exists in apps/, ui-tui/ or web/.
Gate r1 (2c + 2ab, Low): the cap check monkeypatched
resolved_max_export_messages, so dropping the
_session_profile_runtime_scope around the stored-session read in
session.save stayed green. The cap now comes from a real config.yaml,
and a tui-profile row launches the process from an uncapped
HERMES_HOME: only the session profile's config refuses with 4131.
Removing the scope line turns that row red.
session.save now reads the whole stored session and renders it (up to sessions.max_export_messages rows,
~0.8s for a 20k-row session on a laptop); inline on the reader thread that stalls every other RPC.
session.save wrote the in-memory history, so turns in-place compaction archived were missing from the
snapshot and sessions.max_export_messages never applied, unlike CLI and messaging /save json since the
#130339 fix. The two existing call sites' cap check + export + undone-row filter move into
hermes_cli.session_export.load_save_snapshot; the CLI, the messaging gateway (one off-loop hop) and the
tui_gateway handler all call it. The TUI handler reads the session's own profile db under that profile's
config, refuses an oversized session with rpc error 4131 (the same message as the CLI), and keeps the
in-memory export as the fallback when no stored row exists. Turn-isolated sessions run the same handler on
the compute host; the host now forwards the handler's error code so the refusal arrives as 4131, not 5011.
Refs #130339
The terminal tool arms a heartbeat only after its spawn bookkeeping, and a
fast-starting process has already written its first lines by then.
arm_heartbeat reset the output baseline to the current total, so those lines
were in no heartbeat at all: the agent never saw them until completion.
Baseline is the spawn (field default 0). Every produced line now lands in
exactly one heartbeat regardless of how long the spawn->arm gap is.
CI: tests/tools/test_process_heartbeat.py FLAKY on main (run 36033989953),
beats read ["", "", "second\n", ""]. Reproduced locally with a 0.3 s gap
between spawn_local and arm_heartbeat: base loses "first", fixed keeps it.
The description named a register_system_prompt_section call the plugin
never makes; the opt-in rerank sends data off-host, so it gets a
Disclosure line.
Gate r1 quality finding: test_empty_ghost_session_does_not_block_title
read as "ghosts never block", but three of its four rows assert the
opposite. Rename it to
test_title_conflict_yields_only_to_ended_empty_visible_holder, make the
holder_messages count a holder_has_message bool (only zero vs non-zero
matters), and state the rule in the docstring: a holder yields only when
ended, empty, visible and unarchived; every other holder conflicts.
Gate r1 2c finding (same bug class as #81888): _handle_create_session ran
its own "SELECT id FROM sessions WHERE title = ?" and 400'd whenever any
row held the title, so creating a session named after an abandoned empty
chat still failed even though renaming to that name now succeeds.
Extract the conflict block of _set_session_title into
SessionTitlesMixin._resolve_title_conflict(conn, session_id, title) and
call it from both the rename path and the API create transaction, so the
uniqueness rule (compression ancestor transfer, retired Bot Chat, ended
empty visible ghost, otherwise ValueError) lives in one place. On
ValueError the create still deletes its just-inserted row and returns
400 invalid_title; the message is now the DB's "Title 'X' is already in
use by session Y". A brand-new session has no compression ancestors, so
that branch is a no-op on create. api_server.py net growth is 0.
Filtering the conflict check with ``message_count > 0`` let two sessions
hold one title in the DB: the full unique index then raised
IntegrityError, and under the PR's partial ``message_count > 0`` index the
ghost's first append_message would fail instead. Keep the full index and,
in the same CAS transaction (like the archived Bot Chat branch), retire
the title from a visible row matching _EMPTY_SESSION_WHERE: ended, no
messages, not archived -- the same definition delete_empty_sessions uses.
Live empty rows (a /title queued before the first turn), hidden rows (a
fresh canonical Bot Chat) and archived rows keep their names.
Empty sessions (message_count=0) created by interrupted flows (e.g.,
desktop session creation that never received a message) would block
session rename attempts even though they were invisible in the UI.
Changes:
- Updated title uniqueness index to exclude sessions with 0 messages
- Modified _set_session_title conflict check to skip empty sessions
- Added migration to drop and rebuild the index with new filter
- Added regression test for ghost session rename blocking
Fixes#81888
(cherry picked from commit a28e084278694a3b7e5f0adbfae90b14ac575db2)
When every DRM card's vendor file or every Windows display adapter's
MatchingDeviceId was unreadable, classify_gpu_vendors returned 'none' and
availability refused with unsupported_gpu, contradicting the documented
'unreadable GPU passes' rule. Windows now records an unreadable numbered
adapter as an empty id so the classifier sees it.
Also drops the type-narrowing assert in availability() and makes
_action's gpu_label required.
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
README rule 9 (#130795): listed plugins extend Hermes only through public
surfaces. These 11 entries rebind core functions/methods/module tables or
reach into the Desktop app's own markup, and fail the no core override /
app DOM reach admission checks at their pinned shas.
Plain delisting, not removed.yaml: installed copies keep working, and each
plugin is welcome back with a SHA bump once it moves to a public hook. Every
author has an issue on their repo explaining the finding and the public path.
Dropped from the original list because their compliant re-pins were
approved: clinepass (#130439), prompt-enhance (#130900),
custom-dangerous-patterns (#131920), hermes-zh (#131464) and
skill-retrieval (#131290).
plugins.isolation is profile config, so a profile (or its agent) can set it
back to in_process. The managed scope already beats profile config and makes
`hermes config set` refuse, and the isolation reader goes through that
overlay; document it as the operator lock and pin the contract with a test
(red when isolation_mode() reads the raw profile file).
The credential-free profile extraction child got HOME but not USERPROFILE.
Windows resolves Path.home() from USERPROFILE only, so the child exited with
"Could not determine home directory" and every user model-provider plugin was
silently dropped under host isolation.
Reported-by: Enough1122
Independent review of the plugin host found eight defects; each is fixed here and
the reproducible ones are covered by two new invariant tests (red on the previous
head, green now).
- Async plugin code: `await ctx.<facade>.<async method>` works (the facade probe
reports coroutine methods and the host returns a coroutine function), and ctx
calls made from coroutines keep the caller's session. The wire origin is now a
ContextVar carried onto host event-loop tasks, and the host's fallback context
starts empty instead of copying whichever caller created the host.
- Memory / context-engine / cron providers survive a host crash: proxies record
the host generation, and a category proxy reloads its plugin into the new host
on next use (bounded by the same restart budget). A proxy from an earlier host
can no longer address a recycled object id.
- Hosted provider objects no longer leak: the host imports a category plugin's
module once and drops objects whose Hermes proxies were collected.
- Every public attribute of a hosted object is read live (class-level defaults
included), and attributes Hermes assigns reach the plugin's object unless they
hold a live handle.
- A user memory provider's config_schema.py runs in the plugin host.
- Dashboard plugin APIs resolve the plugin in the requesting profile (its copy,
its plugins.enabled, its host) and keep repeated headers such as Set-Cookie.
- platform_actions coroutines run on the gateway loop their adapters use.
- Each incoming request gets its own thread (no pool exhaustion under nested
callbacks); a late close from a replaced host no longer triggers a reload; the
model-provider extraction cache is keyed by full path.
plugins.isolation: host imports every non-bundled plugin in a host process started
on demand per profile with only that profile's environment, and hands plugins a
RemotePluginContext; Hermes registers proxies for tools, hooks, commands, skills and
provider objects (subclasses of the ABC it checks). Re-entrant: handlers may call
ctx back into Hermes while Hermes waits on them. Every user-code import path routes
to the host (general, memory/context-engine/cron, model-provider profiles via a
cached credential-free extraction, dashboard APIs via an ASGI bridge) or refuses.
One table names what cannot cross (live gateway objects); the static audit shown
by `hermes plugins validate/show` reads it. A host that dies is restarted with its
plugins (bounded). Default stays in_process: nothing changes unless opted in.
Why: shared-pool deployments must never run one tenant's plugin Python in an
interpreter holding another tenant's data, without plugins becoming tenant-aware.
The worker isolated HERMES_HOME only. The longrange_backup_pipeline task
("schedule it nightly at 2am") let agents run the operator's real crontab
and write into the real ~/.hermes: a past battery left three nightly cron
jobs and two scripts behind on the host, pointing into deleted run dirs.
sandbox.isolate_host turns on the product's own TERMINAL_HOME_MODE=profile
contract, points the worker's HOME at the same profile home (in-process
~ expansion for write_file), puts a file-backed crontab shim first on PATH
and unsets the systemd user bus. README quickstart drops its /tmp paths.
The scheduler persists last_delivery_unverified as a list of unconfirmed
platform:chat_id[:thread_id] targets and cron.manage passes it through, but
CronJobRow typed it bool | None, so contract validation rejected any job
with an unconfirmed delivery. Regenerate the TS/OpenRPC contract.
Follow-up to #131853 and #131867 from an independent review, each finding
reproduced in native Electron against two real serve backends.
- Cold composer path: the cache reconciliation (mergeServerMeta) ran only in
the Bots pane's publish effect, so a launch that never opened the pane kept
a stale cached title ahead of the backend's own in @handles and group
prompts ("You are @local-stale-name"). It now runs where every snapshot is
produced (data.ts::fetchRosterSnapshot), with the same fetch-time write
fence, so the pane query and primeRoster reconcile alike.
- Pane label vs @handle: displayName ranked display_name above the backend's
Bot Mode title on a thin remote row (and a titled remote `default` with no
display_name fell to the connection label), so the row read "Remote Human
Name" while mentions and prompts said @remote-bot-title. It now takes the
same backend-title rung botFriendlyNames uses.
- Relay trace: a connection removed and re-added under a new id was logged
`MISROUTED (same install as <removed id>)`; the remembered owner is now
pruned against the current connection set.
The roster-pane-lifecycle test probed "republished" via mergeServerMeta's call
count; reconciliation now happens per fetch by design, so that invariant is
asserted via trackInboundActivity alone.
A `GOCSPX-` literal is the client secret of a Google installed-app (desktop/CLI)
OAuth client. Google's OAuth docs state such secrets cannot be kept confidential:
gcloud ships its own in every copy. The generic
`hardcoded_secret` pattern scored it critical, so any plugin that talks to a
Google installed-app client (the catalog's quota plugin) scanned `dangerous` and
could not be installed even with --force. Cap that one shape at high: the
finding stays in the report and the install asks for review (caution); every
other secret-shaped literal keeps its critical severity.
Salvage of #131963 by @mekenthompson onto main after #131946.
The shutdown reaper's SIGKILL pass only checked whether the leader PID was
still alive. Once the leader had exited, a descendant that ignored SIGTERM
kept the process group alive and was never escalated. The pass now also
probes the group with killpg(pgid, 0) and sends SIGKILL through the same
start-time guard as the SIGTERM pass.
The per-spawn environment marker from #131963 is not carried over: it left
helpers launched with a scrubbed environment running on Linux and brought
back the dead-leader refusal on macOS when a start time was recorded.
Reverts the nine commits merged by #132260 (merge 05ab8766e7), from
31194ea00e "fix(cli): revalidate worktree cleanup gates before removal"
through 05ab8766e7, restoring hermes_cli/worktree_gc.py,
hermes_cli/worktree_ops.py and tests/security/test_gitspawn_config_injection.py
to their pre-merge state and removing
tests/hermes_cli/test_worktree_reclaim_revalidation.py.
Maintainer decision: back the behaviour change out (trees kept when their
state changed since the audit, plain `git worktree remove` under the tree's
git env, dirty submodule trees kept). No commit on main touched these files
after the merge, so this is the exact inverse of the merged diff.
The contributor mapping for @rudycelekli stays (attribution record only).
Review finding (BR1 r3 efficiency, Low): the _ENCODED_URL_PARAM_RE key
group excluded only `%3D`, so a key could run across `%26`/`%3F`/`%23`/
`%3B`. Two consequences: a nested `?next=%2Fx%3Ffoo%26token%3D<secret>`
matched the key `foo%26token`, which is not a sensitive name, and the
secret leaked; and each separator restarted a match that scanned up to
160 chars, about 8x slower than base on 1 MiB of `%26a` (1.72s vs 0.21s;
0.36s now).
Exclude every encoded separator from the key so it ends at the nearest
one. The nested leak now redacts to `%3D%2A%2A%2A`, and the
`redirect=...%3Fapi_key%3D...` case is unchanged.
Review finding (BR1 r3 2c Medium / 2ab Low): the `***` arm added to
_BARE_HEADER_RE treated ANY same-line word after an already-masked
value as the secret, so benign prose was eaten and every re-scrub ate
one more word (`x-api-key: *** see docs for details` -> `... docs for
details` -> `... details`; same for `Authorization: *** then prose`,
`X-Api-Key: *** (set in env)`). The scrub was not a fixed point.
Require the follow-on value to look like a credential (>=8 token chars,
optional `=` padding, then a token boundary) before `***` counts as a
masked scheme. The floor is 8, not 16, so 15-char keys still redact.
The 336-case header sweep stays at 0 leaks / 0 unstable, the 18
`name: <scheme> <key>` cases are still redacted, and the benign lines
are stable over three passes.
agent.redact's _SECRET_HEADER_RE treats the first word after an API-key
header as the value. With `x-api-key: Basic <key>`, that word is the scheme,
so the scheme becomes `***` and the key is left in the text. The support scrub
then saw `***` as the value and stopped, so `x-api-key: *** <key>` reached the
debug/share upload with the raw key. This affects the x-api-key, api-key and
x-auth-token families with Bearer/Basic/Digest, in both bare and `-H '...'`
forms. Base and main leak the same input.
_BARE_HEADER_RE now also accepts `***` plus a same-line value as a scheme.
That value must not be another secret header, so `x-api-key: *** cookie=...`
is not consumed. The key is redacted, and the redacted output is a fixed
point. The fix stays in the support layer. Teaching agent.redact the scheme
does not help, because its YAML/assignment passes mask the scheme word first.
Gate finding: BR1 fold2 "still open", a bare-header scheme leak.