GLM-5.2 became the silent default (the model Hermes lands on when the user
never picked one) in #64635 as a capable low-cost pick. GLM-5.3 has since
superseded it at about the same price on Nous Portal and OpenRouter, with
the same 1M context, and both curated catalogs already list it.
Move every copy of the default together:
- PREFERRED_SILENT_DEFAULT_MODEL and the OpenRouter "default" description.
- The "default": true label in website/static/api/model-catalog.json,
regenerated with scripts/build_model_catalog.py, so clients rotate from
their cached catalog without a release.
- The TUI's _resolve_model last resort, which hardcoded "z-ai/glm-5.2" and
would drift on every rotation; it now reads the constant from the
data-only models_catalog_static module.
- The example manifest in the model catalog reference docs.
The new TUI test pins the no-model fallback to the constant, and the
manifest contract test is renamed since it checks whatever the constant is.
On macOS webbrowser.open can return True with no page the user sees: over SSH osascript opens the browser on the Mac's own screen, and a sandboxed browser may refuse a file: page in a hidden folder. After a private launch, say how to get the link. The docs no longer say an SSH session always prints the link: that holds on the BSDs and illumos. The private-launch docstring notes that on macOS the URL reaches osascript on stdin, so the page mainly protects the BSDs and illumos.
Where nothing checks who redeems a one-use launch link (macOS, the BSDs, illumos, WSL), hermes webapp printed it, and any local user who read it first could redeem it within its 60 s. TCP loopback has no peer-credential call on those platforms. The launcher now writes the link into a 0600 page in the gateway home's user-only webapp folder and opens that page, so the browser launcher's command line carries only the page's path and nothing is printed. It still prints the link where it cannot open a browser and on WSL.
Refs #144648 (WA-12)
The seam had a selector and reserved members that nothing in this PR
uses: HERMES_CONFIG_BACKEND accepted only `file` (every other value
exited), the .env override existed only to decide when to refuse,
UserLayer.locks/provenance and ConfigBackend.locked() were always empty,
and the supports_file_tooling()/require_file_tooling() checks at ten
file-copy sites were always true. All of it is for the remote backend
(#122509), so it lands there with the code that makes it real.
get_config_backend() now returns the file backend. The env var, its
docs row, the dotenv override, ConfigBackendUnavailable and the
file-tooling gates are gone, with the tests that covered them. The
explicit-file routing test now proves a non-config.yaml path never
consults the backend.
probe_config_readable() calls a probe_readable() protocol method
instead of an isinstance(FileBackend) check, and version_token()
documents how it treats an int-led version.
- The environment-variable reference had no row for HERMES_CONFIG_BACKEND,
a selector that stops Hermes at startup when set to anything but
`file`. Added, with where it is read from and a link to the developer
guide.
- hermes_time called the config backend module "dependency-free"; its
file backend imports utils. Say what the fallback actually does.
Teknium: the ElevenLabs variant of text_to_speech is where voice search belongs, not a
separate tts_voices tool. The ElevenLabs schema now carries voice_id plus a find_voices
object (text optional there); other providers' schema is byte-identical to main.
Live search findings folded in: the library is the default source and sorts by a year of
usage (most-used first); use_case (e.g. characters_animation) and sort filters; library
voices with a custom credit rate show credit_rate; the description tells the model the
library matches words literally, so a role word that finds nothing ('dwarf') falls back to
describing the sound ('gravelly old' + filters).
With tts.provider: elevenlabs, text_to_speech gains an optional voice_id and a new
tts_voices tool searches the user's account voices (/v2/voices) or the public
Voice Library (/v1/shared-voices) with query/gender/age/accent/language filters and
an opaque next_page cursor. Both are gated on the ElevenLabs provider (dynamic
schema override + check_fn), so every other provider's surface is unchanged. A
per-call voice_id never rewrites config; a stale one sent under another provider is
ignored with a warning.
The guides and the launcher's docstring said, or implied, that on Linux and Windows the gateway
checks who redeems a one-use launch link. Only Linux does: on Windows the check returns yes for
every connection, and the link is protected because it travels only in the browser launcher's
command line, which other standard users cannot read. A reader weighing a shared Windows machine
was told the server authenticates the browser when it does not.
The peer check's docstring, the launcher's module docstring, the CLI reference and the Desktop
guide now say what each platform does: Linux checks the connection's user, Windows relies on the
launcher's private command line, and macOS, the BSDs and WSL print the link instead.
Under WSL2 the Windows browser reaches the gateway's loopback port through WSL's localhost relay, and
inside Linux that connection belongs to root. The gateway treated WSL as Linux: it gave hermes webapp
a launch link that only a connection owned by the gateway's user may spend, so every link was
refused, and the page told the user it "was already used or has expired. Run hermes webapp again",
which failed the same way. The loopback Webapp could not be opened from Windows at all.
The connection cannot be tied to the user there (any Windows account can reach the relayed port), so
WSL now takes the macOS path: the link is printed for the user to open by hand and spends without
the peer check. Native Linux keeps the check. Live on this machine's WSL2 (NAT): before, curl.exe from
Windows arrived as uid 0 and got 403; after, the printed link spent with 200.
With the Webapp on every interface (`webapp.host: 0.0.0.0`) and no `webapp.public_url`, `hermes
webapp` on the host opened the loopback address. Web Access Start registers Nous sign-in for the
LAN address's callback, so a sign-in started there sent the Portal a callback for 127.0.0.1 that it
had never seen, and failed.
For a launch behind the sign-in gate the gateway now opens, in order: `webapp.public_url` when set,
as before; on the LAN, the address in Start's `webapp/sign-in-registration.json`, while that is
still this machine's LAN address and the Webapp's port (a moved address or port would send the
browser elsewhere); otherwise the loopback address, which still serves a password provider and
sign-in set up by hand for it. Native clients keep the loopback address. The docs say so.
Where nothing can check who redeems a launch link (macOS and the BSDs), `hermes webapp` printed the
gateway's browser credential as `#hermes-session=<token>`. That link signed a browser in as the user
until the next sign-out, and when Hermes ran the command through its terminal tool the credential
landed in the transcript.
The gateway now mints a launch ticket there too: the same one-use, 60-second link Linux and Windows
get, which spends without the peer check, as a window ticket does. `hermes webapp` prints it with its
lifetime and still never passes it to a browser launcher, whose arguments other local users can read.
A copy that leaks is spent once the user opens it and dead after 60 seconds, instead of working until
the next sign-out. The docs describe the macOS link as the one-use link it now is.
A browser that signs in to the gated Webapp keeps the sign-in provider's access and refresh token
in its cookies. Only the cookie path checked the login's binding to the browser credential, so
after `hermes webapp --sign-out` those tokens still worked when presented on their own: the access
token as `Authorization: Bearer` (enough to mint a host-terminal ticket) and the refresh token on
`/auth/native/refresh`, where the bundled password provider hands out a new 30-day refresh token
every time. Refusing a recorded list of those tokens would not hold: the password provider decodes
base64 leniently, so another spelling of the same token, such as the quoted value the cookie
stores, verifies too.
On the gateway's Webapp behind the sign-in gate, a provider token now works on its own only when
the listener gave it to a native app's RFC 8252 sign-in (`/auth/native/token`, then each
`/auth/native/refresh`). The listener keeps a digest of each such token until it expires, in
`webapp/native-sign-in-tokens.json` in the gateway home, so native apps stay signed in across a
sign-out and a restart. A browser login's tokens work only as that login's cookies, which
sign-out, or a credential replaced while no gateway ran, already ends. The standalone dashboard
and the gateway's private listener keep main's behaviour. `hermes webapp --sign-out` and the docs
now say that those tokens end and that native apps stay signed in.
Behind the sign-in gate, hermes webapp opened the gateway's loopback address even when
webapp.public_url was set. Sign-in started there and kept its state cookie on the loopback
address, but its callback is built on the public URL, so an OAuth sign-in such as the Nous
account's came back to an address without that cookie and failed with "Missing PKCE state
cookie".
A gated launch now opens the listener's own public URL, path prefix kept, with the profile
query after it, so sign-in starts and finishes on the same address. Without a public URL it
still opens the loopback address, and native clients keep dialling loopback. The CLI and
configuration docs say which address is opened.
The Webapp docs said `hermes webapp --sign-out` signs out the tabs a launch link signed in. They
now say it also signs out every browser that signed in with a dashboard account behind the
sign-in gate, closes Bot Desktop viewers with the rest of a tab's connections, reports how many
connections it closed, waits for a gateway that is still starting, and replaces the stored
credential itself only when no gateway runs. The private-link fallback is documented for the BSDs
as well as macOS, and the developer guide records how a Webapp login is bound to the browser
session token (the hermes_webapp_signin cookie).
The docs said only a 0.0.0.0 or :: host turns on the Webapp's sign-in gate. A public URL does too,
on a loopback host, and `hermes webapp` then opens the Webapp's address instead of a one-use link.
The configuration reference, the Desktop guide, the `hermes webapp` reference and the gateway
sessions guide now name both triggers (webapp.public_url, not dashboard.public_url), say that the
listener native clients use never asks for sign-in, and say that a taken Webapp port replaces the
browser sign-in so open tabs need `hermes webapp` again.
Documents what hermes webapp does behind the sign-in gate, that sign-out closes open tabs, the
gateway restart after fixing an unavailable Webapp, terminal file drop and the terminal's starting
folder.
A real gateway with a user-plugin messaging platform and a loopback model:
a resident /refine, then a restart (which drops the cached agent the way
the idle sweep does, without the sweep's one-minute first pass) and a cold
/refine on the same chat. The cold summary must land in that chat and its
review request must equal the resident one (model, provider routing,
session id, prefill, tools). Red on the previous base: "Nothing to refine
yet - send a message first."
Docs: the messaging slash table says /refine works on a chat left idle.
The shell pointed HERMES_PYTHON at the bare pm-pinned interpreter and reached
the uv2nix site-packages only through PYTHONPATH. Every consumer that replaces
PYTHONPATH (Ink's gateway bootstrap, image attachments, run_tests.sh's env -i)
got an interpreter with no dependencies, so 'hermes --tui' in the shell failed
with 'No module named ruamel'.
devVenv re-homes uv2nix's editable venv onto the pinned interpreter: a real
pyvenv.cfg whose home is the lock's python, devPython's site-packages, and
console scripts re-shebanged onto it. HERMES_PYTHON, VIRTUAL_ENV, python,
pytest and pm are now one interpreter and the shell sets no PYTHONPATH.
The shipped package and the shell share one tool list: runtimeDeps takes
ripgrep and ffmpeg from pm/lock.json too. Reverts the run_tests.sh PYTHONPATH
passthrough, which leaked a caller's PYTHONPATH into every HERMES_PYTHON run.
The dev-venv-self-contained check runs the venv under env -i.
install.sh --skip-browser / --no-playwright and install.ps1 -SkipBrowser
now hand PM `--without agent-browser` again instead of `--without chromium`.
The downloaded installer is the newest script, but by default it installs
the stable tag, whose PM (v0.22.5) rejects `--without chromium` with
"--without accepts only optional default packages (agent-browser,
cua-driver)" and exits 1, failing the whole install. The new PM already
reads `agent-browser` as `chromium` (pm.defaults.RETIRED_DEFAULTS), so the
legacy spelling works against both the stable tag and main. Direct
`hermes pm install --without chromium` stays valid on the new PM.
The tools reference and the browser page said all four browser tools work on
launched and cloud sessions. browser_cdp and browser_dialog are gated on a
static endpoint (_browser_cdp_check: /browser connect, BROWSER_CDP_URL or
browser.cdp_url) and are absent otherwise. The pages (and their zh-Hans
mirrors) say so again; "Requires environment" is back to "CDP endpoint".
Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351613180
engine: auto / headed: false yield to AGENT_BROWSER_*, a relative profile is pinned, unquoted
backend: off gets the notice, and only pre-split Camofox homes (CAMOFOX_URL or cloud_provider) convert.
Rewrite the browser feature page, configuration, tools/toolsets/env-var/pm
references, docker, windows, bot-screen, termux/installation and the secondary
developer-guide/integration/feature pages (EN + zh-Hans): drop agent-browser,
the built-in browser_* tools, browser.backend and /browser use; document
browser.executable_path / browser.user_data_dir / browser.chromium_args and the
AGENT_BROWSER_* migration; point Camofox users to 'hermes plugins install camofox'.
The dev shell took uv, node, ripgrep and ffmpeg from nixpkgs, so it ran
different versions than pm/lock.json ships (uv 0.11.32 vs 0.13.5, node
26.5.0 vs 26.12.1, ffmpeg 8.1.2 vs 9.0.1). Compose its tool set from
nix/pm-packages.nix instead, and wrap the pinned npm onto the pinned node
so `npm` and `node` are one generation.
pm-packages.nix also shipped every artifact nested under its archive's
versioned directory ($out/node-v26.12.1-linux-x64/bin/node), so no consumer
could reach them through PATH; flatten that and link root-level executables
into $out/bin.
git and openssh stay nixpkgs: the lock carries no Linux artifact for either.
The curl|sh and irm|iex installers bootstrap uv (and, on Windows, git)
before any checkout exists, so they carry those pins as fragments that
scripts/gen-bootstrap-pins.py derives from pm/lock.json. Both re-pin
paths wrote only the lock: `pm update uv` left the installers on the old
uv, and the bootstrap-installer drift check failed until someone ran the
generator by hand.
Run the generator after either path saves the lock. It keys off the
saved lockfile's own checkout, so a fixture lock outside a checkout
regenerates nothing.
`hermes claw` (hermes_cli/claw.py) and the openclaw-migration optional skill
moved into the agent-importers plugin (hermes-official-plugins #140/#141), which
keeps `hermes claw migrate|cleanup` as the older spelling of
`hermes import-agent openclaw`. Core drops them with their tests (ported to the
plugin) and the eval leg that loaded the skill's script.
Setup: hermes_cli/setup_agent_import.py replaces setup_migration's OpenClaw
offer. On a first-time `hermes setup` it checks ~/.openclaw (or ~/.clawdbot /
~/.moltbot), ~/.claude, ~/.codex and ~/.cursor; when any is found it asks
"Import your setup from <found>?" (default yes), shows a checklist when more
than one was found, installs agent-importers through the normal catalog install
(or enables it when present), then runs `hermes import-agent <agent>` for each
pick in a child process, which previews and asks first. OpenClaw gets
--migrate-secrets, as core's step did. Nothing found, a no, or
HERMES_NONINTERACTIVE: no prompt, nothing installed. The skip-configured-section
offer now follows any import that changed config.yaml or .env.
LEFT_CORE: `claw` joins the agent-importers row as old_cli, so `hermes claw`
without the plugin names the install. No automatic install for it: an OpenClaw
migration is one-shot and leaves no state that needs the plugin later.
Docs: import guide, OpenClaw guide, cli-commands, quickstart, bundled
cli-reference; locale tips and README pointers.
The Claude Code / Codex CLI importer (hermes_cli/agent_import.py,
agent_import_sync.py, the import-agent subparser and its tests) now
ships as the official agent-importers catalog plugin, which also adds
Cursor. The command spelling, flags and the per-profile import-sync.json
manifest are unchanged.
Migration: a LEFT_CORE row (cli=import-agent) makes the old spelling,
run without the plugin, print the install line for the active home.
Only homes with a registered sync source (import-sync.json, which
scheduled --sync runs read) get the plugin installed automatically; a
one-shot import leaves no other trace, so everyone else installs it when
they next need it.
hermes profile create --sync-imports keeps copying import-sync.json (a
plain file at the profile root); it no longer imports the importer.
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 termux pool lags nodejs.org, so one bionic 404 blocked a node bump for
every shipped target even though termux is off by default in release.py.
--skip-target drops the target from resolution; _pin_artifacts already
keeps rows for targets the decision does not cover, so the old pin stays.
certifi 2026.10.19 drops the emSign roots whose intermediate CA keys
were compromised. It was published 2026-10-20, inside the 3-day
`exclude-newer` lock quarantine, and holding a CA revocation for the
quarantine keeps the compromised root trusted for those days.
CA trust stores therefore get the one per-package exemption
(`exclude-newer-package = { certifi = false }`). The packaging contract
test now allows exemptions only for CA-trust packages and only as a
full opt-out, so any other package or a dated exemption still fails.
The pm/AGENTS.md and package-management docs state the exemption.
The MCP config reference and the MCP guide's config table did not list
events. Both now do, and the reference lists the event utility tools and
says why they are not capability-gated. The guide notes that with lazy: true
or idle_timeout_seconds a subscription can lapse until the next connection
resubscribes it.
Desktop restarts a ready gateway that runs older code (785f1c86a6) with a
bounded, piped `hermes gateway restart` client. With no installed service,
`_cmd_restart` falls back to stop + `run_gateway()` in the FOREGROUND, so the
client itself became the new gateway: a Desktop child that never exits (the
"gateway restart exited" line is never logged), is killed at the 120 s client
timeout, and holds app quit open. install-e2e caught it on Linux HEAD -> NEXT
installer-script legs ("Desktop did not close gracefully", job timeout); the
Models-page "Restart Hermes" goes through the same function. The updater's
recovery restart (update_restart_recovery) runs the same command under a 90 s
captured timeout and kills a service-less gateway the same way.
`hermes gateway restart --detach` starts the fallback's replacement the way
`gateway ensure` starts an absent owner (spawn_unmanaged_gateway) and returns.
Desktop and the recovery restart pass it. Service, external-supervisor and
Windows-task restarts are unchanged, and a person typing the command keeps
today's foreground behaviour. The choice lives in the gateway_supervised_restart
sibling, so the gateway.py facade does not grow.
The live-session guard refused outright and needed a flag to proceed. On a
terminal it now lists the live turns, cron jobs and delegations and asks y/N
(default no); -y/--yes skips the question. With no terminal (scripts, cron,
Desktop, the update) it still refuses unless --yes is passed. --interrupt stays
as an alias, so the update recovery and dashboard callers are unchanged.
The /queue docs said move acts on the pending queue mid-run; it is
refused for prompts the gateway holds. They now also say that
/queue list|rm|edit|clear act on the session's shared gateway queue
(every window's and automation's queued prompts), that queue-mode Enter
uses the same queue, and that a slash command typed after your own
/queue prompt waits briefly for its turn.
Code notes: what a QueueOutcomeUnknown leaves behind (the turn runs
once but is not painted live here), and why an unknown /queue admission
stays tracked and is re-read once per pending list.
Refs #134889 (N5)
Running `hermes plugins update` without a name now runs the check-updates
probe, lists the plugins the update path can actually move (catalog re-pin,
git pull), names every one it skips and why (pinned, needs fixing, unknown,
pip distributions Hermes never upgrades — #132494), asks once, and updates
each through cmd_update so per-plugin consent prompts still apply. One
failure does not stop the rest; the summary quotes each failure's reason.
Without a terminal nothing moves unless --yes is passed; --yes runs the
updates with interactive=False, so consent stays fail-closed.