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.
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.
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.
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
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>".
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 authserv_id pin now matches the topmost Authentication-Results host
exactly; main also accepted a subdomain of the pin. Operators who pinned a
parent domain for a provider that stamps from changing MX hostnames would
see every message dropped with no guidance (gate 2c Medium finding).
The adapter now trusts only the topmost Authentication-Results field and
requires a set authserv_id to match it exactly (no subdomain alignment),
so operators whose relay stamps a different top authserv-id see the
untrusted-authserv warning. Say so where the warning is documented.
Control verbs with trailing words now run the verb instead of replacing
the goal, so goal text that starts with one of those words needs the
`--` escape. Without a docs line the change is only discoverable from the
in-chat hint.
The E4 2c review (convergent with quality) found /save json now loads the
transfer projection -- every stored row, archived included -- with no
sessions.max_export_messages guard, while `hermes sessions export` and the
console export refuse oversized sessions before materializing that same
projection. A heavily compacted session could load its whole archive into
memory from one chat command. Both /save surfaces now call the existing
assert_export_safe() before a JSON export and reply with the shared
SessionExportTooLargeError text; md/html transcripts are unaffected.
The E4 2c review found the new docs say a `/save json` snapshot holds the
same rows as the transfer backup, but the TUI/desktop /save
(tui_gateway session.save) still dumps in-memory history. Narrow the
English and zh-Hans sentences to the CLI and messaging /save surfaces;
the TUI path is left as a follow-up.
The CLI and gateway /save json wrote the live rows only, the projection the
removed comment justified by import_sessions replaying archived turns as live
context. Import now restores the active/compacted flags, and the dashboard
import accepts a /save json file, so a snapshot of a compacted session lost
every turn the summary replaced. One export_projection() rule now picks the
display history for transcripts and every flagged row for JSON, shared by
`hermes sessions export` and both /save surfaces.
(cherry picked from commit 12f98c891cd70083d35dda9bcabe17459e4c7bb0)
Gate finding (2c, Low): the "Always excluded" list in the export-contents
section did not mention the .ssh/.aws/.gnupg/.kube/.envrc exclusion this stack
adds, nor that nested copies under a skill are dropped too. Add them to the
English list and to the zh-Hans credential-store list (that translation has no
export-contents section; its credential list notes exports strip them too).
"Keep computer awake" was a boolean: off, or a machine-global power-save
blocker held for as long as Hermes Desktop is open. That is the wrong shape
for its own stated purpose. An unattended overnight run dies when the
machine sleeps unless the user predicted, before starting, that they would
need the toggle — and a user who did flip it leaves a laptop pinned awake
all week, including the idle hours after the run finished (#101991).
Main already knows, live, whether any turn is in flight:
`mergeActiveWork(activeWorkByWebContents.values()).count > 0` is the exact
expression that drives stream throttling. This change wires the blocker to
that same signal behind a three-way mode:
off never hold the blocker (unchanged default)
while-working hold it only while a turn is in flight; release at idle
always hold it whenever the app is open (what `on` used to mean)
Both persisted copies migrate in place. The renderer's localStorage boolean
(`hermes.desktop.keepAwake.v1`) and main's `keep-awake.json` `{ on }` are
read once and mapped `true -> always`, `false -> off`, so an existing user
sees exactly the behaviour they had. The IPC channel keeps its name and
accepts either the mode or the old boolean. An unrecognised saved value is
refused rather than trusted and falls back to `off`.
No new listener, timer, or IPC channel: `updateStreamThrottleFromActiveWork`
computes the merged picture once and hands it to both consumers, and the
cold-launch apply reads the mode instead of the flag. No trailing delay
between back-to-back turns — a sub-second release cannot let a machine
sleep, and `powerSaveBlocker` start/stop are cheap.
The policy is three pure functions in power-save.ts (parse, read, wanted)
so it is testable without the main process; main only wires them.
(cherry picked from commit c6dab931a2891ea7026eea2c07c271bcf48446b9)
_resolve_allowed_usernames resolved a DISCORD_ALLOWED_USERS name through member.name,
member.display_name or member.global_name, and the first match took the slot. Any member of a
shared server could set a nickname or display name to an allowlisted username, get the
resolved ID (DMs, tools, approval buttons) and lock the real user out.
Match the unique account username only, as #125034 did for SimpleX display names. An entry
that matches only a display name stays unresolved and gets a connect-time hint. Tests cover
a copied nickname and a copied display name seen before the real user, and the hint; the
Discord docs now say display names and nicknames are never matched.
(cherry picked from commit 252f0bccab39afe94f0f71254e47a8f785ec1fb0)
`hermes sessions export` and the console's `sessions export` each
re-implemented the per-session max_export_messages loop and carried a
near-identical refusal string, so a wording or limit-resolution fix had to
land twice. SessionDB.assert_exports_safe resolves the limit once and
asserts each id; export_too_large_message is the single refusal text.
The CLI now resolves the limit before the bare-export search_sessions
scan, so `max_export_messages: 0` skips that scan entirely instead of
listing every session only to check nothing. The round-trip test now
asserts the refusal names the config key, and the export-guard docs say
the cap counts every stored message, not just active ones.
The backup now loads every stored row, archived generations included,
but `_cmd_export` skipped the per-session `assert_export_safe` guard
that the console export applies. A heavily compacted session with a
small live tail could therefore load an unbounded archive into memory.
All three selection paths (`--session-id`, filters, bare export) now
run the `sessions.max_export_messages` guard over the stored row count
before loading anything, and print the refused session id. Transcript
views keep their display projection and are not guarded, as before.
website/docs/user-guide/sessions.md no longer says JSON/JSONL exports
are live-only. It now says they carry every stored row, including
compaction-archived and rewound or edited-away messages, with their
flags. It also says an import restores those rows as archived, and
that the guard applies to the backup.
(cherry picked from commit 36cdc89e342b7bda4e362294c1bd0ba5d44fed6d)
A recurring job with a finite repeat count (for example repeat=3, every
24h) lost one repeat slot for each automatic re-run after a fire that
could not reach the model. The failed occurrence counted, and then each
5/15/30-minute re-run of that occurrence counted again, so a half-hour
outage finished a repeat=3 job with zero model calls. With
cron.retry_unreachable off, the same outage costs only the one failed
occurrence.
Cause: cron/jobs.py:_advance_after_run bumps repeat.completed on every
mark_job_run, and the re-runs parked by
cron/unreachable_retry.py:plan_retry go through it like scheduled
occurrences. cron/unreachable_retry.py:will_retry also treated a re-run
on the last slot as the job's final run.
Fix: cron/scheduler.py:_finish_completed_run passes ladder_rung=True
when the dispatched instant is the parked re-run instant (new
is_retry_run, which compares the dispatch snapshot's _scheduled_instant
with the ladder state). mark_job_run then calls _advance_after_run with
count_run=False, and will_retry leaves that run out of its final-run
check the same way. The first failed occurrence still counts, and manual
runs still count.
(cherry picked from commit 27038f7064bb611811efee8c527dddecd4fcb57b)
The homeserver rejects any event whose canonical JSON is over 65,536 bytes
(M_TOO_LARGE), but Matrix chunked outbound text on characters, 16,000 by
default with a 65,535 ceiling. Each message carries its text twice (body plus
formatted_body HTML), and an edit carries it four times (the "* " fallback
plus m.new_content).
On the default config this caused three failures:
- A formatted chunk of CJK text came to about 95 KB and was rejected, so
_send_with_retry fell back to a plain 3,500-char truncation.
- A streamed edit near the limit came to about 80 KB even for English, so
edits failed mid-stream.
- The standalone sender (cron without a running gateway) posted each
16,000-char chunk as one event, and hit the same failure on non-Latin text.
The fix measures Matrix text in UTF-8 bytes, the unit of the cap:
- The adapter's message_len_fn now returns UTF-8 bytes, so send() and the
stream consumer's split and progress-edit budgets count the same unit.
- A chunk gets a third of a 45,000-byte content budget, which leaves room for
the event envelope and E2EE's 4/3 base64 inflation. That makes 15,000 the
default and the ceiling for max_message_length, so English chunks stay
about the same size.
- _build_text_message_content drops the HTML when escape-heavy text would
push the content past the budget.
- An edit that would still be too large keeps its full m.new_content and
shortens only the top-level fallback, which edit-aware clients never
render.
- The standalone sender re-chunks on UTF-8 bytes and applies the same HTML
guard.
(cherry picked from commit cab00c94175955a68ecd4d38c5748d63148ca6be)
The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.
(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
_validate_cron_base_url accepted a job base_url whose hostname matched
the provider's endpoint or was a subdomain of it, so a cronjob tool call
could pair the stored key with plain HTTP, another port, or a subdomain.
Compare the full origin (scheme, host, effective port) via
utils.base_url_origin, the rule the other secret-to-new-URL checks use.
The scheduler backstop calls the same validator, so every surface that
stores a job is checked before the key is sent.
(cherry picked from commit a281efeedac02f87e168743b389b78f6feaf5472)
_read_main_api_key_if_same_host read base_url and api_key through the
per-field readers, which fall back to config.yaml field by field. A live
main runtime that is keyless or uses key_cmd therefore paired its own
base_url with config.yaml's key, and an auxiliary task pointed at the live
endpoint received the key issued for config.yaml's endpoint.
The anchor and the key now come from one source: the runtime bound for the
turn, else config.yaml. Documents the origin rule (EN + zh-Hans).
(cherry picked from commit ad3de0cf6fd35ee783c5d999ed98e913d23ffcab)
r2 review: moving explicit failing verdicts to debug hid a listed contact whose
own domain breaks DKIM or whose forwarder breaks alignment. Those drops warn
again, with the verdict and no opt-out hint; forged stranger mail under open
access stays at debug. The two operator hints now live in one reason->hint
table behind a single warning call.
The previous commit moved every non-missing-header auth failure to debug,
which made a mis-pinned authserv_id (the only stamp comes from another
authserv-id) drop allowlisted mail silently at default log levels. That is
an operator misconfiguration, not routine forgery, so it warns again, with
a hint pointing at platforms.email.authserv_id instead of the opt-out.
Under open access every stranger is granted, so each forged or DMARC-failing
message logged a WARNING telling the operator to disable sender
authentication. Keep that warning (and hint) for the one verdict that points
at a server that never stamps the header; log every other unauthenticated
drop at debug, including an explicit failing verdict for an allowlisted
address. The reason string is lifted to _NO_AUTH_RESULTS_REASON so the
parser and the check share it.
teams_pipeline_store.json persists each Graph subscription's client_state, the shared secret
MSGraphWebhookAdapter._verify_client_state checks on every notification. mem0.json is read for
api_key (the documented self-hosted layout keeps the server key there). A named export shipped
both and a distribution could install both; they are on the shared inventory now.
The two invariant tests name both stores, the Teams one through the writer's own filename
constant, so they fail without the inventory entries at both boundaries.
(cherry picked from commit 6e44670c62c077521c90cb270c1ace3dacef6651)
A named export still shipped credential stores the list did not name, most with the secret intact:
home/ (the subprocess HOME, where git, ssh, gh, npm and skill CLIs such as xurl keep credentials;
.git-credentials and private keys have no suffix the scrub edits), weixin/accounts/ (WeChat bot and
context tokens), proxy/ (iron-proxy CA key and proxy tokens), chrome-debug/ (the /browser connect
Chrome profile), .copilot_jwt.json and runtime/photon-sidecar.json. They are on the list now.
The recovery copies (backups/, state-snapshots/, auth.json.*, .env.bak*, config.yaml writer copies)
move into profile_path_is_private, so a distribution can no longer plant a state snapshot or an
auth.json.corrupt either; before, only the export dropped them. config.yaml copies match the writers'
formats, so a hand-named config.yaml.bak-my-note (user-owned per config_backups) is exported again,
and the export scrub now covers config.yaml.* names so its secrets are redacted like config.yaml's.
The three invariant tests become two: the export test runs the real recovery writers and checks
case-folded names; the distribution test covers plant and replace for the legacy and
distribution_owned layouts.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 2bd63066f48798a849394df131860ed3140d1597)
honcho.json holds the Honcho apiKey and the OAuth grant (oauth.refreshToken,
a key name the export scrub does not mask), so it joins the profile credential
stores: never exported, never installed or overwritten by a distribution.
A distribution payload that ships a regular file where the profile has a
directory holding credential stores (a file named `platforms`, or
`platforms/whatsapp`) was a whole-directory replace that deleted
platforms/pairing and the platform sessions. The update is now refused in the
pre-write guard, before any file is replaced.
(cherry picked from commit 10a3b017c56862b73ed4affffc2786ef65e2dcbe)
PROFILE_CREDENTIAL_PATHS was honoured by the export, but a distribution
install only checked top-level names. A distribution that owns platforms/
(the legacy whole-payload layout, or an explicit distribution_owned entry)
planted its own platforms/pairing approvals and platforms/whatsapp/session
into the installer's profile, and replaced the installer's on update.
profile_path_is_private() / profile_path_contains_private_store() match
root-relative paths case-folded. The install skips a private path, refuses
it in distribution_owned, and merges any ancestor of one instead of
replacing it. The export uses the same predicate.
Also lists the legacy single-user Google Chat token file (it carries the
OAuth client_secret, which the text scrub does not redact), the legacy
Google Chat PKCE directory, and the Google Meet node RPC token.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 395959f4ae4ca2c92a45199709f54ccfd9fd1724)
A named-profile export dropped the stores themselves but shipped the copies Hermes writes of them: backups/ (pre-update zips of the whole home, config.yaml copies), state-snapshots/ (per-profile update snapshots with pairing stores), auth.json.corrupt, and the config migration's .env.bak-<stamp> / config.yaml.bak-<stamp>. None of their suffixes is one the text scrub edits. The default export's root allow-list already omits all of them.
Also lists feishu_comment_pairing.json beside the other pairing stores, and mirrors the credential-store wording in the zh-Hans distributions page.
(cherry picked from commit 0a24aeaaf55279bfa9af4cc9f5e80873fb611020)
Named-profile exports dropped only auth.json, .env and bot-desktop, so
other credential stores Hermes reads from a profile home were archived
as-is: .op.env (1Password service-account token), npmrc, OAuth token
files, mcp-tokens/, vault/, browser profiles and platform sessions.
.op.env and npmrc also escape the text scrub pass.
Add PROFILE_CREDENTIAL_PATHS (root-relative) as the one list, exclude it
from named exports, and fold its root entries into
DEFAULT_EXPORT_EXCLUDE_ROOT so distribution installs treat them as
user-owned.
(cherry picked from commit fb8ce7a9999049c1bc11aaf7a3d9b2190d8ed1aa)
Browsers send no Origin on a GET, so a cross-site page (or a DNS-rebound
page on a wildcard bind) could still reach /v1/models with the operator's
credential. Sec-Fetch-Site, which a page cannot forge, now refuses those.
The Origin check compares the parsed host and port instead of the raw
string, so an Origin that omits the default :80 or spells an IPv6
address differently is matched the way a browser means it.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 72bf4e9c34f6c6200658e42345f775291462697c)
A wildcard bind accepts any Host name, so a DNS-rebound page reaches it
with Origin equal to its own Host and the same-origin rule let it through
to the operator's credential. A wildcard listener has no origin of its
own, so an Origin-bearing request is refused there; API clients without
Origin keep working. Also treat the absolute form localhost. as localhost
and detect expanded IPv6 unspecified binds as wildcard.
(cherry picked from commit 5ce7aac4d7e4d160b2a6447f0442e747f489d72f)
hermes proxy attaches the operator's subscription credential to every
forwarded request but accepted any Host header and any Origin. A
loopback or specific-IP bind now serves only its own Host names, and a
request whose Origin is not the proxy's own is refused with 403.
Non-browser clients send no Origin and are unaffected; a wildcard bind
keeps accepting any Host.
(cherry picked from commit 3cf3870cfc2a795e439b1a8c0c53424f79c24505)
launch_budget read free memory only from nvidia-smi, so a discrete AMD/Intel card launched at its
capacity window regardless of other programs' VRAM, the case that silently pages on Windows. It
now asks the engine's device probe afresh at launch (the polled view stays cached); the driver's
free count includes other processes (live: 16.2 GB held elsewhere read as 8.2 GB free of 26.0).
Docs: auto is a preference with the platform's pinned fallbacks, not an unconditional CUDA pick.
The previous commit budgets any Vulkan/HIP device that `llama-server
--list-devices` prints as a discrete card. That output carries no
integrated-vs-discrete verdict, so an iGPU (Intel ADL-N reports 11.8 GB of
a 16 GB machine's shared memory) would be counted twice: once as VRAM and
again as system-RAM spill.
Ask the installed engine's ggml library instead, in a bounded child
process: ggml_backend_dev_type() is GPU for a discrete card and IGPU for
an integrated one (Vulkan reads it from VkPhysicalDeviceType). llama.cpp
places layers on discrete devices whenever one exists and falls back to an
integrated GPU only otherwise, so the budget follows the same rule:
- discrete device -> its own memory, system RAM as spill (uma=False)
- integrated GPU, no answer, CPU/Metal -> RAM-as-unified, unchanged
The probe runs only when no NVIDIA device answered and the configured
engine is Vulkan/HIP, and is cached per engine binary (the hardware
endpoint polls every few seconds). The CUDA --list-devices fallback for the
NVIDIA unified-pool quirk is left as it was on main.
Co-authored-by: Belaquaa <eee_pokkk@mail.ru>
DELETE/PUT /api/learning/node, POST /api/skills and PUT /api/skills/content
read the target profile from the JSON body only. A shared-backend Desktop
(global remote, registry connections) scopes these calls with ?profile= and
sends a body without one, so the star map's delete archived, and its edit
rewrote, the same-named skill of the backend's launch profile while the
selected profile's skill stayed as it was.
Take the body profile first and fall back to the query, as
PUT /api/skills/toggle and the tools routes already do.
A symlink anywhere under skills/ makes _safe_skills_path serve the skills
mount from a fresh mkdtemp copy per process, so hashing its host path into
hermes-environment made the label differ across processes: the persistent
container was never reused and every session started fresh, losing the
long-lived-container contract (installed packages, filesystem state).
Canonicalize tempdir-sourced mount specs to a stable placeholder that
keeps the container path; real mounts, image and HERMES_HOME still force
a fresh container on change.
Follow-up to #131895.