Two fixes from live testing (Teknium):
1. Desktop installs now ship the wake/voice stacks up front.
install.sh + install.ps1 desktop stages run 'uv pip install
-e .[wake,voice]' (best-effort, lazy-install remains the fallback)
before building the app, so the first ear-click arms instantly
instead of sitting through a multi-minute onnxruntime download.
CLI-only installs keep the lazy path — [all] curation unchanged.
2. The vanished ear: the STT/TTS gate made wake.status call
check_tts_requirements(), whose edge path runs _import_edge_tts →
lazy_deps.ensure — a synchronous PIP INSTALL inside a status poll.
On a venv without edge-tts that blew the desktop's 30s RPC timeout,
armWakeWord caught the error, the atom never learned enabled=true,
and the ear unmounted. _tts_ready is now a pure probe: deps missing
+ lazy installs allowed counts as ready (installs at first speak)
WITHOUT touching pip; check_tts_requirements only runs once deps
are present. Regression test asserts the probe never calls it while
deps are missing.
The wake loop is wake → record → STT → agent → TTS. Arming without
either end configured gives a mic that hears you and then does nothing
perceivable — a useless experience. check_wake_word_requirements now
probes both (same probes /voice uses: stt.enabled + provider != none;
check_tts_requirements) and refuses with a pointer to `hermes tools`
naming exactly which half is missing. The desktop ear hides (available:
false already hides the button), /wake on prints the hint on CLI/TUI.
wake.start also validates requirements BEFORE persisting
wake_word.enabled, so a refused gesture can't leave config claiming on
while nothing can ever arm.
Tests: per-half and both-missing hint assertions; existing requirements
tests pinned via _voice_loop_ready so they don't depend on the test
venv's installed voice stack. E2E: stt.enabled=false in a real config
-> unavailable with the speech-to-text hint.
Internal testing (macOS): clicking the ear froze the button for ~30s,
then it went blue but 'hey hermes' never fired even though STT worked.
Two distinct bugs:
1. Frozen-then-timeout button: first-use wake.start lazy-installs the
detection engine (onnxruntime is a large wheel), which blows the
desktop's default 30s WS request timeout — the RPC 'failed' client-
side while the backend kept installing and armed later on its own.
wake.start now gets a 180s budget and the pending state says
'arming — first use may take a minute while the engine installs'
instead of a silent disabled button.
2. Armed but deaf: macOS grants mic permission per PROCESS. The
renderer having mic access (STT working) does not grant the Python
backend anything — CoreAudio hands an unentitled process a 'working'
stream that delivers zeros forever, so the listener looks healthy
and can never hear the phrase. The detector now tracks frame peaks:
10s of consecutive near-zero frames sets audio_silent, logged with
the exact macOS Settings path, cleared automatically when audio
appears. Surfaced everywhere: wake.status (audio_silent + hint),
desktop ear tooltip (kept visible while listening), /wake status on
TUI (⚠ line) and classic CLI, plus a docs troubleshooting section.
Tests: detector silent-flag set/recover cycle (fake silent/loud
streams), desktop tooltip keeps the dead-mic hint while listening.
44 wake Python tests, 22 desktop + 14 TUI vitest green.
check_wake_word_requirements() gated 'available' on the audio probe,
but the probe imports sounddevice + numpy — two of the packages the
lazy installer would install. On a fresh machine deps_ok was False, so
audio_ok was always False and /wake on printed the manual pip hint and
bailed before the engine constructors' lazy_deps.ensure() could run.
Now the audio probe only runs once deps are installed; with deps
missing and lazy installs allowed (the default), /wake on proceeds and
ensure() installs the pinned engine deps in-process — no restart. The
manual pip hint remains for security.allow_lazy_installs=false, and a
mic hint still blocks when deps are present but no audio device works.
The CLI announces the one-time engine install so the pause is explained.
96-utterance TTS matrix (6 enrolled profile phrases x 4 voices/accents +
4 negative phrases x 4 voices) through the real engine: the old mapping
(default threshold 0.35) missed 3/24 positives; remapping so sensitivity
0.5 lands on sherpa's recommended 0.25 recovers 2 of 3 while keeping
0/16 false fires. Detection 23/24, routing accuracy 23/23.
One sherpa listener now enrolls every wake-enabled profile's phrase
(defaulting to "hey <profile name>") and reports WHICH phrase fired.
wake.detected gains a profile field; the desktop live-switches to the
matching profile (same path as the profile rail), opens a fresh session
there, and starts hands-free voice. The single-profile CLI/TUI print the
hermes -p switch command for foreign-profile phrases instead of
answering as the wrong profile. Opt out per listener with
wake_word.profile_routing: false.
New "sherpa" wake_word provider: the configured phrase is BPE-tokenized
at runtime against a small streaming zipformer KWS model (~13 MB English,
one-time download cached under HERMES_HOME), so ANY typed phrase works
with zero training — including per-profile phrases like "hey coder".
wake.sherpa lazy-dep group + [wake] extra grow sherpa-onnx/sentencepiece;
requirements probe routes per provider; sensitivity maps onto sherpa
keywords_threshold. E2E-verified on real audio (target phrase fires,
foreign phrase stays silent, reset drops buffered state).
From #53378: ships hey_hermes.onnx/.tflite (openWakeWord pipeline,
Apache-2.0) under tools/wakewords/, resolves the default (and hey_hermes
aliases) to the bundled file, ensures openWakeWord base feature models
are fetched for custom paths too, and updates config defaults + docs
from hey_jarvis to hey hermes.
Two bugs surfaced by the desktop wake conversation:
1. Runaway loop: wake -> voice -> resume -> wake fired again within
~200ms. openWakeWord keeps its rolling feature buffer across
pause/resume, so on resume it immediately re-scored the "hey jarvis"
captured before the pause and re-fired, reopening a session and
restarting voice in a tight cycle. Reset the engine buffer on every
detector (re)start so resume begins from clean audio.
2. Empty-transcript toast: a silent re-listen returns
success:false / "… STT returned empty transcript", which the desktop
transcribe endpoint turned into a 400 -> thrown error -> "Voice
transcription failed" notification on every silent gap. Treat an empty
transcript as no-speech: return {ok, transcript: ""} so the voice loop
quietly re-listens. Real failures still 4xx/5xx.
The detector logged listen/detect/close at debug, invisible at the
default level. Promote listen-start, phrase-detected, stream-closed, and
the wake.start outcome (disabled / unavailable / listening) to INFO, and
log wake.detected emission, so a non-triggering setup is diagnosable from
gateway/gui.log without flipping global log levels.
Makes the wake word a tri-surface feature with one configurable owner.
- wake_word.surface ("auto" | "cli" | "tui" | "gui") + shared
wake_surface_enabled() gate consulted by every surface, so exactly one
place owns the listener and the new session it opens.
- tui_gateway: wake.start/stop/pause/resume/status RPCs + a wake.detected
event, sharing one server-side detector for both TUI and desktop. The
detector yields the mic to voice.record (pause on capture start, resume
on terminal) and to the desktop's browser mic (wake.pause/resume).
- TUI (Ink): arm wake.start on gateway.ready; on wake.detected open a
fresh session and start voice capture.
- Desktop (Electron): arm wake.start on connect; on wake.detected open a
fresh session.
- CLI now gates on wake_surface_enabled("cli"); /wake status shows surface.
- Tests for the surface gate; docs cover the surface knob + cross-surface.
Adds an opt-in, on-device hotword listener for the CLI. With
wake_word.enabled (or /wake on), Hermes listens in the background for a
wake phrase; on detection it starts a fresh session, captures one
utterance through the existing voice pipeline, and answers — the
"Hey Siri" pattern.
- tools/wake_word.py: provider-pluggable detector (openWakeWord, free
local default; Porcupine, premium) over the shared 16 kHz sounddevice
capture path. Background daemon thread with pause/resume so it yields
the mic during a voice turn.
- CLI wiring: startup listener (off-thread), on-wake flow, an idle
watchdog that resumes the detector after each turn, cleanup hook, and
a /wake [on|off|status] command.
- config.yaml wake_word section; PORCUPINE_ACCESS_KEY as an optional
secret. Engines lazy-install via the [wake] extra.
- Hands a transcript to the input queue exactly like voice mode, so no
system-prompt/cache mutation. No new core model tool.
- Tests (mocked, no live audio/network) + feature docs.
Skip hashing active copies when the bundled origin is unchanged, build rename and optional migration indexes lazily, and reuse one optional-skill directory index per sync.
Add regression coverage for unchanged copies, deferred modification detection, lazy rename recovery, and optional provenance scans.
Figma's mcp.figma.com register endpoint is a client_name allowlist
(Claude Code / Codex succeed; Hermes Agent 403s) and returns a client
secret while advertising auth_method=none, then requires the secret on
token exchange. Auto-set client_name + client_secret_post for Figma
hosts, pass oauth cfg through login/add paths, force interactive OAuth
for hermes mcp login from non-TTY desktop shells, and ship a catalog
entry. Proven: hermes mcp login figma → 26 tools.
Saying EXACTLY a configured stop phrase (default: 'stop') and nothing
else now ends the voice conversation instead of being sent to the agent
as a prompt. Match is deliberately strict — whole utterance,
case-insensitive, surrounding punctuation stripped — so 'stop doing
that and try X' still reaches the agent.
- tools/voice_mode.py: is_voice_stop_phrase() + voice.stop_phrases
config loader (default ['stop'], [] disables, malformed config falls
back safely).
- hermes_cli/voice.py: shared continuous loop (TUI + desktop) halts on
a stop phrase exactly like the silent-cycle limit (fires
on_silent_limit so every UI turns voice off); stop_continuous
force-transcribe path swallows the phrase without counting a silent
cycle.
- cli.py: classic CLI push-to-talk/continuous path and barge-in
utterance path disable voice mode on a stop phrase.
- Config default voice.stop_phrases: ['stop']; docs updated.
Tests: tests/tools/test_voice_stop_phrase.py (27 tests,
sabotage-verified: loop test fails when detection is disabled);
voice suites green (110 + 44 passed).
Root-cause fix for the 'TTS voice bubble broken' issue family (#57048,
#54589, #57213, #58845, #14841, #45557, #57049). Two class-level defects:
1. Several backends silently write MP3/WAV bytes into a .ogg output path
(Edge only emits MP3, Piper writes WAV, xAI writes MP3, some
OpenAI-compatible servers ignore response_format=opus). Platforms that
need real Ogg/Opus render 0-second/broken voice bubbles. Instead of
per-provider patches, text_to_speech_tool now sniffs magic bytes once
after synthesis (_sniff_audio_container) and repairs the container
centrally (_repair_ogg_container): ffmpeg transcode in place, or rename
to the honest extension when ffmpeg is unavailable. Covers every
current and future provider, including command providers and plugins.
2. want_opus only recognized Telegram, so Matrix/Feishu/WhatsApp/Signal
auto-TTS voice replies were synthesized as MP3 and delivered as broken
attachments. New OPUS_VOICE_PLATFORMS set covers all voice-bubble
platforms.
_convert_to_opus refactored onto a shared _ffmpeg_transcode_to_opus that
supports safe in-place transcodes (-f ogg forced muxer, temp file +
os.replace).
Tests: tests/tools/test_tts_container_repair.py (13 tests incl. a live
ffmpeg round-trip); full TTS suite green (153 + 187 passed).
Class-level fix for the 'STT transcribes the wrong language' issue family
(#55551, #50181 and siblings). Previously language handling was per-provider
chaos: local honoured stt.local.language, Groq/OpenAI/Mistral/DeepInfra sent
no language hint at all, xAI silently forced 'en', ElevenLabs used its own
language_code key, and there was no global setting.
- New _resolve_stt_language() helper: stt.<provider>.language >
stt.language (new global key) > HERMES_LOCAL_STT_LANGUAGE > auto-detect.
- Threaded through ALL providers: local, local_command, groq, openai,
mistral, xai, elevenlabs, deepinfra (shared OpenAI handler), command
providers, and plugin dispatch.
- xAI no longer forces English when nothing is configured (auto-detect).
- Mistral Voxtral now receives a language hint when configured.
- stt.groq.model is now honoured from config (previously env-only).
- DEFAULT_CONFIG gains stt.language, stt.groq, stt.xai, stt.mistral.language.
- Tests: tests/tools/test_stt_language_resolution.py (11 tests, sabotage-
verified) + full transcription suite green (236 passed).
Builds on cherry-picked contributor work from #19786 (@zombopanda),
#23161 (@materemias), #50684 (@BlackishGreen33).
`stt.groq: null` in config.yaml yields `groq_cfg = None`, so the
subsequent `.get("language")` raised AttributeError. Use `or {}`
(matching main's widened xai/local provider guards) and add a
`{"groq": None}` regression test confirming auto-detect stays intact.
Addresses hermes-sweeper review on #23161.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zc7Tgei4kav4n6WSsBq5F
- Normalize stt.groq.language: cast to str, strip, treat
empty/whitespace as unset (parity with xAI's str().strip()).
- Clarify "blank = auto-detect" inline comments in 4 docs/configs to
reflect the env-var fallback (HERMES_LOCAL_STT_LANGUAGE).
- Document that HERMES_LOCAL_STT_LANGUAGE also drives the local
faster-whisper provider, not just the CLI fallback.
- Add unit tests covering: omitted language when unset, config-supplied
language, env fallback, config-over-env precedence, whitespace
normalized to unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read stt.groq.language from config.yaml (with HERMES_LOCAL_STT_LANGUAGE
env fallback) and forward it to the Groq Whisper API to skip
auto-detection on known-language audio. Omit when unset so Groq
auto-detects, preserving today's behavior. Bonus: swap xAI's hardcoded
env literal for the LOCAL_STT_LANGUAGE_ENV constant for consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add optional stt.openai.language config for OpenAI transcription. Forward non-empty hints to the API while preserving auto-detection when unset. Document the config-only setting, add its default, and cover configured and unset request arguments.
Direct tool HTTP calls already identified as Hermes-Agent, but the main
OpenAI-SDK chat path still sent OpenAI/Python. Set Hermes-Agent/<ver> for
api.x.ai clients (xai + xai-oauth) so normal text traffic is attributed correctly.
The read-only org mirror broke the learning loop precisely where it matters
most. The system prompt tells every agent to patch a skill the moment it
finds a gap, and shared skills are the ones the most people use — but every
write to _org/ was refused, and the curator was excluded from them outright.
So org skills froze while personal skills kept improving, and the offered
alternative ("fork it into a personal skill, then propose the fork") is not
something an agent does mid-task. The refusal WAS the feature; improvements
were simply lost, and manual forks would have fragmented the shared set.
Edit in place:
- skill_manage patch/edit/write_file now work on org skills. Only delete is
still refused (the mirror is a view of org HEAD — a local delete returns on
the next pull; removing a shared skill is an admin action).
- Org skills are curation-eligible again, so the curator can improve the
highest-leverage skills in the system instead of skipping them.
- The load-time provenance header now says edits are allowed and kept,
instead of instructing the agent not to edit.
Local edits are never overwritten:
- pull_org_skills previously rmtree'd each skill dir and re-materialized it,
silently destroying local work on the next session start. It now records a
content fingerprint per skill (.org-baseline.json) when it writes one, and
SKIPS any skill whose local content diverges from that baseline.
- When upstream ALSO changed such a skill, it is reported in the pull
result's "conflicted" list and left untouched for the user to resolve
deliberately (propose the local version, or delete it and re-pull to take
theirs). A missing baseline is treated as unmodified so pre-existing
mirrors do not raise phantom conflicts.
- Fingerprints are content-based (path + bytes, sorted), so a touch/mtime
change is not mistaken for an edit.
Sharing back:
- Default: the edit stays local and the tool result tells the user to run
"hermes skills propose <skill>".
- Opt-in sync.org_auto_propose / HERMES_SYNC_ORG_AUTO_PROPOSE submits each
edit immediately. Defaults OFF — pushing every agent edit to a whole
organisation is not a safe default. A failed submission never fails the
edit; the change is saved and can be proposed later.
- "hermes sync status" lists org skills with unshared local edits;
"hermes sync pull" reports conflicts it declined to overwrite.
Tests: 25 in the namespace suite (was 15). The two that asserted the old
read-only behaviour now assert the opposite. New coverage for edit-applied,
share-back guidance, delete-still-refused, curation-allowed, edit detection,
missing-baseline tolerance, mtime-insensitivity, and the auto-propose
default. 428 passed / 0 failed via scripts/run_tests.sh.
Verified through the REAL pull path against a mock plane: pull v1 -> edit in
place -> upstream ships v2 -> pull leaves the local edit intact, reports the
conflict, and surfaces it in status.
Tool-schema assembly at CLI/Desktop startup runs the browser-family
check_fns (browser, browser_cdp, browser_dialog, browser_vision). Each
of those gates called _get_cdp_override(), which resolves the configured
endpoint over HTTP (GET /json/version, timeout=10) — so a *stale*
browser.cdp_url pointing at a dead debug browser cost ~7 serial blocking
socket connects before the banner rendered. Measured on a real Windows
install with a dead http://[::1]:9222 config: 15.1s of an 18s launch,
with no warning or error — just mystery slowness. The value is easy to
leave behind: /browser connect writes a session-scoped env override, but
'hermes config set browser.cdp_url' persists forever while the debug
Chrome it pointed at dies on the next browser restart.
Split the helper:
- _get_cdp_override_raw() — returns the configured value (env var or
config.yaml) with zero network I/O. Used by every is-it-configured
gate: check_browser_requirements, _browser_cdp_check, _is_local_mode,
_is_local_backend, _navigation_session_key, _should_inject_engine
(via _is_local_mode), and the hermes doctor chromium-skip check.
- _get_cdp_override() — unchanged contract (raw + /json/version
resolution), now only called on paths that are about to connect:
session creation and the dialog-supervisor attach.
This follows the existing rule in check_browser_requirements ('do not
execute agent-browser --version here') and the browser.manage status
path, which already banned _get_cdp_override for exactly this reason
(test_browser_manage_status_does_not_call_get_cdp_override): schema
assembly must not perform blocking I/O.
A/B on the same machine, same dead endpoint: get_tool_definitions()
15.08s unpatched -> 1.89s patched, with browser_cdp/browser_dialog
still advertised (gate now keys off configuration, not reachability —
matching the documented lazy-supervisor contract in
_browser_dialog_check).
Adds a regression test asserting the browser_cdp check_fn never touches
the network.
pull_org_skills/propose_skill resolved and demanded HERMES_SYNC_BASE_URL
before using the caller-provided `client`, which already carries its own
base URL. Only resolve/require it on the path that actually constructs a
client.
Caught by scripts/run_tests.sh, which blanks env vars to match CI. Plain
`pytest` masked it: my shell had HERMES_SYNC_BASE_URL exported from live
testing, so the redundant check silently passed. Reproduced deliberately
with `env -u HERMES_SYNC_BASE_URL pytest` before fixing.
371 passed / 0 failed across the sync, skills, prompt, and skill-utils
suites via the canonical runner.
Two defects found by manual testing on the branch.
1. ORG SYNC NEVER RAN. The org pull/mirror/gating machinery was fully
implemented and unit-tested but had ZERO runtime callers —
maybe_pull_org_skills() was referenced only inside a comment, and
`hermes sync` had no org path at all. Every code path fell through to
personal sync (refs/user/<sub>/), so org skills never loaded and the
feature looked like 'everything syncs to my personal org' even with a
valid org token. The unit tests could not catch this: they invoked the
functions directly, which is exactly the gap they left open.
- cli.py session startup now calls maybe_pull_org_skills() alongside the
personal maybe_pull_skills(), fail-quiet.
- Auto-pull is gated on real org membership: resolve_org_identity()
requires an org role on the token, only issued for multi-member orgs,
so a solo account never reaches the network.
- `hermes sync pull` refreshes the org mirror too (one pull, both
surfaces) and reports what it refreshed.
- `hermes sync status` exposes org_available/org_id/org_role/org_skills
plus a plain-language summary, so a user can tell whether the org
workflow applies instead of it being invisible.
2. INTERNAL JARGON LEAKED TO USERS. Help text and errors exposed internal
milestone/spec coordinates: 'Propose a skill ... (M2)', 'Personal skill
sync (HSP/1)', 'DEV-PHASE gate closed: your token lacks
tool_gateway_admin', 'contract §4.3', and an inert message describing our
internal personal-vs-multi-member design split. All rewritten in user
language. Feature-local comments/docstrings lost their internal
coordinates (§N, M1/M2, design.md, PR numbers) while keeping the
explanatory prose. Pre-existing issue references elsewhere in the tree
were deliberately left untouched.
Tests: 4 new guards, including two that assert the CALL SITES exist so the
org pull cannot silently become dead code again (verified failing when the
wiring is removed) and one that fails if user-facing help leaks jargon.
344 passed across the sync/skills/prompt suites.
Verified against live staging with a real org token: sync status reports
org_available=true, org_role=OWNER; sync pull performs the org refresh; the
.active_org marker is written with the org id from the token.
Port from nearai/ironclaw#5149 (the describe-first live-hardening fix in
their progressive tool disclosure work): when a model invokes a deferred
tool through the tool_call bridge without the schema-required arguments,
return the tool's parameter schema instead of dispatching blind.
Pre-fix, a blind call produced an opaque downstream failure
("[TOOL_ERROR] Tool execution failed: KeyError: 'document_id'") that
teaches the model nothing about what the tool expects — IronClaw observed
cheap models looping ~30 identical invalid calls until the iteration
budget died. Post-fix, the model repairs the call in one round-trip.
- tools/tool_search.py: new validate_deferred_call_args() — key-absence
check of schema 'required' fields only; no type checking (coerce_tool_args
already repairs types downstream); fails open on any validator error so
it can never block a legitimate dispatch.
- model_tools.py: probe after the scope gate in the bridge dispatch.
- agent/tool_executor.py: probe in both unwrap sites (concurrent +
sequential) before the underlying tool replaces the bridge; sequential
path flattens the payload to match its {"error": str} wrapping.
- tests: TestDeferredCallSchemaProbe — blind call returns schema (not
KeyError), valid/optional calls dispatch, unvalidatable tools fail open,
out-of-scope rejection unchanged.
Port from openai/codex#33464: GNU rm permutes options, so
`rm build/ -rf`, `rm build/ -r -f`, and `rm build/ --recursive
--force` are equivalent to the flags-first spellings — but every
existing rm pattern required the flag group BEFORE the path, so these
spellings ran with no approval prompt at all (proven live on main).
The hardline floor was NOT affected: protected paths (/, system dirs,
$HOME) match regardless of flag position because the hardline path
matcher does not require flags. The gap was the approval-prompt layer
for arbitrary paths.
New DANGEROUS_PATTERNS entry with a tempered operand run: cannot cross
command separators (; | & newline), quotes, or a bare -- end-of-options
separator (after --, -rf is a literal filename). Flag token must be
whitespace-anchored so the r inside long options like --registry does
not count.
9 positive + 7 negative shapes in tests; approval cluster (868 tests)
green.
Port from anomalyco/opencode#38133/#38134 (patch Unicode matching corpus):
extend UNICODE_MAP with the Zs space-separator family (en/em quad, en/em/
three-per-em/four-per-em/six-per-em/figure/punctuation/thin/hair spaces,
narrow NBSP, medium mathematical space, CJK ideographic space) and the
Unicode minus sign U+2212.
Before: a file containing typographic spacing (French narrow NBSP, CJK
ideographic spaces, math minus) never matched a model's ASCII old_string
via the precise strategies — the edit only succeeded through the
similarity-based context_aware fallback, which (a) can pick the wrong
region (#54572 family) and (b) silently flattens the file's Unicode to
ASCII on replacement. After: these match at unicode_normalized (strategy
7), whose _preserve_unicode_in_replacement keeps the file's typographic
characters in unchanged spans.
All additions are 1:1 mappings, so the existing position-mapping and
preservation logic apply unchanged. Proven live before/after with a
multi-line probe; 59 fuzzy-match + 188 file-tools/patch/skill-manager
tests pass.
Inspired by Claude Code 2.1.214, which added permission prompts for
container-CLI commands (including the Podman shim) carrying
daemon-redirect flags (--url, --connection, --identity, remote mode)
that previously ran without one.
A daemon redirect makes a local-looking command operate on a different
(often remote) daemon, silently acting on production infrastructure.
Any container-CLI invocation carrying a redirect now requires approval
regardless of subcommand:
- -H/--host and --context global flags (value required, global-flag
position only — bare -h help and run-level -h <hostname> stay allowed)
- context use (persistently switches the default daemon)
- podman --url/--connection/--identity and -r/--remote
- DOCKER_HOST=/DOCKER_CONTEXT=/CONTAINER_HOST=/CONTAINER_CONNECTION=
environment prefixes
Sibling-site widening: the existing container lifecycle rules matched
only the verb directly adjacent to the binary name, so a global flag or
a compose -f file flag slipped past the guard, and the legacy hyphenated
compose binary was never covered. They now tolerate global flags — the
same treatment the 'hermes ... gateway' rule already has — and match the
hyphenated compose binary.
Validation: 33 new tests; 339 pass in test_approval.py + new file;
442 pass across the adjacent guard suites; E2E battery of 12 dangerous
+ 17 safe commands via real imports, hot path ~330us/call.
Root cause: delegate_task children run through three nested daemon-thread
layers (async-delegation executor -> per-child timeout executor -> the
interrupt worker interruptible_api_call spawns). After multi-day gateway
uptime the deepest layer wedges BEFORE the socket opens — the same
fingerprint as the gateway-cron hang (#62151): zero stale-detector output
(the worker never reaches dispatch), all providers, foreground/restart
works. The cron fix (should_use_direct_api_call) explicitly excluded
delegation 'for lack of evidence' — #60203 is that evidence.
- should_use_direct_api_call: extend the inline gate to delegated
children, detected via the delegation ContextVar set by
_run_single_child (platform='subagent' stamp as fallback). Scope
unchanged otherwise: chat_completions wire only; Codex/Anthropic/
Bedrock/MoA keep their established workers. Interrupts still work —
the inline path registers _active_request_abort, which interrupt()
invokes cross-thread (same mechanism the #72227 stall monitor uses).
- _dump_subagent_timeout_diagnostic: dump ALL thread stacks (bounded,
40), not just the conversation worker — a pre-HTTP wedge is
indistinguishable from a slow provider without seeing where the
nested helper threads sit.
Line-based grep on export -p only drops the first declare -x line, so a
newline in HERMES_SESSION_CHAT_NAME/USER_NAME leaves shell payload in the
shared snapshot and runs it on the next source. Unset bridged vars in a
subshell before export -p instead (issue #71296).
`hermes skills update` could silently replace a same-named skill from a
DIFFERENT registry — deleting the user's files and rewriting the lockfile's
recorded provenance. Two defects on main:
- tools/skills_hub.py: check_for_skill_updates fell back to *all* sources
(`... or sources`) when no adapter matched the recorded source, so any
registry with a same-named skill could satisfy the fetch and be reported
as an update — silently reassigning provenance. Now reports the entry as
`unavailable` instead of cross-registry fallback.
- hermes_cli/skills_hub.py: do_update called do_install with a bare, slash-
less identifier and no source constraint, letting _resolve_short_name fuzzy-
match a same-named skill in another registry. do_install now takes an
optional source_id pin that ABORTS (rather than falls back) when no adapter
matches, and do_update forwards the lockfile's recorded source as that pin.
Includes regression tests reproducing the cross-registry hijack.
Salvaged from PR #72216 onto current main; re-attributed to the human
contributor.
Co-authored-by: menhguin <menhguin@users.noreply.github.com>