Under multiplex, authorization reads each profile's own secret scope, so the
DISCORD_ALLOWED_USERS the YAML bridge copies from discord.allow_from into os.environ is never
seen. The fallback to the adapter's extra then read group_allow_from for 'group' sources:
Telegram's group-only list, which Discord does not have. An allowlisted user's guild-channel
messages were dropped and their button clicks refused, while threads and DMs worked.
Discord declares allow_from_covers_groups, so its groups read allow_from. Adapters with a
separate group list (Telegram) are unchanged; a test pins that a Telegram DM-only allow_from
still does not admit the user in a group.
#133213 confirms a snapshot-admitted clicker with the gateway's live check, but labelled every
guild click 'group' and passed no thread id. Message intake labels the same channel 'thread',
so a user the gateway admits in a thread could be refused on its approval card ("This bot is
private..."), and a thread_id profile route never matched the click. Label the click like
intake does: a discord.Thread is 'thread' with its id as thread_id.
python -m / -c put the current directory ahead of PYTHONPATH and site-packages, so a
hermes_cli/, tui_gateway/, agent/ or plugins/ package in the directory Hermes runs in
(a cloned repo, a task workspace) ran in place of Hermes' own. Spawn these children with
-P (safe path, Python >= 3.11) and keep their cwd: TUI slash worker, TUI cli.exec, setup
import-agent, profile uninstall, gateway restart watcher, plugin validate probe, Google
Meet bot, and the codex hermes-tools MCP server config. The validate probe now names the
Hermes tree on PYTHONPATH instead of finding providers through the cwd.
CI on the salvage was red on the code-health ratchet (over-cap facades grew; two blind excepts)
and on the pm observer fixture, whose isolated source tree carries pm/ + hermes_cli/ but no utils.
- config_backend imports become module-level in the facades (it imports only stdlib); gateway,
auth and telegram take config_exists through the hermes_cli.config import they already have.
- Pure helpers move to topical siblings, offsetting the growth: _csv_or_list_to_set ->
gateway/run_common.py, the dotted config-path pair -> hermes_cli/backup_restore.py, the
voice/timezone/web-backend checks -> hermes_cli/config_value_checks.py, _load_config_dict ->
hermes_cli/profile_memory_config.py (both callers are memory-provider code).
- _load_config_dict catches YAMLError/OSError/UnicodeError instead of Exception.
- _load_config_cache_hit probes readability with config_backend.probe_config_readable (a 1-byte
read through the backend's open) instead of a full parse plus a blind except.
- pm/plugins_state.read_home_selection checks config_exists before importing the YAML stack, so
a home with no config never needs utils.
- developer-guide/config-backend.md describes this build (file backend only).
Same approach as @benbarclay's 74421be709 in #122509, which does not cherry-pick onto current main.
Add hermes_cli/config_backend.py (config-config design §4.1, D10/D11): a
ConfigBackend protocol, the FileBackend (today's behaviour), selection by
HERMES_CONFIG_BACKEND only (default file; 'remote' and unknown values fail
closed with ConfigBackendUnavailable), and path-level helpers
(read_config_doc, read_config_doc_readonly, config_version, config_exists,
write_config_key, write_config_document, supports_file_tooling).
Migrate every direct reader/stat/exists/bypass writer of a user config.yaml
to the helpers (config.py loaders + caches, config_effective, env_loader,
gateway, TUI, CLI watchers, cron, tools, profiles, logging, time). File
tooling (config edit, backups, xAI migration rewrite, raw dashboard editor)
checks supports_file_tooling() first; migrations skip write-back without it.
Add scripts/check_config_yaml_readers.py (CI lint next to the writers gate)
that rejects a direct read of a config path outside the backend.
Review finding (P2, #144640): LazyHeaders was a read-only Mapping, so a
user provider override doing get_provider_profile('gmi').default_headers.copy()
(or |, update, json.dumps, ==) raised AttributeError and the provider
disappeared, in-process and under plugins.isolation: host.
LazyHeaders is now a dict subclass that stores every key up front and
resolves callable values (the release-carrying User-Agent) on first value
read. Every value-reading/mutating method resolves first; __iter__ is
overridden so dict(x)/{**x}/f(**x) leave CPython's raw-storage fast path;
json.dumps takes items() because the stored keys make it non-empty.
.copy()/|/copy/pickle yield plain resolved dicts. Importing provider plugins
still runs no git. The plugin-host Mapping materialization is gone: encode()
reads a dict subclass through items(), which resolves.
Every CLI start imports all provider plugins (hermes_cli.config lists their env vars), and
fireworks/gmi/xai/router/kimi/opencode plus auth_constants, models, model_catalog,
auxiliary_client, gemini_native_adapter, web_server_messaging, slack, yuanbao and perplexity
built a `<product>/<release>` string at module level. In a source checkout the release comes
from the 7-call git provenance walk (`git tag --merged HEAD` alone is ~0.35 s in a large
clone), so `hermes sessions list` paid it for headers it never sends.
Provider profiles take a LazyHeaders mapping (resolved on first read by the client builders,
which already copy with dict()); core modules call version_info.hermes_user_agent() where the
request is built. Header values are unchanged.
A plugin that rewrites the reply (redaction, policy filters) can declare that it
must see the reply before the user does. The declaration rides the hook's
registration handle, so it lives exactly as long as the registration and is
scoped to the plugin manager of the profile that loaded it; stream_held()
answers for the active profile. Plain transform registrations keep streaming
(opt-in), and the flag on any other hook is ignored with a warning. The memory-provider hook proxy and the
`plugins validate` probe stub accept the keyword so a declaring plugin loads
through every registration seam.
Shape and seams salvaged from #102481 (mutates= on register_hook, the memory
proxy and validate-stub forwarding).
Co-authored-by: Halldrix <12357213+Halldrix@users.noreply.github.com>
Under plugins.isolation: host every value crosses as JSON, so a dataclass
arrived as a generic Record, a Path as str, an enum as its value and a
plugin object as an Opaque placeholder. Core consumers that type-check
broke:
- secret sources: SecretSource.fetch() came back as Record, the
orchestrator rejected it ("fetch returned Record instead of
FetchResult") and applied no secrets; fetch() also received home_path
as str, so `home_path / "x"` raised in the plugin.
- context references: ContextCompletionItem (a __slots__ class) became
Opaque, so @prefix: autocomplete silently returned nothing.
- context engines: clone_for_agent() returned Opaque, so no agent could
get its own copy of a hosted engine.
The provider ABC's annotations are the contract, so the wire now
restores against them: the parent rebuilds results from the declared
return type (dataclasses, enums, Paths, typed containers; a declared
provider ABC becomes an object proxy) and the host rebuilds method
arguments from the declared parameter types. Type names never come from
the host. ContextCompletionItem becomes a slots dataclass so it crosses
as data.
The previous change kept the Webapp's Portal client in the Nous sign-in plugin's module. Plugin
discovery imports that bundled plugin under a module name of its own
(hermes_plugins.dashboard_auth__nous), so the gateway's import of plugins.dashboard_auth.nous was
a copy discovery never ran. In a real gateway the Webapp kept signing in with
HERMES_DASHBOARD_OAUTH_CLIENT_ID, and on a home with only Web Access's client the Webapp found no
sign-in provider and fell back to the private listener. A live run of the gateway's own start
showed both; the earlier tests imported the plugin directly and passed.
The listener's client and the plugin's provider swap now live in
hermes_cli.dashboard_auth.listener_client, one module that the plugin, however it was imported,
and the gateway share. The plugin offers its swap when discovery runs it, so a plugin that
plugins.disabled keeps from loading still gives the Webapp no Nous sign-in. The tests load the
plugin the way discovery does.
Web Access registered the Webapp's sign-in callback on the standalone dashboard's Portal client
(HERMES_DASHBOARD_OAUTH_CLIENT_ID), saved that id in .env, and `hermes dashboard register` called
back into Web Access so it would register again. If the Portal replaces a client's redirect URI on
update, turning on Web Access broke Nous sign-in on an existing `hermes dashboard` deployment.
Start now registers a client of Web Access's own (named webapp_<adjective>_<noun>) and keeps its
id, its Portal and the callback in webapp/sign-in-registration.json under the gateway home, user
only. It never sends the dashboard's client id and never writes .env. A second Start with the same
callback asks the Portal nothing; a moved callback updates Web Access's client only. While the
gateway serves the Webapp, the Nous sign-in provider uses that client, and a plugin reload keeps
it. The gateway's private listener and the standalone dashboard keep reading
HERMES_DASHBOARD_OAUTH_CLIENT_ID as on main, and a Webapp whose Nous sign-in was set up by hand
keeps the configured client. A home whose record came from an earlier build of Web Access names
the dashboard's client; its next Start registers a client of its own and leaves the dashboard's
alone. `hermes dashboard register` no longer touches Web Access's record.
The desktop guide said Start saves the client id in .env; it now says where the id lives.
Independent review findings on update-disabled plugins:
- Recorded plugins are tried only after the enabled plugins have built, one
at a time on top of them; an update that has to disable an enabled plugin
re-enables nothing, so a download failure can never swap two plugins.
- A resolver or build verdict records a fingerprint of what was tried (core's
dependency inputs plus the plugin's) and is not built again until one side
changes; a download failure is retried by the next update, and those go
first because they fitted beside the rest before.
- The update publishes against the config snapshot it decided from, and a
rejoin needs the record to still be there, so a user's mid-update choice
stands.
- Recorded reasons keep their readable start, not the tail of a uv error.
- An installed provider that fails to import is reported as such instead of
running recovery, and the startup notices dedupe per kind.
- A provider-only record is retried only while memory.provider still names
it, so a rejoin never installs a provider the user switched away from.
- An installed provider that still fails to load after startup recovery is
reported even when recovery printed an install notice.
- One spelling reader: plugin_spellings uses the shared native manifest
reader (plugin.yaml or plugin.yml), and the memory loader uses it too.
When an update had to disable a plugin it also cleared memory.provider, so
the memory provider stayed lost even after the plugin was re-enabled, and
nothing ever retried the plugin: one transient failure (a TLS proxy, a mirror
outage, a since-fixed resolver bug) left it off for good.
- The dependency union now honours plugins.disabled for memory.provider the
same way the provider loader already does, so eviction no longer has to
blank the selection. Re-enabling the plugin brings the provider back.
- The eviction is recorded in the home's config under
plugins.disabled_by_update (was it enabled, and why). Every later update
retries recorded plugins after the enabled ones and undoes exactly the
edit once a plugin fits again.
- A user's own enable/disable/remove takes the decision back: PM's selection
publication forgets the record for the plugin the user settled, and
`hermes plugins disable` on an update-disabled plugin now writes that.
- Spellings come from the plugin's own manifest instead of a plugin scan of
the PM process's home, so a profile's plugins are matched in that profile.
- A parked provider is reported, not re-installed: agent init tells the
user once why external memory is off (naming the update's reason), and
`hermes memory status` and `hermes doctor` show it instead of "plugin not
found" or "All checks passed". Doctor lists every update-disabled plugin.
- Feishu routed .ogg/.opus to an "audio" (voice) message by extension even
for send_document / is_voice=False; Opus is now an audio message only when
the caller requested one.
- Discord _ogg_opus_duration walks page headers by segment table instead of
rfind("OggS"), which could match packet data and misread the granule.
- Tests: successful transcode path (bytes, duration, temp removed), OggS run
inside packet data, Feishu plain .ogg/.opus and the voice-intent control.
Same class as the Discord and Feishu fixes: these adapters' send_voice
ignored is_voice, so a plain audio MEDIA file (music, a sound effect)
became a voice note. The WhatsApp bridge always sends "audio" as ptt
(transcoded to Opus), WhatsApp Cloud converted every local mp3 into an
Opus voice bubble, and BlueBubbles flagged every file isAudioMessage.
is_voice=False now goes as a named document (WhatsApp bridge), as the
original audio/mpeg (WhatsApp Cloud), or as a plain attachment
(BlueBubbles). Voice intent and the no-flag auto-TTS path are unchanged.
The media dispatch routes every audio MEDIA file to send_voice with is_voice,
but the Discord adapter swallowed the flag and uploaded every file as a native
voice message named voice-message.ogg (audio/ogg), whatever the bytes were.
A sound-effect mp3 or a wav arrived as a voice note that does not play, with a
duration guessed from the byte size (mutagen is not a dependency).
is_voice=False now goes out as a regular attachment under its real name.
Voice intent ([[audio_as_voice]], auto-TTS) sends real Ogg/Opus: non-Opus
input is transcoded with ffmpeg, without ffmpeg the original goes out as an
attachment, and duration_secs is read from the Ogg granule position.
The deferred tool_search catalog shows each tool's first sentence clipped to
60 chars, so lines like 'image_generate: Generate high-quality images from
text prompts, or edit /…' hid that it edits with reference images on the
user's configured model. An agent skipped it and called FAL directly with a
hardcoded model.
- catalog line cap 60 -> 80 (CATALOG_SUMMARY_MAX_CHARS); every built-in
tool's first sentence now fits and leads with what it does
- image_generate/video_generate say they use the configured model;
image_generate says to use it rather than direct API calls/scripts
- web_search/web_extract/browser_exec name when to use each other
- drop a duplicated cronjob sentence and filler in skill_view/video_generate
- regenerate tests/fixtures/prompt_surface
Model-facing text, startup tips (all 17 locales; the tips probe needs contiguous keys, so the
five stale tips are rewritten to live browser.* features), .env.example, developer-guide file
maps and the provider parity script still named browser_navigate/vision/console, record_sessions,
agent-browser/AGENT_BROWSER_* and the deleted browser_tool* modules.
browser_exec is the only core browser tool (plus browser_cdp, browser_dialog,
browser_vault, all CDP). Deleted: browser_navigate/snapshot/click/type/
scroll/back/press/get_images/vision/console and every module that served
them (browser_tool_session/_lifecycle/_cloud/_cdp/_real_profile/
_lightpanda_fallback/_eval_policy/_origin/_snapshot/_vision) plus
browser_camofox*.py (Camofox ships as the catalog plugin 'camofox').
tools/browser_tool.py and tools/browser_tool_install.py shrink to the
frozen old-updater stub warm_agent_browser_npx_cache.
toolsets: _HERMES_CORE_TOOLS / browser toolset list only the CDP tools.
model_tools: browser_tools legacy alias and the browser_navigate /
browser_type schema rewriters removed. Cloud providers no longer request an
install post_setup hook (nothing to install).
Move today's delegation routing (every child follows the delegation section,
else the parent's route) out of core into plugins/delegation/default, a bundled
kind: backend plugin that registers through ctx.register_delegation_provider
like any other strategy. Core keeps only the name 'default' as the fallback
selection; no built-in provider class and no reserved name remain.
The registry loads a bundled strategy on first use when the plugin sweep never
registered it (runtimes that pinned discovery with zero plugins), so
delegate_task never depends on the sweep; plugins.disabled still wins and a
disabled default is a clear refusal. Config names are validated before they
become a path.
Resolves the desktop i18n conflicts: main moved en/ru errorCodes into
en_error_codes.ts / ru_error_codes.ts, so usage_window moves there; ar/ja/zh/
zh-hant keep both usage_window and main's workspace_unavailable. _keys.desktop.json
regenerated.
The Singularity/Apptainer backend (tools/environments/singularity.py) now ships
as the official 'singularity' plugin in NousResearch/hermes-official-plugins,
registered through the terminal-environment provider seam under the same
backend name and config keys (terminal.backend: singularity,
terminal.singularity_image / TERMINAL_SINGULARITY_IMAGE, terminal.container_*).
- 'singularity' leaves BUILTIN_BACKEND_NAMES so the plugin can register it;
every hardcoded classification set drops it (the provider declares
is_remote/is_container/supports_streams instead).
- LEFT_CORE gains a singularity row: homes whose terminal.backend (or .env
TERMINAL_ENV) is singularity get the catalog plugin once, on hermes update
or agent/gateway start. An unknown-backend error for 'singularity' names
the install command (new row field terminal_backend).
- A plugin backend receives its per-task '<backend>_image' override; batch
runs register one for the active backend.
- BaseEnvironment's sudo-probe knob is renamed _passwordless_sudo_probe_supported:
plugin backends set it, and the old spelling trips the install scanner.
- Core's scratch-dir disk warning and process-exit sweep were Singularity-only
and move to the plugin; the singularity setup row, dashboard probe/row and
config defaults/bridges go too. Docs point at the plugin.
`hermes mcp serve` (mcp_serve.py) moves to the official mcp-serve plugin in
hermes-official-plugins, which registers `hermes mcp-serve` and also carries
the `--tools` mode from #83364.
- delete mcp_serve.py and tests/test_mcp_serve.py (the suite moved with it)
- drop the serve subparser, its dispatcher branch, the mcp agent-subcommand
row and the inline MCP discovery path only `mcp serve` reached
- LEFT_CORE row (command + old_command): `hermes mcp serve` and a bare
`hermes mcp-serve` without the plugin print the new spelling and the
install line on stderr. MCP clients live outside Hermes, so no home shows
the feature in use and nothing installs automatically.
- docs, locale tips and the bundled cli-reference point at the plugin
agent/transports/hermes_tools_mcp_server.py stays: the codex_app_server
runtime launches it as its hermes-tools MCP server.
The tool gateway now tags execute results with dataUse.requires (no_training,
zero_retention). Hermes keeps that as `data_use` metadata on the tool message
(content unchanged; a batch carries the union), persists it inside the existing
display_metadata column (no migration; /branch, resume and compaction copies
keep it), and strips it from every provider request. The Nous profile reads the
union over the outgoing messages and sends provider.data_collection=deny (+ zdr
for zero_retention) on both the chat-completions and Anthropic Messages paths;
no other provider sees it. A 403 data_policy_denied classifies as its own
non-retryable reason with no fallback and copy that tells the user to switch
model, compress, or start a new session.
Direct dispatch passes image_gen.model to generate() explicitly, like the
managed path, so a KREA_IMAGE_MODEL override naming Krea 2 made the schema
hide Krea 3's thinking/resolution controls and describe edit inputs as style
references while Krea 3 ran.
Folds the two IPv6 server classes into one: every IPv6 host gets
AF_INET6, and V6ONLY=0 makes the :: wildcard answer IPv4 too.
A dual-stack socket reports an IPv4 peer as ::ffff:a.b.c.d, so the
shared-token / no-token identity became ip:::ffff:127.0.0.1 and an
A2A_TRUSTED_PEERS entry written as ip:127.0.0.1 no longer matched
(403 "peer not trusted"). _client_ip unmaps it. The test drives a
token-gated :: bind over both stacks plus an IPv4 tasks/list call
behind an IPv4 allow-list.
A2A_HOST=[::1] was not recognised as loopback (no-token runs fell back
to 127.0.0.1 with a misleading warning), failed to bind with a token, and
advertised http://[[::1]]:port/. Strip URL brackets when capturing the
host, and pin the Host-less advertised URL in the IPv6 test.
A2A_HOST=::1 is an accepted loopback bind, but ThreadingHTTPServer is
AF_INET only, so the listener failed with bind_failed. An IPv6 host now
uses the AF_INET6 server, and the Agent Card URL brackets the literal.