Teknium's review on #63457: existing tests pin the final boolean but not
that the slow PROVIDER_REGISTRY sweep is skipped. Add three tests that
booby-trap hermes_cli.auth.get_auth_status and verify
_has_any_provider_configured() short-circuits on:
- config.yaml model.provider
- config.yaml base_url/api_key (custom endpoint shape)
- auth.json active_provider (sweep-only call-pattern guard)
Mutation-checked: reverting the reorder makes all three fail.
test_custom_provider_context_avoids_false_shrink_warning used
qwen3.8-max-preview as a slug that deliberately falls through to the
generic 'qwen' 131K catalog match. The new qwen3.8-max
DEFAULT_CONTEXT_LENGTHS entry (1M) now substring-matches the preview
slug too, so the no-custom-providers branch stopped warning. Swap the
fixture to qwen3.9-max-preview, which still hits the generic fallback
— the test's intent (custom_providers threading) is unchanged.
Simplify-pass follow-up on the #70782 salvage: _bearer_auth_headers
hard-emits ${MCP_<NAME>_API_KEY} but install_entry only persists
auth.env-declared vars — a manifest naming its key differently (the
shipped n8n style) would install cleanly yet send a literal-placeholder
header at connect time (silent 401, the #37792 bug class). Enforce the
naming contract at parse time. Also pins the secret-stays-in-.env
property in the install test (raw config.yaml carries the template,
never the secret). Mutation-checked: validation disabled -> guard test
fails.
When an optional-mcps manifest declares transport.type=http with
auth.type=api_key, install_entry() prompts for the key and saves it to
.env, but _build_server_config() only handled the oauth case — the
api_key case produced a bare url entry with no headers, so every
request to the server was unauthenticated (-> 401).
Reuse _bearer_auth_headers(entry.name) from mcp_config.py so the
catalog path emits the same 'Authorization: Bearer ${MCP_..._API_KEY}'
template as the manual 'hermes mcp add --url' path.
Salvaged from #70782 (production hunk applied clean; tests re-anchored
onto current main). Credit: JonthanaHanh.
Three fixes for the Desktop/TUI cold-start stall where the event loop
is blocked for ~14s between HERMES_BACKEND_READY and the first
prompt (#60800):
1. copilot_auth: skip subprocess fallback when any
Copilot env var is explicitly set (even if invalid). The user
expressed token intent via env var; silently substituting a CLI
token is surprising and the subprocess adds up to 5s on Windows.
2. tui_gateway/ws: run resolve_skin() via asyncio.to_thread so config
loading + skin engine init do not block the WS read loop during
the cold-start RPC burst.
3. web_server: extend _warm_gateway_module to pre-import the heavy
module chains (auth, copilot_auth, runtime_provider, skin_engine,
inventory, model_switch) that the first WS connection + RPC burst
would otherwise import on the loop thread. These trigger .pyc
compilation and Defender scans on Windows (15-30s per the existing
comment) and were not covered by the original gateway-only warm.
Tests: 5 new tests in test_cold_start_gil_stall.py + 2 new tests in
test_copilot_auth.py. All 36 copilot_auth tests + 16 ws/web_server
tests pass.
Every hashed bundle chunk under /assets/ was served with no caching
directives, so each dashboard load re-fetched (or at best revalidated)
every JS/CSS chunk. Those filenames carry a Vite content hash — the
bytes behind a given URL can never change; a rebuild mints new
filenames referenced by a freshly served index.html.
Mark them Cache-Control: public, max-age=31536000, immutable:
- the /assets StaticFiles mount, via a subclass that stamps the header
on 200s only (404s stay uncached — a rebuild can create the file),
- serve_css, preserving its X-Forwarded-Prefix url() rewrites for
/fonts/, /fonts-terminal/, /ds-assets/, /assets/.
index.html keeps no-store, no-cache, must-revalidate — it is the
mutable entry point that binds users to the current hashes.
The original PR also added hand-rolled per-request gzip compression of
asset responses; that part is deliberately dropped. This server is a
localhost-default dashboard backend: compressing every response on the
CPU to save loopback bandwidth is a pessimization, and callers that
front it with a real proxy already get compression there.
Salvaged from PR #28543 (idea by @sea-monsters; gzip groups dropped as
described above).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
le=100 would 422 real desktop callers: sessions-settings fetches
archived at limit=200, the command palette lists at 200, and the
electron remote-merge over-fetches limit+offset (exceeds 100 at
offset>=81, and its .catch(()=>null) silently drops remote sessions).
Clamp must sit above real client maxima. New test pins limit=200 w/
offset.
Re-derivation of aydnOktay's twin clamp PRs onto current main (the
session-list endpoints moved into web_routers/; the analytics endpoints
gained asyncio.to_thread wrappers since the originals):
- limit le=100 on /api/sessions, /api/sessions/search and the
/api/profiles/sessions fan-out (one unbounded request could drag every
session row + correlated-subquery preview work out of SQLite, times
every profile's state.db on the fan-out).
- days ge=1 le=365 on /api/analytics/usage + /api/analytics/models
(huge or non-positive values force full-history InsightsEngine work or
inverted windows; the UI only offers 7/30/90 presets).
FastAPI Query bounds reject at the validation layer (422). 8 new tests;
both clamp classes mutation-checked (clamp removed -> its tests fail).
The 2-line alias had zero production consumers (web_server calls
get_usage_breakdown directly). Tests rewired onto the real API; the
contracts they pin are unchanged. Stale test docstring fixed.
On dashboard-only sessions nothing else executes check_fn warmers (they
live only in the tool-schema build), so the hub's read-only cache lookup
would report auth_required=False forever. On a cache miss, schedule a
deduplicated daemon-thread probe off the request path; the short hub TTL
surfaces the verdict on the next fetch.
The as_completed drain + `with` join made the parallel version WORSE than
sequential main in the common case (first endpoint succeeds fast, others
slow/unreachable): main returned at first success, the parallel version
waited for every straggler. Now: after each completion, walk endpoints in
priority order and return as soon as a success is unbeatable (all
higher-priority probes already finished); pool uses shutdown(wait=False) so
losers drain in the background. Mutation-checked: removing the early exit
makes the new timing test fail (8.2s vs <1.5s).
Rebase fold: the original PR predates ZAI_ENDPOINTS growing per-endpoint
probe_models lists; the parallel worker now preserves that candidate-model
fallback loop (was: scalar model). Tests (both mutation-checked):
- candidate-model fallback within one endpoint worker
- ZAI_ENDPOINTS priority order wins over completion order
- all-fail returns None
Re-derivation of PR #39399 onto current main: pass --prefer-offline to
the web-UI workspace install (both silent and verbose arms of
_install_web_deps) and to the update-time Node dependency refresh in
_update_node_dependencies, so npm reuses its local cache instead of
re-fetching metadata. Test expectations updated to match, mirroring the
PR's own test-update commit.
The picker path fetches the Copilot /models catalog multiple times per
process (list_authenticated_providers -> provider_model_ids ->
_fetch_github_models, plus get_copilot_model_context / normalize
helpers). Cache the filtered catalog at module level with a short TTL
so repeated picker opens do not pay a TLS handshake each time.
Fold-fixes on top of the original patch:
- key the cache by api_key so a mid-process credential swap never
serves the previous account's catalog
- use time.monotonic() so wall-clock adjustments cannot extend the TTL
- deep-copy on store/serve so callers cannot mutate cached entries
- tests updated to patch _urlopen_model_catalog_request (main routes
catalog fetches through open_credentialed_url now), plus TTL-expiry
and credential-change coverage
Extracted from #40276.
The gateway lifecycle guard (cron/lifecycle_guard.py) applied shell-style
tokenization and script-reference resolution to non-shell content, with two
regressions:
#77131 - every .py cron script using pathlib division was hard-blocked:
Path.home() / ".hermes" / ".env" tokenizes the bare "/" operator as an
executable path, which resolves to the filesystem root; the regular-file
check then fails closed as unsafe. Since Python runs under the
interpreter, never through a POSIX shell, the shell-script reference walk
is a false-positive generator on Python sources. check_gateway_lifecycle
now skips the walk for *.py scripts (the direct command regex still scans
the full text), and _iter_referenced_shell_scripts skips pure-separator
tokens.
#76762 - terminal commands invoking a binary by absolute path (e.g.
/usr/bin/python3) crashed the guard with ValueError: embedded null byte:
the walk read the binary's bytes, decoded them as text, and re-tokenized
machine code; the recursion then hit Path.resolve() on a NUL-bearing
path while only OSError was caught. _read_referenced_script now skips
NUL-containing files (binaries are not referenced shell scripts) and
resolve() tolerates ValueError.
Shell scripts (.sh/.bash/.zsh) keep the full deep scan; literal lifecycle
commands in .py scripts are still blocked by the direct regex. New tests
cover all four behaviors.
Follow-up for the salvaged #29239: regression test drives the real
gateway _reload_runtime_env_preserving_config_authority() path with a
stale .env TERMINAL_ENV=docker vs config.yaml terminal.backend=local,
and the hermes debug dump test that pinned the old stale-env-wins
symptom now pins the fixed contract (config wins, override line kept
as defense-in-depth for post-load env mutation).
A leftover TERMINAL_ENV in ~/.hermes/.env (written by `hermes setup` or
shell exports) was silently overriding terminal.backend in config.yaml,
so users switching from docker to local saw `hermes config show` agree
with their change while the gateway / cron / batch_runner still ran
against the old backend.
load_hermes_dotenv now re-applies config.yaml's terminal.* values on top
of whatever the .env files set, so the documented source of truth wins
for every entrypoint that goes through the loader.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Three mechanisms to detect and notify when gateway sessions stall silently:
1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
and hermes status show progress during long turns without new message rows.
2. Stall watchdog: when a busy session has pending inbound and the shared
activity clock is idle past agent.session_stall_timeout (default 300),
log a WARNING and notify the user once to try /new. Notify-only; does
not kill the turn.
3. Compaction timeout: fenceless compress_context callers get a progress-aware
host budget (compression.context_timeout_seconds default 120 idle,
compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
cancel via commit fence, skip compaction without dropping messages, and
continue the turn.
Closes#72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).
Cherry-picked from PR #72424 by @fangliquanflq.
Builds on the adapter list_channels() hook (cherry-picked from #43545 by
@Guoen0):
- plugins/platforms/simplex: implement list_channels() — enumerates
contacts (/contacts) and groups (/groups) over the live daemon
WebSocket into the channel directory. Returns None when the WS is
down so the directory falls back to session discovery instead of
wiping known targets.
- hermes send --list: merge configured-but-undiscovered platforms into
the listing. Previously a platform configured only via env (e.g. a
fresh SimpleX setup used for outbound sends) was silently omitted,
leaving users guessing at platform names.
- format_directory_for_display(): accept an explicit platforms view and
render empty platforms with a targeting hint instead of hiding them.
- docs: simplex hermes-send section.
Reported by Fedpostoffice on Discord (simplex missing from
hermes send --list; guessed platform names simplex-chat/simplex-relay).
The inverse of the inbound webhook platform: hooks.outbound in
config.yaml lists HTTP targets + the plugin-hook events they subscribe
to (on_session_end, subagent_stop, post_tool_call, ...). Each firing
POSTs a JSON payload (same top-level shape as shell hooks' stdin wire)
signed GitHub-style with HMAC-SHA256 (X-Hermes-Signature-256).
Rides the existing hook bus — notify-only callbacks registered on the
plugin manager at the same CLI/gateway/main entry points as shell
hooks. Delivery is fire-and-forget via a bounded queue + single daemon
worker thread, so a dead endpoint can never stall a tool call. Bounded
retries (5xx/conn errors once; 4xx never). secret_env preferred over
inline secret. HERMES_SAFE_MODE skips registration. hermes hooks list
shows outbound targets with signed/UNSIGNED status.
Zero new model tools, zero new subsystems.
_format_price_per_mtok collapsed any per-Mtok price below one cent to
"$0.00" (and readers treated near-zero as free). Nous Portal's DeepSeek
V4 Flash 0731 promo prices cache hits at $0.0018/Mtok, which rendered as
free in the /model picker and hermes model listings.
Prices under $0.01/Mtok now widen precision to the first significant
digit plus one, with trailing zeros trimmed: 0.0000000018/tok →
$0.0018. Standard prices keep the aligned two-decimal format; exact
zero still renders as "free".
- Pass extra_exclude={pid} to _reap_unsupervised_gateway_orphans so the
killed PID isn't double-killed during the sweep (#75936).
- Add extra_exclude param to _reap_unsupervised_gateway_orphans signature.
- Replace bare except:pass with logger.debug for diagnosability.
- Fix existing test (mock _reap_unsupervised_gateway_orphans so it
doesn't scan real processes and trigger conftest live-system guard).
- Add regression test asserting the killed PID is excluded from the sweep.
Efficiency-pass follow-up on the #66355 salvage: force=True bypassed the
cooldown entirely, and AIAgent.close() fires a forced trim for EVERY
in-process child subagent close (delegate_tool child.close(), parent
close step 5). A delegate batch of N children closing back-to-back in
the gateway process stacked N+1 uncooled full gc.collect()+malloc_trim
passes (50-500ms each with a large live heap). Forced trims now honor a
5s floor — bursts coalesce, the parent's final close-trim still fires.
Guard test mutation-checked (floor zeroed -> test fails).
Simplify-pass follow-up on the #66355 salvage:
1. _config_settings runs on EVERY trim attempt (before the cooldown
check) and only reads — swap load_config for load_config_readonly.
Deep-copying the whole config per attempt generates exactly the
allocator garbage this module exists to release. Tests re-seamed.
2. Trim-failure logs demoted warning->debug at all 3 periodic sites
(gateway housekeeping, idle reaper, slash worker): sibling failure
branches in the same loops log at debug, and a persistent failure
(e.g. broken import after a partial update) would otherwise warn
every 60s forever.
3. The frame-inspection test now asserts the expected locals exist
before reading them — a rename in _run_prompt_submit fails the test
loudly instead of vacuously passing on None.
Add config-driven glibc malloc_trim for long-lived Hermes processes:
- hermes_cli/mem_trim.py: trim_memory() with configurable cooldown,
RSS snapshot telemetry, and forced-trim INFO logging
- gateway/run.py: periodic trim in gateway housekeeping loop
- tui_gateway/server.py: trim in idle reaper (~every 5 min)
- tui_gateway/slash_worker.py: trim on turn boundary
- run_agent.py: force trim on agent close
- hermes_cli/config.py: context.memory_trim config section
(enabled, cooldown_seconds, log_every_n, info_log_min_delta_mb)
CSA tier-4 reviewed (4 rounds, 0 HIGH/MEDIUM/CRITICAL remaining).
Supersedes PR #63708 + #64591 with enhanced telemetry and gateway/slash_worker coverage.
test_churn_across_more_files_than_fit_in_one_argv (e65ff9625f) asserts all
1200 checked-out files read dirty before normalization. Whether git diff
content-compares an entry (seeing the CRLF churn) or trusts the stat cache
depends on racy-git detection: entries whose recorded stat is non-racy
(mtime older than the index write) read CLEAN. On CI a 1200-file checkout
straddles that boundary nondeterministically — observed 92/1200 and
661/1200 dirty on two unrelated PRs within minutes (runs 30738759530,
30738842393). Empirically reproduced: freezing a non-racy stat cache gives
0/N dirty; bumping worktree mtimes past the index write forces content
comparison and gives N/N deterministically.
Fix: bump every worktree mtime after checkout in _managed_repo so all
entries are stat-stale. Affects only the fixture; the production
_normalize_managed_eol path is untouched.
Three tests pin the exact kwargs of the picker probe call
(test_model_switch_custom_providers + two in
test_user_providers_model_switch, the latter caught by CI slice 2);
the picker-timeout change now always passes timeout explicitly (5.0 on
the non-picker path), so the pinned shapes gain the key.
Follow-ups on the startup-burst memo:
- Populate the memo on the valid-token fast path as well. The startup
burst usually finds a VALID token, and each check_fn call still paid
two cross-process file locks + state reads to reach that return; the
original memo only engaged after a refresh. The token has at least
refresh_skew_seconds (>=120s) of life at that return, so a 5s memo can
never serve an expired token.
- Clear the module-level memo in test_nous_portal_staging_allowlist's
refresh-capture helper: with the fast-path populate, a token memoized
by an earlier test would otherwise short-circuit the refresh these
tests assert on (3 tests failed without this).
- Add dedicated memo behavior tests (TTL hit, TTL expiry, insecure
bypass) — the original PR shipped none. Mutation-checked: all 3 fail
against main's un-memoized function, pass on this branch.
Architecture fix for the bug class behind the Termux --version NameError
(live on main since eb4040242): version-printing kept being reimplemented
as *_fast() copies at the top of hermes_cli/main.py, each duplicating
canonical logic (project-root resolution, container detection, profile
detection). The copies drift silently — eb4040242 edited the canonical
output and referenced the PROJECT_ROOT module constant inside the fast
function, which doesn't exist yet at the fast exit point.
- hermes_cli/_startup_fast.py: THE implementations, stdlib-only. main.py's
*_fast() names become thin delegates (kept for test/back-compat), and
PROJECT_ROOT itself derives from the same helper — the constant and the
fast path can no longer disagree.
- Fast output now includes the .install_method stamp (one cheap file read)
and a 'Run hermes version for update status' pointer, so globalizing the
fast path doesn't silently drop slow-path info.
- Guard tests: (1) import-weight — subprocess-imports _startup_fast and
fails if any heavy module (config/yaml/argparse/cli/run_agent/httpx)
lands in sys.modules; (2) subprocess parity on+off Termux — the test
that would have caught eb4040242 the day it landed; (3) install-method
stamp surfacing.
hermes --version: ~3.8s cold / 0.2-0.4s warm -> 0.01-0.02s everywhere.
- agent_import.dump_yaml_file now calls utils.atomic_yaml_write instead
of hand-rolling safe_dump + atomic_write_text — same temp+fsync+atomic
rename and symlink preservation, plus mode/owner preservation a
0600-secured config.yaml needs
- openclaw script: the EXDEV/EBUSY copy fallback gains copystat + target
fsync so the docstring's 'mirrors utils.atomic_replace' durability
claim is true on cross-device deployments
- trim load_yaml_file's docstring to the behavior contract
The inlined temp-file + os.replace in openclaw_to_hermes.dump_yaml_file
replaced a symlinked config.yaml with a regular file, silently detaching
managed deployments that symlink ~/.hermes/config.yaml into a dotfiles repo or
profile package. The bare path.write_text it replaced followed the link, and
utils.atomic_replace -- which the hermes_cli twin reaches through
atomic_write_text -- resolves the link for exactly this reason (#16743).
Mirror that here: resolve the symlink before creating the temp file so the
rename lands on the real file, and fall back to copyfile on EXDEV/EBUSY now
that the target can live on another device. Covered by a regression test that
fails when the resolution is removed.
Also guard the permission-denied test for Windows: os.geteuid does not exist
there and chmod-based denial is unreliable, so skip on non-POSIX.
agent_import.py carries a private load_yaml_file/dump_yaml_file pair that
returned {} for an absent file AND for a present file it could not read or
parse. Three importers -- import_permission_allowlist, import_permission_denylist
and import_mcp_servers -- read config.yaml through it, merge one section into
the result, and write the whole mapping straight back. So a YAML syntax error,
a permission problem or a broken mount meant the importer replaced every
setting the user had with only the one to three keys it merged, and still
reported the item as "imported". The write was a bare path.write_text(), so an
interrupted import truncated the file instead.
Distinguish the two cases at the read. Absent, or present but empty, still
yields {} so first-time creation works. Present but unreadable, unparseable, or
not a mapping raises ConfigReadError; the three sites funnel through a new
load_target_config() that records the refusal as a per-item error and leaves the
file byte-identical. Dry-run refuses too, rather than previewing an "imported"
that would destroy the config. dump_yaml_file now writes through
utils.atomic_write_text, which the module already imports and uses for the
memory store.
This is the invariant hermes_cli/config.py already enforces for its own writers
via require_readable_config_before_write / atomic_config_write, whose docstring
names this exact root cause and calls itself "the single chokepoint every
config-update path should use". agent_import.py has its own helper pair and so
was never covered; it was the last config.yaml writer without the guard.
The identical helper pair lives in openclaw_to_hermes.py, the script this module
was ported from, where twelve config.yaml read-modify-write sites share the same
defect; fixed there too. Its refusal is recorded at the run_if_selected dispatch
point, which flips the existing _config_apply_blocked flag so the remaining
config-mutating options short-circuit instead of each rediscovering the same
unreadable file. The atomic write is inlined with tempfile + os.replace because
that script runs standalone with only the stdlib on its path.
/simplify-code review found _poll_for_token has a second caller:
web_server._nous_poller (dashboard/desktop device login), which surfaces
str(e) as the UI error_message — so wrapping only in
_nous_device_code_login left the dashboard showing the bare timeout.
Move the enrichment into _poll_for_token's deadline raise so every
caller inherits the guidance, and drop the now-redundant try/except
wrap in the CLI login. Add a source-level regression test driving the
real poll loop (authorization_pending stub client) to the deadline.
A bare 'Timed out waiting for device authorization' gives the user
nothing to act on. The most common cause is Portal sign-in failing in
the opened browser tab (including the server-side CAPTCHA loop from
issue #20605), so point at the Portal login page and the hermes portal
retry command.
Salvaged from PR #75290 by @HexLab98 (timeout-guidance kernel only).
The URL-rewrite portion of that PR was dropped: the live Portal has no
/device route (verified 404 with a real user_code), so rewriting the
manage-subscription verification URL would break login entirely.
Guidance text reworded to reference only real URLs.
`hermes update` aborted its managed-Python runtime repair with an error
that reads like a contradiction:
⚠ Managed Python runtime repair skipped: cannot import name
'venv_python_path' from 'hermes_constants'
(/home/teknium/.hermes/hermes-agent/hermes_constants.py)
The named file does contain the symbol. The module in memory does not.
main.py imports hermes_constants from the OLD checkout, `git pull` then
replaces that file on disk, and the freshly-pulled managed_uv runs its
lazy `from hermes_constants import venv_python_path` against the module
object Python already cached in sys.modules — the pre-upgrade one. The
ImportError reports the path of the new file, so it looks like the symbol
is missing from a file that plainly has it.
Same update-boundary class already documented on `_UvResult` for the
ensure_uv() arity skew. It fires on the first update from any release
older than 83314ca38, which introduced the symbol.
Both lazy call sites now reload the module from disk on ImportError:
- hermes_cli/managed_uv.py::_venv_python — the reported path
- hermes_cli/gateway.py::get_python_path — same flaw, same fix; a gateway
restarted mid-update hits it identically
Reload rather than a local fallback on purpose. Recomputing the layout
inline would hand-roll `Scripts`/`bin` a second time — exactly what #76105
deduped into venv_bin_dir()/venv_python_path(), and what
test_no_open_coded_venv_layout_remains_in_hermes_cli bans. Reloading fixes
the actual problem (a stale module) and keeps one owner for the layout.
hermes_cli/update_cmd.py imports the symbol at module scope, which is a
different failure mode (the module fails to import at all) and is already
covered by the installer's retry-once for the update-boundary crash.
Tests reproduce the stale-module state by deleting the attribute from the
imported hermes_constants: recovery resolves through the reloaded shared
helper (asserted via a sentinel, so an open-coded copy cannot pass), and
the normal path never reloads. Against origin/main's managed_uv they fail
with the exact reported ImportError.
The SQLite runtime repair staged its replacement environment with
`uv sync --extra all --locked --no-config`, and managed_python_env also
exports UV_NO_CONFIG=1. Both drop `[tool.uv]` from pyproject.toml —
including `exclude-newer = "14 days"`, which uv.lock was generated with.
uv 0.12 treats the missing setting as a resolver change, re-resolves, and
then refuses to write under `--locked`:
Resolving despite existing lockfile due to removal of global exclude newer
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
So every repair attempt failed at the dependency-sync gate and reported
"replacement environment did not pass dependency and import smoke tests",
leaving vulnerable-SQLite installs stuck on journal_mode=DELETE with a
guaranteed-failure warning on each `hermes update`.
Drop `--no-config` from the sync argv and pop UV_NO_CONFIG from its env.
Interpreter provisioning keeps both: only the sync has to agree with the
lockfile the project shipped.
Salvaged premise from #67065 (@webtecnica, issue #67027), reimplemented:
get_env_value() read os.environ first with no secret-scope check, so a
multiplexed profile turn could serve another profile's credential. Its
siblings get_env_value_prefer_dotenv and gateway.config._getenv were
already scope-aware.
Reimplementation note: the original diff called get_secret() but fell
through to os.environ on a scoped miss — re-opening the exact leak it
targeted (flagged by the sweeper review). This version delegates policy
fully to agent.secret_scope.get_secret (global vars pass through; scope
authoritative under multiplexing; legacy environ behavior when off;
UnscopedSecretError propagates fail-closed), then falls back to .env.
6 regression tests incl. the #67027 repro (envless profile + multiplexed
turn -> None, not the other profile's key); sabotage-verified RED on the
old implementation.
The salvaged cleanup (#75197) scrubbed every known Hermes key absent from
the profile .env — deleting user-shell-exported credentials
(export OPENAI_API_KEY=...) on every hermes invocation, a documented flow
the author's own failing test_dump_flags_shell_only_key_not_in_dotenv
confirmed. A child process cannot distinguish shell exports from
parent-process leakage, so the scrub now covers ONLY
_PROFILE_MANAGED_ENV_KEYS (ACP routing keys: HERMES_ACP_*,
HERMES_COPILOT_ACP_*, COPILOT_CLI_PATH, COPILOT_ACP_BASE_URL) —
the vector from #75141. Cross-profile credential isolation is owned at
read time by agent.secret_scope.get_secret.
Adds shell-export survival regression + a scope-invariant test that fails
if the scrub set is ever widened toward credential-shaped keys.
Align load_hermes_dotenv() with reload_env() so known Hermes env vars
absent from the active profile .env are removed from os.environ instead
of leaking from a parent process / other profile.
Register ACP-related keys (HERMES_ACP_AUTH_METHOD, HERMES_COPILOT_ACP_*,
COPILOT_CLI_PATH, COPILOT_ACP_BASE_URL) in _EXTRA_ENV_KEYS so they
participate in known-key cleanup.
This is the same isolation gap class as #68367 / #66930, but:
- Not Desktop-only spawn scrub — CLI/gateway restart inheritance
- Not Matrix/messaging auto-enable only — copilot-acp provider/ACP config
- Startup dotenv clear so *any* inheritance path is covered
Example: HERMES_ACP_AUTH_METHOD=cursor_login leaking into a Claude Code
ACP profile caused authenticate -> Internal error -> Discord
'model provider failed after retries'.
The npm 12 requirement (f88ed6c717) strands every system-Node install:
no shipping Node bundles npm >=12, engine-strict makes EBADENGINE fatal,
and the recovery in npm_engine.py refuses to touch a system npm — so
'hermes update' leaves the install in a mixed state (updated code, stale
Node deps, no TUI/web/desktop rebuild) with only a manual-fix hint.
Instead of modifying the user's toolchain (still never done), the
EBADENGINE recovery now provisions Hermes' own managed Node tree under
$HERMES_HOME/node — the same pinned-nodejs.org path install.sh and
install.ps1 use — upgrades THAT npm into the required range, and hands
the caller the managed npm for its single retry.
- hermes_constants.bootstrap_hermes_managed_node(): cross-platform
provisioning (POSIX via node-bootstrap.sh _nb_install_bundled_node,
Windows via the existing portable-zip download); reuses a healthy tree.
- node-bootstrap.sh: HERMES_NODE_SKIP_LINKS=1 skips the ~/.local/bin
node/npm/npx symlinks so the private tree never shadows the user's
own toolchain on PATH.
- maybe_repair_npm_engine() now returns the npm path to retry with
(managed-in-place upgrade or freshly provisioned runtime); both call
sites retry with the returned path and put the managed tree first on
PATH so npm lifecycle scripts resolve the managed node.
- Node-only mismatches on a foreign npm are now also recoverable (the
managed tree ships a supported Node); on a managed npm they still
correctly decline.
E2E (real download, temp HERMES_HOME): provisioned node v22.23.2,
upgraded bundled npm 10.9.4 -> 12.0.2, system npm byte-identical after,
no ~/.local/bin links re-pointed, healthy-tree reuse in 0.05s.
Cancelling the API-key prompt mid-wizard (Enter → 'Cancelled.') let the
wizard continue through Terminal/Gateway/Tools and finish 'successfully'
with no model configured — the user exits believing they're set up, then
hits a broken chat.
_print_setup_summary() (called by every setup path: full, quick,
blank-slate, portal) now probes resolve_provider() and, when nothing is
configured, prints an unmissable warning with the two one-line fixes
(hermes model / hermes setup --portal).
Consumer-onboarding audit finding #7 (sev 4), Aug 2026.
The remaining /model picker stall after the Copilot backoff fix: whenever
the 1h provider-models disk cache TTL (or the remote model-catalog manifest
TTL) lapsed mid-session, the next picker open blocked on 8-9 serial
/v1/models round-trips (~2-3s measured) plus the catalog manifest fetch
before rendering anything.
Model catalogs change on release timescales, not hourly — so both caches
now use stale-while-revalidate:
- cached_provider_model_ids(): an expired entry whose credential
fingerprint still matches is served immediately; a deduped daemon thread
re-fetches the live catalog and rewrites the disk cache for the next
open. Entries older than 7 days still block on a live fetch, credential
rotation still busts the entry, and force_refresh still bypasses SWR.
- model_catalog.get_catalog(): an expired disk manifest is served
immediately with an off-thread refresh; only a truly cold cache (no disk
copy) blocks on the network.
Measured picker payload build with deliberately-expired caches:
2.9s -> 0.93s (first open in process) / 0.06s (subsequent opens).
Combined with the Copilot fix (#76386): 7.3s -> ~0.06s for the common case.
The Desktop client writes the SSH session token under $HOME/.hermes/desktop-ssh
(a literal ~/.hermes/desktop-ssh in apps/desktop/electron/remote-lifecycle.ts,
expanded against the account's $HOME), independent of HERMES_HOME and the active
profile. But _read_ssh_session_token_file validated it against
get_hermes_home()/desktop-ssh, which a non-default sticky profile re-homes to
<root>/profiles/<name>/desktop-ssh (and any custom HERMES_HOME points elsewhere).
relative_to() then rejects every token as "not under the desktop-ssh directory",
so SSH remote mode is broken under any non-default profile.
Anchor to Path.home()/.hermes/desktop-ssh so the validator matches the exact
directory the client writes to, across default, profile, and Docker layouts.
Adds profile / custom-root acceptance tests and a profile-local rejection test.
Fixes#69551.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-args /model picker calls list_authenticated_providers(), which walks
every provider through load_pool(). For copilot, _seed_from_singletons()
re-runs the raw-token -> API-token exchange on every pass. When the exchange
is rejected (HTTP 403: token not Copilot-entitled, revoked, org-blocked),
the transient-network retry loop slept ~4.5s (1.5s + 3.0s backoff) before
degrading to the raw token — and nothing cached the failure, so EVERY picker
open, provider discovery pass, delegation spawn, and dashboard credential
listing paid the full 4.5s again.
Measured on a machine with a 403-rejected gh token: /model picker payload
build went from 7.3s to 1.0s cold and 0.06s warm.
Fixes:
- Permanent HTTP rejections (401/403/404) skip the retry backoff entirely —
the loop exists for startup network races, not auth rejections.
- Negative cache keyed on token fingerprint: failed exchanges are not
re-attempted for 30min (auth rejection) / 60s (transient network error).
- Success and evict_cached_exchanged_token() both clear the negative-cache
entry, so the runtime stale-credential recovery path still forces a fresh
exchange.
Extends the shared _GATEWAY_LIFECYCLE_PATTERN (used by BOTH the cron
creation-time guard in cron/lifecycle_guard.py and the terminal
execution-time hard-block in tools/terminal_tool.py) so Branch B covers
launchctl submit and bootstrap alongside kickstart/unload/load/stop/
restart, and normalizes POSIX shell line continuations before matching
so the exact multi-line reported shape in #62891 cannot slip past.
Also extends the execution-aware, label-independent detector
(contains_launchctl_submit_command, cherry-picked from #63272) to cover
launchctl bootstrap, since a neutral label like ai.hermes.svc-reload-tmp
defeats any label-anchored regex — the second production reproduction.
Regression tests cover both sites, including
`launchctl submit -l com.foo -- /path/gateway` and the bootstrap
variant, plus outside-gateway pass-through.
Branch B regex extension and continuation normalization drawn from
PR #62896; bootstrap coverage and test shapes drawn from PR #51003.
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com>
- Resolve guard cwd against get_session_cwd(session_key); fall back to env.cwd
when no session record exists yet, matching current main's per-session cwd
architecture.
- Make referenced-script reads backend-aware: local read first; if missing,
fall back to env.execute('cat ...') for SSH/Modal/Daytona backends.
- Reuse the recursive scanner in check_gateway_lifecycle so nested cron
wrapper scripts are caught, and resolve relative refs inside a script
against that script's directory.
- Add regression tests for remote-backend reads, two-session cwd, and nested
cron wrappers.
Verification: 80 passed tests/hermes_cli/test_gateway_restart_loop.py;
694 passed tests/cron; ruff + git diff --check clean.
Follow-up to the a11d0bdb01 dedupe of test_doctor.py. Two gaps in the
surviving copies:
- Nine tests stubbed get_nous_auth_status, but run_doctor calls
get_nous_auth_status_local (hermes_cli/doctor.py:1373-1380) — the
stubs weren't stubbing the called function. Point them at the local
variant, keeping the gemini OAuth stub where the newer copies had it.
- The catalog-alias parametrize lost its nvidia and moa cases in the
dedupe; restore them alongside ai-gateway.
54/54 pass (test_doctor.py + the shadow guard).
Two amplifiers behind the 779K api.osv.dev DNS queries/16h report:
1. tools/osv_check.py: check_package_for_malware() hit OSV on EVERY
call. MCP reconnect ladders, stdio recycles, and parked-server
self-probes re-run the preflight for the same package on every spawn
attempt, so a flapping server became a sustained OSV query/DNS
stream. Verdicts (clean or blocked) are now cached for 1h
(OSV_CHECK_CACHE_TTL to tune); network failures stay uncached so
fail-open never masks a real advisory once connectivity returns.
2. hermes_cli/security_audit.py: cmd_security_audit() ran full
component discovery twice per audit (_count_components + run_audit).
Discovery now runs once via _discover_components() and run_audit()
accepts the pre-discovered list.
Both regression tests fail against the previous code (verified via
sabotage run).
Converged Phase 2 finding (two reviewers independently): _discard_staged
only ran when phase-1 staging failed. A phase-2 (commit) failure rolled the
live tree back correctly but orphaned staging copies for every not-yet-
swapped entry — up to most of a full tree. The retry's up-front free-space
check runs BEFORE the lazy per-entry leftover cleanup, so the litter makes
the retry fail 'not enough free disk space' on exactly the space-constrained
machines the 1.2x threshold was chosen for: the same 'retry fails harder'
failure mode _discard_staged's docstring says it exists to prevent.
Two tests: a behavioral one pinning rollback+discard leaves the old tree
intact with zero litter, and an AST wiring contract on _update_via_zip so a
refactor can't silently drop the cleanup. Mutation-verified: removing the
try/except around _commit_staged_replacements fails the wiring test.
Phase 2 review HIGH (empirically reproduced): a hard kill between
os.rename(dst, backup) and os.rename(staging, dst) leaves dst missing and
the backup as the ONLY copy of that entry. On retry, _stage_replacement
deleted that backup as a 'leftover' BEFORE staging the fresh copy — so a
staging failure (disk exhaustion is likeliest exactly after writing a full
staging copy) left a hole in the install with nothing to roll back to.
Restore the backup to dst first when dst is missing; it's a same-filesystem
rename. Mutation-verified: removing the restore makes the new test fail.
CI slice 8/8 red:
test_verify_core_dependencies.py::test_uses_virtual_env_from_environment
AssertionError: assert None == PosixPath('.../newvenv/Scripts/python.exe')
The Phase 2 reviewer flagged this exact risk (W4) and I under-weighted it as
"latent, not broken". It was neither — it was already failing.
The suite exercises Windows-only paths on Linux CI by patching predicates
(`hermes_cli.main._is_windows`, `is_windows`, `platform.system`). Routing
those call sites through a helper that reads `sys.platform` unconditionally
meant the patches no longer reached the path derivation: the test built
`Scripts/python.exe` while the code looked for `bin/python`.
venv_bin_dir/venv_python_path now take an optional `windows=` verdict,
defaulting to the host. Every converted site passes its own predicate, so
the patched-predicate coverage is restored — the dedup keeps the layout in
one place without hijacking the platform decision.
Verified by causation: dropping `windows=` reproduces the CI failure exactly;
restoring it goes green. Added two regression tests, including one asserting
a patched `_is_windows` still reaches the derivation.
Phase 2 review findings on the first commit.
C1 (critical) — the two-phase replace covered directories only, so the 20
first-party modules at the repo root (run_agent.py, cli.py,
hermes_constants.py, model_tools.py, toolsets.py, ...) were still copied
one-at-a-time with shutil.copy2 straight onto live paths. A failure in that
loop left all directories new and the root modules stale: precisely the
ImportError shape this PR exists to prevent. Worse, copy2 truncates in place,
so a crash mid-copy could leave a half-written cli.py — strictly worse than
stale on the flaky-AV path this code runs on.
Stage files the same way as directories and swap them in the same commit
phase. The docstring's "wholly new or wholly old" is now actually true.
C2 (critical) — a phase-1 failure (disk exhaustion being the likely one)
orphaned one staging copy per entry already processed, up to a second copy
of the tree. The user then follows our "re-run hermes update" advice with
LESS free space and the retry fails harder. Added _discard_staged() on the
staging path. Verified: staging failure now leaves zero litter.
W1 — _stage_replacement duplicated _atomic_replace_dir's first half verbatim.
_atomic_replace_dir is now a 1-line shim over the two-phase helpers; its
#49145 regression test still passes.
W2 — the failure message still said "some directories were replaced and
others were not", which the fix makes false. Now says the install was left
in place.
W3 — the free-space gate demanded 2x the tree when only the staging copy is
new (the live tree already occupies its space; swaps are renames). Relaxed
to need * 1.2, so we stop blocking updates that would have succeeded on the
space-constrained machines most likely to hit this.
W5/W6 — the lint-style guard used `"if" in line`, which matches "modify" and
"verify" and still missed os.path.join(venv, "Scripts"). Rewritten as an AST
check; it immediately found the real offender the substring version missed
(stdio.py, now explicitly exempted — it lists literal Windows-only PATH
candidates, not a cross-platform derivation). Softened venv_bin_dir's
"single source of truth" claim, since sites outside hermes_cli/ remain.
S1 — the rollback loop now logs instead of silently swallowing OSError.
Both C1 and C2 fixes are mutation-verified: reverting either makes the new
tests fail.
Closes#76104, closes#76105.
#76104 — `_atomic_replace_dir` (#49145) made each individual directory swap
safe, but `_update_via_zip` replaced ~70 top-level entries in a loop with no
atomicity across iterations. `agent/` lands at os.listdir index 13 and
`tools/` at 66, so an interruption between them left the new
`agent/context_compressor.py` (module-level `from tools.todo_tool import
TODO_INJECTION_HEADER`) beside a stale `tools/todo_tool.py` — every file
valid Python, the tree unbootable. That is the mechanism behind the
ImportError fixed in #76091, and the "partial update" field report in #63717.
Split into stage-all-then-swap-all:
- `_stage_replacement` copies each dir to a sibling staging path, touching
nothing live, so a failure during the long copy phase is a no-op.
- `_commit_staged_replacements` performs the renames and, if any fails,
restores every entry already swapped — the tree lands wholly new or
wholly old, never mixed.
This shrinks the failure window from a full tree copy to N renames and makes
what remains recoverable. Added an up-front free-space check, since staging
needs a second copy of the tree; a clear error beats running out mid-swap.
#76105 — venv interpreter resolution was open-coded in 7 places across 4
files using 3 different Windows predicates. #76091 added the seventh because
the correct behaviour lived 2400 lines away. Hoisted `venv_bin_dir()` /
`venv_python_path()` into hermes_constants (import-safe, no new imports) and
routed every site through them; `managed_uv._venv_python` now delegates so
its 6 callers are untouched.
`_atomic_replace_dir` is retained — it is re-exported from main.py and has
its own #49145 regression test; removing it is out of scope here.
Tests: 10 new (rollback-on-mid-swap-failure is mutation-verified — it fails
when the rollback loop is removed), plus a guard that fails if a new call
site hand-rolls Scripts/bin again. E2E-verified against the real staging +
commit helpers with a live tree.
/simplify-code reuse reviewer (HIGH): the probe and the user-facing hint
each carried their own hand-written list of first-party package roots,
and they had already diverged on day one —
module probe hint
cli False True <- rollback with no explanation
hermesx True False <- third-party blamed on our updater
Hoist a single FIRST_PARTY_MODULE_ROOTS + is_first_party_module() into
hermes_constants (import-safe, no new imports) and have both consume it;
the probe gets the set injected into its source rather than re-typing it.
Also completes the roster — cron, utils, run_agent, model_tools,
toolsets, tui_gateway, acp_adapter were missing from both copies.
Verified by executing the real probe source against 19 module roots:
0 disagreements. Added a test that fails if either side grows a private
copy again.
Phase 2 review (C2) and /simplify-code findings.
C2 — the git path ran the import guard before `_clear_bytecode_cache`,
wired into the syntax guard's `git reset --hard` rollback. But
`cannot import name 'X'` is ALSO the documented signature of the
stale-bytecode class (#6207, #60242, see
_sweep_stale_bytecode_if_checkout_changed), which the very next steps —
and the launch-time sweep — already self-heal. A false positive there
would destroy a good update over a state that fixes itself.
Remove the guard from the rollback path entirely and re-add it at the
end of the git path, after bytecode sweep + dependency reinstall + lazy
refresh, as a WARNING only. By then every benign source of a transient
ImportError has run, and we never reset the user's checkout.
W6 — the headline regression test was vacuous: it patched
`hermes_main._UPDATE_CRITICAL_FILES`, but the syntax guard reads
`update_cmd`'s global, so the stub files were never examined and the
(True, None, None) came from "no files found" rather than "parses
clean". Patch the right module; mutation-checked (the test now fails
when the guard is disabled).
S5 — `startswith(("tools","agent","hermes","gateway"))` also matched
third-party `agents`/`agentops`/`toolsets`. Compare the first dotted
segment against an exact set instead.
S6 — hoist the per-line ChatConsole() instantiation.
Phase 2 review caught a false-rollback I introduced: on the git path the
import guard runs at the post-pull syntax check, which is BEFORE the
dependency sync. A release that adds a new third-party requirement would
fail the probe and trigger `git reset --hard` on a perfectly good update.
Rather than reorder the git path (the guard belongs with the rollback it
feeds), make the probe ignore a missing module that isn't ours. A missing
third-party package means deps aren't installed yet; a missing first-party
module means the update dropped a file, which IS the skew we're hunting.
This also makes the ZIP path's ordering non-load-bearing.
Verified: third-party absent -> (True, None, None); first-party absent ->
flagged; and the original TODO_INJECTION_HEADER skew is still caught.
Self-review against the sibling probe `_venv_core_imports_healthy`
surfaced this: that helper deliberately resolves the project venv's
python rather than using `sys.executable`, because `hermes update` may
be driven by a different interpreter than the install's own.
The new import guard had the same requirement and missed it. Probing
`sys.executable` would validate a tree the user never actually runs —
and that divergence is most likely on Windows, the exact platform this
guard was added for.
Falls back to the running interpreter when there is no venv (normal in
a dev checkout). Regression test asserts the venv python is chosen; it
fails when the fix is reverted.
A Windows user reported every startup dying with `ImportError: cannot
import name 'TODO_INJECTION_HEADER' from 'tools.todo_tool'`. The symbol
exists on main; their tree had the new `agent/context_compressor.py`
(which imports it at module level) alongside a pre-update
`tools/todo_tool.py`.
The post-update guard missed it. `_validate_critical_files_syntax` only
py_compiles files, and every file in a skewed tree parses fine — it is
the combination that is broken. The guard reported success and the
update completed over an install that could not start.
The ZIP-update path (Windows-only, used when git file I/O is broken)
is where the skew comes from: its copy loop replaces top-level entries
one at a time in `os.listdir` order, so `agent/` lands at index 13 and
`tools/` at index 66. Any failure between them leaves exactly this
mismatch — and that path had no post-copy validation or rollback at all.
- Add `_validate_critical_modules_import`: imports the four startup
modules in a subprocess (~0.4s) so cross-module breakage is caught.
Non-import errors (config/env) are ignored; a probe that cannot spawn
is non-fatal so we never block an update on our own tooling.
- Run it after the syntax guard on the git path, reusing the existing
auto-rollback.
- Run it on the ZIP path after dependency install (so a genuinely-new
requirement is not misreported as a partial copy), and make the ZIP
failure message state the install may be half-updated.
- Add `partial_update_hint()` and print it under "Failed to initialize
agent", so users see "re-run hermes update" instead of a bare
ImportError. Stays silent for ModuleNotFoundError and third-party
imports, which need different remediation.
Verified by simulating the exact skew: the syntax guard returns ok=True
while the import guard returns the user's error verbatim.
Non-interactive sessions (hermes chat -q, hermes -z) snapshot the tool
registry at AIAgent construction time. If background MCP discovery hasn't
finished, MCP tools are invisible for the entire session — and unlike
interactive mode, there is no between-turns late-binding refresh to recover.
Root cause: wait_for_mcp_discovery() only joins an already-created discovery
thread, so it no-ops if a direct/single-query path reaches agent construction
before MCP startup created that thread. Oneshot._run_agent() didn't call it
at all.
Fix:
- Add ensure_mcp_discovery_before_agent_build() helper to mcp_startup.py:
idempotently starts discovery if needed + bounded wait. Fail-open on errors.
- Add single_query parameter to _resolve_discovery_timeout/wait_for_mcp_discovery:
uses mcp_single_query_discovery_timeout (default 15s) instead of the
interactive mcp_discovery_timeout (1.5s) because one-shot sessions have no
second turn to recover.
- Wire into CLI _init_agent (single_query from _single_query_mode flag set
in cli.py's single-query path) and oneshot._run_agent (single_query=True).
- Interactive sessions unchanged: keep 1.5s bound (between-turns refresh covers).
Closes#38448, #51316, #37013, #68137
Composite salvage of #60017 (chrishart0), #51322 (Bartok9), #38620 (buptwz),
#43544 (halonke), #36882 (vanhoof).
get_custom_provider_extra_headers() was returning the result of
normalize_extra_headers() on the first matching base_url, even when
that entry had no extra_headers configured. A later providers.<name>
entry sharing the same URL but with headers set was therefore ignored.
Fix: store the normalized headers and only return when non-empty,
otherwise continue searching the remaining entries.
Fixes#74465
Verify that set_config_value and unset_config_value refuse to write
when config.yaml contains YAML syntax errors, and the original file
is left intact.
2de1e86c16 appended updated versions of five doctor tests without
removing the originals; the earlier definitions were silently shadowed
(dead) and tests/test_no_shadowed_test_definitions.py now fails on every
PR slice that runs it. Keep the later (runtime-winning) definitions,
delete the stale earlier ones.
Follow-ups to the previous commit (#74414 by @webtecnica, re #74373):
- When distribution_owned is OMITTED, restore the legacy contract: every
staged entry outside USER_OWNED_EXCLUDE is copied. The cherry-picked
filter consulted owned_paths(), which silently narrowed omitted-list
distributions to DEFAULT_DIST_OWNED and dropped undeclared payload
(extra top-level files/dirs existing distributions legitimately ship).
- Make explicit allowlists path-aware so documented nested entries like
skills/research/ and cron/digest.json select exactly that subtree/file
instead of being dropped by the top-level name comparison. Traversal
segments (.., absolute) and USER_OWNED_EXCLUDE roots are still rejected.
- Regression tests: omitted-list legacy behavior + nested-path allowlist.
_copy_dist_payload() in profile_distribution.py iterated all staged
entries without consulting the manifest's distribution_owned allowlist,
so manifests that restricted distribution_owned only had cosmetic effect.
Fix: compute manifest.owned_paths() at the top of _copy_dist_payload()
and skip entries not in that set, after the USER_OWNED_EXCLUDE check.
The owned_paths() method already existed on DistributionManifest and
correctly falls back to DEFAULT_DIST_OWNED when no explicit
distribution_owned is set, so the new filter preserves backward
compatibility for existing manifests.
Closes#74373
Follow-ups to the previous commit (#74155 by @Drexuxux):
- enrich_model_switch_warnings_for_gateway() -> merge_preflight_compression_warning()
still called the sync resolve_display_context_length() provider probe ladder
inline in both async /model call sites; dispatch it via asyncio.to_thread.
- Replace the inspect.getsource() test (source-reading tests are banned by
AGENTS.md) with behavioral tests that drive the real _handle_model_command:
assert the resolver runs off the loop thread and that the warning enrichment
is dispatched through asyncio.to_thread.
resolve_display_context_length() runs two blocking chains: the route
comparison in should_clear_context_pin() and the provider probe ladder in
get_model_context_length() (blocking requests calls to Anthropic /v1/models,
Copilot, Nous, Codex, GMI, Ollama, models.dev and OpenRouter).
The gateway message path already offloads both via
get_model_context_length_async() and should_clear_context_pin_async(), but
the /model slash-command handlers (_handle_model_command, _finish_switch)
called the sync helper directly, freezing the whole event loop for the
duration of the probe ladder - no messages processed on any platform, and
the Discord heartbeat timeouts that get_model_context_length_async() was
introduced to prevent.
Add resolve_display_context_length_async(), a thin asyncio.to_thread wrapper
mirroring the two existing *_async helpers (no logic duplication), and await
it at both handlers.
7b5a18817 migrated the sibling slug sites to custom_provider_slug, which
keeps a keyed providers: entry's config key as its durable identity. It
covered find_custom_provider_identity_by_model; canonical_custom_identity's
third recovery source - the configured-provider fallback - still built
f"custom:{normalized}" out of whatever string the caller happened to hold.
_get_named_custom_provider matches on either spelling, so a display name
that differs from its config key matches the entry and then heals to
custom:<display-name>. That is a second identity for one endpoint: the
endpoint- and model-based sources of the same function return
custom:<config-key>, and so does everything that persists or restores a
session's provider override. canonical_custom_identity exists precisely to
make a bare "custom" routable again, and tui_gateway calls it on the
session-persist, resume and recovery paths - so the divergence lands in
stored session identity.
Re-resolve through the endpoint the matched entry owns, reusing the
function's own URL-based canonicaliser rather than duplicating the match
logic. Legacy unkeyed custom_providers: entries keep their name identity,
and an unconfigured candidate still returns None.
Ports the negative limit/offset fix onto the current router modules
(hermes_cli/web_routers/sessions.py, profiles.py) since the handlers
moved out of web_server.py in 011ec4513e after this PR was opened.
Per review feedback: only add Query(..., ge=0) — no le=500. The
messages route already clamps oversized requests with min(limit, 500)
and must keep that behavior (succeed + cap) rather than reject them;
the two session-list routes never had a public 500 cap and shouldn't
gain a new rejecting one as a side effect of this fix.
_resolve_explicit_runtime's generic-provider branch accepted model_cfg's
persisted api_mode unconditionally, letting a stale mode from a previous
provider (e.g. anthropic_messages) leak into a newly-switched provider
(e.g. gemini) and break the transport. Reuse the existing
_provider_supports_explicit_api_mode guard, already used by the copilot
and named-custom-provider resolution paths for exactly this case, so the
persisted mode is only honored when model_cfg's provider matches the one
being resolved.
Closes#74318
resolve_entry_api_key() and the duplicated _fallback_entry_api_key()
read key_env via a raw os.getenv(), bypassing per-profile secret
scoping in the multiplexed gateway. Under multiplexing this can hand
a fallback request another profile's credential. Both now resolve
through agent.secret_scope.get_secret(), which reads the active
profile scope when multiplexing is on and falls back to os.environ
unchanged when it's off, so single-profile behavior is preserved.
Closes#74311
_load_auth_store() treated every exception from reading auth.json as
corruption and returned an empty store. EMFILE under fd exhaustion,
EACCES, EIO and a stalled network mount all reached that branch. This
module does read-modify-write in roughly fifteen places, so the empty
store was one _save_auth_store() away from erasing every stored
credential.
Separate OSError from parse failure: a file that exists but cannot be
read now raises, naming the real cause and leaving the file on disk
untouched. Only a genuine parse failure takes the preserve-and-start-
empty branch, which is unchanged.
The backup was also unreliable in exactly the conditions that triggered
it: shutil.copy2 opens a file, so under EMFILE it failed too, its bare
except swallowed that, and the log still said "Corrupt file preserved
at ..." when nothing had been written. Track whether the copy landed
and say so accurately.
_venv_launcher_ancestors() ran after
_wait_for_windows_update_gateway_exit(), but the drain stops tracking a
PID exactly when it dies - for the common graceful-drain case the worker
is gone by the time the wait returns, and a dead pid's parent cannot be
recovered, so the launcher stop never fired on that path. Resolve
launcher ancestors before draining and stop the snapshot afterwards
alongside the survivors; a launcher that already exited with its worker
raises ProcessLookupError at the kill and is skipped.
The set-cover invariant test now marks drained workers uninspectable
(construction raises, like psutil.NoSuchProcess), so a post-drain
launcher lookup can never reappear unnoticed.
_is_pausable_gateway() hand-rolled a second gateway parser and regressed
a valid form: in `--profile gateway gateway run` the profile VALUE
shadowed the subcommand token, so the scan reported that gateway as a
fatal preflight holder. Delegate to
gateway.status.looks_like_gateway_command_line() - profile-selector
aware, shlex-tokenizing, run-only - so the preflight exemption, the
pause discovery, and the updater's guard fallback share one parser.
Non-run gateway subcommands, serve backends, and REPLs still block; the
bare-`gateway` form now classifies as a running gateway, mirroring the
canonical matcher's contract.
The pause stops every gateway its discovery maps, but the venv-holder
guard sees the process table as it is now: a gateway respawned by its
supervisor (Scheduled Task, login watchdog) inside the pause-to-guard
window, or one started through a spawn path discovery does not map,
still holds venv .pyds - and the guard dead-ended the update on exactly
the kind of process the pause machinery exists to stop.
When every remaining holder classifies as a pausable gateway - using the
same _is_pausable_gateway matcher the Desktop preflight uses, so the two
views cannot drift - stop them and re-scan once. Any non-gateway holder
(REPL, stray script, Desktop backend) keeps the hard refusal exactly as
before, and a survivor after the stop still aborts.
The Desktop update preflight (`scanVenvBlockers` -> `python -m
hermes_cli._scan_venv_blockers`) reports every venv-side python as a
blocker and aborts the handoff:
main.ts: scanVenvBlockers(...) <- aborts HERE
return { ok:false, error:'venv-blocked' }
spawnUpdaterProcess(hermes-setup ...) <- never reached
But a *gateway* is not a dead-end holder. `hermes-setup` invokes
`hermes update --yes --gateway`, and the CLI updater's
`_pause_windows_gateways_for_update()` gracefully drains and stops
running gateways before touching the venv — machinery added for exactly
these processes (#50090 and follow-ups). The preflight replicated the
CLI's *guard* without its *pause*, so a Windows service-mode gateway
(e.g. a Scheduled Task running `gateway run`) made every Desktop update
abort forever with
[updates] venv-blocked: N process(es) hold the install
PID ... python.exe ... -m hermes_cli.main gateway run --replace
while the component one layer down was never allowed to run and handle
it. The abort points at a process the updater knows how to stop.
Fix: `_is_pausable_gateway()` exempts `hermes_cli.main ... gateway run`
invocations (both halves of the venv-shim launcher/worker chain match,
since the uv-side worker re-runs the same argv). Everything else keeps
blocking — the Desktop `serve` backend, other `gateway` subcommands,
operator REPLs and stray scripts have no pause machinery downstream.
The CLI updater's own post-pause venv guard is untouched, so a pause
that genuinely fails still aborts before any .pyd mutation.
The JSON gains a diagnostic `pausable_gateways` count. The TS consumer
validates only `ok`/`blocked`/`processes` and ignores unknown keys, so
old and new Desktop builds both accept the new document; no Electron
rebuild is required for the fix to take effect (the scan runs from the
repo's Python).
`import pty` at module scope pulls in `termios`, which does not exist on
Windows. That raised ModuleNotFoundError during *collection*, so pytest
aborted the whole module with
Interrupted: 1 error during collection
before any skip marker could take effect. The single PTY-dependent test
was already correctly marked `skipif(sys.platform == "win32")` — the
import crashed ahead of it and took the module's 13 other, entirely
platform-agnostic tests down as collateral. Windows contributors got zero
gateway coverage and, worse, a collection error that masks real failures
in any batch that includes this file.
Two changes:
- Move `import pty` into the `stdin_is_tty` branch that actually uses it
(the sole `pty.openpty()` call). Nothing else in the module needs it.
- Skip `test_systemd_install_checks_linger_status` on Windows. It drives
`_systemd_linger_enabled()` -> `os.getuid()`, which does not exist on
Windows; the production helper is annotated
"windows-footgun: ok — POSIX systemd helper, never invoked on Windows",
so the test is Linux-only by nature. It was previously hidden behind
the collection crash.
POSIX behaviour is unchanged: both guards are `skipif(win32)`, inactive
off Windows, and the local import resolves exactly where the module-level
one did.
before (Windows): 0 collected, 1 collection error
after (Windows): 14 collected, 9 passed, 5 skipped
On Windows a gateway started through the venv shim is a two-process chain:
venv\Scripts\python.exe (launcher — keeps venv .pyd files mapped)
└─ uv\python\...\python.exe (worker — writes the gateway PID file)
`_pause_windows_gateways_for_update()` builds its pause set from
`find_gateway_pids()`, which reads the PID file and therefore only ever
sees the *worker*. The venv-holder guard immediately downstream
(`_detect_venv_python_processes()`) matches on the venv path prefix, so it
only ever sees the *launcher*.
The two sets are disjoint. A gateway the updater had just gracefully
drained still left its launcher alive, the guard reported that launcher as
a venv holder, and the update aborted — every time. On the Desktop path
this surfaces as the dead-end dialog:
[updates] venv-blocked: 2 process(es) hold the install
PID ... python.exe ...\venv\Scripts\python.exe -m hermes_cli.main gateway run --replace
Note the reported holder is a gateway the updater believes it stopped.
The Desktop path is affected because `hermes-setup.exe` runs
`hermes update --yes --gateway --force`, and `--force` deliberately does
NOT bypass the venv guard (that needs `--force-venv`), so the abort is
correct behaviour reacting to an incomplete pause.
Fix: after the graceful drain, walk one hop up from each mapped gateway
PID and force-kill parents that live under the project venv.
Deliberately additive, not a substitution:
- The planned-stop marker and the graceful drain still target the worker
(the PID that wrote the PID file), so clean shutdown is unchanged and
updates don't get pushed onto the hard-kill path.
- `terminate_pid(force=True)` is `taskkill /T` (tree kill), so killing a
launcher that outlived its worker also reaps stragglers.
- `_resume_windows_gateways_after_update()` needs no change: the mapped
respawn argv is rebuilt from the profile name
(`_gateway_run_args_for_profile`), never from the killed PID, and the
restart watcher's `_pid_exists()` wait still terminates because the
tree kill takes the whole chain down.
- Only the venv-side parent is returned. Unrelated ancestors (a Scheduled
Task's `cmd.exe`, an operator's shell) are ignored, and the caller's own
process chain is excluded so a CLI `hermes update` never nominates
itself.
Tests assert the invariant the two PID-resolution paths must satisfy —
the pause's kill set must cover the guard's abort set — rather than
snapshotting PIDs. Verified to fail without the fix:
AssertionError: pause stopped [] but the venv guard aborts on [400]
— disjoint sets abort the update
--force was silently ignored for 'model' keys — the guard always
redirected to model.default even when the user explicitly asked to
replace the entire section. Now --force triggers a warning and
proceeds with the destructive overwrite for model too, matching
the non-model mapping --force behaviour.
Prevent 'hermes config set <section> <scalar>' from silently destroying
an existing mapping. The bare 'model' shorthand is preserved by
redirecting to 'model.default' — all other mapping sections are refused
with a helpful error unless --force is used.
Closes#74995
- Bound ALL reads of the on-disk JWT store through one _read_jwt_store()
helper (load, eviction, save-merge) — the 1 MiB cap previously only
covered the load path; eviction and save could still parse an
oversized/corrupt store and rewrite it back out (sweeper finding).
- Fix the class, not the site: the recovery gates checked the literal
provider == "copilot" while /model and profile configs can leave the
alias spelling in place (the reporter's own log shows provider=copilot
AND provider=github-copilot in one session — the aliased turns would
have silently skipped recovery). Single owner:
AIAgent._is_copilot_provider() (slug aliases + Copilot base-URL
fallback), used by both run_agent recovery methods and both
conversation_loop gates.
- Update the salvaged 401 test to current main's client-retirement
contract (release deferred to GC — no synchronous .close()).
- Add copilot_stale_cred_retry_attempted to the TurnRetryState field
contract test; add bounded-store and alias-gate regression tests.
The stale-staged-updater deadlock is not Windows-specific: hermes-setup
under ~/.hermes is only refreshed by a full installer run
(copy_self_to_hermes_home no-ops during --update), so every desktop whose
staged updater predates the HERMES_UPDATE_HANDOFF_PID export (8c76fe19)
runs an old parent that never sends the env var against a new child that
demands it — exit 2 ('Hermes is still running') forever, on macOS and
Linux just as on Windows.
Replace the wmic ancestry walk (deprecated, absent on current Win11,
GBK decode juggling) with psutil.Process().parents() — psutil is already
a hard dependency and is the project's canonical no-kill process probe.
Drop the os.name == 'nt' gate so all platforms heal. Add tests: a marker
owned by our parent process is recognized as our orchestrator; a live
non-ancestor holder is still refused.
Independent review pass: utils.base_url_host_matches already owns the
exact-or-dot-suffix hostname contract (userinfo/port stripped, lowercased,
trailing dot removed), so the predicate delegates instead of hand-rolling
a second suffix match to keep in sync. Also locks in the normalization
behavior the review verified empirically: uppercase+port, trailing-dot,
userinfo-stripped, and IPv6-literal cases added to the contract tests.
Three catalog-side defects from the same report, all downstream of the
exact-host assumption and the config/env asymmetry:
- Discovery read only $OPENAI_BASE_URL, so a config-set
model.base_url (the supported way to select a data-residency host)
was ignored and /model listed the catalog of api.openai.com, not the
configured endpoint. New _openai_discovery_base_url() resolves
env override -> matching model.base_url -> canonical default, the same
precedence inference uses.
- _credential_fingerprint() hashed env vars only, so hermes config set
model.base_url kept serving the previous endpoint's cached catalog
until TTL expiry. The effective endpoint is now folded into the
fingerprint for openai/openai-api.
- is_default_openai matched two literal URLs, so regional hosts (which
serve the identical 120+ entry dump) bypassed the curated intersection
and flooded the picker with whisper/tts/embedding/dall-e rows. Now uses
the shared official-host predicate; custom OpenAI-compatible proxies
keep the verbatim live list.
- validate_requested_model's curated-catalog soft-accept (#46850) no
longer applies on official OpenAI hosts: their /v1/models listing is
access-scoped and authoritative, so accepting an absent model
manufactures a selection that 400s at first use. Custom proxies and
other providers keep the #46850 fallback. The #37404
empty-intersection -> curated picker fallback is deliberately left
unchanged.
The P1 from the enterprise data-residency report: with
model.base_url=https://us.api.openai.com/v1, every tool-calling turn 400'd
('Function tools with reasoning_effort are not supported ... use
/v1/responses') because the runtime resolvers hardcoded
api_mode=chat_completions and consulted URL detection only. openai-api
declares codex_responses in its overlay; the declaration was never
consulted, so any OpenAI host that wasn't literally api.openai.com landed
on the wrong wire protocol.
New _fallback_api_mode(provider, base_url, model): URL detection first
(host-mandated wire shapes keep priority), then
providers.determine_api_mode() (the provider's declared transport), then
chat_completions only for genuinely unknown providers. All three runtime
fallback sites route through it: the pool-entry path, the explicit-runtime
path, and the API-key-provider path, so the lanes cannot drift apart.
Blast radius beyond openai-api: minimax, minimax-cn, and copilot-acp were
the other overlays whose declared non-chat transport fell through to
chat_completions on the same paths (same latent bug class). openrouter is
unaffected (declares openai_chat). _detect_api_mode_for_url also now uses
the shared official-host predicate, so regional hosts detect as
codex_responses on the direct-URL lane too.
Pointing openai-api at OpenAI's documented regional hosts
(us.api.openai.com / eu.api.openai.com, mandatory for customers with
data-residency obligations) silently degraded Hermes because three
subsystems tested 'is this OpenAI' with exact-hostname equality against
api.openai.com.
Adds providers.is_official_openai_host(): canonical host plus dot-suffix
subdomains of api.openai.com, hostname-parsed only. Lookalike hosts
(api.openai.com.attacker.test) and path spoofs (proxy.test/api.openai.com/v1)
stay rejected, preserving the #32243 hardening: a genuine *.api.openai.com
subdomain requires control of openai.com DNS.
host_mandated_api_mode() now routes through the predicate, so regional
hosts mandate codex_responses exactly like the canonical host.
The retag gate was global, so once one board reclaimed its legacy rows a
second board on the same state.db never got swept. Key the state_meta gate
on the workspaces root and skip reopening state.db on every spawn via an
in-process set. Align the dispatcher-spawn test with the worker's own
`kanban` source tag and cover the per-board gate.
The repo's .npmrc sets engine-strict=true and package.json pins
engines.npm, so an npm outside that range aborts every npm ci /
npm install we run inside the checkout:
npm error code EBADENGINE
npm error notsup Required: {"npm":"<11.10.0 || >=12.0.0"}
npm error notsup Actual: {"npm":"11.10.0"}
Our callers made that worse: _run_npm_install_deterministic sees
`npm ci` fail and falls through to `npm install`, which fails
identically, so the user got a buried EBADENGINE and no remedy.
React to the failure instead of predicting it. npm states the
required range in its own error, so there is no need for a version
probe on the happy path or a semver range matcher — the recovery
reads the constraint out of the output it just produced, upgrades,
and retries once.
Scope is deliberately narrow. Hermes only upgrades an npm inside its
own managed Node tree ($HERMES_HOME/node), installing with --prefix
so bin/npm keeps resolving to the upgraded lib/node_modules/npm; a
managed install writes prefix=~/.local into node/etc/npmrc, so
without the override the "upgrade" would land elsewhere while the
managed npm stayed stale. A system / nvm / brew / Nix npm belongs to
the user, so that case prints the exact command and lets the original
failure stand.
The upgrade runs from a temp cwd with npm_config_min_release_age=0,
otherwise the checkout's own min-release-age gate would refuse the
npm release we need.
_run_npm_install_deterministic's capture_output=False callers (the
desktop install) streamed npm output and returned stderr=None, which
would leave the recovery nothing to read — stderr is now teed, so
live output is unchanged and the text stays inspectable.
Verified end to end against real npm binaries on copies of a managed
tree: managed npm 11.10.0 -> EBADENGINE -> upgraded to 12.0.2 ->
retry exits 0; a foreign npm 11.10.0 hard-fails with the manual
command and is left untouched.
Resolves conflicts from upstream's DEFAULT_CONFIG extraction into
hermes_cli/config_defaults.py (password_store default moved there) and
the test-pruning waves (dropped the pruned pre-existing launch-option
tests; kept the new password-store tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Use providers keys as the canonical custom-provider identity while accepting legacy bare keys, display-name slugs, bare custom fallback, and doubled custom prefixes across resolution, pickers, doctor, and runtime reverse lookup.
Co-authored-by: Bakhtier Sizhaev <bakhtiersizhaev@users.noreply.github.com>
A running worker now polls its comment thread and folds new operator notes
into the live turn via the OUT-OF-BAND steer channel (list_comments_after +
a heartbeat-driven bridge, watermarked so history isn't re-injected and the
worker's own notes are skipped). No block→comment→unblock dance. Desktop's
composer sends notes live ("delivered within a few seconds") with "Requeue
with note" as the restart option and a help tooltip.
Boards gain an optional project_id. When set, the board's default_workdir
mirrors the project's primary repo and every new task inherits the project —
a deterministic worktree + branch per task — unless it names its own. New
GET /projects; board create/patch/list carry project_id + resolved name; the
create dialog defaults its workspace to the board's and allows a per-task
path override. Desktop: "Board settings…" gains a project picker.
Salvaged from #57016 by @lEWFkRAD:
- cli.py: handle file:///C:/... drive-letter URIs on nt (strip the
leading slash urlparse leaves); join Termux example paths with literal
forward slashes so hints stay POSIX on Windows.
- gateway/status.py + hermes_cli/gateway.py: normalize backslashes to
forward slashes before the HERMES_HOME substring match so separator
style cannot defeat profile ownership detection.
- hermes_cli/banner.py: cprint degrades to plain print when
prompt_toolkit has no console (NoConsoleScreenBufferError on
redirected/absent Windows stdout).
- hermes_cli/browser_connect.py: posixpath.join for WSL /mnt/c/... bases
(os.path.join would emit backslashes on nt).
- Test hardening: symlink skip-guards, USERPROFILE alongside HOME for
ntpath.expanduser, SIGKILL absence skipif fixed via monkeypatch,
drive-letter URI / separator-normalization / banner-fallback coverage.
Dropped from the original PR: tests/cli/conftest.py fixture and the
AppSession _output monkeypatch — main's merged tests/cli/conftest.py
already handles that prompt_toolkit pollution.
The cross-process update lock (fe8e4d93d) made the in-progress marker
mutually exclusive across every update entrypoint — but the Tauri
updater holds that marker for its WHOLE run and then spawns
hermes update as a child stage. The child read the marker, found its
own parent's live pid, refused with exit 2, and the GUI mapped that to
"Hermes is still running. Close all Hermes windows and try the update
again." Retry spawns a fresh updater that deadlocks against itself the
same way, so every GUI-driven update dead-ends on the failure screen
with no winnable retry (observed: three consecutive self-refusals in
bootstrap-installer.log within 90 seconds).
Hand the claim off explicitly: update_child_env exports
HERMES_UPDATE_HANDOFF_PID naming the updater's own pid, and
UpdateLock.acquire treats a live holder matching that pid as the lock
we are already running under — run without claiming, and release
leaves the parent's marker untouched. The env var alone grants
nothing: the pid must also be the live marker owner, so a stale or
forged value cannot bypass the lock, and a dashboard-spawned
hermes update (no handoff env) is still refused exactly as before.
Autouse fixture also resets approval_module._YOLO_MODE_FROZEN so a
HERMES_YOLO_MODE=1 host env can't poison every case (the one
startup-frozen test still patches it back explicitly). Adds the darwin
'ps -o stat=' zombie branch to _is_alive_like_dispatcher, mirroring
production hermes_cli/kanban_db.py — a no-op on Linux.
Salvaged from PR #34069 by @sunwz1115.
Co-authored-by: sunwz1115 <192549904+sunwz1115@users.noreply.github.com>
Guard the .lazy-refresh-incomplete marker writer (update_cmd), launch-time
recovery (main.py), and _early_recovery repair paths behind a two-condition
check: running under pytest AND the target is this live checkout. Sandboxed
tmp_path tests still exercise the real code paths.
Salvaged from PR #72002 by @fcavalcantirj. Fixes#72000.
Co-authored-by: fcavalcantirj <felipe.cavalcanti.rj@gmail.com>
The HERMES_DISABLE_LAZY_INSTALLS=1 conftest gate (from #43782) correctly
blocks real mid-run pip installs suite-wide, but
TestInstallDependenciesRunner exercises the install ladder itself against
a fully mocked subprocess.run — it needs the gate open. Same
both-directions override pattern tests/tools/test_lazy_deps.py already
uses. Sibling sweep of all install_specs/_pip_install/ensurepip test
files: 274 tests green.
Extract _resolve_session_token() in hermes_cli/web_server.py so tests can
exercise token resolution directly instead of importlib.reload(ws), which
re-executed the whole module mid-suite (fresh FastAPI app + token) and
split module identity between test and app state.
Salvaged from PR #39038 by @rodboev (maintainer-endorsed direction);
rebased onto the rewritten test_web_server.py — dropped the PR's hunks for
test_falls_back_to_random_token's old body (test deleted in the prune,
re-added here in the PR's new form).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Autouse conftest fixture patches kanban_db.connect to refuse writes whose
resolved DB path lands under the REAL kanban root (captured at conftest
import time, before fixtures rewire the environment). Deny-list, not
allow-list, so hermetic tests moving HERMES_HOME to sibling tempdirs are
unaffected. Lazily attaches only when hermes_cli.kanban_db is already in
sys.modules.
Salvaged from PR #69385 by @smfworks; rebased by hand onto the pruned
conftest and adapted to guard on the resolved DB path (explicit db_path
or kanban_db_path()) rather than kanban_home() alone.
Co-authored-by: Jasmine Naderi <jasmine@smfworks.com>
The restart-routing, systemd-support, and subprocess-HOME tests asserted
branch behavior but left part of the real probe surface unmocked, so they
fail when the suite itself runs inside a container (self-hosted CI) or a
launchd-descended shell:
- /restart routing tests: the handler also consults the real /.dockerenv —
extract the inline probe to gateway.restart.is_container_restart_context()
(patchable seam, no behavior change) and pin it False; scrub ALL four
supervisor env markers (ambient XPC_SERVICE_NAME on macOS flipped one).
- supports_systemd_services tests: pin shutil.which('systemctl') and
is_container() so the test asserts the branch, not the host.
- copilot ACP real-HOME test: pin is_container() (auto mode prefers profile
home in containers) and scrub ambient HERMES_REAL_HOME/TERMINAL_HOME_MODE.
97 tests green on macOS dev box AND inside a docker CI runner container.
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Follow-up to the salvaged success-path removal: installs that already
repaired (or predate the cleanup) still carry leaked ~1 GB parked venvs.
When the runtime probes safe, reclaim aged (>1h) stale markers next to
the live venv — age-gated to avoid racing an in-flight sibling repair,
boundary-checked via _remove_tree so symlinked names can't escape the
checkout. Also drop the now-stale 'before removing the parked venv'
user guidance in update_cmd.
Tests: success-path removal, safe-path sweep (aged removed, fresh kept).
- apply_database_pragmas: journal_mode ownership stays with
apply_wal_with_fallback/resolve_journal_mode (single guarded owner);
the helper now only applies wal_autocheckpoint / journal_size_limit,
via load_config_readonly (hot-path safe).
- Silent-refusal WAL success path re-applies the macOS
checkpoint_fullfsync barrier and synchronous=FULL enforcement.
- Test doubles updated for connect_tracked's factory kwarg and the
WAL-reset vulnerability gate (fixed-SQLite assumption made explicit).
The WAL→DELETE fallback on WAL-incompatible filesystems (NFS / SMB / FUSE /
the AgentFS NFS overlay) was logged at WARNING, treating a real loss of
concurrency — under the kanban dispatcher + workers a write blocks readers,
surfacing as SQLITE_BUSY — as if it were cosmetic. Escalate the deduplicated
fallback log to ERROR so the degradation is observable, not silent.
Add an opt-in require_wal=True to apply_wal_with_fallback that raises a typed
WalUnsupportedError (subclass of sqlite3.OperationalError, so existing DB-init
handlers still catch it) instead of degrading to DELETE, for callers that
mandate WAL concurrency. All four current callers keep the default
require_wal=False so NFS-homed installs keep working unchanged.
Tests: 4 new require_wal cases; WARNING→ERROR assertion updates in both
test_hermes_state_wal_fallback.py and test_kanban_db.py.
Git for Windows ships core.autocrlf=true in its system config, which
renormalizes this repo's LF text files to CRLF in the working tree.
install.ps1 pins core.autocrlf=false on the managed clone for that reason
(#67730), but a checkout created before that landed never got the pin --
and cannot get it, because hermes-setup.exe resolves install.ps1 by an
immutable build-time commit pin and reuses the cached script forever. A
Windows install from May 2026 still runs the May install.ps1 no matter how
many times it updates. `hermes update` ships with the checkout itself, so
it is the only path left that reaches those installs.
The pin and the cleanup have to be one operation. Under autocrlf=true git
compares normalized content, so a CRLF working tree reads clean; pinning
alone would expose every tracked text file as modified and hand the very
next update an autostash and pop of the whole tree -- strictly worse than
the state it set out to fix. So the tree is evaluated as it would look
pinned (git -c, nothing persisted), the files whose only difference is the
line ending are restored, and the pin is written only once that is
verified clean. A checkout we cannot fully normalize is left exactly as it
was found.
Files still dirty under --ignore-cr-at-eol are never touched, so a real
edit survives even when it also got renormalized. The restore takes its
pathspec over stdin because a fully renormalized checkout is thousands of
paths, well past the Windows command-line limit.
repair_vulnerable_runtime() hardcoded <checkout>/venv as the live venv,
so uv-default/dev checkouts installed into .venv got 'not-applicable' on
every hermes update — no repair path ever fired, leaving state.db-class
DBs on journal_mode=DELETE forever (measured 26 ms + ~5.5 fsyncs per
append vs ~0.01 ms under WAL, ~2,600x) while the WAL fallback warning
falsely promised hermes update would repair the runtime.
- _default_live_venv(): target venv/ when it has an interpreter (managed
layout precedence), fall back to .venv/, keep not-applicable when
neither exists. Explicit venv_dir arg unchanged; all staging/smoke/
cutover/rollback machinery untouched.
- Rebuilt against the pruned test suite (main's test-prune waves 1+2
rewrote test_managed_uv.py, so this reapplies cleanly): 3 new
TestDefaultLiveVenv tests + repair neutralized in the 6 unit tests
whose subject is uv install/self-update mechanics — with .venv now
probed for real, CI's own vulnerable .venv made the unmocked repair
hook fire inside those tests and re-invoke _install_uv.
33/33 tests green on the pruned suite.
The gateway keeps one PairingStore per served profile, but every
`/api/pairing` endpoint built the global one. An operator managing a named
profile saw the wrong pending list, and approving wrote a grant into a
whitelist their running gateway never consults — the user stays locked out
while the UI shows them as approved.
`_pairing_store(profile)` now resolves per profile and validates the name
(400/404 on an unknown one). No `_profile_scope` needed: PairingStore
resolves the profile's home itself, so nothing process-global is swapped
across an await.
Both GUIs had to change to match. The listing rides the query param — for
the dashboard that meant deleting `pairing` from the "machine-global, must
NOT be rewritten" exclusion list, a comment this change makes false. The
mutating endpoints read the profile off the BODY, which no query-param
rewrite reaches, so approve/revoke send it explicitly on both surfaces.
Three surfaces start updates against one checkout: a terminal
"hermes update", the dashboard's Update button (which spawns that same
command detached), and the desktop's, which hands off to the Tauri
updater. Only the Tauri updater published the in-progress marker, and
only Electron read it -- to gate backend startup, not to stop a second
updater. So a dashboard-spawned update and an installer-driven git
checkout could mutate the same tree concurrently, rewriting source under
a live interpreter.
Claim the same marker from cmd_update rather than adding a second
mechanism: same path, same pid+started_at payload the Rust and Electron
readers already parse. A marker only counts as live when its pid is alive
and it is inside the shared age ceiling, so a crashed updater self-heals
instead of wedging every future update. Release only removes a marker we
still own, leaving a handoff partner's claim intact.
Refusing exits 2, matching the existing concurrent-instance contract the
Tauri updater already recognizes.
Main's 243c9182b1/a16fd675df/7142dc4580 added load_config_readonly
sibling stubs across 38 files; our pruned versions of 11 of those files
kept only the load_config stubs. Re-applied the pairing at every
surviving site (26 patch()/setattr sites) — same return_value/
side_effect as the adjacent load_config stub. 494 tests green across
the 11 files.
Pins the audit findings: normalizer never mutates its input
(api_key_env + camelCase forms), providers-dict round-trips leave a
cached config byte-identical, and the normalized models mapping does
not alias the caller's dict.
The normalizer writes alias keys into the entry it is given
(entry['key_env'] = entry['api_key_env'] and entry[snake] =
entry[camel]) while building its normalized copy. Two of its three
callers — get_compatible_custom_providers and
providers_dict_to_custom_providers — pass live sub-dicts straight
from load_config_readonly()'s shared cache (only
_custom_provider_entry_to_provider_config defends with dict(entry)).
A config written with the documented camelCase / api_key_env aliases
therefore gets its cached copy polluted with injected duplicate keys,
violating the cache's explicit no-mutation contract; every later
load_config() deepcopy inherits the duplicates, and any
save_config(load_config()) flow (setup wizard, dashboard writes,
model persist) writes them back to config.yaml. The aux-client TLS
resolution runs this on every auxiliary client build, so the
mutation also happens unlocked on worker threads against a shared
object.
Shallow-copy the entry up front; the function's return value is a
separately-built dict, so behavior is otherwise unchanged.
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
it prepended the tests/ dir itself to sys.path, so 'import agent' /
'import hermes_cli' resolved to the test packages and collection died
with ModuleNotFoundError depending on import order (2 files failed in
every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
unmocked: each burned ~50s attempting live LLM traffic through the
relay before falling back (572s file — the slowest in the suite, and
flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
the compressor's redaction pass on large payloads —
_STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
output-equivalence fuzz-verified on 20k random strings), and the
_CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
signature check; the repo pins lark-oapi==1.6.8 but stale local
installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
agent.redact + agent.credential_persistence in the fake agent package
(empty __path__ blocks all real agent.* imports added since the fake
was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
baseline run (passes instantly when the box is quiet).
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.
Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
(DEFAULT_CONFIG smart-approval leaked in) — pinned approval
mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
silently probed live provider catalogs (~2s/test) — stubbed
cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
MagicMock agents — interrupt.side_effect now clears _running_agents
(22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
(24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
with rationale comment — the watchdog-dump assertion needs the loop
blocked well past deadline+grace under parallel load (flaked once in
the 40-worker verification run at a 0.2s margin).
Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
it prepended the tests/ dir itself to sys.path, so 'import agent' /
'import hermes_cli' resolved to the test packages and collection died
with ModuleNotFoundError depending on import order (2 files failed in
every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
unmocked: each burned ~50s attempting live LLM traffic through the
relay before falling back (572s file — the slowest in the suite, and
flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
the compressor's redaction pass on large payloads —
_STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
output-equivalence fuzz-verified on 20k random strings), and the
_CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
signature check; the repo pins lark-oapi==1.6.8 but stale local
installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
agent.redact + agent.credential_persistence in the fake agent package
(empty __path__ blocks all real agent.* imports added since the fake
was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
baseline run (passes instantly when the box is quiet).
Detect a missing cua-driver-serve scheduled task after the installer runs
and retry registration via Start-Process -FilePath/-ArgumentList instead of
interpolating the binary path into a PowerShell command string (which splits
at the first space in a username-space path).
Salvaged from #60880 by @embwl0x. Related: #60808.
enabled() now reads via read_raw_config_readonly(); the 7 monkeypatch/
patch sites in test_relay_shared_metrics_runtime.py that stubbed
hermes_cli.config.read_raw_config no longer intercepted the read,
failing 18 tests on CI slice 7/8. Repro'd locally, retargeted the
mocks; 147 passed + 2 skipped across both relay metrics files.
Four hot-path consumers paid a full config deepcopy per read:
- telemetry gate relay_shared_metrics.enabled() — runs 2-3x per agent
turn (2x per API call from lifecycle hooks + 1x per tool call) and
called read_raw_config(), which deepcopies the whole raw config every
call. New read_raw_config_readonly() serves the cached dict directly:
248 us -> 4.6 us per call (54x) on Teknium's real 77-key config.
- interruptible_streaming_api_call local-endpoint stale-timeout branch
called load_config() once per API call for every local-model user.
- gateway get_inbound_media_max_bytes() + _get_ephemeral_system_ttl_default()
called load_config() on per-message paths. All three switched to
load_config_readonly() (345 us -> 12 us; PR #28866 lineage).
Together these account for ~90% of the ~1,900 deepcopy primitives per
turn measured in the 26-call stubbed-LLM profile.
read_raw_config_readonly() keeps the (mtime_ns, size) freshness key so
config edits are picked up next call, and preserves the identity
invariant (cache-miss returns the same object later hits serve) —
regression-tested with 'is', per the PR #28866 identity-bug lesson.
The mutable read_raw_config() is unchanged for save-path callers.
581 targeted tests green (config, relay metrics x2, ephemeral reply,
platform base, new readonly suite).
The disease: ~15 scattered raw yaml.safe_load(config.yaml) reads that
silently miss managed-scope overlay, ${ENV_VAR} expansion, profile-aware
pathing, and root-model normalization. Every new config feature needed an
N-site sweep (incident chain 9cbcc0c9c8 → 732293cf87 → b0e47a98f9 →
1928aa0443). This commit assigns every raw read to an owner and adds a
lint-guard test so the class cannot regrow.
New primitive (additive-only change to hermes_cli/config.py):
read_user_config_raw(path=None) — reads the user file EXACTLY as
written; docstring states it is ONLY legal for write-back round-trips
and raw-file diagnostics. Behavioral reads must use
load_config()/load_config_readonly().
BEHAVIOR FIXES (class-a sites migrated to a canonical loader — these
previously read values that could DIFFER from the effective config):
gateway/run.py _try_resolve_fallback_provider → _load_gateway_runtime_config
keys: fallback_providers/fallback_model (provider, model, base_url,
api_key). Drift fixed: a managed-pinned fallback chain was ignored;
an api_key of "${OPENROUTER_API_KEY}" reached the resolver unexpanded.
gateway/run.py GatewayRunner._load_provider_routing → same loader
key: provider_routing. Drift fixed: managed-pinned routing prefs and
${VAR} templates were ignored.
gateway/run.py GatewayRunner._load_fallback_model → same loader
keys: fallback chain. Same drift as above.
gateway/run.py GatewayRunner._refresh_fallback_model
keeps the raw primitive (its last-known-good-on-parse-failure contract
forbids the fail-open loader, which returns {} on a torn write) but now
applies managed overlay + env expansion inline. Drift fixed: chain
edits under managed scope / env templates were previously frozen out.
tui_gateway/server.py _load_cfg (72 behavioral call sites)
now = raw read + managed overlay (pre-existing) + NEW ${VAR} expansion,
split from a new _load_cfg_raw() write-back primitive. Drift fixed:
e.g. custom_prompt: "hello ${VAR}", agent.system_prompt, model,
api_key/base_url templates reached sessions unexpanded. DEFAULT_CONFIG
is deliberately NOT merged (callers treat missing keys as unset;
`_load_cfg() == {}` sentinels and _save_cfg round-trips depend on it).
tui_gateway/server.py _profile_configured_cwd
keys: terminal.cwd of a NON-launch profile. Drift fixed: managed
overlay + ${VAR} expansion now apply (load_config() would resolve the
wrong profile's home, so the raw primitive + inline pipeline is used).
plugins/platforms/telegram/adapter.py _reload_dm_topics_from_config
→ load_config_readonly(). keys: platforms.telegram.extra.dm_topics.
Drift fixed: managed overlay + profile-aware pathing + expansion.
plugins/memory/holographic _load_plugin_config → load_config_readonly().
keys: plugins.hermes-memory-store.*. Same drift class.
WRITE-BACK ROUND-TRIPS (class-b: stay raw BY DESIGN via read_user_config_raw;
merging defaults/overlay would pollute the saved user file):
gateway/slash_commands.py: model persist x2, _save_gateway_config_key,
memory/skills write_approval toggles
gateway/platforms/yuanbao.py auto-sethome
tui_gateway/server.py _write_config_key + all cfg→_save_cfg blocks
(reasoning show/hide/full/clamp, details_mode[.section], prompt)
→ new _load_cfg_raw()
plugins/memory/holographic save_config
RAW-FILE DIAGNOSTICS + presence-sensitive bridges (class-c: stay raw,
now via the shared primitive with an explanatory comment):
hermes_cli/doctor.py x5 (model validation, stale-root-keys, .env drift,
deprecation sweep, memory-provider probe — the latter two keep their
inline managed overlay where they had one)
gateway/run.py _bridge_max_turns_from_config and the module-level
TERMINAL_*/HERMES_* env bridge (bridging merged defaults would export
all of DEFAULT_CONFIG into the environment; both keep their inline
overlay + expansion)
hermes_cli/send_cmd.py env bridge (same presence-sensitivity)
hermes_cli/gateway.py multiplex-conflict probe (reads the DEFAULT root's
config, not the active profile's — load_config is the wrong owner)
hermes_cli/profiles.py / hermes_cli/web_server.py / tools/wake_word.py
multi-profile reads (load_config targets only the ACTIVE profile home)
cron/jobs.py _resolve_default_model_snapshot and cron/scheduler.py
run_job config read keep their existing inline overlay+expansion but
now share the primitive (their fail-open + last-value semantics and
the deliberate no-defaults merge are preserved exactly).
Failure-semantics audit: every migrated site preserves its exact previous
behavior on missing file ({} / early return) and parse failure (raise into
the caller's existing except, warn, last-known-good, or fail-open) —
read_user_config_raw intentionally mirrors bare open()+safe_load semantics
(raises on parse errors, {} only on FileNotFoundError/non-dict root).
Guard: tests/hermes_cli/test_config_read_guard.py scans the tree for
yaml.safe_load within 6 lines of a 'config.yaml' reference outside an
explicit ALLOWLIST (hermes_cli/config.py, gateway/config.py, gateway/run.py
fallback path, hermes_cli/managed_scope.py which reads the MANAGED file,
gateway/readiness.py parse-health probe) and fails on new offenders.
E2E: tests/hermes_cli/test_config_loader_e2e.py runs a subprocess with a
temp HERMES_HOME (config.yaml containing ${E2E_PROMPT_SUFFIX}) plus a
HERMES_MANAGED_DIR overlay pinning agent.reasoning_effort, asserting
tui _load_cfg resolves "hello world"/"high" while _load_cfg_raw +
_save_cfg round-trip the template and user value verbatim with no
managed/default leakage.
Four fixes on the updater path:
1. uv self update freshness gate + timeout (managed_uv.py): the network
self-update ran on EVERY hermes update — including the 'Already up to
date!' fast path — with NO timeout (unbounded hang risk offline). Now
skipped when it succeeded within 7 days (stamp file under
HERMES_HOME/cache), capped at 60s, force= override available. The
CVE-driven vulnerable-runtime repair probe is NEVER gated — it still
runs on every invocation.
2. Drop the second network fetch from the pull step (main.py): the update
flow fetched origin/<branch>, counted commits, then ran
'git pull --ff-only origin <branch>' — a SECOND fetch of the same ref
(~0.5-1.5s). Now merges the already-fetched tracking ref via
'git merge --ff-only origin/<branch>'; the diverged-history reset
fallback is unchanged.
3. Probe the upstream remote locally before fetching it (_cmd_update_check):
non-fork installs have no 'upstream' remote, and --check burned a
failed network attempt (~0.3-1s) on every run before falling back to
origin. 'git remote get-url upstream' (~1ms local) now gates the fetch.
4. Desktop rebuild check reads the content-hash stamp in-process before
spawning 'hermes desktop --build-only' (a full CLI re-import, ~1-3s)
just to learn nothing changed. Stamp errors fall through to the
subprocess path unchanged.
Savings on a no-op 'hermes update': ~2-6s (uv self-update 0.5-3s +
second fetch 0.5-1.5s + desktop spawn 1-3s when applicable).
187 targeted updater tests green incl. 5 new stamp-gate tests.
The banner update-check ran an unscoped 'git fetch origin', transferring
all ~1,400 remote heads (measured 3.0s dry-run vs 0.55s scoped, and up to
70s on a cold ref store) and frequently burning its full 10s timeout on
slow links. cmd_update already scopes its fetch for exactly this reason.
A scoped 'git fetch origin main' updates both the origin/main tracking
ref (full-clone count path) and FETCH_HEAD (shallow compare path), so
behind-count semantics are unchanged — verified empirically on a full
clone (rewound tracking ref restored to tip, count correct) and a
--depth 1 shallow clone (FETCH_HEAD updated, boundary preserved).
The upstream cua-driver installer scripts on trycua/cua@main carry a
baked default version that Release Please bumps in the release PR
*before* the release assets are published. During that window an
unpinned installer run 404s on the asset download and the
`hermes update` cua-driver refresh fails with:
error: download failed: The remote server returned an error: (404) Not Found.
⚠ cua-driver refreshing did not complete. Re-run manually: ...
Observed live 2026-07-29: baked version 0.14.0 vs latest published
release 0.13.1 — every `hermes update` run with an out-of-date driver
hit the warning until upstream publishes the assets.
We already know the correct version: `cua-driver check-update --json`
returns `latest_version` straight from the GitHub Releases API, whose
entries by definition have published assets. When the check positively
confirms an update, export that version as CUA_DRIVER_RS_VERSION into
the installer child env (both install.sh and install.ps1 honour it over
their baked default), so the refresh downloads the release that
actually exists instead of racing the upstream release pipeline.
Malformed / missing latest_version values fall back to the previous
unpinned behaviour. The explicit `hermes computer-use install
--upgrade` force path and fresh installs are unchanged.
Streamed responses no longer insert real newlines at terminal width —
logical lines are emitted whole and the terminal soft-wraps them, so
highlight-copy rejoins the full line (emulators only keep linebreaks
the app actually printed). This is the CLI equivalent of the TUI's
selection copy, which reads logical source lines from its screen
buffer. TTFT perception is preserved by mirroring the unfinished
line's tail into the spinner status text instead of chunk-printing.
/copy now prefers OSC 52 when running over SSH (SSH_CONNECTION /
SSH_TTY / SSH_CLIENT) — native tools there write the REMOTE clipboard,
which is never what the user wants. The CLI's OSC 52 writer also gains
tmux/screen DCS passthrough wrapping, mirroring the TUI's
wrapForMultiplexer. Fixes#31528 for the CLI surface.
Sabotage-verified: restoring the old chunk emitter fails 3 of the new
tests (hard-wrap detection, spinner mirror, unbreakable-run split).
The continuous-voice no-speech counter (3 strikes -> voice off) counted
every silent capture cycle unconditionally. During a long agent turn
(thinking/tool-calling for minutes) or while TTS is speaking, the user
is CORRECTLY silent — those cycles ended the voice chat under them.
- hermes_cli/voice.py: new set_voice_busy_probe() seam + _voice_activity_held()
(TTS-playing via the existing _tts_playing Event, agent-busy via the
registered probe). Both the continuous-loop strike path and the
force-transcribe single-shot strike path skip counting while held.
Fail-open: a broken probe counts cycles as before.
- tui_gateway/server.py: registers _any_session_running() as the probe
on voice.record start (voice is process-global; any running session holds).
- cli.py: classic CLI strike path skips counting while _agent_running
or TTS playback is in flight.
Stop phrase and barge-in still work during the hold (own paths).
Includes a fixture fix for the #71083 cherry-pick: the fake tools.tts_tool
module needs _load_tts_config (main's tts_streaming imports it).
Session rows served without ?profile= carried no profile field, so in
multi-profile desktops the default profile's sessions circulated unowned:
resolveStoredSession cached them profile-less, resolveSessionProfile returned
undefined, and session.resume targeted whichever gateway was active -- opening
a default-profile session from a non-default window failed with
"session can't be found" while the reverse direction worked (#67603 family).
Server: GET /api/sessions/{id} and GET /api/sessions now stamp profile/
is_default_profile unconditionally -- the serving profile is always known
(_cron_default_profile() when the request is unscoped).
Renderer: resolveStoredSession treats a profile-less $sessions cache hit as
unresolved when >1 profile exists (falls through to the stamped by-id ladder)
and back-fills the active profile on bare by-id hits from older backends, so
unowned rows are never re-cached.
Verified via CDP against a live 4-profile renderer: bare by-id GET returned
hasProfileField:false and the stale cache rows matched; with the fix both
lookups return the owning profile and the resume routes to the right backend.
memories/MEMORY.md is the "§"-delimited store written by MemoryStore, not a
markdown document. parse_existing_memory_entries() fell back to
extract_markdown_entries() -- the *source* parser for CLAUDE.md / AGENTS.md --
whenever the destination held no delimiter, which is exactly the case for a
single-entry store or one that was hand-edited or shell-appended. That
extractor skips fenced code blocks, skips table rows, splits a block into one
entry per bullet and reflows paragraphs. The shredded result was then written
straight back over the user's store and reported as "Imported", with no backup
to recover from.
Parse the destination the way MemoryStore._parse_entries does: split on
ENTRY_DELIMITER only, so a store with no delimiter is one intact entry.
extract_markdown_entries() is unchanged and still used on the sources, where
it is correct.
Also restore the safety net the port dropped. The openclaw migration script
this module was ported from calls maybe_backup(destination) before rewriting a
memory store; the port did not. Snapshot the store to <name>.bak.<unix_ts>
(same naming as MemoryStore._backup_drifted_file), refuse to rewrite when the
snapshot fails, and write via temp file + atomic rename so an interrupted
import cannot leave a truncated store and a symlinked MEMORY.md stays a
symlink.
The identical fallback lives in openclaw_to_hermes.py, where it is reached
from migrate_memory() (memories/MEMORY.md and memories/USER.md) and
migrate_daily_memory(); fixed there too.
The old test asserted _warm_gateway_module was fire-and-forget (startup
completes in << SLOW_SECONDS). PR #73291 intentionally reversed this:
the import now runs synchronously before the lifespan yield because
run_in_executor didn't release the GIL on Windows + Python 3.11.
Updated the test to assert startup blocks for >= SLOW_SECONDS.
STT previously had no configuration surface outside hand-editing
config.yaml — no category in the hermes tools picker, no provider
matrix in the GUI capabilities tab, no status line in hermes setup.
- TOOL_CATEGORIES['stt']: 7 provider rows (Local Whisper, Nous
Subscription managed, OpenAI, Groq, xAI, ElevenLabs Scribe,
DeepInfra) with key prompts, badges, and post-setup hooks
- stt_provider marker wired through _write_provider_config,
_configure_provider, _reconfigure_provider, and
_is_provider_active — GUI and CLI share one write path
(apply_provider_selection)
- STT model picker (_configure_stt_model + STT_MODEL_CATALOG) runs
after provider pick: local sizes, Groq whisper family, OpenAI
whisper-1/gpt-4o-*/gpt-transcribe, ElevenLabs scribe (model_id key)
- faster_whisper post-setup hook auto-installs the local backend;
registered in _POST_SETUP_READY
- stt is CONFIG-ONLY (_CONFIG_ONLY_TOOLSETS): it ships no tool
schemas, so it is excluded from the per-platform enable checklist;
the GUI toolset toggle writes stt.enabled instead of
platform_toolsets
- hermes setup shows a Speech-to-Text status line per provider
- Mistral row omitted (mistralai PyPI quarantine), mirroring the
dashboard stt.provider options
Tests: tests/hermes_cli/test_stt_picker.py (20 cases) incl. invariant
checks against agent.transcription_registry builtins and the runtime
OPENAI_MODELS/GROQ_MODELS sets.
Follow-up on the #53205 salvage: replace bare is_file() probes of the
managed (~/.hermes/node[/bin]) and legacy (node_modules/.bin) locations
with shutil.which(..., path=dir) so Windows resolves the executable
.cmd shim instead of the extensionless POSIX script — the same miss
class fixed for _has_agent_browser() in #73932. Also covers the
Windows managed layout where the binary sits in node/ directly.
`hermes acp --setup-browser` installs agent-browser into the Hermes-managed
node prefix (~/.hermes/node/bin/agent-browser), which isn't necessarily on
PATH. doctor only checked PROJECT_ROOT/node_modules and PATH (shutil.which),
so it false-negatived with "agent-browser not installed" even though the
binary was present and runnable. Mirror dep_ensure._has_hermes_agent_browser()
by also checking HERMES_HOME/node/bin and the legacy
HERMES_HOME/node_modules/.bin path, each gated by agent_browser_runnable().
Tested with tests/hermes_cli/test_doctor.py (added positive + not-runnable
cases) and pytest tests/hermes_cli/test_doctor.py -q (66 passed).
Streamed response text carried a 4-space _STREAM_PAD indent and the
final-response Rich Panel used padding=(1, 4), so every line selected
out of the terminal came with leading whitespace. Both now render
flush-left (pad empty, panel padding=(1, 0)); the table-realignment
width budgets were widened to match.
/copy now writes the ORIGINAL message text through native clipboard
tools (pbcopy / PowerShell Set-Clipboard via base64 / wl-copy / xclip /
xsel — same fallback chain as the TUI's writeClipboardText), falling
back to OSC 52 only when no native backend succeeds. This is the
TUI-equivalent answer to soft-wrap mangling: the clipboard gets the raw
text, not the rendered layout.
_has_agent_browser()'s new managed-Node rung calls
shutil.which('agent-browser', path=...); tests that monkeypatch
shutil.which globally with 1-arg lambdas raised TypeError when their
code path reached the browser readiness probe (test_post_setup_gating,
test_setup_model_provider).
Three GUI Capabilities-tab defects reported on Windows:
1. Browser rows stuck on 'Setup required' after a successful setup run.
Root causes, all in the readiness probe (not the installer):
- _has_agent_browser() never searched the Hermes-managed Node dir
(%LOCALAPPDATA%/hermes/node / $HERMES_HOME/node/bin) where the
Windows install lands, and probed node_modules/.bin/agent-browser
as the extensionless POSIX shim, which fails exec on Windows
(WinError 193) — now resolved via PATHEXT-aware shutil.which
against both rungs, mirroring _find_agent_browser().
- Cloud rows (Nous Subscription Browser Use, Browserbase, Browser
Use, Firecrawl) declared post_setup: agent_browser, whose
readiness gate requires a LOCAL Chromium build the cloud never
uses — switched to the cloud-scoped 'browserbase' hook (CLI-only).
- _agent_browser_installed() could read browser_tool's stale cached
'Chromium missing' result from before the install ran in the
spawned post-setup process — cache now dropped before probing so
the pill flips to Ready right after a successful run.
2. No way to tell which backend is active, and clicking a row to read
its details silently rewrote config. Row click now only
expands/collapses; activation is an explicit 'Use this backend'
button, the active row carries an 'Active' pill, and the expanded
active row says 'This is your active backend'.
3. OpenAI TTS showed one model and one voice. The options were always
defined but rendered through a native <datalist>, which filters by
the field's current value — a field already set to a valid option
suggested only itself. Replaced with a real combobox (Input +
dropdown) that lists every option, and voice suggestions now track
the selected model per the OpenAI TTS docs: tts-1/tts-1-hd = 9
voices, gpt-4o-mini-tts = 13 (adds ballad, verse, marin, cedar).
show_status now reads get_nous_auth_status_local(); the test_status.py
mocks still patched the old live-resolve entry point, so the patched
dict was never consumed (CI slice 8/8 red on the salvage PR).
Follow-up widening of the /api/status fix: add get_nous_auth_status_local(),
a refresh-free auth-store snapshot (local invoke-JWT decode only), and use it
on the read-only display surfaces that previously called
get_nous_auth_status() -> resolve_nous_runtime_credentials() -> live OAuth
refresh POST:
- hermes_cli/status.py (hermes status auth-provider panel)
- hermes_cli/doctor.py (hermes doctor auth-provider checks)
- hermes_cli/portal_cli.py (hermes portal status display)
- hermes_cli/web_server.py /api/portal endpoint and the accounts-tab
provider card dispatcher (_resolve_provider_status nous branch)
Action paths (login flows, portal operations needing a live credential)
keep using get_nous_auth_status(). Part of NS-592.
Hosted agents that die uncleanly (kernel OOM kill, SIGKILL, whole-VM
death) leave no trace: shutdown_forensics only covers graceful signals,
gateway-exit-diag.log only covers exit paths that actually run, and the
VM reboot wipes dmesg before anyone can capture it. NS-608 (BlueAtlas
hourly crash cycle, July 12-15) took days of manual log correlation to
classify because nothing recorded 'the previous life ended violently'.
Add gateway/lifecycle_ledger.py — a sentinel state machine persisted to
<HERMES_HOME>/state/gateway.lifecycle.json:
- start_gateway() claims the sentinel (phase=running) right after the
PID-file/runtime-lock claim, and reports any prior life that never
reached an exit path as gateway.previous_unclean_exit in
gateway-exit-diag.log + a WARNING log line.
- Every exit funnel marks the sentinel exited with a reason:
_exit_after_graceful_shutdown (graceful_shutdown), the shutdown
watchdog (shutdown_watchdog), and the loop-liveness watchdog
(loop_liveness_watchdog).
- Ownership-guarded for --replace takeovers: a live matching owner is
never reported dead, and the old life cannot clobber the
replacement's freshly claimed sentinel on its way out.
The 30s loop heartbeat now embeds a cheap /proc memory sample (own RSS,
MemAvailable, swap used) so every unclean-death report carries a
'memory N seconds before death' snapshot; the detector flags
suspected_oom when the last sample shows <64MiB or <5% available.
container-boot.log lines gain prior_exit=clean|unclean|unknown per
profile, stamping unclean container deaths into the volume-persisted
boot log where support can grep for them.
Tests: tests/gateway/test_lifecycle_ledger.py (16 cases) + 4 new
container-boot annotation cases. Existing watchdog/forensics/boot
suites all green; ruff clean.
_install_dependencies now routes through tools.lazy_deps.install_specs
(NS-605); the force-reinstall test still stubbed
hermes_cli.tools_config._pip_install, so its spy list stayed empty
(CI slice 3/8 red on the salvage PR).
Installing a memory provider (Honcho, mem0, hindsight, ...) from the
dashboard Plugins page failed on hosted deployments with a permission
error: the setup endpoint shelled out to
`uv pip install --python sys.executable`, which targets the sealed
read-only venv under /opt/hermes (immutable hosted image, NS-579/#49113).
The correct mechanism already exists: tools/lazy_deps.py redirects
installs to the writable durable target on the data volume
(HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages) when the venv is
sealed (HERMES_DISABLE_LAZY_INSTALLS=1), appends the target to the END
of sys.path (core venv always wins collisions), and constrains shared
deps to core-venv versions. The dashboard installer simply never used
it.
Fix:
- tools/lazy_deps.py: new public install_specs() — installs arbitrary
manifest-declared pip specs through the same environment routing as
ensure(): venv-scoped by default, durable-target on sealed images,
refused with an actionable reason when gated off (config kill switch
or sealed venv without a target — never surfaces raw EROFS/EACCES).
Specs are validated with _spec_is_safe(); post-install it invalidates
import/metadata caches so availability rechecks in the same process
see the new packages without a restart. Never raises.
- hermes_cli/web_server.py: _install_memory_provider_pip_dependencies
now calls install_specs() instead of building its own uv/pip
subprocess. Blocked installs surface the gate reason in the setup
results; the response's status block reflects post-install
availability (stale 'missing deps' state clears immediately).
- hermes_cli/memory_setup.py, plugins/memory/honcho/cli.py,
plugins/memory/mem0/_setup.py: CLI setup wizards routed through
install_specs() too — same sealed-venv failure mode, same fix.
No hosted setup path writes to /opt/hermes anymore; provider discovery
and installation now use the same environment (sys.path activation is
shared with the lazy-install bootstrap in hermes_bootstrap).
Tests:
- tests/tools/test_lazy_deps.py: TestInstallSpecs — gating matrix
(sealed+no-target blocked with immutable-deployment reason, config
kill switch, sealed+target proceeds), spec-safety rejection before
any subprocess, venv-scoped vs --target command display, failure
stderr passthrough, never-raises contract.
- tests/hermes_cli/test_web_server.py: setup endpoint routes pip
through lazy_deps (regression guard asserts no direct 'pip install'
subprocess), blocked-reason surfacing, same-response availability
recheck clears stale missing state.
Fixes NS-605 (Plain T-1111).
speak_text (hermes_cli/voice.py — the TUI/gateway one-shot TTS entry
point) now checks resolve_streaming_provider() first: when the
configured provider has a chunked streamer, the reply is spoken through
the same stream_tts_to_speaker pipeline CLI voice mode uses, so audio
starts on sentence one instead of after whole-file synthesis. No
streamer (edge/piper/etc.) or a streaming failure falls back to the
existing whole-file path unchanged — one dispatcher, zero parallel
streaming implementations.
Refs: #58930
Cancelling a pending OpenAI Codex device-code login only popped the
session dict; the background worker had no way to observe the
cancellation and kept polling, exchanging the code, and saving tokens
regardless. Once the session was gone, _oauth_session_profile()
returned None and the save fell back to the caller's current profile
scope instead of the profile the login was started in.
Fix: cancel_oauth_session marks the dict cancelled=True before
popping it, and _codex_full_login_worker (which holds a reference to
the same dict object) checks that flag before every remaining
sleep/poll, before the token exchange, and before saving. The profile
is captured once up front so it can never be re-derived from a
session that no longer exists.
Sibling fix for #65977 — _model_flow_bedrock_api_key used only
get_env_value for AWS_BEARER_TOKEN_BEDROCK, missing pool-backed
keys. Now uses _resolve_api_key_provider_secret like the other
flows.
Sibling of #65254 (main-slot endpoint preservation): the auxiliary scope of
POST /api/model/set dropped the request's base_url/api_key on the floor, so
an aux slot pinned to a custom/local endpoint silently depended on
model.base_url — and broke the moment the main slot switched away and
cleared it. The aux resolver already reads auxiliary.<task>.base_url/api_key
(_resolve_task_provider_model); this persists them.
Desktop side: setAuxiliaryToMain / applyAuxiliaryDraft now carry the
user-defined provider's api_url as base_url, mirroring applyMainModel.
Port from openai/codex#34540 / #34612 ("detach non-interactive
subprocesses from stdin"): internal git invocations that run with nobody
attached — MCP catalog installs, plugin install/update, profile
distribution staging, worktree base fetches, and the desktop review
pane's git/gh backend — could hang on a credential prompt when a remote
is private, misconfigured, or requires auth. git prompts on the
inherited terminal (or via Git Credential Manager on Windows), so the
operation silently waits until its timeout, or forever at sites without
one (mcp_catalog clones have no timeout at all and inherit the parent
terminal).
- Add noninteractive_git_env() to hermes_cli/_subprocess_compat.py:
GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=Never on a copy of the
environment; GIT_ASKPASS/SSH_ASKPASS deliberately preserved so
working non-interactive auth still succeeds.
- Wire it + stdin=DEVNULL into: mcp_catalog._do_git_install (clone/
checkout), plugins_cmd (clone + pull), profile_distribution._git_clone,
web_git._git/_gh (gh also gets GH_PROMPT_DISABLED=1), and cli.py's
worktree base fetch helper.
- Tests: env contract, a real-git E2E against a local 401 Basic-auth
HTTP server proving fail-fast ("terminal prompts disabled") instead
of a hang, and per-call-site plumbing assertions. Sabotage-verified:
removing the env from web_git._git fails the site test.
Hardened-runtime restrictions are enforced even for ad-hoc signatures,
so signing with --options runtime without the allow-jit entitlements
would leave Electron/V8 crashing on launch — strictly worse than the
legacy plain ad-hoc sign. Raise instead, so the fixup falls back to the
legacy path and the bundle always stays launchable.
Local/self-updated macOS builds were finished with a plain
'codesign --force --deep --sign -', leaving a cdhash-only Designated
Requirement and stripping electron-builder's entitlements. Every rebuild
changes the cdhash, so TCC treats the new bundle as different code and
forgets Full Disk Access, Desktop/Downloads/Documents, Accessibility,
Automation, and microphone grants — users re-approve everything after
every update.
Rework the relaunch fixup to sign inside-out (standalone Mach-O
binaries, nested frameworks/helpers, then the main bundle), preserving
the repo's entitlement plists, and pin an identifier-based Designated
Requirement when signing ad-hoc so TCC has a stable identity to persist.
Opt-in desktop.macos_signing_identity names a persistent keychain cert
(self-signed Code Signing cert works — no Apple Developer account) for a
certificate-anchored DR, the strongest form. An intact Developer ID
signature is detected and never clobbered, callers can pass the
publisher-signing decision explicitly so a later dotenv load can't flip
it, and the legacy deep ad-hoc sign remains the last-resort fallback.
Co-authored-by: lewis4x4 <lewis4x4@users.noreply.github.com>
Co-authored-by: natebransc <natebransc@users.noreply.github.com>
Co-authored-by: caseyanthony <caseyanthony@users.noreply.github.com>
Co-authored-by: gvago <gvago@users.noreply.github.com>
Co-authored-by: twe-cloud <twe-cloud@users.noreply.github.com>
The autouse _audio_playback_guard from this PR stubs voice.speak_text
globally — but these tests exercise speak_text itself with their own
playback stubs (no real audio possible). Mark real_audio_playback so
the guard yields; the tests' own monkeypatches keep speakers silent.
/api/audio/transcribe, /api/audio/speak, /api/audio/elevenlabs/voices, and
the /api/audio/speak-stream WebSocket resolved TTS/STT config from the
dashboard's own HERMES_HOME regardless of the active profile, so a
non-default profile's voice settings were silently ignored. Give all four
the same optional profile param as the rest of the dashboard surface,
entering _config_profile_scope (await-safe, config-only — the audio paths
touch no skills globals) inside their worker threads.
Backend half of the desktop fix; completes the renderer-side profileScoped()
threading. Fixes#53441#45506#66012#64057.
Two in-house micro-fixes from issue triage:
- #49883: voice.beep_enabled gates in cli.py and hermes_cli/voice.py used
bool() on the config value, so a quoted YAML string like "false" or
"off" kept beeps on. Route through utils.is_truthy_value.
- #18432: AudioRecorder.start() collapsed OSError from _import_audio into
the 'pip install sounddevice numpy' hint — but OSError means the
PortAudio SHARED LIBRARY is missing, which pip cannot fix. Mirror
detect_audio_environment's system-package hint (libportaudio2 /
brew portaudio / Termux pkg install portaudio) on that path.
Fixes#49883Fixes#18432
Use mark_provider_active_if_unset after dashboard token save, unsuppress
device_code after TTS setup login, and lock default-active plus refresh
active_provider contracts in tests.
Save side-tool OAuth tokens without promoting xai-oauth via active_provider
or model.provider so hermes setup tts login no longer hijacks inference routing.
Existing installs predate the install.sh hermes-acp launcher, and hermes
update never re-runs setup_path, so ACP hosts (Zed, JetBrains, Buzz)
still resolve Hermes as unavailable until a reinstall. _ensure_acp_launcher()
writes the launcher next to an existing hermes command in ~/.local/bin or
/usr/local/bin during hermes update — delegating to the sibling launcher so
it is correct for every install layout. Never follows symlinks (#21454),
skips unwritable dirs, no-op on Windows (venv Scripts is already on PATH).
Docs: add a Buzz Desktop section to the ACP page (en + zh-Hans).
Per-job cron inference pins are now user-owned: the agent-facing cronjob
tool schema no longer exposes model/provider/base_url, and the registered
handler ignores them even if a model hallucinates the old parameters.
Users set pins via the dashboard, hermes cron create/edit --model/--provider,
or jobs.json directly — and once set, a pin sticks until the user changes it.
Existing agent-era pins are grandfathered untouched.
New cron.model / cron.model_provider config keys give the cron fleet its
own default model, independent of the chat model. Fire-time resolution:
per-job pin > cron.model > HERMES_MODEL > model.default. An axis covered
by the explicit cron-fleet default is deliberate routing, not drift, so
the #44585 fail-closed guard skips it — switching your chat model with
/model or hermes model no longer breaks unpinned cron fleets.
- tools/cronjob_tools.py: drop model param from agent schema + handler;
remove now-dead _resolve_model_override
- cron/scheduler.py: cron.model/model_provider resolution + per-axis
drift-guard skip
- cron/jobs.py: snapshot resolution mirrors the new precedence
- hermes_cli/subcommands/cron.py + hermes_cli/cron.py: --model/--provider
on hermes cron create/edit
- hermes_cli/config.py: cron.model / cron.model_provider defaults
- docs: cron.md model-resolution tip rewritten
Responds to hermes-sweeper review on #54717: existing tests only mocked
platform_registry.get(). Add a hermetic fake deferred-loader test that
runs real PlatformRegistry resolution → PluginContext.register_cli_command
→ argparse subparser/choices visibility, without Photon SDK imports.
Closes#54678
`hermes photon ...` could fail with argparse `invalid choice: 'photon'`
even when the bundled Photon platform plugin is present. Photon registers
its top-level CLI command from the platform adapter module via
`ctx.register_cli_command(name="photon", ...)`, but bundled platform
plugins are cheap-registered as *deferred* entries to avoid importing every
gateway SDK during normal startup.
On the unknown-top-level-command slow path, `discover_plugins()` records the
deferred loader but never imports the matching platform module, so the CLI
registration side effect doesn't run and `photon` stays absent from
`_cli_commands` — argparse then rejects it.
Fix: after `discover_plugins()` on that slow path, resolve only the deferred
platform whose name matches the first positional token (via
`platform_registry.get(name)`) before reading `_cli_commands`. This imports
exactly the targeted platform, leaving normal startup cheap (a bare `hermes`
or flags-only invocation has no positional token and touches nothing). The
resolution is best-effort: registry/import failures are logged at debug and
never crash startup.
Added 3 tests in tests/hermes_cli/test_startup_plugin_gating.py: resolves the
matching platform, ignores empty/None command, and swallows registry errors.
Fails without the fix (symbol absent).
- Extract the cmdk filter into exported rankSearchOption and cover it,
SearchableSelect selection/clear/placeholder, and ConfigField
searchable-schema routing with 12 vitest cases (sabotage-verified:
the backend schema test fails on unfixed main).
- Add searchPlaceholder/noResults/systemDefault strings to ja/ar/zh-hant
(zh + en came with the salvaged commits; defineLocale would have
fallen back to English otherwise).
- Add a backend invariant test: timezone ships as a searchable,
clearable select of sorted IANA ids with a UTC fallback.
The cap is shared across CLI, desktop/TUI and the messaging gateway, so the
surface that gets rejected is rarely the one holding the slots. The rejection
read "Hermes is at the active session limit (5/5). Try again when another
session finishes." while every slot was an idle desktop tab, which took
filesystem access to work out.
Name the holders in the message, and show slot usage plus each holder in
`hermes status`. Both are inert when max_concurrent_sessions is unset, which
is the default. The gateway's duplicate copy of the message now reuses the
shared helper.