fix(terminal): make hermes install dir reachable in subshell PATH - #50534
Merged
Conversation
Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
unresolved-import |
1 |
invalid-return-type |
1 |
First entries
tests/tools/test_local_env_blocklist.py:15: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tools/environments/local.py:331: [invalid-return-type] invalid-return-type: Return type does not match returned value: expected `str | None`, found `object`
run_agent.py:2984: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
✅ Fixed issues (1):
| Rule | Count |
|---|---|
invalid-assignment |
1 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
Unchanged: 5982 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
2 tasks
kpadilha
pushed a commit
to kpadilha/hermes-agent
that referenced
this pull request
Jun 24, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
ppazosp
added a commit
to useomnia/hermes-agent
that referenced
this pull request
Jun 25, 2026
* fix(desktop): filter undefined entries in AttachmentList to prevent refText crash on session switch (#49624)
* fix(desktop): filter undefined entries in AttachmentList to prevent refText crash on session switch
When switching sessions, the attachments array can contain stale/undefined
entries from the previous session's state. Accessing attachment.refText on
an undefined entry throws TypeError, breaking session switching entirely.
Fix: add .filter(Boolean) before .map() to skip undefined/null entries.
Fixes #49614
* fix(desktop): update I18nConfigClient usage in attachment test
The i18n config API changed from getLocale/saveLocale to
getConfig/saveConfig. Update the test fixture to match.
* fix(security): quote HERMES_TIMEZONE in remote code execution to prevent shell injection
* fix: show desktop approval fallback (#46548)
* chore(release): add AUTHOR_MAP entry for #45205 salvage (EtherAura)
* fix(desktop): relaunch on Linux after in-app update instead of hanging (#45205)
On a Linux source install the in-app updater ran the full backend update +
desktop rebuild successfully but never restarted the app — it hung forever on
the applying overlay with no close button. Two causes:
- applyUpdatesPosixInApp() only handled the macOS .app bundle swap;
runningAppBundle() is null off macOS, so Linux fell through to
{ ok: true, backendUpdated: true } without ever relaunching.
- The renderer store had no terminal state for that result shape, so
$updateApply stayed { applying: true } and the overlay's close button
(hidden while applying) never appeared.
Fix (new electron/update-relaunch.cjs, pure + unit-tested):
- Decide the Linux outcome from whether the *running* binary is the one we
just rebuilt (execPath under release/<plat>-unpacked, path-segment-aware so
linux-unpacked-evil can't masquerade) and whether its chrome-sandbox helper
is launchable (root:root + setuid, or an --no-sandbox / ELECTRON_DISABLE_SANDBOX
opt-out):
relaunch — detached watcher waits for this PID to exit (graceful, then
SIGKILL), self-deletes, and re-execs the rebuilt binary with the original
launch context (filtered args + HERMES_*/sandbox env + cwd) restored.
guiSkew — AppImage/.deb/.rpm/dev: backend updated but this GUI package was
NOT changed; surface an honest closeable 'reinstall the desktop app'
terminal state instead of lying that it loads next launch (#37541 skew).
manual — rebuilt binary but sandbox helper not launchable: keep the
working window, don't quit into a dead app.
- store/updates.ts lands a terminal, closeable state for EVERY resolved apply
outcome (handedOff / guiSkew / manualRestart / updated-not-relaunched / error)
so the hang is impossible regardless of platform or result.
- New DesktopUpdateStage values (update/rebuild/done/guiSkew) + GuiSkewView so
progress reads correctly and the skew state is closeable. i18n in all four
locales (en/ja/zh/zh-hant) in parity.
- electron/update-relaunch.test.cjs (16 tests) + store outcome tests.
Salvaged from #45205 onto current main. Linux quit dwell uses the shared
UPDATE_HANDOFF_DWELL_MS (2.5s) from #50448 for consistency. Four-locale i18n
parity, AUTHOR_MAP entry, and the test wiring added on top.
Closes #45205.
* refactor(kanban): fold worker/orchestrator skills into injected guidance (#50473)
The kanban-worker and kanban-orchestrator bundled skills existed only to
be force-loaded into dispatcher-spawned workers, gated by
environments:[kanban] so they wouldn't leak into normal CLI listings.
That gating was fragile (the leak that #50443 patched) and the
--skills auto-load was already best-effort — most workers ran without it
because the bundled skill isn't present in profile-scoped skills dirs.
Remove the skills entirely and promote their load-bearing content
(workspace kinds, deliverable artifacts, created-card integrity, profile
discovery) into KANBAN_GUIDANCE, which is already injected into every
kanban worker's system prompt. Net result: every worker reliably gets
the guidance, nothing can leak into a CLI/blank-slate session, and the
gating machinery is gone.
- agent/prompt_builder.py: promote the 4 load-bearing rules into KANBAN_GUIDANCE
- hermes_cli/kanban_db.py: drop --skills kanban-worker auto-injection + _kanban_worker_skill_available probe
- hermes_cli/kanban_swarm.py: drop skills=[kanban-orchestrator] on the root card
- hermes_cli/kanban.py: drop kanban-init skill seeding; fix help text
- delete skills/devops/kanban-{worker,orchestrator}
- docs: delete the two skill pages (EN+zh), fix sidebars/catalog/kanban.md/kanban-worker-lanes.md and the video-orchestrator + codex-lane references
- tests: update spawn-argv expectations; re-bound the guidance-size guard
Supersedes the skill-leak half of #50443 (credit @helix4u for flagging the area).
* fix(process-registry): re-validate PID identity before killing host processes
The background-process registry signalled host PIDs (recovery adoption,
detached-session kill, tree-kill) using a number captured at spawn, guarded
only by a bare liveness check. Once a session's process exits and is reaped the
kernel recycles that PID onto an unrelated process, so an alive-but-different
PID passed the check and got tree-killed.
Observed in the wild: a recycled background-session PID landed on Firefox's
session leader; a later kill/refresh walked its process tree and SIGTERMed
every tab — Firefox "closing" at irregular intervals with no crash/coredump.
This is the same PID/PGID-recycling class fixed for the MCP orphan reaper in
7bd1f8a2d, but the process_registry subsystem was never guarded — so the bug
persisted.
Fix: record each host process's kernel start time (/proc/<pid>/stat field 22)
at spawn, persist it in the checkpoint, and re-validate it before every signal
via `_host_pid_is_ours`. A PID whose start time no longer matches — or that is
gone — is never signalled:
- recover_from_checkpoint: a recycled PID is not adopted as a session.
- _refresh_detached_session: a recycled detached PID is marked exited.
- kill_process / _terminate_host_pid: refuse to tree-kill a stranger.
Legacy checkpoints and platforms without /proc (no baseline) degrade to the
prior best-effort liveness behaviour, so nothing else changes.
Adds TestPidReuseGuard: real-process tests proving a mismatched start time
refuses termination while a matching one still kills, plus recovery/refresh
recycling paths. 74 registry + 22 MCP-stability tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(whatsapp): validate bridge PID identity before killing stale pidfile entry
`_kill_stale_bridge_by_pidfile` SIGTERMed the PID recorded in `bridge.pid`
after only a bare liveness check. Once the bridge exits and is reaped the
kernel recycles that PID onto an unrelated process; because the WhatsApp bridge
crash-loops ("Bridge process died (exit code 1)" repeating), this cleanup ran
on every restart and could SIGTERM a recycled PID that had landed on the user's
browser — closing Firefox at irregular intervals with no crash and no coredump
(a clean kill of a stranger).
Same PID-recycling class as the MCP reaper (7bd1f8a2d) and the process-registry
host-PID guard (e6a99cef2); this was the third, and most actively-fired, path.
Fix: `_write_bridge_pidfile` now also records the leader's kernel start time
(line 2). `_kill_stale_bridge_by_pidfile` re-validates identity via
`_bridge_pid_is_ours` before signalling — the (pid, start time) pair must match,
or for legacy single-line pidfiles the live cmdline must name `node` + this
session's unique path. A recycled PID (different start time / cmdline) is logged
and skipped, never signalled. Legacy pidfiles stay readable.
Adds TestWhatsappBridgePidfile: real-process tests proving a genuine bridge is
reaped while a recycled PID (start-time mismatch, or non-bridge cmdline) is
spared. 7 new + 108 gateway/registry tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(whatsapp): only kill LISTENers when freeing the bridge port, never clients
This is the bug that was actually closing Firefox. `_kill_port_process`, run on
every bridge (re)start to free the port, used `lsof -ti :PORT` / `fuser PORT/tcp`
— both of which match a process whose socket merely *involves* that port number
in ANY state, including ESTABLISHED client connections. It then SIGTERMed every
match.
The bridge defaults to port 3000 — a ubiquitous local dev-server port. With a
browser tab open on localhost:3000, `lsof -ti :3000` returned Firefox's PID, so
each restart of the (crash-looping) WhatsApp bridge SIGTERMed Firefox, closing
the whole browser at irregular intervals with no crash and no coredump.
Proven live with the kernel `signal:signal_generate` tracepoint:
hermes-gateway(3396516) -> sig=15 (code=0/SI_USER) -> comm=firefox pid=3371585
captured immediately after a gateway start, while Firefox held a socket on the
bridge port. Demonstrated over-match: `lsof -ti :8080` returns the listener AND
the gateway's own client connection; `lsof -ti tcp:8080 -sTCP:LISTEN` returns
only the listener.
Fix: `_listener_pids_on_port` resolves only LISTEN-state sockets
(`lsof -ti tcp:PORT -sTCP:LISTEN`, with an `ss -ltnp` fallback) and
`_kill_port_process` signals just those. A client whose connection happens to
involve the port number is never touched — which is also more correct, since a
client never blocks the new bridge from binding. Windows already filtered
LISTENING; the broad `fuser -k` path is removed.
Adds TestKillPortProcess: real-socket tests proving a separate client process
is excluded from the listener lookup and survives port cleanup. 9 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(whatsapp): add missing re import + fix test import path after adapter relocation
Follow-up to the salvaged #43846 commits: the WhatsApp adapter moved from
gateway/platforms/whatsapp.py to plugins/platforms/whatsapp/adapter.py since the
PR was authored. The cherry-pick brought _listener_pids_on_port's `re.finditer`
ss-fallback and the new test's import, but the new module location doesn't import
`re` (latent NameError on the lsof-absent fallback path) and the test imported the
old module path. Add `import re` to the adapter and repoint the test import.
* chore: add valentt to AUTHOR_MAP for #43846 salvage
* test(whatsapp): fix port-spares-client test race (listen before announce + retry connect)
The salvaged test spawned a listener subprocess that printed its port
immediately after bind() but BEFORE listen(), so under CI's loaded 8-worker
box the parent connected before the socket was listening -> ConnectionRefused
(flaked on test slice 2/6). Reorder the child to listen() then print the port,
and make the client connect with a short bounded retry to absorb scheduler
jitter. 15/15 green locally including direct hammering.
* fix(status): cross-platform start-time fingerprint via psutil fallback
The PID-reuse guard (#43846) reads /proc/<pid>/stat field 22, which only
exists on Linux — on macOS/Windows it returned None and the guard silently
degraded to a bare liveness check (a no-op, safety-wise). Add a
psutil.create_time() fallback (psutil is a hard dep, cross-platform),
quantized to centiseconds for stable equality, so the recycled-PID guard
actually protects macOS/Windows too. /proc always wins first on Linux and
always misses on macOS/Windows, so the two sources never mix on one host and
same-source equality is all the guard needs.
* feat(mem0): add self-hosted support via MEM0_HOST / host config
The mem0 plugin previously hardcoded api.mem0.ai as the endpoint.
This adds a `host` config key and MEM0_HOST env var so users can
point the plugin at a self-hosted Mem0 instance.
Changes:
- _load_config(): read MEM0_HOST env var
- is_available(): accept host OR api_key (self-hosted may not need a real key)
- get_config_schema(): add host field
- initialize(): read host from config
- _get_client(): pass host kwarg to MemoryClient when set
- system_prompt_block(): show target (cloud vs URL)
- README: document self-hosted setup
* fix(mem0): address PR review — restore docstrings, keep api_key required
Addresses reviewer feedback on #13377:
1. Restore all stripped docstrings (_load_config, _is_breaker_open,
sync_turn, register, _get_client, _read_filters, _write_filters,
_unwrap_results, save_config) and section dividers
2. Revert api_key to required:true in schema — self-hosted Mem0 also
requires auth by default; validation in _get_client() handles the
either/or logic separately from the schema
3. Confirm secret:true remains on api_key (already correct)
* chore: add buihongduc132 to AUTHOR_MAP for mem0 salvage
* fix(agent): strip stale reasoning_content when falling back to a strict provider (#50480)
* fix(agent): strip stale reasoning_content when falling back to a strict provider
A reasoning primary (DeepSeek/Kimi/MiMo thinking mode) pins reasoning_content
on every assistant tool-call turn (a single space " " pad). api_messages is
built once under the primary; on a mid-session fallback to a strict
OpenAI-compatible provider (Mistral, Cerebras, Groq, SambaNova), those stale
pads were replayed verbatim and rejected with HTTP 400/422:
body.messages.2.assistant.reasoning_content: Extra inputs are not
permitted (input: ' ')
reapply_reasoning_echo_for_provider() only ever ADDED pads, so it never
reconciled history built under a reasoning primary against a strict fallback.
copy_reasoning_content_for_api() also leaked empty-string and 'reasoning'-only
shapes to non-pad providers.
Fix both sites: when the active provider does not enforce echo-back, strip
reasoning_content (empty, space-pad, or non-empty) entirely. Re-padding when
switching TO a reasoning provider is preserved. Covers the Cerebras 400 from
#45655 and the DeepSeek->Mistral 422 fallback report.
Refs #45655.
* test: update reasoning-replay tests for strict-provider stripping
test_explicit_reasoning_content_beats_normalized_reasoning_on_replay was
implicitly running on the OpenRouter fixture (non-pad); pin it to a reasoning
provider so the precedence it checks is observable. Add a positive
strict-provider test asserting reasoning_content is stripped on replay.
* fix(compressor): remove logging.basicConfig from library class __init__
logging.basicConfig() in TrajectoryCompressor.__init__ overrides the
root logger configuration every time the class is instantiated. Library
code should use logging.getLogger(__name__) and let the application
entry point configure the root logger.
Fixes inconsistent log formatting when the compressor is used alongside
other logging configuration in the gateway.
* fix(swe-runner): move logging.basicConfig out of Runner __init__ into main
Same library-code anti-pattern as the compressor fix: MiniSWERunner.__init__
called logging.basicConfig(), overriding the application's root logger config
every time a runner was instantiated. Moved the call into main() (the CLI
entry point) where it belongs; __init__ now only does getLogger(__name__).
Standalone verbose logging is preserved.
* fix(security): close hermes-0day MCP-persistence attack surface
Remove the dashboard --insecure auth-bypass, add an MCP persistence guard +
IOC blocklist, and raise the API-server key entropy floor.
Driven by the June 2026 hermes-0day campaign (r/hermesagent, live 854.media
instance): scanners find exposed Hermes dashboards/API servers, drive the
root agent to plant a 'command: bash' MCP entry that appends an attacker SSH
key to authorized_keys, which cron + startup then re-execute every tick.
- dashboard: --insecure no longer disables the auth gate. should_require_auth
returns True for every non-loopback bind; a public bind ALWAYS requires an
auth provider (bundled password provider or OAuth). --insecure kept as a
warned no-op for backward compat. Fail-closed error now points at the
password provider, not at --insecure.
- mcp_security: validate_mcp_server_entry now also rejects shell payloads that
write to OS persistence surfaces (authorized_keys/.ssh/pam.d/sudoers/cron/
rc files) and hard-rejects a hermes-0day IOC blocklist (attacker SSH key +
source IPs) anywhere in command/args/env. Runs at save AND spawn time.
- api_server: raise network-bind API_SERVER_KEY entropy floor 8->16 chars;
warn when a network-accessible API server runs an unsandboxed local backend.
* fix(docker): replace dashboard --insecure with basic-auth provider
The s6 dashboard entrypoint and docker integration tests relied on
HERMES_DASHBOARD_INSECURE=1 to bring up a 0.0.0.0 dashboard with no auth
provider. With --insecure now a no-op (auth gate mandatory on non-loopback
binds), that path fails closed.
- s6 dashboard/run: drop --insecure derivation; warn that the env is a no-op
and point operators at HERMES_DASHBOARD_BASIC_AUTH_* / OAuth.
- docker tests: supervision tests now register the bundled basic password
provider (HERMES_DASHBOARD_BASIC_AUTH_USERNAME/_PASSWORD) so the gate has a
provider and the dashboard binds. Rewrote the insecure-opt-out test to
assert fail-closed (dashboard does NOT serve) instead of gate-bypass.
- docs (en + zh-Hans): HERMES_DASHBOARD_INSECURE documented as deprecated
no-op; basic-auth is the zero-infra way to authenticate a containerized
public dashboard.
* feat(security): startup security posture audit (warn-on-load)
Surface dangerous host/deployment posture at gateway startup so operators get
the 'you're exposed' signal the June 2026 MCP-config persistence campaign
victims never had. Warn-only — never blocks startup, never raises.
Checks (each independently fail-safe):
- Running as root (POSIX uid 0)
- SSH daemon with PasswordAuthentication enabled (incl. the 'yes' default)
- Running in a container with no persistent volume mount over HERMES_HOME
- Network-accessible API server with no API_SERVER_KEY
New module hermes_cli/security_audit_startup.py; invoked once per process from
start_gateway() right after setup_logging(). Cross-platform (root/SSH checks
no-op on Windows). Idea: @Cthulhu.
* style(security-audit): add explicit encoding to read_text calls (ruff PLW1514)
* feat(process): escalate SIGTERM->SIGKILL on host-pid termination after grace
A daemon that ignores or stalls in its SIGTERM handler currently survives the
process-registry reap and leaks until reboot (observed as agent-browser
daemons accumulating to EMFILE on long-running gateways). _terminate_host_pid
now snapshots the tree, SIGTERMs it, waits a bounded grace window
(terminal.daemon_term_grace_seconds, default 2.0s, 0 disables), then SIGKILLs
any survivor. The recycled-PID identity guard still gates the whole path, so
escalation never reaches a stranger; Windows is unchanged (taskkill /F is
already a hard kill).
Config lives in config.yaml (terminal.daemon_term_grace_seconds), NOT an env
var, per the .env-secrets-only policy.
Implements the SIGKILL-escalation idea from @tkwong's #15008, reworked onto the
current _terminate_host_pid tree-kill path (the original predated it) and
config-gated instead of env-var-gated.
Co-authored-by: Benjamin Wong <tkwong@inspiresynergy.com>
* chore: map tkwong co-author email for #15008 SIGKILL-escalation credit
* fix(process): SIGKILL the whole tree on escalation, not just wait_procs survivors
Live testing against a real SIGTERM-ignoring process TREE (parent + children,
the agent-browser daemon + renderer shape) revealed psutil.wait_procs's
gone/alive partition mis-handles a parent/child tree: it reaps via
Process.wait() and could mark targets gone/alive inconsistently across the
tree, leaving survivors un-killed (flaky — sometimes the parent lived,
sometimes a child). Replace it with: sleep out the grace window, then
directly re-probe every captured target (_proc_alive, treating zombies as
dead) and SIGKILL any that's still running. Add a multi-child-tree regression
test. 6/6 escalation tests green across repeated runs; the real-tree E2E now
kills the full tree 6/6 runs.
* fix(banner): don't advertise toolsets/skills the agent wasn't given (#50497)
The welcome banner's 'Available Tools' merged in every toolset from the
global check_tool_availability() registry walk, regardless of whether it
was enabled for the current platform. On a Blank Slate CLI (file +
terminal only) that surfaced discord / feishu / kanban tools the agent
was never actually given — they are not in the agent's tool schema, but
the banner displayed them, making it look like they were exposed.
- Filter the unavailable-toolset merge to toolsets actually in
enabled_toolsets (a toolset that's enabled but has unmet deps still
legitimately shows as disabled/lazy).
- Gate the 'Available Skills' section on the skills toolset being
enabled — when it's off, the agent can't load any skill, so show
'Skills toolset disabled' instead of the on-disk catalog.
When enabled_toolsets is empty (older callers), behavior is unchanged.
Validation: blank-slate banner now shows only file + terminal and
'Skills toolset disabled'; a skills-enabled banner still lists the
catalog. Added regression tests; full banner suite green (15/15).
* feat(providers): remove google-gemini-cli + google-antigravity OAuth providers (#50492)
* feat(providers): remove google-gemini-cli + google-antigravity OAuth providers
Google now actively bans accounts for third-party tools that piggyback on
Gemini CLI / Antigravity / Code Assist OAuth, and because abuse prevention
sits at a backend layer the ban can extend to the entire Google account
(Gmail/Drive), with a second violation being permanent.
Ref: https://github.com/google-gemini/gemini-cli/discussions/20632
Removes both OAuth inference providers entirely (modules, provider profiles,
auth/runtime/config/models wiring, the /gquota Code Assist quota command,
the antigravity-cli optional skill, desktop + docs surface in en + zh-Hans).
The API-key 'gemini' provider (GOOGLE_API_KEY/GEMINI_API_KEY against
generativelanguage.googleapis.com) is unaffected and stays fully supported.
* fix(skills): keep the antigravity-cli skill — only the OAuth provider is removed
The antigravity-cli optional skill orchestrates the external `agy` binary as
a coding-agent tool via the terminal tool — it does NOT wrap Hermes inference
through the banned google-antigravity OAuth provider, so it carries none of
the account-ban risk that motivated removing that provider. Restore the skill,
its docs page, the sidebar entry, and the optional-skills catalog row. The
google-antigravity / google-gemini-cli inference providers stay fully removed.
* docs(agents): fix stale platform adapter path in token-lock note
gateway/platforms/telegram.py no longer exists (adapters moved to
plugins/platforms/<name>/adapter.py) and telegram no longer uses the
scoped-lock pattern. Point the token-lock canonical-pattern reference to
plugins/platforms/irc/adapter.py, which acquires the lock in connect()
and releases it in disconnect() — and is already cited as a canonical
example in ADDING_A_PLATFORM.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: repoint remaining stale gateway/platforms adapter refs to plugins/platforms
Sibling-site follow-up to the AGENTS.md token-lock fix (#50481). Platform
adapters migrated from gateway/platforms/<name>.py to
plugins/platforms/<name>/adapter.py; a handful (signal, weixin, bluebubbles,
qqbot, yuanbao, msgraph_webhook, webhook, api_server) still live in
gateway/platforms/.
- adding-platform-adapters.md: new-adapter creation path + reference-impl table
- gateway-internals.md: rewrite the adapter tree to reflect the actual split
- zh-Hans mirrors of both kept in parity
- scripts/release.py: add TutkuEroglu to AUTHOR_MAP (CI gate)
* fix(terminal): make hermes install dir reachable in subshell PATH (#50534)
Plugins shelling out to bare `hermes` via the terminal tool hit
`command not found` (exit 127) when the gateway was launched without the
hermes install dir on PATH (systemd, service managers, cron, desktop
launchers) — even though `hermes` works in the user's own interactive
terminal, which sources the shell rc that exports that dir.
The terminal tool's subshell PATH was the agent process PATH plus a
static set of system dirs (_SANE_PATH); it never included wherever the
hermes console-script actually lives (~/.local/bin, the venv bin/Scripts,
pipx, nix). Resolve that dir once (which/argv0/sys.executable) and
prepend-if-missing it so bare `hermes` resolves regardless of launch
method.
* feat(cli): /reasoning full — show complete thinking, not 10-line clamp (#50499)
* feat(cli): /reasoning full to show complete thinking, not 10-line clamp
The post-response Reasoning recap box hard-clamped long thinking to the
first 10 lines, so there was no way to see the full reasoning trace after
a turn (live streaming already shows it in full). Add display.reasoning_full
(default off) plus /reasoning full|clamp to toggle it at runtime; the clamp
truncation note now points at the command. Addresses repeated user requests
to show all thinking tokens.
* test(gateway): de-snapshot /reasoning help assertion
The test froze the exact args-hint literal '/reasoning [level|show|hide]',
which the new full/clamp args change to '[level|show|hide|full|clamp]'.
Convert to an invariant: assert /reasoning is in help and carries its core
args, not the exact hint string.
* feat(tui): /reasoning full|clamp parity in tui_gateway
The classic-CLI reasoning_full toggle had no TUI equivalent — typing
/reasoning full in the TUI fell through to parse_reasoning_effort and
errored. The TUI renders thinking as an expand/collapse section (no fixed
10-line recap), so map full -> sections.thinking=expanded (raw, uncapped
via thinkingPreview mode='full') and clamp -> collapsed, persisting
display.reasoning_full for cross-surface config consistency.
* feat(cli): /prompt — compose your next prompt in $EDITOR (#50509)
* feat(cli): /prompt — compose your next prompt in $EDITOR
Adds /prompt (alias /compose): opens $VISUAL/$EDITOR on a temp markdown
file so you can hand-edit a multi-line prompt, then sends the saved buffer
as the next agent turn. Text after the command pre-seeds the buffer; an
empty save cancels. Reuses the one-shot _pending_agent_seed the interactive
loop already consumes (same mechanism as /blueprint), so no changes to the
input event loop or message pipeline. CLI-only.
* feat(tui): /prompt slash command opens $EDITOR (parity with CLI)
The TUI already opens $EDITOR via Ctrl+G (openEditor), but had no /prompt
slash command like the classic CLI. Wire openEditor into the slash handler
context and register /prompt (alias /compose) to call it; inline text after
the command is dropped into the composer first so it carries into the editor,
matching the CLI's /prompt <text>.
* feat(dashboard): interactive auth setup on no-provider non-loopback bind (#50551)
When `hermes dashboard --host 0.0.0.0` is run interactively with the auth
gate engaged but no DashboardAuthProvider configured, prompt to set up the
bundled username/password provider on the spot (or point at `hermes dashboard
register` for OAuth) instead of only emitting the fail-closed error.
- main.py: `_maybe_setup_dashboard_auth_interactively()` runs before
start_server. No-ops on loopback binds, when a provider is already
registered, or when stdin/stdout isn't a TTY (Docker/s6, CI, piped runs) so
the fail-closed SystemExit stays the backstop for unattended deploys. On the
password path it writes dashboard.basic_auth.{username,password_hash,secret}
to config.yaml (scrypt hash, never plaintext), then force-rediscovers
plugins so the basic provider registers before the gate check.
- web_server.py: fix the fail-closed hint — it told operators to set
`dashboard_auth.basic.username` but the provider reads `dashboard.basic_auth`.
- docs: note the interactive setup under Fail-closed semantics.
No new env vars; reuses the existing dashboard.basic_auth config surface.
* fix(container): detect dashboard role under s6-overlay v3 (#49196) (#50600)
* fix(gateway): walk /proc/*/cmdline to find main-wrapper.sh under s6-overlay v3 (#49196)
(cherry picked from commit 3a108c2df0edce4ce0e6f9f3a8eb8db3839a4630)
* fix(container): peel s6-v3 rc.init prefix so dashboard role is detected
kyssta-exe's preceding commit (#49238) fixed _read_container_argv() to
locate the rc.init-launched main-wrapper.sh process under s6-overlay v3,
but the skip still never fired: _strip_container_argv_prefix() only peeled
a prefix when args[0] was init/main-wrapper.sh/hermes. Under s6 v3 the
matched argv is
/bin/sh -e /run/s6/basedir/scripts/rc.init top
/opt/hermes/docker/main-wrapper.sh dashboard ...
so args[0] stayed /bin/sh, _is_dashboard_container() returned False, and
the dashboard container reconciled + started its own gateway-default —
the exact dual Telegram getUpdates 409 in issue #49196.
Fix: strip everything up to and including the main-wrapper.sh token (the
stable boundary the image owns), covering both the v2 (/init ...) and v3
(/bin/sh ... rc.init top ...) shapes with one rule, instead of matching
launcher tokens positionally. This also repairs _is_legacy_gateway_run_request()
under v3, which shares the same strip helper (the issue called this out).
Tests: extend the dashboard true/false parametrize sets with the s6-v3
argv shape, and add test_main_skips_reconcile_in_dashboard_container_s6v3
exercising main() end-to-end with the v3 argv. Verified via mutation that
both new v3 assertions fail under the old positional strip and pass with
the fix.
---------
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
* fix(terminal): bridge docker_extra_args to TERMINAL_DOCKER_EXTRA_ARGS in CLI + gateway (#50631)
terminal.docker_extra_args passes flags verbatim to `docker run` (e.g.
--gpus=all, --shm-size=16g). It was wired into DEFAULT_CONFIG,
TERMINAL_CONFIG_ENV_MAP (so `hermes config set` bridged it),
terminal_tool._get_env_config (reads TERMINAL_DOCKER_EXTRA_ARGS), and
DockerEnvironment (applies extra_args) -- but it was MISSING from cli.py's
env_mappings and gateway/run.py's _terminal_env_map.
Consequence: a user who hand-edits config.yaml (rather than running
`hermes config set`) has docker_extra_args silently dropped on the CLI and
gateway/desktop startup paths, while docker_image / docker_volumes (which
ARE in those maps) bridge correctly -- producing the reported 'Hermes
partially reads the Docker config' symptom where --gpus=all and
--shm-size=16g never reach docker run.
This is the same bridge-coverage bug class that shipped before for
docker_run_as_host_user (cli + gateway) and docker_mount_cwd_to_workspace
(gateway). Fix by adding the key to both maps, plus a dedicated regression
pin in test_terminal_config_env_sync.py mirroring the existing
test_docker_*_is_bridged_everywhere guards.
* fix(gateway): accept any inbound file type across all messaging platforms
Authorization to message the agent is the gate, not the file extension.
Previously the inbound-attachment allowlist (SUPPORTED_DOCUMENT_TYPES) was
opt-OUT on Discord (allow_any_attachment defaulted false) and had no bypass
at all on Telegram/Slack — so an .html (or any non-allowlisted type) was
dropped or hard-rejected before the agent saw it.
Now every authorized upload is cached and surfaced to the agent regardless
of type:
- base.cache_media_bytes(): unknown types cache as octet-stream (or the
caller-supplied MIME) instead of returning None — fixes the chokepoint
that Teams/Telegram-media route through.
- discord/telegram/slack adapters: removed the allowlist reject/skip; any
non-media attachment is typed DOCUMENT and cached. Known types keep their
precise MIME.
- Text inlining now gates on a shared _TEXT_INJECT_EXTENSIONS set (text +
code + config + markup) instead of a blind UTF-8 decode, so binary formats
(PDF/zip/docx) with ASCII headers are never inlined.
- gateway/run.py emits the path-pointing context note for every DOCUMENT,
including non text/application MIME types.
- discord.allow_any_attachment is now a documented no-op kept for config
back-compat.
Validation: 357 gateway tests pass; E2E confirms .html/.bin/custom types
cache, known types stay precise, PDFs are not inlined.
* fix(telegram): observed/replied group docs of any type are cached too
Follow-up to the accept-any-file-type change. The observe-unmentioned and
replied-media paths relied on cache_media_bytes() returning None for
unsupported document types to emit an 'unsupported, not cached' note. Now
that any file type is always cached, those docs are cached and surfaced with
a path-pointing note — consistent with the main document path. The
remaining cached-is-None branch is image-validation-failure only; its note
is reworded accordingly. Updates the group-gating test to the new contract.
* fix Nous auth refresh for idle agents
* feat(cli): Ctrl+G submits the edited draft on save (TUI parity) (#50560)
Ctrl+G already opened $EDITOR with the current draft, but used
open_in_editor(validate_and_handle=False), which only loaded the saved text
back into the input area — the user still had to press Enter. The TUI's
Ctrl+G (openEditor) submits the draft on a clean exit. Since CLI submission
is driven by the custom Enter keybinding (not the buffer accept_handler),
validate_and_handle can't route through it; instead chain a done-callback on
the editor Task that calls the new _submit_editor_buffer(), which mirrors the
Enter handler's idle/queue/slash branches and drops an empty save.
* Make email pairing opt-in
* Address email pairing review feedback
* fix dashboard chat session titles
* feat(cli): /timestamps command + timestamps in /history (#50506)
display.timestamps already drove the [HH:MM] suffix on live submitted and
streamed message labels, but there was no runtime command to toggle it and
/history ignored the setting entirely. Add /timestamps [on|off|status]
(alias /ts) and render [HH:MM] in /history for turns that carry a stored
unix timestamp (resumed sessions). Live unsaved turns without a stored time
are never given a fabricated one. Uses the existing sanctioned non-wire
'timestamp' message key (stripped before the API call in chat_completions),
so message-alternation and prompt-cache invariants are untouched.
* fix #39550: detect token-only compression success
Compression can materially reduce request size (tool-result pruning,
in-place summarization) without reducing message count. The two
compression-success checks in conversation_loop.py (413 handler and
context-overflow handler) only compared len(messages) to detect
success, missing token-only compression.
Now re-estimates tokens after compress_context() returns and treats
any >=5% reduction as a successful compression pass. Error logs
also use the post-compression token count instead of the stale
pre-compression estimate.
Fixes: #39550
* no-mistakes(review): guard token-delta status msg on actual compression in overflow handler
* test(agent): regression for token-only compression progress (#39550, #23767)
Adds test_413_retries_on_token_only_compression: same message count but
materially fewer tokens after compaction must count as progress and retry,
not abort. Fails on main without the salvaged fix, passes with it.
* feat(desktop): PR-style file diffs in chat
Render write_file/edit_file/patch as a reviewable diff instead of raw
result JSON, closer to a Cursor/T3 per-edit review.
- Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise,
drop the +/- gutter, color by line with a 2px gutter accent, full-bleed
to the card, transparent context lines, compact scroll height.
- Header shows filename + language icon + +N/-N stats; full path moves to
a hover tooltip (no Edited verb, no ms).
- Treat the three file-edit tools uniformly (isFileEditTool); read diff
from inline_diff or patch's diff field; suppress raw-arg detail.
- Reusable FileTypeIcon primitive sharing the code-block icon mapping
(codiconForFilename), codicon fallback.
- Per-row scaffolding fade (not the group wrapper, which trapped child
opacity); expanded edits stay full, collapsed fade; keyboard-only focus
lift. Hide diff-less rehydrated creates that read as dupes.
* style(desktop): lead --dt-font-mono with bundled JetBrains Mono
Code/diff blocks preferred a system Cascadia Code before the bundled
JetBrains Mono, so they drifted from the terminal (which leads with
JetBrains Mono) on machines where Cascadia is installed. Reorder so every
mono surface uses the face we actually ship.
* feat(desktop): syntax-highlight inline diffs via Shiki
Unify the diff renderer onto the same Shiki path as code blocks: highlight
the marker-stripped change content in the file's language, then a per-line
transformer layers the add/remove tint + gutter accent on top. Falls back
to the plain color-only renderer when the language is unknown, over budget,
or while Shiki loads.
- shikiLanguageForFilename(): extension → bundled-language id (shared
filename-token helper with codiconForFilename).
- code display:grid so full-width line tints don't double with newline
nodes; theme surface stripped so context lines stay transparent.
* feat(relay): handle passthrough_forward over the WS (Phase 5 §5.1, gateway half) (#50702)
The connector half (gateway-gateway) moves the passthrough plane's post-ACK
forward off the HTTP gatewayEndpoint onto the gateway's outbound /relay WS via
a new passthrough_forward frame. This is the gateway side: the relay adapter
now RECEIVES and handles that frame, so a hosted gateway (no public IP) can
process forwarded Class-2/3 traffic (Discord interactions, Twilio) over the
socket it already holds — closing the "passthrough inbound doesn't work for
hosted gateways" gap.
- ws_transport.py: decode the passthrough_forward frame; PassthroughForward
dataclass + _passthrough_from_wire (base64 body -> exact bytes, byte parity
with the connector's toPassthroughForward); set_passthrough_handler mirrors
set_interrupt_inbound_handler.
- transport.py: PassthroughHandler type + set_passthrough_handler on the
RelayTransport protocol.
- adapter.py: connect() wires the passthrough handler; _on_passthrough decodes
the (already-sanitized, token-free) forward and, for a Discord interaction,
converts it to a MessageEvent routed through the normal agent path
(handle_message) — the reply egresses over the outbound / token-less
follow_up path, so the gateway never holds the interaction credential. Never
raises (a bad forward can't kill the read loop). Non-discord forwards (Twilio)
are logged + dropped for now.
- docs/relay-connector-contract.md: document the passthrough_forward frame +
PassthroughForward shape + §3.1.
The interaction -> MessageEvent CONVERSION semantics (slash-command vs button
UX, option rendering) are the open sub-design flagged in the spec; the TRANSPORT
+ receive mechanism (this) is settled per Ben's Gate-2 decision: "the relay
adapter handles receiving these events over the WS."
Tests (tests/gateway/relay/test_relay_passthrough.py): byte-preservation
round-trip (+ malformed-body tolerance), connect() wiring, application-command
and message-component interactions route through handle_message with correct
session source + scope capture, malformed/non-discord forwards dropped cleanly.
100 relay tests green. Pairs with the connector PR (gateway-gateway).
* style(desktop): soften dark-mode syntax highlighting
Share one SHIKI_THEME (github-dark-dimmed) across code blocks and inline
diffs so they can't drift, and pull token saturation/brightness back via a
`.shiki` dark-mode filter. The dimmed theme alone only changes the
background — which both surfaces strip — so the bright foregrounds needed
the filter to actually calm down.
* fix(agent): count tokens, not just rows, as preflight compression progress
Rebased onto god-file Phase 1 refactor — preflight compression has moved
from agent/conversation_loop.py to agent/turn_context.py (no semantic
change in the refactor itself; the bug below was carried over verbatim).
The preflight compression loop in ``turn_context.py`` uses
``len(messages) >= _orig_len`` to decide whether a compression pass has
made progress. That conflates two different conditions: a true no-op
(transcript materially unchanged) and effective token compression that
summarises message contents but keeps the same number of rows. The
second case is misread as "Cannot compress further" — the session then
surfaces ``Context length exceeded`` and auto-resets even when the
post-compression estimate is far below the model context window.
Observed example from #39548: a Telegram session on GPT-5.5 with a 1M
context dropped from ~288k → ~183k tokens (a 36% reduction) while
preserving 220 messages. The loop treats that as exhaustion and the
gateway auto-resets the session.
Fix
---
Add ``_compression_made_progress(orig_len, new_len, orig_tokens, new_tokens)``
and call it after the post-pass ``estimate_request_tokens_rough`` (which
is moved up to run *before* the progress check instead of after it).
Either a row-count reduction OR a token-count reduction now counts as
progress; only when neither moves do we break out as "stuck".
Fixes #39548
* refactor(auth): drop dead select() fallback in anthropic pool resolver
/simplify-code QUALITY finding: the `if callable(_available_entries): ... else:
pool.select()` ladder was dead for the real CredentialPool type (`_available_entries`
is always a bound method) AND the select() fallback violated the helper's read-only
contract — select() -> _select_unlocked() runs _available_entries(clear_expired=True,
refresh=True), which persists to auth.json and triggers a network refresh. Call
_available_entries(clear_expired=False, refresh=False) directly inside the existing
try/except instead.
Also drops the now-dead `select=` stubs from the 6 pool tests (they only existed to
satisfy the removed fallback branch). Behavior unchanged; 6 pool tests pass and the
read-only / null-token contract tests were mutation-checked (flipping the flags /
removing the None-guard fails the respective test).
* fix(agent): align preflight token-progress floor to 5% (#23767, #39548)
Follow-up to the salvaged preflight token-progress fix: require a material
(>5%) token reduction to count as progress, matching the overflow-handler
retry path (conversation_loop.py, #39550), so a sub-5% wobble can't keep the
3-pass preflight loop spinning. Adds boundary + zero-token regression tests.
* fix(cron): layer enabled MCP servers onto per-job enabled_toolsets
A cron job that sets `enabled_toolsets` to a list of *native* toolsets (e.g.
`["web", "terminal"]`) silently got ZERO MCP tools, while a job with no
per-job list got every globally-enabled MCP server. `_resolve_cron_enabled_
toolsets` returned the per-job list verbatim, bypassing the MCP-merge that the
platform-fallback branch performs via `_get_platform_tools`. So
`discover_mcp_tools()` registered the MCP tools into the registry, but
`get_tool_definitions(enabled_toolsets=...)` kept only the named native
toolsets — the agent then rejected every `mcp_*` call as "Unknown tool". (R2
of #23997.)
Fix: `_merge_mcp_into_per_job_toolsets` layers MCP membership onto a per-job
allowlist with the SAME semantics as `_get_platform_tools`:
* `no_mcp` sentinel present -> no MCP servers (sentinel stripped)
* one or more MCP server names already listed -> treat as an allowlist
* otherwise -> union in every globally-enabled MCP server
To avoid duplicating the "which MCP servers are enabled" computation (it
already existed inline in `_get_platform_tools`), this extracts a shared
`enabled_mcp_server_names(config)` helper in `hermes_cli.tools_config` and has
BOTH the gateway/CLI platform resolver and the cron per-job resolver call it —
so every path agrees on MCP membership (extend, don't duplicate).
Note: the issue's *headline* — bare MCP server names rejected, registry never
includes them — was already fixed on main (commits c10fea8d2 + 04918345e,
both before the issue was filed). This PR closes the remaining cron-specific
gap (R2). The `server:*` / `mcp:server` alias-notation rejection (R1) and the
quiet-mode silent-drop (R3) are tracked separately.
Salvaged from #32788 by sherman-yang (credited below). Reworked to reuse the
shared `enabled_mcp_server_names` helper instead of re-implementing the MCP
membership set in cron/scheduler.py.
Fixes #23997
Co-authored-by: sherman-yang <58446328+sherman-yang@users.noreply.github.com>
* chore(release): add sherman-yang to AUTHOR_MAP
* fix(compressor): count tool_call envelope in tail-budget token estimate (#28053)
The tail-protection budget walks estimated an assistant message's tokens from content + function.arguments only, dropping each tool_call's id, type and function.name (plus JSON structure). Assistant turns that fan out into parallel tool calls were undercounted by 2-15x (a 4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected tail overshot tail_token_budget and compression ran far below its intended ratio — context kept growing.
Consolidate the three duplicated budget walks (_prune_old_tool_results and the two passes in _find_tail_cut_by_tokens) into a single _estimate_msg_budget_tokens() helper that counts the full tool_call envelope via len(str(tc)), consistent with how _estimate_message_chars estimates message size elsewhere.
Tested on Windows: new tests/agent/test_compressor_tool_call_budget.py plus the existing compression suite (test_context_compressor, compressor_image_tokens, cross_session_guard, infinite_compaction_loop) — 209 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(release): map basilalshukaili@gmail.com in AUTHOR_MAP
Committer email for the salvaged #43293 commit; required by the contributor
attribution check.
* fix(agent): defer preflight compaction until real usage after a compaction (#23767, #36718)
After a compaction, the post-compression path parks last_prompt_tokens=-1 and
sets awaiting_real_usage_after_compression=True, but last_real_prompt_tokens
still holds the stale pre-compression value (above threshold). should_defer_
preflight_to_real_usage() hit the 'last_real_prompt_tokens >= threshold => False'
short-circuit and let preflight fire a SECOND compaction before the provider
reported real post-compaction usage. Add an early-return on the awaiting flag so
deferral holds for exactly one turn; update_from_response() clears it.
The flag-setting half (#36718) already landed on main via the in-place
compaction path (conversation_compression.py); this adds the missing
should_defer guard that consumes it.
Credit:
- @ashishpatel26 (#38133) — diagnosis + the should_defer early-return design
- @Tranquil-Flow (#36769) — same #36718 fix, identical guard placement
Closes #36718.
* fix(gateway): redact credentials from approval prompts before sending to clients (#48456) (#50767)
Tirith redacts its own findings, but the approval-request callbacks built the
operator prompt from the RAW command string, so a credential-shaped value
Tirith flagged was sent verbatim to clients, undoing the redaction one layer up.
Two egress transports carried the leak; both are fixed via a shared
module-level seam _redact_approval_command() (redact_sensitive_text force=True):
1. chat platforms — _approval_notify_sync (gateway/run.py): redact before
both the button path (send_exec_approval) and the plain-text /approve
fallback.
2. SSE/API stream — _approval_notify (gateway/platforms/api_server.py):
redact event['command'] before it is enqueued to API/desktop clients.
(whole-bug-class: sibling call path on a separate transport.)
force=True so the prompt — a hard secret-egress boundary — honors redaction
even when security.redact_secrets is off. Clean commands pass through unchanged.
Tests bind the seam (synthetic credential-format fixtures, force-when-disabled) AND assert
BOTH callbacks ASSIGN the redacted result before the send/enqueue sink, via an
AST contract that rejects a discarded-result call. All mutation-checked.
* feat(relay): forward a stable instance id at self-provision (Phase 6 Unit α) (#50772)
Add relay_instance_id() (env GATEWAY_RELAY_INSTANCE_ID first, then
gateway.relay_instance_id in config.yaml, mirroring the other relay readers) and
forward it in the /relay/provision body so the connector can bind
gatewayId -> instanceId and route inbound per-instance once Phase 6 delivery
lands.
The value is gateway-asserted but safely scoped: the org/tenant stays
NAS-token-verified at the connector, so a dishonest gateway can only bind its
OWN tenant's instance — same posture as relay_endpoint(). instanceId is only
added to the body when present, so omitting it lets the connector store null
(back-compat: self-hosted / pre-Phase-6 gateways simply have no binding yet).
For a managed (NAS-hosted) agent the id is NAS's AgentInstance.id, stamped into
the container env beside GATEWAY_RELAY_URL.
Tests: reader (env/config/absent), self_provision_relay forwards the id (set +
absent), and the real _post_provision body includes instanceId ONLY when set.
Refs: ~/nous/specs/gateway-gateway plan.md Phase 6 Unit α; decisions.md Q11.
* fix(compress): reserve output tokens in the compaction threshold (#23767, #43547)
The compaction trigger compared estimated input against context_length *
threshold, but the provider reserves max_tokens of OUTPUT out of the same
window. With a large max_tokens (e.g. 65536 on a custom provider) the usable
input budget is materially smaller than the raw window, so sessions hit a
provider 400 before compaction ever fired.
_compute_threshold_tokens now subtracts the output reservation
(context_length - max_tokens) before applying the percentage and the
small-window 85% guard. max_tokens is stored on the compressor (threaded from
agent.max_tokens at construction) and reused across update_model() switches;
None = provider default = no reservation (full-window behavior, unchanged).
Reimplemented on the current _compute_threshold_tokens surface (the inline
threshold calc the original PR targeted was since refactored for the
small-window #14690 fix); composes with that 85% guard on the effective budget.
Credit: @kyssta-exe (#43651) — original design for the output-token
reservation in the compaction threshold.
Closes #43547.
* fix(security): restrict dashboard plugin backend import to bundled plugins (#43719)
Defense-in-depth for the dashboard plugin auto-import path. The web server
auto-imports and mounts the Python backend (dashboard/manifest.json -> api file)
of plugins found in ~/.hermes/plugins/ (user) and ./.hermes/plugins/ (project),
not just bundled plugins. So any plugin that reaches one of those dirs gets
arbitrary Python executed on the next dashboard start.
NOTE ON THREAT MODEL: #43719's originally-documented delivery chain (a public
--insecure dashboard + open API used to git clone a malicious repo into
~/.hermes/plugins/) is ALREADY mitigated on main — since the June 2026
hermes-0day hardening, a non-loopback bind ALWAYS requires an auth provider and
--insecure no longer bypasses the auth gate. This change is therefore NOT
closing that (now-authenticated) network path; it removes the residual
'arbitrary code executes merely because a plugin is on disk' hazard, which still
applies when a plugin arrives by other means: a socially-engineered git clone,
a supply-chain drop, an authenticated-but-malicious actor, or a future
regression in the auth gate. Untrusted on-disk code should not auto-execute.
Restrict dashboard backend Python auto-import to BUNDLED plugins only. User and
project plugins may still extend the dashboard UI via static JS/CSS, but their
api Python file is never auto-imported. Two layers: _discover_dashboard_plugins
scrubs api/_api_file for user/project sources (and bundled wins name conflicts
so a non-bundled plugin cannot shadow a trusted backend route);
_mount_plugin_api_routes re-refuses user/project at mount time. Tightens the
prior GHSA-5qr3-c538-wm9j / #29156 hardening (bundled+user) to bundled-only.
Salvaged from #44472 (@egilewski) onto current main.
* feat(mem0): v3 API, OSS mode, update/delete tools, telemetry & review fixes (#15624)
* fix: update to version 3 endpoints and adding update and delete tool
* chore: removing the test md file
* fix: prevent circuit breaker on client errors in Mem0 provider
* chore: add telemetry for platform version
* feat: add OSS mode support to Mem0 memory provider
* chore: bump mem0ai dependency to >=2.0.1 in memory plugin
* refactor: enhance dependency checks and embedder config in mem0 backend
* refactor: adjust fact storage message for OSS mode
* refactor: expand user paths, add collection recreation on dimension change for Qdrant
* fix(mem0): make MEM0_USER_ID override gateway-native ids and tag writes with channel
When MEM0_USER_ID was configured (env or mem0.json), the gateway-native id
from kwargs (Telegram numeric id, Discord snowflake, ...) still won, so the
same human ended up under different user_ids per channel and memories never
merged across CLI / Telegram / Slack / Discord. Mirrors openclaw's cfg.userId
pattern: configured override wins, gateway-native id is the fallback.
The legacy "hermes-user" placeholder default written by the setup wizard is
treated as unset to avoid silently bucketing every gateway user together.
Also tag every write with metadata.channel (cli/telegram/discord/...) so the
dashboard can offer per-channel filtered views without coupling identity to
the channel; document the read/write filter asymmetry as intentional
(reads scope to user_id only for cross-agent recall).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: improve Mem0 memory provider backend, pagination, config, and error handling
* refactor: update mem0 telemetry code, docs, and bump version
* fix(mem0): make get_config_schema() return unified schema with mode-aware required flag
Schema always includes api_key field so picker shows "API key / local" for
both modes. In OSS mode api_key.required=False so status won't mislead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: improve mem0 telemetry, add env var key and OSS mode detection
* chore: bump mem0ai lower bound to 2.0.4 (latest SDK release)
* refactor: set telemetry sample rate to 1.0 and update docs for opt‑out
* fix(mem0): resolve 15 correctness, thread-safety, and resource bugs
Thread safety:
- Protect circuit breaker counters with _breaker_lock (race between
prefetch/sync daemon threads and main thread)
- Wrap sync_turn thread creation in _sync_lock; skip if previous sync
is still alive after 5 s join to prevent duplicate memory ingestion
- Guard _schedule_flush timer creation under _queue_lock (TOCTOU race)
- Capture local `backend` reference in prefetch/sync closures so
shutdown() nulling self._backend cannot crash in-flight threads
Correctness:
- Fix bool("false")==True for rerank param; parse string values explicitly
- Guard page/top_k with max(1,...) and move int() inside try blocks
- Fix fact_count=0 always in OSS mode (Memory.add returns list, not dict)
- Fix prefetch() not clearing result when thread still alive after timeout
- Fix atexit.register accumulating on repeated initialize() calls
Backend / setup:
- Handle Qdrant named-vector collections in _recreate_collection_if_dims_changed
(vectors is a dict; .size access raised AttributeError, swallowed silently)
- Wrap QdrantClient and psycopg2 conn/cursor in try/finally to prevent leaks
- Resolve ollama_bin at top of _ensure_ollama; use it for ollama pull
- Fix embedder key lookup when LLM provider has no env_var (e.g. ollama)
Also: remove _telemetry_enabled cache (env var check is cheap), bump
required mem0ai to >=2.0.7, minor README wording fix.
* fix(mem0): fix brittle qdrant path test + add telemetry sample-rate docs
- Replace generator-throw lambda with a proper def in
test_qdrant_path_not_writable; use tmp_path instead of a hardcoded
/nonexistent path so the test is root-safe
- Add MEM0_TELEMETRY_SAMPLE_RATE to memory-providers.md (was only
in the plugin README, not the user-guide docs)
* revert: remove MEM0_TELEMETRY_SAMPLE_RATE from user-guide docs
* refactor: remove telemetry from mem0 plugin and update documentation
* fix(mem0): set stdin=DEVNULL on setup subprocess calls
The TUI stdin guard (scripts/check_subprocess_stdin.py) requires every
subprocess call in plugin code to set stdin= so it can't inherit the
gateway's JSON-RPC stdin fd. Muzzle the docker/ollama calls in the OSS
setup wizard with stdin=subprocess.DEVNULL (none need interactive input).
Also covers the docker-inspect call the linter's regex misses.
---------
Co-authored-by: chaithanyak42 <chaithanya.kumar42a@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): don't count across shallow-clone boundary (bogus '12492 commits behind') (#50784)
* chore: re-trigger CI (workflows did not dispatch on prior head)
* fix(update): don't count across shallow-clone boundary (bogus '12492 commits behind')
Installer checkouts are shallow (git clone --depth 1). The CLI banner and
hermes update --check both did a plain git fetch (silently unshallowing the
repo) then git rev-list --count HEAD..origin/main, which counts across the
shallow boundary and prints a huge nonsense number like '12492 commits behind'.
Detect shallow up front, fetch with --depth 1 to preserve the boundary, and
compare tip SHAs instead of counting:
- banner _check_via_local_git: returns UPDATE_AVAILABLE_NO_COUNT when behind
(renders as 'update available') instead of the bogus count.
- _cmd_update_check: reports presence-only on shallow clones.
Full clones keep the exact count path unchanged. Mirrors the desktop fix in
apps/desktop/electron/main.cjs (commit 2950c6fa2).
* fix(delivery): make cron output truncation configurable + adapter-aware
Gateway-level truncation (MAX_PLATFORM_OUTPUT=4000) was pre-empting
adapter-side message splitting. Discord and Telegram both chunk long
content natively in their send() via truncate_message(), but the
delivery router truncated to 3800 chars + footer before the adapter
ever saw the full payload — so long cron output was cut short instead
of being delivered as multiple messages (issue #50126).
Changes:
- HERMES_DELIVERY_MAX_PLATFORM_OUTPUT env var makes the cap configurable
(default 4000, backward compatible). Set to 0 to disable truncation.
- TRUNCATED_VISIBLE (3800) removed — visible portion now derived
dynamically from max_output minus the actual footer length.
- New BasePlatformAdapter.splits_long_messages capability flag (default
False). Adapters that chunk in send() set True; delivery skips
truncation for them but still saves full output to disk as audit.
- Flagged Discord and Telegram (both verified to chunk in send()).
Fixes #50126
* fix(delivery): drop env-var knob, flag all chunking adapters
Follow-up to ScotterMonk's cron-truncation fix:
- Remove HERMES_DELIVERY_MAX_PLATFORM_OUTPUT env var. Behavioral config
belongs in config.yaml, not a new HERMES_* env var (.env is secrets
only). The actual bug is fixed entirely by the adapter-aware skip; the
configurable cap was unneeded scope. MAX_PLATFORM_OUTPUT is a constant
again, collapsing the max_output=0 disable branch and the
audit-vs-truncation threshold divergence.
- Flag the remaining verified-chunking adapters (slack, matrix, feishu,
mattermost, teams, whatsapp, whatsapp_cloud, weixin, bluebubbles,
yuanbao) with splits_long_messages=True so the fix covers the whole
bug class, not just Discord/Telegram. Each verified to chunk in its
own send() via truncate_message().
- SMS deliberately left False: it chunks for normal replies but a
multi-segment cron blast is cost-bearing; the 4000-cap + file save is
the safer default there.
- Update tests: drop the two env-override tests, add a test asserting a
save failure during truncation (non-chunking) propagates.
* chore(release): map ScotterMonk for PR #50145 salvage
* fix(gateway): cold-start installed Windows gateway after update when none was running (#50804)
The post-update gateway resume path (`_resume_windows_gateways_after_update`)
only relaunched gateways that were *running* when the update began — it
enumerates live PIDs in `_pause_windows_gateways_for_update` and respawns
exactly those. A gateway that had already died between updates (e.g. it was
launched attached to a terminal/TUI that later closed, taking the child with
it) was never brought back: the Startup-folder / Scheduled-Task autostart
entry only fires on the next login, not after an in-place update.
So a Desktop-GUI update (which runs `hermes update --yes --gateway`) on a box
whose gateway had quietly died would complete with no gateway running, and the
user had no indication anything should have come up.
Fix: when no gateway is running at pause time but an autostart entry is
installed (`gateway_windows.is_installed()` — an explicit "I want a gateway"
signal), return a `cold_start_if_installed` token. The resume step then does a
fresh detached spawn via `gateway_windows._spawn_detached()` — the same
windowless `pythonw` + `CREATE_BREAKAWAY_FROM_JOB` path `hermes gateway start`
uses. It re-checks liveness immediately before spawning so a concurrent start
(autostart entry firing) can't produce a duplicate.
Gateway-less users (no autostart entry) get nothing forced on them — the
pause step still returns None for them. POSIX is unaffected: enabled systemd
units already restart via `Restart=always`.
Windows-only; best-effort throughout (logs at debug and no-ops on any error).
Tests: pause returns the cold-start token only when installed, returns None
when not installed, resume cold-starts on the token, and resume skips the
cold-start when a gateway is already running.
* fix(picker): keep flat-namespace reseller first-party models in desktop picker
OpenCode Go (and OpenCode Zen) showed only a subset of the models they
serve in the desktop/CLI model picker — e.g. opencode-go rendered 13 of
19, silently dropping minimax-m3/m2.7/m2.5, glm-5/5.1, deepseek-v4-flash.
Root cause: the picker dedup in build_models_payload strips any model
from an aggregator row that overlaps a user-defined provider's catalog
(so a local proxy isn't shadowed by OpenRouter). It gated on
is_aggregator(), which is True for opencode-go/zen because their flat
/v1/models returns bare IDs the model-switch resolver searches. But
those are flat-namespace RESELLERS, not routing aggregators — every
model they list is first-party, so deduping them against a user proxy
that happens to serve a same-named model guts their own catalog.
Fix: add is_routing_aggregator() (True only for true routers like
OpenRouter and custom:* proxies; False for opencode-go/zen) and gate the
picker dedup on it. is_aggregator() is unchanged so model-switch flat
catalog resolution keeps working. Both desktop entry points
(model.options JSON-RPC and /api/model/options REST) and hermes model
share build_models_payload, so all surfaces get the full list.
Fixes #47077
* fix(desktop): show all of a provider's models when searching the composer picker
The composer model picker capped each provider's search matches at 12
(PER_PROVIDER_SEARCH). A provider serving more than 12 models (e.g.
opencode-go with 19) showed only a truncated subset when the user typed
its name to find it — exactly the models they were searching for got
cut. Edit Models showed the full list because it never applied this cap.
A search is already a narrowing action, so capping a single provider's
own matches is wrong. Remove the slice; search now lists every matching
model for the provider. The no-search default still shows the curated
top-N per provider via the visibility set.
Follow-up to #47077 (the backend dedup fix); this closes the remaining
frontend truncation users saw in the composer.
* feat(goals): /goal wait <pid> — park the loop on a background process (#50503)
* feat(goals): add /goal wait <pid> barrier to park the loop on a background process
The /goal loop re-pokes the agent every turn via the post-turn judge. When a
goal is gated on a long-running background process (CI poller, build, test
matrix, deploy) that produces nothing to judge yet, this spins the agent into
'is it done?' busy-work and burns the turn budget.
/goal wait <pid> [reason] parks the loop: while the PID is alive, the judge is
skipped, no turn is consumed, no continuation fires, and /goal status shows a
parked indicator. The barrier auto-clears the moment the process exits (the
agent's notify_on_complete watcher is the natural wake signal), then the next
turn resumes normal judging. /goal unwait clears it manually; pause/resume/clear
drop it; a dead/stale PID can never wedge the loop.
Wired across CLI, gateway, and the mid-run command guard for parity. Barrier
persists in SessionDB.state_meta (survives /resume); GoalState gains
backward-compatible waiting_on_pid/wait…
pai-scaffolde
pushed a commit
to pai-scaffolde/hermes-agent
that referenced
this pull request
Jun 28, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
waefrebeorn
pushed a commit
to waefrebeorn/slermes
that referenced
this pull request
Jul 2, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
habarmc1223-sudo
pushed a commit
to habarmc1223-sudo/hermes-agent-fluxmem
that referenced
this pull request
Jul 8, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
santhreal
pushed a commit
to santhreal/hermes-agent
that referenced
this pull request
Jul 13, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
Gravezzz
pushed a commit
to Gravezzz/hermes-agent
that referenced
this pull request
Jul 21, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
teknium1
pushed a commit
that referenced
this pull request
Jul 25, 2026
Path.read_text() and Path.write_text() without explicit encoding default to the system locale encoding. On Windows this is typically cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON configs, user data, service scripts). Add encoding="utf-8" to all read_text() and write_text() calls across 8 CLI files, matching the pattern established in PR #50534 (security_audit_startup.py) and ruff rule PLW1514. Fixed files: - main.py: 4 read_text calls - auth.py: 3 read_text calls - banner.py: 1 read_text + 1 write_text - service_manager.py: 1 read_text + 4 write_text - container_boot.py: 1 read_text + 4 write_text - doctor.py: 3 read_text calls - uninstall.py: 2 read_text calls - gateway.py: 1 write_text call
Th0rgal
added a commit
to Th0rgal/hermes-agent
that referenced
this pull request
Jul 26, 2026
#25) * fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist Follow-up for salvaged PR #69141, addressing the last open review point: cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans', so a zero-orphan preview passed the unrestricted None sentinel down to prune_checkpoints(), authorizing deletion of any project that became orphaned between the preview and the rescan — with zero confirmation calls. The allowlist is now bound unconditionally for every non-force run (empty preview => empty allowlist); --force keeps None. Adds the zero-orphan-preview timing regression plus allowlist-identity tests. * fix(agent): cache static system prompt prefixes * fix(prompt-caching): inject cache breakpoints after message normalization The conversation loop normalizes message text right before the API call so the request prefix is byte-identical across turns -- the stated reason is KV cache reuse on local inference servers and better cache hit rates on cloud providers. Cache breakpoints were injected *before* that pass, which defeats it. `_apply_cache_marker` rewrites a plain-string `content` into a `[{"type": "text", ...}]` block. The normalization pass is guarded on `isinstance(content, str)`, so every message that just got marked is silently skipped by it and keeps its raw leading/trailing whitespace. A message is only marked while it sits in the last-3 window, so: turn N in the window -> marked, content "file1\nfile2\n" turn N+1 rolled out -> plain, content "file1\nfile2" The same logical message is sent with different bytes on consecutive turns. The prefix stops matching at that position -- which is inside the span the breakpoints were placed to protect -- so the reusable prefix collapses back toward the system breakpoint on every turn. Tool results carry a trailing newline almost by default (any shell command output), so this is the common case, not an edge case. Move the injection below every message mutation. Besides fixing the whitespace divergence this stops breakpoints from being spent on messages that the orphan sweep or the thinking-only drop is about to remove or merge away -- a marker on a dropped message is a wasted breakpoint out of the four available. Nothing between the old and new call sites reads `cache_control`, and the mutators now see the plain-string shapes they were written against. * fix(caching): reconstruct static system prefix on session restore and post-compression reuse Follow-up to the cherry-picked #68258 base: the cross-session-stable prefix (_cached_system_prompt_static) was only recorded on fresh builds, so two paths silently degraded to the legacy single-breakpoint layout (flagged in review of #68258/#69341/#69704): - Session restore: gateway surfaces build a fresh AIAgent per turn and restore the persisted prompt verbatim from the session DB; the static prefix stayed None from turn 2 onward, flip-flopping the wire layout. - Post-compression cached-prompt reuse: _invalidate_system_prompt() clears the static prefix, and the keep-cached-prompt branch never restored it. Both sites now reconstruct the stable tier and adopt it ONLY when the authoritative prompt string literally startswith() it — stable-tier drift (skills edited, identity changed) falls back to the legacy layout with the stored bytes untouched. Fail-open on any builder error. The restore-path rebuild is gated on _use_prompt_caching so non-Anthropic routes skip it entirely. Refs #68191 Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * fix(config): preserve opaque .env values The .env sanitizer inferred missing newlines from known KEY= substrings inside existing values. Plain secrets containing those bytes could therefore be split into synthetic assignments and rewritten to disk. Treat each physical line as the only assignment boundary and keep bytes after the first equals sign opaque for boundary discovery. Preserve safe formatting, null-byte removal, BOM handling, and normal one-assignment-per-line parsing. Cover direct loading, dotenv loading, sanitization, writers, and migration with behavioral regressions. Fixes #29155 * fix(web): resolve per-profile gateway state for ?profile= in /api/status When ?profile=<name> was passed to /api/status, the handler used _config_profile_scope to set the HERMES_HOME contextvar override, but the gateway liveness check (get_running_pid_cached) and runtime status read (read_runtime_status) both resolve _get_process_hermes_home(), which deliberately ignores contextvar overrides (issue #56986) — it always reads os.environ['HERMES_HOME'] or the platform default. A named profile's gateway identity files (~/.hermes/profiles/<name>/gateway.pid, gateway_state.json) were therefore never found and the endpoint always reported the profile's gateway as stopped. Fix: when ?profile=<name> is requested, resolve the profile directory and pass explicit profile-scoped paths: - get_running_pid_cached(pid_path=profile_dir / 'gateway.pid') - read_runtime_status(path=profile_dir / 'gateway_state.json') - get_runtime_status_running_pid(..., expected_home=profile_dir) This is the same explicit-path pattern _collect_profile_gateway_topology already uses for per-profile gateway state, and it works within the #56986 constraint (no HERMES_HOME env mutation; read-only cross-profile access). Plain /api/status without ?profile= keeps the exact zero-arg calls, so its behavior — including the pid-cache signature and runtime-status fallback — is byte-for-byte unchanged. Fixes #69143 * test(web): pin per-profile gateway state scoping on /api/status Follow-up for the salvaged #70498 fix: replace the original PR's mock-signature churn (28 lambda **kw edits, needed only because it changed the no-profile call shape) with two targeted regression tests: - ?profile=<name> must pass the profile's gateway.pid / gateway_state.json paths and expected_home to the gateway status readers (HOME-anchored per-profile state under ~/.hermes/profiles/<name>/) - ?profile=<unknown> must 404 via _resolve_profile_dir The production change keeps plain /api/status on the exact zero-arg calls, so every pre-existing test passes unmodified. * test: accept the new profile-scoped kwargs in status fakes /api/status?profile= now passes pid_path=/path=/expected_home= to the PID and runtime-status readers; the profile-unification fakes had zero-arg signatures and raised TypeError. Plain /api/status call shapes are unchanged (pinned by the existing zero-arg tests in test_web_server.py). * fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344) Three-part fix for the gateway going silently deaf after a retryable fatal adapter error (e.g. httpx.ConnectError on Telegram): 1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced plain asyncio.wait_for with the task-detach pattern used by _await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the overdue task but then waits for it to exit, so a connect() that catches CancelledError can block recovery forever. The detach pattern releases the runner at the deadline via consume_detached_task_result. 2. **Ensure reconnect watcher always runs after escalation** — Added _ensure_reconnect_watcher_running(), called after queueing a retryable fatal error. If the reconnect watcher task has died (exhausted restart budget, terminal exception), it is respawned so queued platforms are never permanently stranded. 3. **Faulthandler at gateway startup** — Enabled faulthandler + SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for post-mortem diagnosis of future event-loop freezes. Tests added for _ensure_reconnect_watcher_running (alive, dead, not-started, not-running), fatal-error integration (retryable calls ensure, non-retryable does not), and _connect_adapter_with_timeout (timeout raises, success returns). * fix: explicit encoding for faulthandler file open (ruff PLW1514) * fix(gateway): stay alive on mixed retryable + non-retryable startup failures When connected_count == 0 and at least one platform failed with a non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE (78) even if OTHER platforms failed for merely transient reasons. Real-world shape (NS-609, hosted instance): WhatsApp enabled but never paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during polling startup (retryable) => exit 78 => the gateway either goes permanently down (supervisors honoring the exit-78 contract via RestartPreventExitStatus / the s6 finish->125 translation from #51228) or crash-loops (anything else). Either way Telegram never gets its retry and the dashboard drops with every exit, so a single unpaired platform plus one network blip disconnected every channel on the instance. Now exit 78 is reserved for the case where ALL startup failures are non-retryable (true config error, nothing to wait for). With mixed failures the gateway stays alive in degraded state: the reconnect watcher recovers the retryable platforms and the misconfigured ones stay fatal-parked and visible in runtime status. * fix: gate SIGUSR2 faulthandler registration behind POSIX check signal.SIGUSR2 and faulthandler.register() don't exist on Windows; the bare reference raised AttributeError at import time per the windows-footgun checker. faulthandler.enable() still covers fatal-error dumps on all platforms. * fix(gateway): detect and escape silent event-loop freezes - A self-rescheduling 5s call_later floor timer, armed before any adapter connects, guarantees the selector always has a finite timeout, so the existing async defenses (polling heartbeat, timeout guards) regain a chance to run after a zero-pending-timer stall. - A resident daemon-thread liveness watchdog probes the loop via call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout misses (~120s of total unresponsiveness) it dumps all thread tracebacks and exits with the established GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the gateway - async-level recovery cannot run on a frozen loop. - stop() disarms both guards before any teardown await so a busy shutdown is never misjudged as a freeze. HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES tune the thresholds. Fixes #69089 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gateway): close watchdog shutdown race against final-strike exit - Re-check stop_event after a missed probe (before the strike increment) and again on entering the final-strike branch (before the critical log, dump, and hard exit), so a normal stop() landing between the last timeout check and the exit path can no longer be misclassified as a freeze and trigger a supervisor restart. - Deterministic boundary tests pin both re-checks independently (mutation-verified: removing either check turns its own test red); frozen-loop semantics are unchanged. Addresses the shutdown-race review on #69164. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gateway): recheck stop immediately before watchdog hard exit - A stop() landing while the final diagnostics (critical log, traceback dump) are executing could still reach os._exit(75) after the pre-diagnostic check. Add a third stop_event recheck immediately before the hard exit: diagnostics may complete, but a disarmed watchdog never exits. - Deterministic regressions for both windows (stop triggered from inside logger.critical and from inside faulthandler.dump_traceback); mutation-verified (removing the check turns both red). Frozen-loop semantics unchanged. Addresses the second round of the shutdown-race review on #69164. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs Follow-up to the salvaged #69164 commits: policy forbids introducing new HERMES_* environment variables, so the four watchdog env knobs (HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are replaced with a single config.yaml boolean: gateway: loop_watchdog: true # default; false disables both guards - gateway/config.py: new GatewayConfig.loop_watchdog field (default True), parsed from top-level or nested gateway: form, round-trips via to_dict/from_dict. - gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog before arming the floor timer + watchdog (getattr-guarded for bare object.__new__ runners). - gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer reads the environment; probe interval/timeout/strikes are module constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog layer's posture). - hermes_cli/config.py: documented gateway.loop_watchdog default so 'hermes config set gateway.loop_watchdog false' validates. - tests: env-knob tests replaced with config-gate + round-trip tests; the final-strike boundary test injects its probe via max_strikes directly instead of patching the removed env helper. * fix: getattr-guard _stop_loop_liveness_guards in GatewayRunner.stop Teardown-path tests build bare runners via object.__new__ without the liveness-guard machinery; the unguarded call raised AttributeError in 8 tests. Same guard pattern as the start path. * fix(desktop): close cross-session leak windows in composer + session refs (#59305) Two React passive-effect timing bugs let a session switch land in the wrong chat: activeSessionIdRef/selectedStoredSessionIdRef (use-session-state-cache) and the composer's attachment-scope swap (use-composer-draft) both mirrored their source props via useEffect, which fires one commit AFTER the new session's view has already painted — a synchronous read/submit in that window observed the outgoing session's ids/attachments. - use-session-state-cache.ts: mirror the session refs synchronously during render instead of a useEffect, guarded to fire only when the prop itself changed (not unconditionally) so an imperative pin from submit.ts / use-session-actions (e.g. a freshly resumed runtime id, intentionally not synced to the source atom) survives an unrelated re-render. - use-composer-draft.ts: the per-thread attachment-scope-swap effect is now a useLayoutEffect, closing the window before paint. - submit.ts / session-context-drift.ts: add a 3rd drift prong comparing the composer's loaded scope (SubmitTextOptions.composerScope) against the submit target, resolved into the same lineage-root domain (resolveComposerSessionKey) the composer itself uses — comparing against the raw tip id would false-positive-abort every submit into any session that has ever auto-compressed. - routes.ts / chat/index.tsx: the primary composer's durable scope key now prefers the route over a possibly-stale store selection (primaryRouteSelectedSessionId). - use-composer-draft.ts: redacted [composer-rehydrate] diagnostic log (counts/kinds/scope only, never raw refs) for future reports in this class. - chat-runtime.ts: normalize attachment id values (url/path) before hashing so a re-attach with a trailing slash or backslash path dedupes correctly. 16 files, 286 tests across the touched/dependent suites (17 files) green, including new regression coverage for each fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): satisfy eslint import-order rules in use-composer-draft.test.tsx CI's check:lint failed on two perfectionist rule violations introduced by the new test file: type import ordering and missing blank line between the parent-relative and same-directory import groups. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agent): prevent shared OpenAI client FD-recycle corruption from stale stream watchdog The streaming stale watchdog was calling _replace_primary_openai_client() from its polling thread, which closes the shared client's connection pool. Worker threads from previous stale-killed attempts may still be unwinding their SSL BIOs, causing TLS application-data to overwrite SQLite file headers via FD reuse. This is the same corruption vector documented in #67142 for Anthropic, where the fix was to never close the shared client from a non-owner thread. Apply the same pattern to the OpenAI-wire path: - Stale stream watchdog: skip shared client replacement - Mid-tool-retry cleanup: skip shared client replacement - Stream retry cleanup: skip shared client replacement The request-local client is already closed via _close_request_client_once. The shared client is replaced lazily by _ensure_primary_openai_client on the next request, which runs on the owning thread. Closes #70773. * fix(agent): retire replaced shared OpenAI clients instead of cross-thread pool close Widen the #70773 fix beyond the three in-request cleanup sites removed in the cherry-picked commit: every remaining path that swaps out the shared OpenAI client could still hard-close its pool from a thread that doesn't own the in-flight sockets (credential rotation/refresh on the turn thread, dead-connection cleanup, gateway cache eviction, transport recovery) — the same FD-recycle corruption vector, just rarer. Add AIAgent._retire_shared_openai_client(): shutdown(SHUT_RDWR) all pooled sockets (FD-safe from any thread, unblocks in-flight readers) but never call client.close() — FD release is deferred to GC, which cannot run until every borrowing thread has unwound its SSL BIO. Refcounting is the ownership handshake; with no borrowers the FDs are released immediately. Wired into: - _replace_primary_openai_client (rotation/refresh/dead-conn cleanup) - try_recover_primary_transport (primary_recovery) - release_clients (gateway cache_evict) agent.close() keeps the hard close: full teardown is a real session boundary where no request may be in flight. Tests: new tests/run_agent/test_70773_shared_client_fd_corruption.py covers the three watchdog/retry sites plus retire semantics; existing close-assertions updated to pin retire-not-close. * test: update credential-refresh tests for retire-not-close contract The three refresh tests asserted the replaced shared client gets close()d — the exact cross-thread close #70773 removes. They now pin the new contract: close() is NOT called from the refresh path; the old client is retired (sockets shutdown, FD release deferred to GC). * fix(doctor): UTF-8/latin-1 fallback when scanning .env Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes. * fix: handle non-UTF-8 files in OpenClaw migration script * fix: decode config and state files as UTF-8 on non-UTF-8 locales Several file-I/O call sites still use open() / Path.read_text() / Path.write_text() without an explicit encoding, so they fall back to the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949) any non-ASCII byte in a config/state/user-content file raises UnicodeDecodeError or UnicodeEncodeError and crashes the caller. to the remaining hot paths: - agent/copilot_acp_client.py: fs/read_text_file and fs/write_text_file (Copilot's read_file / write_file tools, directly reported in #18637 bug 2) - agent/model_metadata.py: context-length YAML cache load + two save sites (context probing is on the call path of every model invocation) - agent/nous_rate_guard.py: cross-session rate-limit JSON state (read + atomic write via os.fdopen) - cron/scheduler.py: user config.yaml read in run_job - gateway/delivery.py: cron output writes for AI-generated content, very likely non-ASCII yaml.dump call sites also gain allow_unicode=True so the emitted YAML preserves non-ASCII chars as-is instead of emitting \u escape sequences. Adds regression tests that monkeypatch builtins.open / Path.read_text / Path.write_text to simulate a GBK locale: each test raises UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly passes encoding='utf-8'. Verified that the tests fail on main and pass with this change, on Linux as well as on Windows. Refs #18637 * fix(cli): add explicit encoding to read_text/write_text calls Path.read_text() and Path.write_text() without explicit encoding default to the system locale encoding. On Windows this is typically cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON configs, user data, service scripts). Add encoding="utf-8" to all read_text() and write_text() calls across 8 CLI files, matching the pattern established in PR #50534 (security_audit_startup.py) and ruff rule PLW1514. Fixed files: - main.py: 4 read_text calls - auth.py: 3 read_text calls - banner.py: 1 read_text + 1 write_text - service_manager.py: 1 read_text + 4 write_text - container_boot.py: 1 read_text + 4 write_text - doctor.py: 3 read_text calls - uninstall.py: 2 read_text calls - gateway.py: 1 write_text call * fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls Path.read_text() without an explicit encoding uses the platform's default encoding. On Windows this is typically cp1252 or mbcs, which causes UnicodeDecodeError or silent data corruption when reading UTF-8 content (JSON files, user text, config with non-ASCII chars). This is the read-side companion to the write_text() encoding fix. Fixed the most critical locations that read JSON data, user content, and config files across 14 files with 31 call sites. Pattern: .read_text() → .read_text(encoding='utf-8') json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8')) * fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN), Python defaults stdout/stderr to the active codepage. tools/skills_sync.py prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK cannot encode, raising UnicodeEncodeError mid-run. The installer (scripts/install.ps1) captures this script's stdout and the Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK byte stream (or the traceback it triggers) surfaces as: WARN stdout read error: stream did not contain valid UTF-8 stage=config-templates state=Failed error=install.ps1 -Stage config-templates produced no JSON result frame (exit=Some(0)) i.e. the stage fails even though the script exits 0. install.ps1 already sets [Console]::OutputEncoding = UTF8, but that does not propagate to the python.exe child (Python reads PYTHONIOENCODING / locale, not the console encoding). Fix in two places for defense in depth: - tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so output is valid UTF-8 regardless of caller or active codepage. - scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped to the call, restored afterwards) around the skills_sync.py invocation. * test(install): add UTF-8 regression guard for skills_sync child path Addresses hermes-sweeper review on PR #54866: the installer runs tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING / PYTHONUTF8 the scoped install.ps1 block sets, but there was no regression test for this child-Python UTF-8 path. The existing test_child_process_inherits_utf8_mode covers a different (bootstrap entry-point) flow. Add TestSkillsSyncUtf8Guard: three subprocess tests that import skills_sync (triggering its import-time stdout/stderr reconfigure) and assert the checkmark/up-arrow glyphs the script prints at tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when the child env is left unset or explicitly hostile (gbk). A third test proves the guard is load-bearing by reproducing the crash without it. Also keep the new install.ps1 comment ASCII-only (the checkmark spelled out as U+2713) per the file's PS 5.1 parser-compatibility contract at scripts/install.ps1:79-80; the literal glyph in the comment violated that contract. * fix: add encoding="utf-8" to Path.write_text() calls (P1) Path.write_text() without encoding defaults to system locale encoding. On Windows (cp1252), this silently corrupts non-ASCII content written to JSON files, config files, and cache files. This is the write-side counterpart to the read_text() encoding fix (PR #56115). PLW1514 only covers open() calls — Path methods are unguarded by ruff. 39 instances across 16 files, all passing py_compile. Files changed: - agent/copilot_acp_client.py (1) - tools/web_tools.py (1) - tools/xai_http.py (1) - tools/skills_hub.py (8) - gateway/slash_commands.py (1) - gateway/run.py (5) - gateway/dead_targets.py (1) - gateway/delivery.py (2) - gateway/platforms/qqbot/adapter.py (1) - hermes_cli/gateway.py (1) - hermes_cli/banner.py (1) - hermes_cli/service_manager.py (5) - hermes_cli/container_boot.py (5) - hermes_cli/uninstall.py (1) - hermes_cli/main.py (2) - hermes_cli/profiles.py (3) * fix(hindsight): specify UTF-8 encoding for file I/O on Windows On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text() defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError when reading .env or .json config files that contain non-ASCII characters. Explicitly pass encoding='utf-8' to all read_text() and write_text() calls in the hindsight memory provider plugin. * fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup The mem0 and hindsight memory-provider setup routines round-trip the user's ~/.hermes/.env: they read existing lines, update the keys they manage, and rewrite the whole file preserving every other line verbatim. Both used env_path.read_text() / write_text() with no encoding. read_text()/write_text() with no encoding fall back to the system locale (cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get mangled or the call crashes on any non-ASCII value, and — because the reader never strips a BOM — a Notepad-edited .env makes the first key fail the in-place match and get duplicated instead of updated. Match the canonical .env readers in hermes_cli/config.py: read with encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'. mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the .env path in the same file. Fixes both memory plugins in one class fix. Adds regression tests: a BOM'd .env updates the first key in place (locale-independent, fails without the fix) and non-ASCII existing lines survive the round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): cover the remaining setup-time .env reads with utf-8-sig Follow-up to review feedback: - mem0 _prompt_api_key read .env with the locale default, so a Notepad BOM hid the first key from the masked current-value lookup; read it with utf-8-sig + errors=replace like the canonical readers in hermes_cli/config.py. - hindsight _load_simple_env used plain utf-8; it also parses the Hermes .env during post_setup, where a BOM stuck to the first key. Switch to utf-8-sig + errors=replace. - Add hindsight regressions: BOM key matching in _load_simple_env and in the cloud post_setup writer, plus non-ASCII round-trip preservation, and a mem0 regression for the BOM'd masked-key lookup. The BOM tests fail without the fix on any platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(profile): read .env as utf-8-sig in the distribution-install preview `_render_distribution_plan` reads the target profile's `.env` to decide whether a required env var is already set (so it doesn't nag the user), using `Path.read_text()` with no encoding. Two bugs: 1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows), which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding `except OSError` does NOT catch that — `UnicodeDecodeError` is a `ValueError` — so a mis-encoded `.env` aborts the entire install preview. 2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key (`KEY`), so the very first required env var is mis-reported as "needs setting" when it is actually present. `.env` is written as UTF-8 everywhere in the codebase. Read it as `utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a genuinely un-decodable file skips the pre-check instead of crashing. Regression tests: a BOM-prefixed `.env` whose first key must still read as "set", and an invalid-UTF-8 `.env` that must not abort the preview. * fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/ Path.read_text() and Path.write_text() without encoding= default to the system locale (cp1252 on Windows), which corrupts non-ASCII JSON content. Coverage-gap fix for files not addressed by prior encoding PRs: - tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files) - tools/skills_sync.py: 1 read_text (lock file) - tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker) - agent/shell_hooks.py: 1 read_text (allowlist) - gateway/status.py: 1 read_text (PID file) - hermes_cli/banner.py: 1 read_text + 1 write_text (update cache) All sites read/write JSON or short text. No behavioral change on Linux (already UTF-8); fixes silent data corruption on Windows. * fix(skills): tolerate non-UTF-8 bytes in hub lock.json _read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a strict utf-8 decode. Hub skill descriptions can carry Windows-1252 typographic bytes (em-dash 0x97, smart quotes, bullets) as single high bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which is a ValueError sibling not caught by the function's except (OSError, json.JSONDecodeError). It escapes and 500s the whole /api/skills endpoint, blanking the desktop Skills panel. Decode with errors="replace" so the offending byte degrades to U+FFFD and the structurally valid JSON — and every other skill — stays readable. Fixes #68053 * fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup _setup_worktree read both files with the locale default encoding. On a cp1251/GBK Windows machine a UTF-8 include list either decodes to mojibake paths (non-ASCII entries silently not copied) or raises UnicodeDecodeError, which the enclosing handler logs at DEBUG and swallows — no include is copied at all, so the worktree starts without .env/keys and the agent breaks invisibly. A Notepad BOM likewise glues to the first include entry on every platform, and to the first .gitignore line, defeating the '.worktrees/' membership check and appending a duplicate entry on each run. Read both files with utf-8-sig + errors=replace, matching the canonical .env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a BOM) and the UTF-8 append this same block already performs on .gitignore. Regression tests exercise the real cli._setup_worktree: the two BOM tests fail without the fix on any platform, the non-ASCII include test additionally reproduces the Windows locale failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts The bundled office skills (#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018). * fix(windows): sweep remaining bare read_text/write_text sites + linter rule AST-driven pass over every Path.read_text()/write_text() without an explicit encoding= across non-test code: 71 sites in 34 files (skills_hub, hermes_cli/main+profiles+service_manager+container_boot, mem0/hindsight/honcho plugins, achievements dashboard, release/CI scripts, productivity+comfyui skill helpers, agent/*). Verified zero positional-encoding collisions before insertion; per-file compile() check after. Adds a check-windows-footguns rule flagging bare single-line read_text/write_text (multi-line forms stay covered by the AST guard test from #38985). Together with the salvaged contributor commits this retires the ~169-site bare file-I/O class (#37423's long tail). * fix: restore utf-8-sig BOM tolerance at .env readers the sweep normalized The cherry-pick auto-resolution + AST sweep applied plain utf-8 at three .env reader sites where the salvaged PRs (#62617, #62123) deliberately use utf-8-sig — a Notepad BOM must not hide/duplicate the first key. Restore the contract (tests pin it). * chore: contributor email mappings for the file-I/O salvage * refactor(desktop): add shared Field form-dialog primitive Dialog forms each hand-rolled their own label+control+hint stack (or borrowed the settings-surface ListRow), so gaps and hint styling drifted between the profile, cron, and webhook dialogs. Add a single Field / FieldHint primitive for label-over-control dialog fields and adopt it in the create/rename profile dialogs as the first consumers. * fix(desktop): unify overlay-pane padding and add primary PanelAction Overlay panes each set their own top padding, so the Settings sidebar and Panel headers sat at different heights than System/Agents and the close X (the #67759 regression). Hoist the shared beside-the-X clearance into OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits under the X), tighten OverlayMain's gutters, and drop the one-off Settings override. Also give PanelAction a `primary` variant so a detail header can promote its main action to a filled button. * refactor(desktop): fold cron Blueprints into the New Job dialog Blueprints lived behind a separate Jobs/Blueprints tab with its own card gallery — a bespoke surface no other overlay uses. Remove the tab and make blueprints a "Start from" dropdown at the top of the New Job dialog (default "Custom" = the manual editor); picking one swaps the form for that blueprint's typed slots. Also promote the detail-view "Trigger now" button to a primary action and adopt the shared Field primitive. * refactor(desktop): webhooks create form uses shared Field; drop status pill The create dialog used the settings-surface ListRow/ToggleRow inside a modal, which read differently from every other form dialog, and the detail header carried an enabled/disabled pill that rendered as a stray dash. Switch the form to the shared Field primitive (+ Switch) and remove the pill. * fmt(js): `npm run fix` on merge (#71099) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(dashboard): add lightweight /api/health liveness endpoint /api/status is the only public liveness route, and its handler loads the gateway config, probes gateway health, and counts sessions before it can answer. That work is wrong for a readiness probe: a caller that only needs to know the process is up pays for a cold plugin import tree. Add /api/health, which returns process liveness, version, and the auth-gate shape and touches nothing else. * fix(desktop): probe /api/health for boot readiness, and survive a stalled loop Desktop boot polls /api/status, so readiness waits on gateway config and a cold plugin import tree. On Windows that regularly outlives the probe and Desktop kills a backend that is already listening, respawns it, and re-pays the same import cost — the reported crash loop. Probe /api/health instead, falling back to /api/status only for the missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so an older remote backend still connects. Timeouts and server errors keep polling health rather than dropping to the heavyweight route. A cheap route is not enough on its own. Warming the gateway import holds the GIL, so the event loop can stall for tens of seconds and starve /api/health too. At the default 15s socket timeout only three attempts fit in the 45s budget; give each probe 5s so the loop keeps retrying across the stall. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com> Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com> * fix(state): decode display_metadata at every message read path get_messages(), get_messages_around() and get_anchored_view() returned the raw display_metadata column instead of the dict every caller expects. The desktop paints a resumed transcript from the REST prefetch, which reads through get_messages(), so any session holding an async_delegation_complete event failed resume with "Cannot use 'in' operator to search for 'task_count'" — on every such session, not just corrupted ones. Route all four read paths through one shared codec that also unwraps rows carrying a second JSON layer, so sessions already broken on disk recover on read rather than needing a migration. Co-authored-by: Studio729 <Studio729@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> * fix(state): stop double-encoding display_metadata on write export_session() reads through get_messages(), so before the read fix an already-serialized string went straight back into _insert_message_rows() and got re-dumped — an export/import round trip permanently corrupted the row. Guard the three write paths the same way tool_calls already is: parse a string argument before storing it, and drop metadata that isn't an object rather than persisting something no reader can use. Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> * fix(desktop): tolerate unparsed display_metadata from an older backend The desktop and the Hermes backend it talks to version independently — a remote VM running an older build still serves display_metadata as JSON text. Indexing into that string with `in` threw and failed the whole resume, so narrow the type to admit a string and parse it before reading task_count. Falling back to the generic label keeps a delegation event renderable even when the metadata is unusable. Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> Co-authored-by: Studio729 <Studio729@users.noreply.github.com> * fix(checkpoints): don't prune a project whose volume is merely unmounted Orphan pruning decides a project is gone from a single probe: if delete_orphans and (not workdir or not Path(workdir).exists()): reason = "orphan" then deletes its ref, index, and metadata — the project's entire checkpoint history. `Path.exists()` is False for a deleted directory, but it is equally False for one whose storage is not attached right now: an unplugged external drive, a share behind a downed VPN, a bind-mount absent from this container, an offline Windows mapped drive. The project is fine; only our view of it is. This is not an opt-in maintenance command. `maybe_auto_prune_checkpoints` runs unattended at startup from both `cli.py` and `gateway/run.py`, with `delete_orphans=True` by default. So starting Hermes once while the drive is unplugged silently destroys the restore points for every project on it — the one thing checkpoints exist to provide, and there is nothing to restore from afterwards. Reproduced against the real store: a project registered under an unmounted path and one on local disk, then a startup prune — prune: {'scanned': 2, 'deleted_orphan': 1} unreachable project index still on disk: False The legacy pre-v2 branch has the same flaw plus a second one: a `HERMES_WORKDIR` marker that exists but cannot be read leaves `workdir = None`, which the same condition treats as an orphan. Failing to read a file is not evidence that a project was deleted. Require corroboration before deleting: the workdir's parent must be present, so its absence is something we actually observed. A missing parent means the volume is not there and we know nothing, so the entry is left alone — and an unreadable marker never deletes at all. Genuinely abandoned projects are still reclaimed, both by the unchanged orphan path (parent present, project gone) and by the retention/stale rule, which runs off `last_touch` rather than a filesystem probe. tests/tools/test_checkpoint_manager.py: a project whose whole mount disappears keeps its history; controls prove a genuinely deleted project is still pruned and a live project is untouched. The data-loss test fails on main; both controls pass there. 81 passed across the checkpoint suites (2 failures in test_checkpoint_manager.py are pre-existing and fail identically on clean main). * fix(checkpoints): an empty surviving mount point is not evidence of deletion Addresses @egilewski's review: the parent-directory check still deleted checkpoint history for the most common unmount layout. Detaching storage removes the parent outright in some layouts (`/Volumes/Ext/proj` on macOS, `/media/<user>/<label>/proj`), which the first commit handles. But in the classic static layout — `/mnt/volume/proj`, an fstab entry, a container bind-mount — unmounting removes the contents and leaves the mount point behind as an empty directory. `parent.is_dir()` is then true, the project is absent, and the startup sweep deletes its ref, index and metadata: exactly the case this PR set out to protect. Reproduced against the real predicate before this commit: mount root vanished (macOS) -> False ok empty surviving mount point -> True <-- history deleted really deleted (siblings) -> True ok An empty parent carries no information: it looks identical whether the volume was detached or the project was deleted. So require the parent to actually say something — it holds some other entry (we observed a populated directory that does not contain the project), or it is itself a live mount point (the volume is attached right now and demonstrably does not hold the project). The cost is that a project deleted out of an otherwise-empty parent is no longer reclaimed by the orphan rule. It is not leaked: the retention rule reads `last_touch` rather than probing the filesystem and still collects it, so reclamation is deferred, not lost. That is the right direction for a predicate whose false positive destroys a user's restore points unattended. `_dir_has_any_entry` stops at the first entry via `os.scandir` instead of materializing a listing, since a project root can hold a large tree. tests/tools/test_checkpoint_manager.py: `test_surviving_empty_mountpoint_ keeps_its_checkpoints` pins the reviewed case, and `test_empty_parent_project_ is_still_reclaimed_by_retention` pins the deferral above so the safety valve cannot silently regress into a leak. Both fail on the previous commit. The real-orphan control now seeds a sibling so it exercises a populated parent rather than the ambiguous empty one. 80 passed in the checkpoint suite; the 2 remaining failures (`TestGitEnvIsolation`, `TestClearFunctions`) fail identically on clean main. * fix(checkpoints): require positive volume-attachment evidence before orphan classification Follow-up to the cherry-picked #69063: egilewski's review found that the _dir_has_any_entry(parent) guard treats ANY entry in the mount point's parent as proof the volume is attached — but unmounting exposes the UNDERLAY directory's own files (e.g. a .keep placeholder), so a populated underlying mount-point dir still classified the project as an orphan and deleted its ref/index/metadata. Reproduced on both main and the PR head. Attachment evidence is now positive instead of circumstantial: * _volume_evidence() records the parent directory's (st_dev, st_ino) identity in the project's metadata while the workdir is observably live (at _register_project/_touch_project time). A mount point resolves to the mounted filesystem's root while attached and to the underlay directory after detach — same path, different directory, different identity. * _workdir_is_observably_gone() now requires the parent visible at prune time to match that recorded identity before the populated-parent check can classify an orphan. A mismatch means a different directory (the underlay) is showing through — a detached volume, not an observed deletion. * Metadata without a recorded identity (written by older versions) is never orphan-classified — unsure never deletes; the retention/stale rule still reclaims genuinely abandoned projects off last_touch. * The frozen pre-v2 layout has no metadata channel for the identity, so it keeps the structural checks only (require_parent_identity=False). * A failed evidence probe on re-registration preserves the previously recorded identity — stale evidence can only make pruning MORE conservative. Windows: st_dev/st_ino of 0 (filesystems without file IDs, some network shares) is treated as "no evidence recorded", which falls into the conservative never-orphan path. os.path.ismount and Path.stat are cross-platform; no POSIX-only calls added. tests/tools/test_checkpoint_manager.py: adds egilewski's exact regression (checkpoint history for mnt/volume/project, detach exposes mnt/volume/.keep, prune with orphan deletion enabled → NOT deleted; fails on the bare cherry-pick, passes with this fix), plus no-recorded-identity conservatism and probe-failure identity preservation. His absent-parent/empty-parent/retention/genuine-deletion/ live-project controls all still pass. Reported-by: egilewski (review on #69063) * fix(telegram): require initial polling readiness Use wall deadlines for deleteWebhook and start_polling, then fail cold startup unless getUpdates proves progress. This lets the gateway discard partial PTB state and retry with a fresh adapter.\n\nRefs #67498 * fix(gateway): allow Telegram readiness budget Give Telegram a 180s default outer connect budget so cold polling can prove getUpdates readiness. Preserve the 30s default for other platforms and all explicit config/env overrides.\n\nRefs #67498 * fix(telegram): bind strict cold-start readiness to its own polling generation Follow-up hardening for the salvaged #69240 readiness gate (#67498): - _start_polling_once now returns its (generation, progress_event) pair so the strict cold-start gate binds to exactly the generation it started, instead of re-reading self._polling_progress_event which a concurrent recovery task may have replaced with a newer generation's event (the G1/G2 race flagged in the #69240 review). - Strict cold start no longer schedules background polling recovery: a polling error during the readiness wait is captured by a strict callback and fails the connect attempt immediately with a loud OSError, so GatewayRunner disposes the partial adapter and retries with a fresh one — no more waiting out the full readiness deadline on a generation that already errored, and no G2-on-partial-app healing. - After readiness is proven the strict callback delegates every later polling error to the real background-recovery callback, preserving the existing degraded/reconnect semantics for the polling lifetime. - The readiness-timeout error message now states the deadline and that the gateway will retry with a fresh adapter (loud failure, not a silent wait). - Regression tests: current-generation progress connects; a polling error during strict cold start fails fast without scheduling background recovery (the #67498 idle-threads shape); stale-generation progress is rejected. Progresses #67498 * test: record getUpdates progress in mocked cold-connect polling flows The strict cold-start readiness gate (#67498) means adapter.connect() no longer returns True until the mocked start_polling records a successful getUpdates round trip for its generation. Update the conflict-suite Application mocks accordingly: - fake_start_polling side effects call adapter._record_polling_progress(adapter._polling_generation) on the initial connect (retry generations intentionally do NOT auto-progress where a test asserts the conflict count survives an unproven retry). - _build_polling_app takes the adapter so its start_polling mock can record progress. Without this, the cold connects in these tests wait out the full 60s readiness deadline and fail — which is exactly the fail-closed behavior the gate is supposed to provide when polling shows no progress. * fix(config): add a collision-safe env var name for custom endpoint keys Both the Desktop panel and the CLI setup flow need somewhere in .env to put a custom endpoint's API key. Deriving the name from the endpoint's hostname collapses two servers on one machine onto a single slot, and every IP-based local endpoint slugs to a digit-leading name that save_env_value rejects outright. Key off the endpoint's own identity and keep a fixed prefix. Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> * fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179) The desktop self-update chain (Desktop -> hermes-setup --update -> hermes update -> hermes desktop --build-only -> relaunch) rebuilds Hermes.exe on the user's machine and declared success on bare file EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted extraction or rcedit rewrite / full disk) or a wrong-architecture unpacked tree therefore shipped as the 'updated' app, which Windows refuses to load with 'This app can't run on your computer' (此应用无法在你的电脑上运行) — and the previous working build had already been wiped by before-pack.mjs, leaving nothing to fall back to. Fix, in three parts: - hermes_cli/main.py: post-build integrity gate on Windows (_ensure_desktop_exe_launchable). Parses the PE header of the freshly built Hermes.exe — MZ/PE magic, section-table completeness vs file size (catches truncation), and COFF machine vs the host arch (catches arm64/x64 mixups). On failure it purges the (likely corrupt) cached Electron zip, invalidates the content-hash build stamp so the updater's retry-once genuinely re-downloads and rebuilds, restores the previous build from the .bak tree when one exists (keeping the corrupt tree as .corrupt for diagnostics), tells the user the update was aborted and their old version kept, and exits nonzero. _desktop_packaged_executable also now prefers a host-loadable PE over pure newest-mtime when multiple win-*-unpacked trees coexist. - apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked tree is preserved as <appOutDir>.bak (only when it holds the product exe — partial/corrupt trees still get the plain wipe) instead of being destroyed, providing the rollback material for the gate above. Non-Windows behavior is unchanged. - Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch, rollback semantics, and the build-only exit contract) and 6 new vitest cases in before-pack.test.mjs for the .bak preservation rules. Progresses #69179 * fix(desktop): persist the whole discovered model list when saving an endpoint Test enumerates a custom provider's catalogue and the panel holds the result in discoveredModels, but the save payload never carried it, so only the one model the user hand-typed reached providers.<id>.models. Every downstream picker reads that map straight from config.yaml with no live probe, which is why a proxy serving 18 models offered exactly one. Send the discovered list and merge it onto the entry, so models already known keep their context lengths. Fixes #69988 Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> * fix(web_server): keep Desktop custom endpoint API keys out of config.yaml The Custom Endpoints panel wrote the raw key to providers.<id>.api_key, so the credential sat in plaintext in a file users routinely share and commit. The input is masked, so nothing warned them. Write the key to .env and reference it via key_env, the same indirection built-in providers use and that runtime_provider already resolves. The read side has to move with it: reporting has_api_key from api_key alone would show "no API key" for every migrated endpoint, and activate copying only api_key would drop the credential entirely. Delete now clears the .env slot too, and an entry still carrying a pre-fix plaintext key is migrated on its next save so existing users get cleaned up without re-entering anything — unless the key is a hand-written ${VAR} template, which is already safe and must not be duplicated into a second env var. Fixes #69449 Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> * fix(cli): store custom endpoint API key in .env instead of config.yaml hermes model's custom-endpoint flow is the other write path that produced a plaintext key, on both the model block and the custom_providers entry. Route it through the same .env indirection as the Desktop panel, and swap an existing entry's inline key for the reference when the URL is re-saved. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> * test: cover custom endpoint key storage and model-list persistence Bug-class coverage for both fixes: the full catalogue survives Save, context lengths are preserved, the key never lands in config.yaml on either write path, blank clears it, a pre-fix plaintext key migrates while a ${VAR} template is left alone, two endpoints on one host keep separate credentials, and an IP-derived name is still a valid POSIX env var. The two delete tests asserted on the plaintext mirror; they now assert the same invariants against the credential reference. * fix(desktop): persist @image: refs instead of the vision-enrichment text The desktop gateway passed the vision-enriched, model-only message text (carrying an `image_url:<path>` hint) straight into run_conversation as the persisted user turn. The renderer only parses `@image:<path>`, so it could not rebuild the attachment from history: after a restart the image was gone and only the caption survived, and on a live session switch the warm cache disagreed with the authoritative text and the frontend "rescued" the image by appending it after the caption. run_conversation already supports persist_user_message for exactly this "what the model sees" vs "what gets stored" split; it was simply never wired up for the attachment path. * fix(desktop): keep cached attachment refs on session resume Persisted history carries no attachment metadata for non-image refs, so resume reconciliation dropped `@file:` chips off a user turn whose text matched. Carry the warm cache's refs forward when the resumed message has none of its own, never replacing refs that are already present. (cherry picked from commit eac5b0a8ac39eab242a5d571531e386ec70e2435) * fix(desktop): quote persisted @image: paths so spaced paths render The unquoted alternative in the directive pattern is `\S+`, so a ref built by string interpolation truncates at the first space and strands the tail as loose text next to a broken thumbnail. Composer images live in the app's userData dir, which on macOS is `~/Library/Application Support/<App>/` — so every pasted or dropped image hit this. Adds format_reference_value next to REFERENCE_PATTERN, mirroring formatRefValue in the desktop's directive-text.tsx, and covers the round-trip through the parser. * fix(desktop): persist the image ref for natively-vision-capable models too A turn routed to a model that takes pixels directly sends `content` as a parts list, and the session store deliberately ignores a plain-string persist override for a list payload — a text override must not erase a turn's image summary. So the override was dropped for every user on a vision-capable main model, and the durable row kept only the caption plus a literal `[Image attached at: ...]` / `[screenshot]`, which the renderer cannot turn back into an image. Only vision-preprocessed (text-mode) turns were actually fixed. Mirror the shape instead: swap the text part for the `@image:` ref form and keep the image parts, so the model still has the pixels for the rest of the session, and drop the `[screenshot]` stand-in on the way into the bubble when a ref was lifted from the same message. * refactor(desktop): memoize the directive image-segment filter Matches the two derived values above it and fixes the indentation. * fix(desktop): lead persisted image turns with the caption Session previews are the first 60 characters of the first user message, so persisting the @image: directives ahead of the caption labelled the session with a truncated file path in the sidebar, session switcher, and command palette. Clients lift the refs out of the body line by line, so moving them after the caption changes nothing about how the turn renders. * test(desktop): cover attached-image resume end to end The unit tests cover each layer in isolation, but nothing exercised the whole chain the bug lived in: the real gateway persisting an attachment, SessionDB holding it after the process exits, and the renderer rebuilding a thumbnail from the stored turn. Seeds a session through the real gateway with an image attached, then launches desktop against it — so the first render is already the relaunch case. Pins native image routing (the majority path, and the one where a text-only persist override is dropped) and stages the file behind directory and file names with spaces, mirroring the macOS composer's Application Support path. * fix(models): resolve custom provider model ids Map picker-prefixed custom provider selections back to their configured model IDs before validation, persistence, and API requests. Fixes #68347 * chore(contributors): map jevin@jevin.org to ijevin Attribution check needs a mapping for the cherry-picked commit's author so release notes credit them correctly. * fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048) A real APPLICATION_COMMAND interaction forwarded over the relay arrived slash-less: _discord_interaction_to_event set text = data['name'] ("new", not "/new"), MessageType.TEXT, and dropped options entirely — so a registered /new dispatched as plain chat instead of a command (MessageEvent.is_command() is text.startswith("/")). Port the connector's Slack slash-command precedent (normalizeSlackCommand builds `${command} ${args}`.trim() with a leading slash and explicit command type): for type-2 interactions build "/" + name, append rendered options space-separated (scalar options contribute their value, matching the native adapter's f"/model {name}" shape; SUB_COMMAND/ SUB_COMMAND_GROUP contribute their name then recurse into nested options), and set MessageType.COMMAND. Type-3 (custom_id) and other interaction types are unchanged. This implements the interaction->command sub-design previously flagged as deferred in the _on_passthrough docstring. Companion connector fix in gateway-gateway: fix(relay): strip own-mention prefix so addressed slash commands dispatch. * feat(desktop): add session link title resolver Resolve @session:<profile>/<id> reference values to the session's title: the in-memory sidebar list answers most lookups, and an unknown id falls back to GET /api/sessions/{id}. Cache, in-flight dedupe, and subscriber fan-out mirror the external-link title resolver. An untitled row resolves to empty rather than "Untitled session" so the caller's short-id fallback stays the chip label. * feat(desktop): show resolved titles on @session chips Route session refs in the transcript through the title resolver so a dropped session reads as its title instead of a truncated id, and use Tabler's funnel for the session chip icon. * feat(desktop): render agent-written @session links as chips Assistant text goes through the markdown renderer, not DirectiveContent, so a session reference an agent wrote came out as literal text. Rewrite bare refs into `#session/<value>` links during markdown preprocessing and dispatch that href to the shared chip in MarkdownLink, alongside the existing media and preview hrefs. Preprocessing already skips code fences and inline code, so a ref being discussed in code stays literal. The pure parsing/href helpers move to session-refs.ts to keep the resolver's React and API imports out of the per-flush preprocess path. * fix(sessions): export delegate cascade before deletion * refactor: extract lineage_is_logical local + document TOCTOU re-query Follow-up cleanup for PR #71123: - Extract getattr(args, 'lineage', 'single') == 'logical' to a local (appeared 3x in the export block) - Document that the double _collect_delegate_child_ids traversal in delete_session is an intentional TOCTOU guard inside the write txn * feat(session-search): give the agent a link to hand back Asked to link to a session, the agent had no way to know the @session reference syntax exists — every mention in the tool schema described consuming a link the user dropped, never writing one — so it answered with the title and timestamp as prose and the desktop had nothing to render. Every result now carries a ready-to-copy `link`, and the schema says to write it inline instead of restating the title around it. The profile segment is omitted when the active profile can't be named confidently; a bare id still resolves. Also skip linkifying a ref a model already wrapped in a markdown link, which would otherwise rewrite into a nested link. * fix(tui_gateway): retain failed turns as replayable inflight snapshots A turn that ended in error cleared inflight_turn and emitted its terminal frame in the same breath. If the client was disconnected during that window (the exact case for a failure like a network drop), the frame went to the detached drop-transport and the in-memory state was already gone — the desktop reconnected to a session with no trace of the failure. Failed turns now retain a compact error snapshot (user prompt, partial assistant text, error, recoverable) that session.resume's inflight payload carries to a reconnecting client. Covers all three loss sites: the returned-error result path, the turn exception path (which now closes with the same status:"error" message.complete frame shape instead of a bare error event), and agent-init failure. The snapshot lives until the next turn starts or the session closes; _run_prompt_submit replaces a retained error leftover instead of appending onto it. Co-authored-by: Reza Sayar <rsayar@uvic.ca> * feat(desktop): crash-survivable in-flight turn journal The renderer's session-state cache is memory-only and the backend's inflight snapshot dies with the backend process, so nothing survived a full app or machine death mid-turn: reopening the session showed the transcript up to the last committed turn and silently dropped everything the crashed turn had streamed. While a turn runs, the visible tail (user prompt + streamed assistant rows, tool calls included) is now journaled to localStorage — throttled off the delta-flush hot path, bounded (24 entries / 7 days), cleared the moment the turn settles. Session resume folds the journaled tail back onto the restored transcript. When the backend also has a live text-only inflight projection for the same turn, the journal overlays its richer structure onto that row (longer text wins, base row id kept so live deltas keep landing) instead of treating it as caught up — the ordering defect that dropped locally recorded tool progress in the original PR. Co-authored-by: Omar Baradei <omar@kostudios.io> * fix(desktop): surface terminal error frames as failed bubbles message.complete frames with status "error" were detected only by a text regex heuristic, which misses the gateway's "Error: <detail>" texts and partial-text failures — a failed turn rendered as a healthy reply. The structured e…
jhjaggars-hermes
added a commit
to jhjaggars/hermes-agent
that referenced
this pull request
Jul 26, 2026
* test(runtime): cover managed SQLite cutover (E-949)
* fix(runtime): preserve cutover lifecycle on retry (E-949)
* fix(runtime): request minor line for SQLite runtime repair + tests
Follow-up on the #70186 salvage. The cherry-picked repair pinned the
candidate to the exact current CPython patch (e.g. 3.11.14). Verified
live with uv 0.11.19: every published python-build-standalone artifact
for 3.11.14 links vulnerable SQLite 3.50.4 — even with --reinstall — so
the exact-patch pin made the repair permanently impossible on the
installs that need it most (repair_vulnerable_runtime returned
'failed: could not provision a fixed private Python runtime').
Request the minor line (3.11) instead — the same resolution a fresh
'uv python install' would make, still inside requires-python — and
tighten the drift gate to 'same minor, no downgrade'. E2E-verified
end-to-end on a real vulnerable venv: repair_vulnerable_runtime()
provisioned 3.11.15, built + smoke-tested the sibling venv, cut over,
and reported SQLite 3.50.4 → 3.53.1 with the old venv parked for
rollback.
* test: accept kwargs in managed_uv fixture fakes
The runtime-repair change passes repair_observer= to update_managed_uv/
ensure_uv; the autouse fixture fakes had zero-arg signatures and raised
TypeError through the mock. Sibling test file to the PR's own suite.
* fix(compression): recover rotated session lineage
* chore: map contributor ruizanthony
* fix: pre-lease drift guard must not fire on in-place compaction or mutated snapshots
The salvaged drift check compared durable rows to the in-memory snapshot
by content and ran in both modes. Two problems:
1. In-place compaction (the default) archives non-destructively — drift
cannot lose data there, and the strict-prefix content comparison
failed against seeded histories, aborting every in-place compaction
(5 test failures in test_in_place_compaction.py).
2. Content equality wedges on sessions with legal in-memory mutation of
past turns (multimodal compression, retry replacement) — the same
permanent-abort shape as #14694.
Now rotation-only and length-based: abort only when the durable parent
has MORE rows than the snapshot (a writer committed in the lease window).
Dead helper _durable_history_matches_snapshot removed.
* test: order compression-tip fixtures around the closed-parent write guard
Two compression-tip hydration tests simulated legacy state by emptying
the parent AFTER end_session(compression) — exactly the durable write
the new closed-parent guard refuses. Reordered: empty first, close
second. The tests' actual contract (old id hydrates from the live tip)
is unchanged and still pinned.
* fix(checkpoints): never auto-delete orphans on unattended startup sweep
Builds on this PR's diagnosis by @Frowtek: a missing workdir is
ambiguous (deleted project vs. an unmounted external volume / network
share / VPN not yet up), so it's not safe evidence for a destructive
GC sweep — especially one that runs unattended at startup.
- cli.py / gateway/run.py: the startup auto-maintenance sweep now
always passes delete_orphans=False to maybe_auto_prune_checkpoints().
It still prunes by retention_days, size cap, and legacy archives —
none of which require guessing whether a project was deleted or is
just temporarily unreachable.
- hermes_cli/config.py: drop the now-unused delete_orphans default.
- hermes_cli/checkpoints.py: `hermes checkpoints prune` (the explicit,
human-invoked path) now previews the orphan project list and asks
for confirmation before deleting, unless -f/--force is passed.
- Docs updated (EN + zh-Hans) to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(checkpoints): include pre-v2 shadow repos in orphan preview
store_status()["projects"] only ever covered v2 metadata, so the
`hermes checkpoints prune` confirmation prompt was blind to pre-v2
base/<hash>/HEAD shadow repos that prune_checkpoints() deletes
separately via shutil.rmtree — a pre-v2-only or mixed store could
lose checkpoint history without ever hitting the confirmation.
Extract the pre-v2 scan into _pre_v2_shadow_repos() and have both
store_status() (preview, new pre_v2_projects key) and
prune_checkpoints() (deletion) read from it, so the CLI prompt can
no longer diverge from what actually gets removed.
Addresses review from egilewski on #69141.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(checkpoints): cover prune decline/accept/--force for pre-v2-only and mixed stores
Requested by egilewski on #69141: the orphan confirmation flow had no
test coverage at all before this. Exercises hermes_cli.checkpoints.cmd_prune
directly against pre-v2-only and mixed (v2 + pre-v2) fake stores —
decline aborts with nothing deleted, accept deletes both layouts,
--force and --keep-orphans skip the prompt as expected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(checkpoints): bind orphan confirmation to previewed identities
Address P1 from PR review: cmd_prune()'s y/N preview reads
store_status() but the confirmed deletion re-scans both the v2 and
pre-v2 layouts from scratch. A workdir that goes missing while the
human is answering the prompt gets swept in as if it had been shown
and approved.
prune_checkpoints() now accepts orphan_allowlist — a set of v2 project
hashes and/or pre-v2 shadow repo paths. When set, only orphans whose
identity is in the set are deleted; anything newly orphaned since the
scan survives the run. cmd_prune() builds this set from the exact
projects it just displayed and passed confirmation for. --force still
passes None (no preview shown, so nothing to bind to).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* doc(checkpoints): add cyberpunk infographic for startup sweep safety
* doc(checkpoints): update infographic to show 8 files
* fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist
Follow-up for salvaged PR #69141, addressing the last open review point:
cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans',
so a zero-orphan preview passed the unrestricted None sentinel down to
prune_checkpoints(), authorizing deletion of any project that became
orphaned between the preview and the rescan — with zero confirmation
calls. The allowlist is now bound unconditionally for every non-force
run (empty preview => empty allowlist); --force keeps None. Adds the
zero-orphan-preview timing regression plus allowlist-identity tests.
* fix(agent): cache static system prompt prefixes
* fix(prompt-caching): inject cache breakpoints after message normalization
The conversation loop normalizes message text right before the API call so
the request prefix is byte-identical across turns -- the stated reason is
KV cache reuse on local inference servers and better cache hit rates on
cloud providers. Cache breakpoints were injected *before* that pass, which
defeats it.
`_apply_cache_marker` rewrites a plain-string `content` into a
`[{"type": "text", ...}]` block. The normalization pass is guarded on
`isinstance(content, str)`, so every message that just got marked is
silently skipped by it and keeps its raw leading/trailing whitespace. A
message is only marked while it sits in the last-3 window, so:
turn N in the window -> marked, content "file1\nfile2\n"
turn N+1 rolled out -> plain, content "file1\nfile2"
The same logical message is sent with different bytes on consecutive
turns. The prefix stops matching at that position -- which is inside the
span the breakpoints were placed to protect -- so the reusable prefix
collapses back toward the system breakpoint on every turn. Tool results
carry a trailing newline almost by default (any shell command output), so
this is the common case, not an edge case.
Move the injection below every message mutation. Besides fixing the
whitespace divergence this stops breakpoints from being spent on messages
that the orphan sweep or the thinking-only drop is about to remove or
merge away -- a marker on a dropped message is a wasted breakpoint out of
the four available.
Nothing between the old and new call sites reads `cache_control`, and the
mutators now see the plain-string shapes they were written against.
* fix(caching): reconstruct static system prefix on session restore and post-compression reuse
Follow-up to the cherry-picked #68258 base: the cross-session-stable
prefix (_cached_system_prompt_static) was only recorded on fresh
builds, so two paths silently degraded to the legacy single-breakpoint
layout (flagged in review of #68258/#69341/#69704):
- Session restore: gateway surfaces build a fresh AIAgent per turn and
restore the persisted prompt verbatim from the session DB; the static
prefix stayed None from turn 2 onward, flip-flopping the wire layout.
- Post-compression cached-prompt reuse: _invalidate_system_prompt()
clears the static prefix, and the keep-cached-prompt branch never
restored it.
Both sites now reconstruct the stable tier and adopt it ONLY when the
authoritative prompt string literally startswith() it — stable-tier
drift (skills edited, identity changed) falls back to the legacy layout
with the stored bytes untouched. Fail-open on any builder error. The
restore-path rebuild is gated on _use_prompt_caching so non-Anthropic
routes skip it entirely.
Refs #68191
Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* fix(config): preserve opaque .env values
The .env sanitizer inferred missing newlines from known KEY= substrings
inside existing values. Plain secrets containing those bytes could therefore
be split into synthetic assignments and rewritten to disk.
Treat each physical line as the only assignment boundary and keep bytes after
the first equals sign opaque for boundary discovery. Preserve safe formatting,
null-byte removal, BOM handling, and normal one-assignment-per-line parsing.
Cover direct loading, dotenv loading, sanitization, writers, and migration
with behavioral regressions.
Fixes #29155
* fix(web): resolve per-profile gateway state for ?profile= in /api/status
When ?profile=<name> was passed to /api/status, the handler used
_config_profile_scope to set the HERMES_HOME contextvar override, but the
gateway liveness check (get_running_pid_cached) and runtime status read
(read_runtime_status) both resolve _get_process_hermes_home(), which
deliberately ignores contextvar overrides (issue #56986) — it always reads
os.environ['HERMES_HOME'] or the platform default. A named profile's
gateway identity files (~/.hermes/profiles/<name>/gateway.pid,
gateway_state.json) were therefore never found and the endpoint always
reported the profile's gateway as stopped.
Fix: when ?profile=<name> is requested, resolve the profile directory and
pass explicit profile-scoped paths:
- get_running_pid_cached(pid_path=profile_dir / 'gateway.pid')
- read_runtime_status(path=profile_dir / 'gateway_state.json')
- get_runtime_status_running_pid(..., expected_home=profile_dir)
This is the same explicit-path pattern _collect_profile_gateway_topology
already uses for per-profile gateway state, and it works within the #56986
constraint (no HERMES_HOME env mutation; read-only cross-profile access).
Plain /api/status without ?profile= keeps the exact zero-arg calls, so its
behavior — including the pid-cache signature and runtime-status fallback —
is byte-for-byte unchanged.
Fixes #69143
* test(web): pin per-profile gateway state scoping on /api/status
Follow-up for the salvaged #70498 fix: replace the original PR's
mock-signature churn (28 lambda **kw edits, needed only because it changed
the no-profile call shape) with two targeted regression tests:
- ?profile=<name> must pass the profile's gateway.pid / gateway_state.json
paths and expected_home to the gateway status readers (HOME-anchored
per-profile state under ~/.hermes/profiles/<name>/)
- ?profile=<unknown> must 404 via _resolve_profile_dir
The production change keeps plain /api/status on the exact zero-arg calls,
so every pre-existing test passes unmodified.
* test: accept the new profile-scoped kwargs in status fakes
/api/status?profile= now passes pid_path=/path=/expected_home= to the
PID and runtime-status readers; the profile-unification fakes had
zero-arg signatures and raised TypeError. Plain /api/status call shapes
are unchanged (pinned by the existing zero-arg tests in
test_web_server.py).
* fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344)
Three-part fix for the gateway going silently deaf after a retryable
fatal adapter error (e.g. httpx.ConnectError on Telegram):
1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced
plain asyncio.wait_for with the task-detach pattern used by
_await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the
overdue task but then waits for it to exit, so a connect() that
catches CancelledError can block recovery forever. The detach
pattern releases the runner at the deadline via
consume_detached_task_result.
2. **Ensure reconnect watcher always runs after escalation** — Added
_ensure_reconnect_watcher_running(), called after queueing a
retryable fatal error. If the reconnect watcher task has died
(exhausted restart budget, terminal exception), it is respawned
so queued platforms are never permanently stranded.
3. **Faulthandler at gateway startup** — Enabled faulthandler +
SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for
post-mortem diagnosis of future event-loop freezes.
Tests added for _ensure_reconnect_watcher_running (alive, dead,
not-started, not-running), fatal-error integration (retryable calls
ensure, non-retryable does not), and _connect_adapter_with_timeout
(timeout raises, success returns).
* fix: explicit encoding for faulthandler file open (ruff PLW1514)
* fix(gateway): stay alive on mixed retryable + non-retryable startup failures
When connected_count == 0 and at least one platform failed with a
non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE
(78) even if OTHER platforms failed for merely transient reasons.
Real-world shape (NS-609, hosted instance): WhatsApp enabled but never
paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during
polling startup (retryable) => exit 78 => the gateway either goes
permanently down (supervisors honoring the exit-78 contract via
RestartPreventExitStatus / the s6 finish->125 translation from #51228) or
crash-loops (anything else). Either way Telegram never gets its retry and
the dashboard drops with every exit, so a single unpaired platform plus
one network blip disconnected every channel on the instance.
Now exit 78 is reserved for the case where ALL startup failures are
non-retryable (true config error, nothing to wait for). With mixed
failures the gateway stays alive in degraded state: the reconnect watcher
recovers the retryable platforms and the misconfigured ones stay
fatal-parked and visible in runtime status.
* fix: gate SIGUSR2 faulthandler registration behind POSIX check
signal.SIGUSR2 and faulthandler.register() don't exist on Windows;
the bare reference raised AttributeError at import time per the
windows-footgun checker. faulthandler.enable() still covers
fatal-error dumps on all platforms.
* fix(gateway): detect and escape silent event-loop freezes
- A self-rescheduling 5s call_later floor timer, armed before any
adapter connects, guarantees the selector always has a finite
timeout, so the existing async defenses (polling heartbeat, timeout
guards) regain a chance to run after a zero-pending-timer stall.
- A resident daemon-thread liveness watchdog probes the loop via
call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout
misses (~120s of total unresponsiveness) it dumps all thread
tracebacks and exits with the established
GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the
gateway - async-level recovery cannot run on a frozen loop.
- stop() disarms both guards before any teardown await so a busy
shutdown is never misjudged as a freeze.
HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES
tune the thresholds.
Fixes #69089
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gateway): close watchdog shutdown race against final-strike exit
- Re-check stop_event after a missed probe (before the strike
increment) and again on entering the final-strike branch (before the
critical log, dump, and hard exit), so a normal stop() landing
between the last timeout check and the exit path can no longer be
misclassified as a freeze and trigger a supervisor restart.
- Deterministic boundary tests pin both re-checks independently
(mutation-verified: removing either check turns its own test red);
frozen-loop semantics are unchanged.
Addresses the shutdown-race review on #69164.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gateway): recheck stop immediately before watchdog hard exit
- A stop() landing while the final diagnostics (critical log,
traceback dump) are executing could still reach os._exit(75) after
the pre-diagnostic check. Add a third stop_event recheck immediately
before the hard exit: diagnostics may complete, but a disarmed
watchdog never exits.
- Deterministic regressions for both windows (stop triggered from
inside logger.critical and from inside faulthandler.dump_traceback);
mutation-verified (removing the check turns both red). Frozen-loop
semantics unchanged.
Addresses the second round of the shutdown-race review on #69164.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs
Follow-up to the salvaged #69164 commits: policy forbids introducing new
HERMES_* environment variables, so the four watchdog env knobs
(HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are
replaced with a single config.yaml boolean:
gateway:
loop_watchdog: true # default; false disables both guards
- gateway/config.py: new GatewayConfig.loop_watchdog field (default True),
parsed from top-level or nested gateway: form, round-trips via
to_dict/from_dict.
- gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog
before arming the floor timer + watchdog (getattr-guarded for bare
object.__new__ runners).
- gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer
reads the environment; probe interval/timeout/strikes are module
constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog
layer's posture).
- hermes_cli/config.py: documented gateway.loop_watchdog default so
'hermes config set gateway.loop_watchdog false' validates.
- tests: env-knob tests replaced with config-gate + round-trip tests;
the final-strike boundary test injects its probe via max_strikes
directly instead of patching the removed env helper.
* fix: getattr-guard _stop_loop_liveness_guards in GatewayRunner.stop
Teardown-path tests build bare runners via object.__new__ without
the liveness-guard machinery; the unguarded call raised
AttributeError in 8 tests. Same guard pattern as the start path.
* fix(desktop): close cross-session leak windows in composer + session refs (#59305)
Two React passive-effect timing bugs let a session switch land in the wrong
chat: activeSessionIdRef/selectedStoredSessionIdRef (use-session-state-cache)
and the composer's attachment-scope swap (use-composer-draft) both mirrored
their source props via useEffect, which fires one commit AFTER the new
session's view has already painted — a synchronous read/submit in that window
observed the outgoing session's ids/attachments.
- use-session-state-cache.ts: mirror the session refs synchronously during
render instead of a useEffect, guarded to fire only when the prop itself
changed (not unconditionally) so an imperative pin from submit.ts /
use-session-actions (e.g. a freshly resumed runtime id, intentionally not
synced to the source atom) survives an unrelated re-render.
- use-composer-draft.ts: the per-thread attachment-scope-swap effect is now a
useLayoutEffect, closing the window before paint.
- submit.ts / session-context-drift.ts: add a 3rd drift prong comparing the
composer's loaded scope (SubmitTextOptions.composerScope) against the
submit target, resolved into the same lineage-root domain
(resolveComposerSessionKey) the composer itself uses — comparing against
the raw tip id would false-positive-abort every submit into any session
that has ever auto-compressed.
- routes.ts / chat/index.tsx: the primary composer's durable scope key now
prefers the route over a possibly-stale store selection
(primaryRouteSelectedSessionId).
- use-composer-draft.ts: redacted [composer-rehydrate] diagnostic log
(counts/kinds/scope only, never raw refs) for future reports in this class.
- chat-runtime.ts: normalize attachment id values (url/path) before hashing
so a re-attach with a trailing slash or backslash path dedupes correctly.
16 files, 286 tests across the touched/dependent suites (17 files) green,
including new regression coverage for each fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(desktop): satisfy eslint import-order rules in use-composer-draft.test.tsx
CI's check:lint failed on two perfectionist rule violations introduced by the
new test file: type import ordering and missing blank line between the
parent-relative and same-directory import groups. No behavior change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(agent): prevent shared OpenAI client FD-recycle corruption from stale stream watchdog
The streaming stale watchdog was calling
_replace_primary_openai_client() from its polling thread, which closes
the shared client's connection pool. Worker threads from previous
stale-killed attempts may still be unwinding their SSL BIOs, causing
TLS application-data to overwrite SQLite file headers via FD reuse.
This is the same corruption vector documented in #67142 for Anthropic,
where the fix was to never close the shared client from a non-owner
thread. Apply the same pattern to the OpenAI-wire path:
- Stale stream watchdog: skip shared client replacement
- Mid-tool-retry cleanup: skip shared client replacement
- Stream retry cleanup: skip shared client replacement
The request-local client is already closed via _close_request_client_once.
The shared client is replaced lazily by _ensure_primary_openai_client
on the next request, which runs on the owning thread.
Closes #70773.
* fix(agent): retire replaced shared OpenAI clients instead of cross-thread pool close
Widen the #70773 fix beyond the three in-request cleanup sites removed in
the cherry-picked commit: every remaining path that swaps out the shared
OpenAI client could still hard-close its pool from a thread that doesn't
own the in-flight sockets (credential rotation/refresh on the turn thread,
dead-connection cleanup, gateway cache eviction, transport recovery) —
the same FD-recycle corruption vector, just rarer.
Add AIAgent._retire_shared_openai_client(): shutdown(SHUT_RDWR) all pooled
sockets (FD-safe from any thread, unblocks in-flight readers) but never
call client.close() — FD release is deferred to GC, which cannot run until
every borrowing thread has unwound its SSL BIO. Refcounting is the
ownership handshake; with no borrowers the FDs are released immediately.
Wired into:
- _replace_primary_openai_client (rotation/refresh/dead-conn cleanup)
- try_recover_primary_transport (primary_recovery)
- release_clients (gateway cache_evict)
agent.close() keeps the hard close: full teardown is a real session
boundary where no request may be in flight.
Tests: new tests/run_agent/test_70773_shared_client_fd_corruption.py
covers the three watchdog/retry sites plus retire semantics; existing
close-assertions updated to pin retire-not-close.
* test: update credential-refresh tests for retire-not-close contract
The three refresh tests asserted the replaced shared client gets
close()d — the exact cross-thread close #70773 removes. They now pin
the new contract: close() is NOT called from the refresh path; the
old client is retired (sockets shutdown, FD release deferred to GC).
* fix(doctor): UTF-8/latin-1 fallback when scanning .env
Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes.
* fix: handle non-UTF-8 files in OpenClaw migration script
* fix: decode config and state files as UTF-8 on non-UTF-8 locales
Several file-I/O call sites still use open() / Path.read_text() /
Path.write_text() without an explicit encoding, so they fall back to
the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949)
any non-ASCII byte in a config/state/user-content file raises
UnicodeDecodeError or UnicodeEncodeError and crashes the caller.
to the remaining hot paths:
- agent/copilot_acp_client.py: fs/read_text_file and fs/write_text_file
(Copilot's read_file / write_file tools,
directly reported in #18637 bug 2)
- agent/model_metadata.py: context-length YAML cache load + two
save sites (context probing is on the
call path of every model invocation)
- agent/nous_rate_guard.py: cross-session rate-limit JSON state
(read + atomic write via os.fdopen)
- cron/scheduler.py: user config.yaml read in run_job
- gateway/delivery.py: cron output writes for AI-generated
content, very likely non-ASCII
yaml.dump call sites also gain allow_unicode=True so the emitted
YAML preserves non-ASCII chars as-is instead of emitting \u escape
sequences.
Adds regression tests that monkeypatch builtins.open / Path.read_text
/ Path.write_text to simulate a GBK locale: each test raises
UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly
passes encoding='utf-8'. Verified that the tests fail on main and
pass with this change, on Linux as well as on Windows.
Refs #18637
* fix(cli): add explicit encoding to read_text/write_text calls
Path.read_text() and Path.write_text() without explicit encoding
default to the system locale encoding. On Windows this is typically
cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON
configs, user data, service scripts).
Add encoding="utf-8" to all read_text() and write_text() calls
across 8 CLI files, matching the pattern established in PR #50534
(security_audit_startup.py) and ruff rule PLW1514.
Fixed files:
- main.py: 4 read_text calls
- auth.py: 3 read_text calls
- banner.py: 1 read_text + 1 write_text
- service_manager.py: 1 read_text + 4 write_text
- container_boot.py: 1 read_text + 4 write_text
- doctor.py: 3 read_text calls
- uninstall.py: 2 read_text calls
- gateway.py: 1 write_text call
* fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls
Path.read_text() without an explicit encoding uses the platform's
default encoding. On Windows this is typically cp1252 or mbcs, which
causes UnicodeDecodeError or silent data corruption when reading
UTF-8 content (JSON files, user text, config with non-ASCII chars).
This is the read-side companion to the write_text() encoding fix.
Fixed the most critical locations that read JSON data, user content,
and config files across 14 files with 31 call sites.
Pattern: .read_text() → .read_text(encoding='utf-8')
json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8'))
* fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales
On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN),
Python defaults stdout/stderr to the active codepage. tools/skills_sync.py
prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK
cannot encode, raising UnicodeEncodeError mid-run.
The installer (scripts/install.ps1) captures this script's stdout and the
Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK
byte stream (or the traceback it triggers) surfaces as:
WARN stdout read error: stream did not contain valid UTF-8
stage=config-templates state=Failed
error=install.ps1 -Stage config-templates produced no JSON result frame
(exit=Some(0))
i.e. the stage fails even though the script exits 0. install.ps1 already
sets [Console]::OutputEncoding = UTF8, but that does not propagate to the
python.exe child (Python reads PYTHONIOENCODING / locale, not the console
encoding).
Fix in two places for defense in depth:
- tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so
output is valid UTF-8 regardless of caller or active codepage.
- scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped
to the call, restored afterwards) around the skills_sync.py invocation.
* test(install): add UTF-8 regression guard for skills_sync child path
Addresses hermes-sweeper review on PR #54866: the installer runs
tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING /
PYTHONUTF8 the scoped install.ps1 block sets, but there was no
regression test for this child-Python UTF-8 path. The existing
test_child_process_inherits_utf8_mode covers a different (bootstrap
entry-point) flow.
Add TestSkillsSyncUtf8Guard: three subprocess tests that import
skills_sync (triggering its import-time stdout/stderr reconfigure)
and assert the checkmark/up-arrow glyphs the script prints at
tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when
the child env is left unset or explicitly hostile (gbk). A third
test proves the guard is load-bearing by reproducing the crash
without it.
Also keep the new install.ps1 comment ASCII-only (the checkmark
spelled out as U+2713) per the file's PS 5.1 parser-compatibility
contract at scripts/install.ps1:79-80; the literal glyph in the
comment violated that contract.
* fix: add encoding="utf-8" to Path.write_text() calls (P1)
Path.write_text() without encoding defaults to system locale encoding.
On Windows (cp1252), this silently corrupts non-ASCII content written
to JSON files, config files, and cache files.
This is the write-side counterpart to the read_text() encoding fix
(PR #56115). PLW1514 only covers open() calls — Path methods are
unguarded by ruff.
39 instances across 16 files, all passing py_compile.
Files changed:
- agent/copilot_acp_client.py (1)
- tools/web_tools.py (1)
- tools/xai_http.py (1)
- tools/skills_hub.py (8)
- gateway/slash_commands.py (1)
- gateway/run.py (5)
- gateway/dead_targets.py (1)
- gateway/delivery.py (2)
- gateway/platforms/qqbot/adapter.py (1)
- hermes_cli/gateway.py (1)
- hermes_cli/banner.py (1)
- hermes_cli/service_manager.py (5)
- hermes_cli/container_boot.py (5)
- hermes_cli/uninstall.py (1)
- hermes_cli/main.py (2)
- hermes_cli/profiles.py (3)
* fix(hindsight): specify UTF-8 encoding for file I/O on Windows
On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text()
defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError
when reading .env or .json config files that contain non-ASCII characters.
Explicitly pass encoding='utf-8' to all read_text() and write_text() calls
in the hindsight memory provider plugin.
* fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup
The mem0 and hindsight memory-provider setup routines round-trip the
user's ~/.hermes/.env: they read existing lines, update the keys they
manage, and rewrite the whole file preserving every other line verbatim.
Both used env_path.read_text() / write_text() with no encoding.
read_text()/write_text() with no encoding fall back to the system locale
(cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get
mangled or the call crashes on any non-ASCII value, and — because the
reader never strips a BOM — a Notepad-edited .env makes the first key
fail the in-place match and get duplicated instead of updated.
Match the canonical .env readers in hermes_cli/config.py: read with
encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'.
mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the
.env path in the same file. Fixes both memory plugins in one class fix.
Adds regression tests: a BOM'd .env updates the first key in place
(locale-independent, fails without the fix) and non-ASCII existing lines
survive the round-trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(memory): cover the remaining setup-time .env reads with utf-8-sig
Follow-up to review feedback:
- mem0 _prompt_api_key read .env with the locale default, so a Notepad
BOM hid the first key from the masked current-value lookup; read it
with utf-8-sig + errors=replace like the canonical readers in
hermes_cli/config.py.
- hindsight _load_simple_env used plain utf-8; it also parses the Hermes
.env during post_setup, where a BOM stuck to the first key. Switch to
utf-8-sig + errors=replace.
- Add hindsight regressions: BOM key matching in _load_simple_env and in
the cloud post_setup writer, plus non-ASCII round-trip preservation,
and a mem0 regression for the BOM'd masked-key lookup. The BOM tests
fail without the fix on any platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(profile): read .env as utf-8-sig in the distribution-install preview
`_render_distribution_plan` reads the target profile's `.env` to decide
whether a required env var is already set (so it doesn't nag the user),
using `Path.read_text()` with no encoding. Two bugs:
1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows),
which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding
`except OSError` does NOT catch that — `UnicodeDecodeError` is a
`ValueError` — so a mis-encoded `.env` aborts the entire install preview.
2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key
(`KEY`), so the very first required env var is mis-reported as
"needs setting" when it is actually present.
`.env` is written as UTF-8 everywhere in the codebase. Read it as
`utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a
genuinely un-decodable file skips the pre-check instead of crashing.
Regression tests: a BOM-prefixed `.env` whose first key must still read as
"set", and an invalid-UTF-8 `.env` that must not abort the preview.
* fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/
Path.read_text() and Path.write_text() without encoding= default to the
system locale (cp1252 on Windows), which corrupts non-ASCII JSON content.
Coverage-gap fix for files not addressed by prior encoding PRs:
- tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files)
- tools/skills_sync.py: 1 read_text (lock file)
- tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker)
- agent/shell_hooks.py: 1 read_text (allowlist)
- gateway/status.py: 1 read_text (PID file)
- hermes_cli/banner.py: 1 read_text + 1 write_text (update cache)
All sites read/write JSON or short text. No behavioral change on Linux
(already UTF-8); fixes silent data corruption on Windows.
* fix(skills): tolerate non-UTF-8 bytes in hub lock.json
_read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a
strict utf-8 decode. Hub skill descriptions can carry Windows-1252
typographic bytes (em-dash 0x97, smart quotes, bullets) as single high
bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which
is a ValueError sibling not caught by the function's
except (OSError, json.JSONDecodeError). It escapes and 500s the whole
/api/skills endpoint, blanking the desktop Skills panel.
Decode with errors="replace" so the offending byte degrades to U+FFFD
and the structurally valid JSON — and every other skill — stays readable.
Fixes #68053
* fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.
Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.
Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts
The bundled office skills (#68595) read user documents and agent-authored
payloads with the locale-default codec:
- docx/powerpoint validators/base.py opened OOXML part XML in text mode
before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to
mojibake that lxml then parses, so validation runs against silently
corrupted document text; on locales where the UTF-8 bytes don't decode
the validator crashes with UnicodeDecodeError instead of validating.
Opening as bytes lets lxml honor the encoding declared in the XML prolog.
- The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations,
create_validation_image, check_bounding_boxes) read the fields JSON the
agent authors — UTF-8 by construction — with the locale codec, so
non-ASCII form values (any Cyrillic/CJK/accented input) either crash or
get written into the user's PDF as mojibake. The json.dump writers use
ensure_ascii=True and were already safe; only the readers needed pinning.
Adds a contract test asserting every document/payload reader is
locale-independent, plus a live regression test that runs
check_bounding_boxes.py on a non-ASCII fields.json under a forced
non-UTF-8 locale — it fails without the fix on both POSIX (C locale)
and Windows (cp1251 chokes on the 0x98 byte of U+2018).
* fix(windows): sweep remaining bare read_text/write_text sites + linter rule
AST-driven pass over every Path.read_text()/write_text() without an
explicit encoding= across non-test code: 71 sites in 34 files
(skills_hub, hermes_cli/main+profiles+service_manager+container_boot,
mem0/hindsight/honcho plugins, achievements dashboard, release/CI
scripts, productivity+comfyui skill helpers, agent/*). Verified zero
positional-encoding collisions before insertion; per-file compile()
check after.
Adds a check-windows-footguns rule flagging bare single-line
read_text/write_text (multi-line forms stay covered by the AST guard
test from #38985). Together with the salvaged contributor commits this
retires the ~169-site bare file-I/O class (#37423's long tail).
* fix: restore utf-8-sig BOM tolerance at .env readers the sweep normalized
The cherry-pick auto-resolution + AST sweep applied plain utf-8 at three
.env reader sites where the salvaged PRs (#62617, #62123) deliberately
use utf-8-sig — a Notepad BOM must not hide/duplicate the first key.
Restore the contract (tests pin it).
* chore: contributor email mappings for the file-I/O salvage
* refactor(desktop): add shared Field form-dialog primitive
Dialog forms each hand-rolled their own label+control+hint stack (or
borrowed the settings-surface ListRow), so gaps and hint styling drifted
between the profile, cron, and webhook dialogs. Add a single Field /
FieldHint primitive for label-over-control dialog fields and adopt it in
the create/rename profile dialogs as the first consumers.
* fix(desktop): unify overlay-pane padding and add primary PanelAction
Overlay panes each set their own top padding, so the Settings sidebar and
Panel headers sat at different heights than System/Agents and the close X
(the #67759 regression). Hoist the shared beside-the-X clearance into
OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits
under the X), tighten OverlayMain's gutters, and drop the one-off Settings
override. Also give PanelAction a `primary` variant so a detail header can
promote its main action to a filled button.
* refactor(desktop): fold cron Blueprints into the New Job dialog
Blueprints lived behind a separate Jobs/Blueprints tab with its own card
gallery — a bespoke surface no other overlay uses. Remove the tab and make
blueprints a "Start from" dropdown at the top of the New Job dialog
(default "Custom" = the manual editor); picking one swaps the form for that
blueprint's typed slots. Also promote the detail-view "Trigger now" button
to a primary action and adopt the shared Field primitive.
* refactor(desktop): webhooks create form uses shared Field; drop status pill
The create dialog used the settings-surface ListRow/ToggleRow inside a
modal, which read differently from every other form dialog, and the detail
header carried an enabled/disabled pill that rendered as a stray dash.
Switch the form to the shared Field primitive (+ Switch) and remove the
pill.
* fmt(js): `npm run fix` on merge (#71099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(dashboard): add lightweight /api/health liveness endpoint
/api/status is the only public liveness route, and its handler loads the
gateway config, probes gateway health, and counts sessions before it can
answer. That work is wrong for a readiness probe: a caller that only needs
to know the process is up pays for a cold plugin import tree.
Add /api/health, which returns process liveness, version, and the auth-gate
shape and touches nothing else.
* fix(desktop): probe /api/health for boot readiness, and survive a stalled loop
Desktop boot polls /api/status, so readiness waits on gateway config and a
cold plugin import tree. On Windows that regularly outlives the probe and
Desktop kills a backend that is already listening, respawns it, and re-pays
the same import cost — the reported crash loop.
Probe /api/health instead, falling back to /api/status only for the
missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so
an older remote backend still connects. Timeouts and server errors keep
polling health rather than dropping to the heavyweight route.
A cheap route is not enough on its own. Warming the gateway import holds the
GIL, so the event loop can stall for tens of seconds and starve /api/health
too. At the default 15s socket timeout only three attempts fit in the 45s
budget; give each probe 5s so the loop keeps retrying across the stall.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com>
Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com>
* fix(state): decode display_metadata at every message read path
get_messages(), get_messages_around() and get_anchored_view() returned the
raw display_metadata column instead of the dict every caller expects. The
desktop paints a resumed transcript from the REST prefetch, which reads
through get_messages(), so any session holding an async_delegation_complete
event failed resume with "Cannot use 'in' operator to search for
'task_count'" — on every such session, not just corrupted ones.
Route all four read paths through one shared codec that also unwraps rows
carrying a second JSON layer, so sessions already broken on disk recover on
read rather than needing a migration.
Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
Co-authored-by: aml1973 <aml1973@users.noreply.github.com>
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
* fix(state): stop double-encoding display_metadata on write
export_session() reads through get_messages(), so before the read fix an
already-serialized string went straight back into _insert_message_rows() and
got re-dumped — an export/import round trip permanently corrupted the row.
Guard the three write paths the same way tool_calls already is: parse a
string argument before storing it, and drop metadata that isn't an object
rather than persisting something no reader can use.
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: aml1973 <aml1973@users.noreply.github.com>
* fix(desktop): tolerate unparsed display_metadata from an older backend
The desktop and the Hermes backend it talks to version independently — a
remote VM running an older build still serves display_metadata as JSON text.
Indexing into that string with `in` threw and failed the whole resume, so
narrow the type to admit a string and parse it before reading task_count.
Falling back to the generic label keeps a delegation event renderable even
when the metadata is unusable.
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
* fix(checkpoints): don't prune a project whose volume is merely unmounted
Orphan pruning decides a project is gone from a single probe:
if delete_orphans and (not workdir or not Path(workdir).exists()):
reason = "orphan"
then deletes its ref, index, and metadata — the project's entire checkpoint
history. `Path.exists()` is False for a deleted directory, but it is equally
False for one whose storage is not attached right now: an unplugged external
drive, a share behind a downed VPN, a bind-mount absent from this container,
an offline Windows mapped drive. The project is fine; only our view of it is.
This is not an opt-in maintenance command. `maybe_auto_prune_checkpoints`
runs unattended at startup from both `cli.py` and `gateway/run.py`, with
`delete_orphans=True` by default. So starting Hermes once while the drive is
unplugged silently destroys the restore points for every project on it — the
one thing checkpoints exist to provide, and there is nothing to restore from
afterwards.
Reproduced against the real store: a project registered under an unmounted
path and one on local disk, then a startup prune —
prune: {'scanned': 2, 'deleted_orphan': 1}
unreachable project index still on disk: False
The legacy pre-v2 branch has the same flaw plus a second one: a
`HERMES_WORKDIR` marker that exists but cannot be read leaves `workdir = None`,
which the same condition treats as an orphan. Failing to read a file is not
evidence that a project was deleted.
Require corroboration before deleting: the workdir's parent must be present,
so its absence is something we actually observed. A missing parent means the
volume is not there and we know nothing, so the entry is left alone — and an
unreadable marker never deletes at all. Genuinely abandoned projects are still
reclaimed, both by the unchanged orphan path (parent present, project gone)
and by the retention/stale rule, which runs off `last_touch` rather than a
filesystem probe.
tests/tools/test_checkpoint_manager.py: a project whose whole mount disappears
keeps its history; controls prove a genuinely deleted project is still pruned
and a live project is untouched. The data-loss test fails on main; both
controls pass there. 81 passed across the checkpoint suites (2 failures in
test_checkpoint_manager.py are pre-existing and fail identically on clean
main).
* fix(checkpoints): an empty surviving mount point is not evidence of deletion
Addresses @egilewski's review: the parent-directory check still deleted
checkpoint history for the most common unmount layout.
Detaching storage removes the parent outright in some layouts
(`/Volumes/Ext/proj` on macOS, `/media/<user>/<label>/proj`), which the first
commit handles. But in the classic static layout — `/mnt/volume/proj`, an
fstab entry, a container bind-mount — unmounting removes the contents and
leaves the mount point behind as an empty directory. `parent.is_dir()` is then
true, the project is absent, and the startup sweep deletes its ref, index and
metadata: exactly the case this PR set out to protect.
Reproduced against the real predicate before this commit:
mount root vanished (macOS) -> False ok
empty surviving mount point -> True <-- history deleted
really deleted (siblings) -> True ok
An empty parent carries no information: it looks identical whether the volume
was detached or the project was deleted. So require the parent to actually say
something — it holds some other entry (we observed a populated directory that
does not contain the project), or it is itself a live mount point (the volume
is attached right now and demonstrably does not hold the project).
The cost is that a project deleted out of an otherwise-empty parent is no
longer reclaimed by the orphan rule. It is not leaked: the retention rule
reads `last_touch` rather than probing the filesystem and still collects it,
so reclamation is deferred, not lost. That is the right direction for a
predicate whose false positive destroys a user's restore points unattended.
`_dir_has_any_entry` stops at the first entry via `os.scandir` instead of
materializing a listing, since a project root can hold a large tree.
tests/tools/test_checkpoint_manager.py: `test_surviving_empty_mountpoint_
keeps_its_checkpoints` pins the reviewed case, and `test_empty_parent_project_
is_still_reclaimed_by_retention` pins the deferral above so the safety valve
cannot silently regress into a leak. Both fail on the previous commit. The
real-orphan control now seeds a sibling so it exercises a populated parent
rather than the ambiguous empty one. 80 passed in the checkpoint suite; the 2
remaining failures (`TestGitEnvIsolation`, `TestClearFunctions`) fail
identically on clean main.
* fix(checkpoints): require positive volume-attachment evidence before orphan classification
Follow-up to the cherry-picked #69063: egilewski's review found that the
_dir_has_any_entry(parent) guard treats ANY entry in the mount point's
parent as proof the volume is attached — but unmounting exposes the
UNDERLAY directory's own files (e.g. a .keep placeholder), so a populated
underlying mount-point dir still classified the project as an orphan and
deleted its ref/index/metadata. Reproduced on both main and the PR head.
Attachment evidence is now positive instead of circumstantial:
* _volume_evidence() records the parent directory's (st_dev, st_ino)
identity in the project's metadata while the workdir is observably
live (at _register_project/_touch_project time). A mount point
resolves to the mounted filesystem's root while attached and to the
underlay directory after detach — same path, different directory,
different identity.
* _workdir_is_observably_gone() now requires the parent visible at
prune time to match that recorded identity before the populated-parent
check can classify an orphan. A mismatch means a different directory
(the underlay) is showing through — a detached volume, not an
observed deletion.
* Metadata without a recorded identity (written by older versions) is
never orphan-classified — unsure never deletes; the retention/stale
rule still reclaims genuinely abandoned projects off last_touch.
* The frozen pre-v2 layout has no metadata channel for the identity, so
it keeps the structural checks only (require_parent_identity=False).
* A failed evidence probe on re-registration preserves the previously
recorded identity — stale evidence can only make pruning MORE
conservative.
Windows: st_dev/st_ino of 0 (filesystems without file IDs, some network
shares) is treated as "no evidence recorded", which falls into the
conservative never-orphan path. os.path.ismount and Path.stat are
cross-platform; no POSIX-only calls added.
tests/tools/test_checkpoint_manager.py: adds egilewski's exact
regression (checkpoint history for mnt/volume/project, detach exposes
mnt/volume/.keep, prune with orphan deletion enabled → NOT deleted;
fails on the bare cherry-pick, passes with this fix), plus
no-recorded-identity conservatism and probe-failure identity
preservation. His absent-parent/empty-parent/retention/genuine-deletion/
live-project controls all still pass.
Reported-by: egilewski (review on #69063)
* fix(telegram): require initial polling readiness
Use wall deadlines for deleteWebhook and start_polling, then fail cold startup unless getUpdates proves progress. This lets the gateway discard partial PTB state and retry with a fresh adapter.\n\nRefs #67498
* fix(gateway): allow Telegram readiness budget
Give Telegram a 180s default outer connect budget so cold polling can prove getUpdates readiness. Preserve the 30s default for other platforms and all explicit config/env overrides.\n\nRefs #67498
* fix(telegram): bind strict cold-start readiness to its own polling generation
Follow-up hardening for the salvaged #69240 readiness gate (#67498):
- _start_polling_once now returns its (generation, progress_event) pair
so the strict cold-start gate binds to exactly the generation it
started, instead of re-reading self._polling_progress_event which a
concurrent recovery task may have replaced with a newer generation's
event (the G1/G2 race flagged in the #69240 review).
- Strict cold start no longer schedules background polling recovery: a
polling error during the readiness wait is captured by a strict
callback and fails the connect attempt immediately with a loud
OSError, so GatewayRunner disposes the partial adapter and retries
with a fresh one — no more waiting out the full readiness deadline on
a generation that already errored, and no G2-on-partial-app healing.
- After readiness is proven the strict callback delegates every later
polling error to the real background-recovery callback, preserving
the existing degraded/reconnect semantics for the polling lifetime.
- The readiness-timeout error message now states the deadline and that
the gateway will retry with a fresh adapter (loud failure, not a
silent wait).
- Regression tests: current-generation progress connects; a polling
error during strict cold start fails fast without scheduling
background recovery (the #67498 idle-threads shape); stale-generation
progress is rejected.
Progresses #67498
* test: record getUpdates progress in mocked cold-connect polling flows
The strict cold-start readiness gate (#67498) means adapter.connect() no
longer returns True until the mocked start_polling records a successful
getUpdates round trip for its generation. Update the conflict-suite
Application mocks accordingly:
- fake_start_polling side effects call
adapter._record_polling_progress(adapter._polling_generation) on the
initial connect (retry generations intentionally do NOT auto-progress
where a test asserts the conflict count survives an unproven retry).
- _build_polling_app takes the adapter so its start_polling mock can
record progress.
Without this, the cold connects in these tests wait out the full 60s
readiness deadline and fail — which is exactly the fail-closed behavior
the gate is supposed to provide when polling shows no progress.
* fix(config): add a collision-safe env var name for custom endpoint keys
Both the Desktop panel and the CLI setup flow need somewhere in .env to put
a custom endpoint's API key. Deriving the name from the endpoint's hostname
collapses two servers on one machine onto a single slot, and every IP-based
local endpoint slugs to a digit-leading name that save_env_value rejects
outright. Key off the endpoint's own identity and keep a fixed prefix.
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
* fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179)
The desktop self-update chain (Desktop -> hermes-setup --update ->
hermes update -> hermes desktop --build-only -> relaunch) rebuilds
Hermes.exe on the user's machine and declared success on bare file
EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted
extraction or rcedit rewrite / full disk) or a wrong-architecture
unpacked tree therefore shipped as the 'updated' app, which Windows
refuses to load with 'This app can't run on your computer'
(此应用无法在你的电脑上运行) — and the previous working build had
already been wiped by before-pack.mjs, leaving nothing to fall back to.
Fix, in three parts:
- hermes_cli/main.py: post-build integrity gate on Windows
(_ensure_desktop_exe_launchable). Parses the PE header of the freshly
built Hermes.exe — MZ/PE magic, section-table completeness vs file
size (catches truncation), and COFF machine vs the host arch (catches
arm64/x64 mixups). On failure it purges the (likely corrupt) cached
Electron zip, invalidates the content-hash build stamp so the
updater's retry-once genuinely re-downloads and rebuilds, restores
the previous build from the .bak tree when one exists (keeping the
corrupt tree as .corrupt for diagnostics), tells the user the update
was aborted and their old version kept, and exits nonzero.
_desktop_packaged_executable also now prefers a host-loadable PE over
pure newest-mtime when multiple win-*-unpacked trees coexist.
- apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked
tree is preserved as <appOutDir>.bak (only when it holds the product
exe — partial/corrupt trees still get the plain wipe) instead of
being destroyed, providing the rollback material for the gate above.
Non-Windows behavior is unchanged.
- Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py
(23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch,
rollback semantics, and the build-only exit contract) and 6 new vitest
cases in before-pack.test.mjs for the .bak preservation rules.
Progresses #69179
* fix(desktop): persist the whole discovered model list when saving an endpoint
Test enumerates a custom provider's catalogue and the panel holds the result
in discoveredModels, but the save payload never carried it, so only the one
model the user hand-typed reached providers.<id>.models. Every downstream
picker reads that map straight from config.yaml with no live probe, which is
why a proxy serving 18 models offered exactly one.
Send the discovered list and merge it onto the entry, so models already
known keep their context lengths.
Fixes #69988
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
* fix(web_server): keep Desktop custom endpoint API keys out of config.yaml
The Custom Endpoints panel wrote the raw key to providers.<id>.api_key, so
the credential sat in plaintext in a file users routinely share and commit.
The input is masked, so nothing warned them.
Write the key to .env and reference it via key_env, the same indirection
built-in providers use and that runtime_provider already resolves. The read
side has to move with it: reporting has_api_key from api_key alone would
show "no API key" for every migrated endpoint, and activate copying only
api_key would drop the credential entirely. Delete now clears the .env slot
too, and an entry still carrying a pre-fix plaintext key is migrated on its
next save so existing users get cleaned up without re-entering anything —
unless the key is a hand-written ${VAR} template, which is already safe and
must not be duplicated into a second env var.
Fixes #69449
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
* fix(cli): store custom endpoint API key in .env instead of config.yaml
hermes model's custom-endpoint flow is the other write path that produced a
plaintext key, on both the model block and the custom_providers entry. Route
it through the same .env indirection as the Desktop panel, and swap an
existing entry's inline key for the reference when the URL is re-saved.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
* test: cover custom endpoint key storage and model-list persistence
Bug-class coverage for both fixes: the full catalogue survives Save, context
lengths are preserved, the key never lands in config.yaml on either write
path, blank clears it, a pre-fix plaintext key migrates while a ${VAR}
template is left alone, two endpoints on one host keep separate credentials,
and an IP-derived name is still a valid POSIX env var.
The two delete tests asserted on the plaintext mirror; they now assert the
same invariants against the credential reference.
* fix(desktop): persist @image: refs instead of the vision-enrichment text
The desktop gateway passed the vision-enriched, model-only message text
(carrying an `image_url:<path>` hint) straight into run_conversation as
the persisted user turn. The renderer only parses `@image:<path>`, so it
could not rebuild the attachment from history: after a restart the image
was gone and only the caption survived, and on a live session switch the
warm cache disagreed with the authoritative text and the frontend
"rescued" the image by appending it after the caption.
run_conversation already supports persist_user_message for exactly this
"what the model sees" vs "what gets stored" split; it was simply never
wired up for the attachment path.
* fix(desktop): keep cached attachment refs on session resume
Persisted history carries no attachment metadata for non-image refs, so
resume reconciliation dropped `@file:` chips off a user turn whose text
matched. Carry the warm cache's refs forward when the resumed message has
none of its own, never replacing refs that are already present.
(cherry picked from commit eac5b0a8ac39eab242a5d571531e386ec70e2435)
* fix(desktop): quote persisted @image: paths so spaced paths render
The unquoted alternative in the directive pattern is `\S+`, so a ref built
by string interpolation truncates at the first space and strands the tail
as loose text next to a broken thumbnail. Composer images live in the app's
userData dir, which on macOS is `~/Library/Application Support/<App>/` — so
every pasted or dropped image hit this.
Adds format_reference_value next to REFERENCE_PATTERN, mirroring
formatRefValue in the desktop's directive-text.tsx, and covers the
round-trip through the parser.
* fix(desktop): persist the image ref for natively-vision-capable models too
A turn routed to a model that takes pixels directly sends `content` as a
parts list, and the session store deliberately ignores a plain-string
persist override for a list payload — a text override must not erase a
turn's image summary. So the override was dropped for every user on a
vision-capable main model, and the durable row kept only the caption plus a
literal `[Image attached at: ...]` / `[screenshot]`, which the renderer
cannot turn back into an image. Only vision-preprocessed (text-mode) turns
were actually fixed.
Mirror the shape instead: swap the text part for the `@image:` ref form and
keep the image parts, so the model still has the pixels for the rest of the
session, and drop the `[screenshot]` stand-in on the way into the bubble
when a ref was lifted from the same message.
* refactor(desktop): memoize the directive image-segment filter
Matches the two derived values above it and fixes the indentation.
* fix(desktop): lead persisted image turns with the caption
Session previews are the first 60 characters of the first user message, so
persisting the @image: directives ahead of the caption labelled the session
with a truncated file path in the sidebar, session switcher, and command
palette. Clients lift the refs out of the body line by line, so moving them
after the caption changes nothing about how the turn renders.
* test(desktop): cover attached-image resume end to end
The unit tests cover each layer in isolation, but nothing exercised the whole
chain the bug lived in: the real gateway persisting an attachment, SessionDB
holding it after the process exits, and the renderer rebuilding a thumbnail
from the stored turn.
Seeds a session through the real gateway with an image attached, then launches
desktop against it — so the first render is already the relaunch case. Pins
native image routing (the majority path, and the one where a text-only persist
override is dropped) and stages the file behind directory and file names with
spaces, mirroring the macOS composer's Application Support path.
* fix(models): resolve custom provider model ids
Map picker-prefixed custom provider selections back to their configured model IDs before validation, persistence, and API requests.
Fixes #68347
* chore(contributors): map jevin@jevin.org to ijevin
Attribution check needs a mapping for the cherry-picked commit's author so
release notes credit them correctly.
* fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048)
A real APPLICATION_COMMAND interaction forwarded over the relay ar…
leewenjie
pushed a commit
to leewenjie/hermes-agent
that referenced
this pull request
Aug 7, 2026
…usResearch#50534) Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method.
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
Path.read_text() and Path.write_text() without explicit encoding default to the system locale encoding. On Windows this is typically cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON configs, user data, service scripts). Add encoding="utf-8" to all read_text() and write_text() calls across 8 CLI files, matching the pattern established in PR NousResearch#50534 (security_audit_startup.py) and ruff rule PLW1514. Fixed files: - main.py: 4 read_text calls - auth.py: 3 read_text calls - banner.py: 1 read_text + 1 write_text - service_manager.py: 1 read_text + 4 write_text - container_boot.py: 1 read_text + 4 write_text - doctor.py: 3 read_text calls - uninstall.py: 2 read_text calls - gateway.py: 1 write_text call
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bare
hermesnow resolves inside the terminal tool's subshell even when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers).Root cause: the terminal subshell PATH was the agent process PATH plus a static set of system dirs (
_SANE_PATH); it never included wherever thehermesconsole-script actually lives (~/.local/bin, the venvbin/Scripts, pipx, nix). A plugin shelling out to barehermesviadispatch_tool("terminal", ...)hitcommand not found(exit 127) — even thoughhermesworks in the user's own interactive terminal, which sources the shell rc that exports that dir.Changes
tools/environments/local.py: add_resolve_hermes_bin_dir()(resolves once viashutil.which→sys.argv[0]→sys.executable's dir, cached) and_prepend_hermes_bin_dir();_make_run_envprepends-if-missing the resolved dir to the subshell PATH. Cross-platform (os.pathsep); no-op when unresolvable.tests/tools/test_local_env_blocklist.py: 7 new tests (resolution paths, idempotence, no-op-when-unresolved,_make_run_envinjection); scope-fence the existing sane-path tests so a realhermeson the runner's PATH doesn't shift their asserted layout.Validation
hermes ...via terminal tool, gateway launched w/o install dir on PATHcommand not foundE2E: ran
LocalEnvironment.execute("hermes --version")under a strippedPATH=/usr/bin:/bin→ rc 0 with real version output. Isolated test with bashrc-sourcing disabled andwhichreturning None → injector alone resolves a hermes shim viasys.executable's dir. Tests: 35/35 in the file.Reported by Smithangshu (plugin author hitting exit 127 on
ctx.dispatch_tool("terminal", {"command": "hermes kanban create ..."})). Plugins can still belt-and-suspenders withresolve_hermes_bin()/python -m hermes_cli.main, but barehermesnow just works.Infographic