Commit graph

1467 commits

Author SHA1 Message Date
Shannon Sands
bf7639138e Use read-only config loader and honor HERMES_IGNORE_USER_CONFIG in delegation config 2026-07-07 17:17:38 -07:00
Shannon Sands
0263f1d12e Fix delegation config precedence 2026-07-07 17:17:38 -07:00
teknium1
2d4fd1d52f test(mcp): unblock recycle-reconnect test from the parked self-probe wait
The salvaged test predates the parked-server self-probe
(_PARKED_RETRY_INTERVAL, landed on main after the PR branched): after the
final failed retry, run() parks in a real asyncio.wait that the patched
asyncio.sleep doesn't cover, stalling the test 300s. Signal shutdown once
the retry budget is exhausted so the park exits immediately.
2026-07-07 15:16:00 -07:00
harjoth
ea0b42c43a Handle minimal MCP server fakes 2026-07-07 15:16:00 -07:00
harjoth
6c731fe591 Recycle idle MCP stdio servers 2026-07-07 15:16:00 -07:00
Sage
5089c84dbf fix(mcp): reap orphaned stdio MCP children on ungraceful parent death
A stdio MCP server (e.g. `npx -y mcp-remote <url>`) is spawned as a direct
child of the Hermes process. Existing teardown (MCPServerTask.shutdown() /
_kill_orphaned_mcp_children()) reaps it correctly on a clean exit, but a
kill -9 / crash / force-quit of the Hermes process skips that path entirely
-- the child (and its own descendants, e.g. mcp-remote's spawned node
process) is orphaned and keeps running. Repeated ungraceful restarts pile up
N orphaned processes racing to hold the same upstream SSE session, producing
errors like 'Invalid request parameters' on legitimate reconnects.

macOS/Linux have no portable equivalent of prctl(PR_SET_PDEATHSIG) at the
Python subprocess level, so this adds a thin supervisor
(tools/mcp_stdio_watchdog.py) that:
  - execs the real command as its own child in its own process group
  - passes stdin/stdout/stderr through untouched (MCP stdio protocol
    talks directly over those streams)
  - polls the original spawning PID with the same orphan-detection
    algorithm already proven in tui_gateway/slash_worker.py (ppid
    comparison + psutil creation-time guard against PID reuse)
  - SIGTERM-then-SIGKILL's the child's process group the moment the
    original parent is gone

Wired into _run_stdio via a new _wrap_command_with_watchdog() helper,
POSIX-only (matches the existing killpg-based cleanup's platform scope),
fails open (any error resolving pid/create-time falls back to the
unwrapped command) so this can never be the reason a working MCP server
stops starting.

Verified: reproduced the exact orphan scenario standalone (fake parent
process spawns watchdog + fake long-running MCP child, kill -9 the fake
parent, confirm the watchdog reaps the child within its poll window with
zero leaked processes). Updated test_mcp_tool_issue_948.py's resolved-path
assertion to check the watchdog-wrapped command instead of the raw
resolved binary. Full test_mcp_tool.py + test_mcp_stability.py +
test_mcp_tool_issue_948.py suite: 232 passed. Full -k mcp sweep across the
whole test tree: 1003 passed, 2 skipped, 0 failed.
2026-07-07 15:16:00 -07:00
rainbowgits
1f6836cd81 fix(mcp): bound stdio initialize handshake to stop subprocess/FD leak
A stdio MCP server that never completes `initialize` (e.g. emits a
non-JSON-RPC frame and then blocks on stdin) leaks a child process plus its
stdio pipes/pidfd on every discovery-retry cycle — unbounded, until the
gateway hits EMFILE and every new open()/spawn fails (#59349).

Root cause (confirmed by instrumenting the live repro, and different from the
issue's own hypothesis): the spawned child IS captured in `new_pids`, so the
report's "new_pids empty at finally" guess is not it. The real cause is that
`session.initialize()` hangs forever on the garbage stream. `connect_timeout`
only bounds the caller's `.result()` wait on the foreground thread — it does
NOT cancel the `_run_stdio` coroutine on the background MCP loop. So the
coroutine is stuck at `await session.initialize()` permanently, its cleanup
`finally` never runs, the child is never reaped, and it stays invisible to the
orphan-reaper (whose `_orphan_stdio_pids` set never gets populated).

Fix: wrap `session.initialize()` in `asyncio.wait_for(..., connect_timeout)`
so a stalled handshake fails instead of hanging. The TimeoutError unwinds
through the SDK context managers (closing the child's stdin -> EOF -> exit)
and lets the existing `finally` reap any straggler. Cross-platform — no
signals/pgid/proc.

Scope: stdio only. The HTTP path has the same `await session.initialize()`
shape but spawns no subprocess (so it can't cause this leak) and already has
httpx transport timeouts.

Verified: the reporter's repro goes from unbounded growth to draining to zero;
added a hermetic regression test (fake transport whose `initialize()` hangs,
asserts the connect is bounded by connect_timeout) that fails on the pre-fix
code and passes on the fix; 566 existing MCP tests pass; ruff clean.

Repro confirmed on macOS (pipe FDs); the Linux-specific pidfd growth in the
report should be equivalent — the reporter offered to validate on Linux.

Closes #59349
2026-07-07 15:16:00 -07:00
yoma
f99e9f0d27 fix(mcp): reap stdio orphans before reconnect 2026-07-07 15:16:00 -07:00
liuhao1024
086596ca2b fix(mcp): reap orphaned subprocesses before spawning new ones on retry
When an MCP stdio subprocess fails to connect (token expiry, port
contention, timeout), the run() reconnect loop retries with backoff.
Each retry calls _run_stdio() which spawns a new process pair, but the
previous failed pair was only detected as orphaned (added to
_orphan_stdio_pids) — never actually killed.  This caused rapid zombie
accumulation: 5 failed attempts × 2 procs each = 10 orphans competing
for the same port.

Add a _kill_orphaned_mcp_children() call at the top of _run_stdio(),
before the _snapshot_child_pids() baseline, so any orphans from prior
failed attempts are reaped before a new subprocess is spawned.

Fixes #57355
2026-07-07 15:16:00 -07:00
doncazper
36308f0667 feat(plugins): pass approve rule keys to approval gate 2026-07-07 15:14:30 -07:00
Eugeniusz Gilewski
a1e6ea7d71 fix(tools): keep shell snapshots owner-only
BaseEnvironment writes shell snapshots and cwd metadata through the process
umask. With a common 022 umask, snapshot files containing exported environment
state landed at mode 0644 even though they can include env-carried credentials
from the parent process.

Set umask 077 only around Hermes metadata writes: the initial snapshot
bootstrap and the post-command snapshot/cwd refresh. User commands still run
under the caller's original umask, while Hermes-owned snapshot and cwd files
are created owner-only.

This intentionally does not copy the source PR's global orphan sweep; deleting
all matching /tmp snapshot files could interfere with concurrent Hermes
processes. The security-critical local disclosure fix is the file mode clamp.

This is salvageable because the source report still identifies a concrete
credential-disclosure path, but the safe subset is smaller than the original
proposal: clamp only the Hermes-owned snapshot writes and leave process-wide
cleanup, user command umask, and concurrent sessions alone.

Salvages source PR: https://github.com/NousResearch/hermes-agent/pull/20056
Related issue: https://github.com/NousResearch/hermes-agent/issues/48441

Co-authored-by: Andrew Homeyer <andrew@hndl.app>
2026-07-07 05:22:42 -07:00
JP Lew
f8723c4781 fix(skills): resolve skills dir from active profile 2026-07-07 05:14:00 -07:00
teknium1
ce038a0e05 fix(schema): preserve multi-type arrays as anyOf instead of dropping branches
Port from anomalyco/opencode#31877: JSON Schema type arrays like
["number","string"] (common in MCP tool schemas) were collapsed to the
first non-null type, silently dropping every other branch. Several
tool-call backends reject the array form outright — llama.cpp's grammar
generator and Gemini via OpenAI-compatible transports (e.g. GitHub
Copilot proxying to Gemini) 400 on it.

_sanitize_node now mirrors @ai-sdk/google: a single non-null type stays
type:X (+nullable if null was present), multiple non-null types become
an anyOf of single-type schemas so no branch is lost, and an all-null
array becomes type:null. Single-null collapse is unchanged.

Verified nested (object props, array items) survive the full sanitize
pipeline — combinator stripping is top-level-only and nullable-union
collapse only fires on single-survivor unions, so multi-type anyOf is
left intact.
2026-07-07 02:52:17 -07:00
ooiuuii
e0bca1cbe2 fix(discord): bound standalone response reads 2026-07-07 02:40:15 -07:00
ooiuuii
b8ce583e05 fix(discord): bound REST response reads
Refs NousResearch/hermes-agent#54745
2026-07-07 02:40:04 -07:00
Teknium
8fc1cb754b
fix: repair URL authority whitespace before web fetches (#46363)
Port from openclaw/openclaw#91950: normalize LLM-generated URLs like 'https:// docs.example' before web tool safety checks while preserving path and query encoding semantics.
2026-07-07 02:39:36 -07:00
Teknium
07d93413e5
fix: default memory null target to memory store (#46356)
Port from nearai/ironclaw#4547: treat a JSON null memory target as omitted so strict providers that fill optional fields with null use the documented default target instead of failing validation.
2026-07-07 02:10:43 -07:00
Ben Barclay
536ffedbf4
feat(docker): re-seed a terminally-dead Nous bootstrap session on boot (#59983)
The stage2-hook auth.json seed is first-boot-only ([ ! -f auth.json ]) to avoid
clobbering rotated refresh tokens on restart. That guard means a container whose
Nous bootstrap session took a terminal invalid_grant (tokens cleared,
providers.nous.last_auth_error.relogin_required stamped) cannot recover from a
restart — it stays unauthenticated until the credential is replaced.

Add a self-heal path: an orchestrator that manages the container supplies a
freshly-issued session via HERMES_AUTH_JSON_REBOOTSTRAP (distinct from the
create-only *_BOOTSTRAP var). On boot, scripts/docker_rebootstrap_nous_session.py
swaps ONLY the providers.nous entry, and ONLY when the on-disk entry is provably
terminal (quarantine marker + no usable tokens). Healthy/rotating/absent/
unparseable auth.json is always a no-op, so the env is safe to leave set across
restarts and never clobbers a good token. Pure stdlib, runs as its own
subprocess, always exits 0 so a re-seed error never fails the boot.

Reuses the same terminal predicate as get_nous_session_validity() so we re-seed
only a session that is genuinely dead.
2026-07-07 06:57:23 +00:00
hellno
d7348bf24b fix(interrupt): run user-approved commands from a clean interrupt slate
A user-approved terminal/execute_code command could be SIGINT-killed
(exit 130 + "[Command interrupted]") by a stale interrupt bit that landed
on the execution thread during the blocking approval-wait, while the
result still carried the "...approved by the user." note. The terminal
tool runs sequentially inline on the execution thread, and nothing
cleared or re-checked the bit between approval-grant and env.execute.

Clear the current thread's interrupt bit once before an approved command
spawns its child (terminal foreground; execute_code local + remote), and
enrich the note to "...approved by the user, then interrupted." on a
genuine post-start interrupt instead of implying success. A genuine
interrupt arriving after execution starts (or during a retry backoff)
still SIGINTs the command; non-approved commands keep current behavior.

Adds regression tests covering stale-bit-clears, genuine-interrupt-still-
kills, the retry-backoff window, natural-exit-130 (not mislabeled), and
execute_code local + remote.
2026-07-06 04:58:42 -07:00
Teknium
27f74b26c5
fix(web): correct 'disabled plugin' diagnosis for web backends (#59573)
When a bundled web provider (firecrawl, tavily, exa, ...) is listed in
plugins.disabled, its provider never registers and the web_search/
web_extract dispatchers emitted the misleading "No web extract provider
configured. Set web.extract_backend to ..." — even though the backend was
configured correctly. The real fix is to re-enable the plugin.

- web_tools.py + web_search_registry.py: when the configured backend names
  a disabled bundled web plugin, both dispatchers now point the user at the
  actual cause (re-enable the plugin) instead of a wrong config hint.
- plugins_cmd.py cmd_enable: enabling by canonical key now also clears the
  manifest-name alias (web-firecrawl) from plugins.disabled, so the
  suggested command actually re-enables the plugin ('explicit disable wins'
  matches on the name too).
- plugins_cmd.py cmd_toggle / _run_composite_ui / _run_composite_fallback:
  the interactive 'hermes plugins' menu now persists the canonical key
  (web/firecrawl), never the bare manifest name — the drift that put the
  offending entry in plugins.disabled in the first place.

Follow-up to #59518 (which fixed web credential resolution, a different
cause). Fixes the disabled-plugin symptom reported after that PR.
2026-07-06 04:38:17 -07:00
Hermes Agent
1a2885535b fix(web): widen config-aware env resolution to exa/parallel/tavily/brave-free providers
Same bug class as #40190: these providers read credentials via bare
os.getenv(), so keys stored in ~/.hermes/.env (hermes config layer)
were invisible in execution paths that never exported them into the
process environment. Add get_provider_env() on the WebSearchProvider
module as the shared config-aware lookup (get_env_value with os.getenv
fallback) and route all credential reads through it. SearXNG already
did this (#34290); Firecrawl fixed in the preceding cherry-picked
commit by @liuhao1024.
2026-07-06 02:42:24 -07:00
liuhao1024
026ab4737d fix(web): use get_env_value for Firecrawl config resolution
The Firecrawl provider used os.getenv() to read FIRECRAWL_API_KEY and
FIRECRAWL_API_URL, which only checks the process environment.  When
values are supplied through Hermes's ~/.hermes/.env config mechanism
(via hermes_cli.config.get_env_value), they are not guaranteed to be
present in os.environ for every gateway/tool execution path.

Switch to get_env_value() which checks both os.environ and the .env
file, matching the pattern used by other providers (nous_subscription,
setup, discord adapter).

Fixes #40190
2026-07-06 02:42:24 -07:00
Frowtek
a88e0fd2ab fix(file-sync): re-deliver deferred Ctrl+C via raise_signal, not os.kill (Windows hard-kill)
_sync_back_once defers a SIGINT that lands mid-sync, then re-delivers it once the
sync completes so the user's Ctrl+C isn't lost. It did so with
os.kill(os.getpid(), signal.SIGINT). That is not graceful on Windows: os.kill
only treats CTRL_C_EVENT(0)/CTRL_BREAK_EVENT(1) as console events; any other
value (SIGINT == 2) routes to TerminateProcess(sig), so a Ctrl+C during a
remote-backend (ssh/daytona/modal) sync-back hard-kills the whole CLI session
(exit code 2) on Windows instead of raising KeyboardInterrupt.

Use signal.raise_signal(signal.SIGINT) (3.8+), which invokes the restored
handler through C raise() on every platform. Verified on Windows: raise_signal
runs the handler (graceful) while os.kill(getpid, SIGINT) TerminateProcess-es
the process. Adds a cross-platform regression test that runs on Windows too (it
stubs the locked sync body, so unlike test_file_sync_back.py it needs no fcntl).
2026-07-06 01:55:58 -07:00
Ben
590a19332e fix(skills): don't request Brotli for the centralized skills index
The Skills Hub 'Browse Hub' landing page and index-backed search render
empty on fresh deployments (e.g. Fly.io VPS agents) with no stale cache.

Root cause: the centralized index at /docs/api/skills-index.json is a
large body (~34MB, tens of MB compressed) served with Content-Encoding:
br. httpx's streaming Brotli decoder — backed by brotlicffi 1.2.0.1,
which is pinned so aiohttp can decode Discord attachments — trips over
its own output_buffer_limit on a payload this size and raises:

  DecodingError("brotli: decoder process called with data when
  'can_accept_more_data()' is False")

_load_hermes_index() catches that (DecodingError is an httpx.HTTPError
subclass) and silently falls back to the on-disk cache. On a fresh box
that cache never existed, so HermesIndexSource.is_available is False,
the index contributes 0 skills, and the hub landing page — which is
built solely from an empty-query index search — is blank. Existing
installs only appear to work because they serve a (possibly weeks-)stale
cached index instead.

Fix: request 'gzip, deflate' on the index fetch so httpx never
negotiates the broken Brotli path, and retry once with 'identity' if a
DecodingError still occurs (defends against a proxy that ignores the
header). Falls through to the stale cache only when both attempts fail.

Verified on a live staging VPS agent: index_available flips False->True
and the featured landing list repopulates from 0 to 12.

Also un-freezes already-deployed images: skills added after an image was
built (e.g. the 'unbroker' optional skill) become reachable again via
the index, which is the whole point of the centralized catalog.
2026-07-05 22:21:05 -07:00
teknium1
43a4256320 fix(mcp): wake stale cached servers on session startup + AUTHOR_MAP
register_mcp_servers now nudges cached entries whose session is None
via _signal_reconnect, so a new agent session recovers a parked server
immediately instead of waiting up to _PARKED_RETRY_INTERVAL for the
next self-probe (#50170). Gate-check idea credit: @izumi0uu (#50184),
@LeonSGP43 (#37772), @Tranquil-Flow (#37899).
2026-07-05 21:48:36 -07:00
teknium1
6f5573c524 test(mcp): make circuit-breaker reconnect stub survive a None session
The dead-session half-open test drives _signal_reconnect with
session=None; the salvaged _ReconnectAdapter assumed a live old
session. Also count set() calls explicitly instead of relying on
MagicMock introspection.
2026-07-05 21:48:36 -07:00
Marin Pesa
27beeb1830 fix: reconnect stale MCP sessions before retry 2026-07-05 21:48:36 -07:00
Teknium
a124d16764
perf: cut first-turn time-to-first-token by ~80% (all platforms) (#59332)
Four independent pre-request stalls sat on the critical path between
prompt submission and the first streamed token, measured with cProfile
against a live process:

1. Discord capability detection (~2.0s, worst 5s): get_tool_definitions
   -> _get_dynamic_schema made a BLOCKING https call to discord.com
   inside AIAgent.__init__ for any user with DISCORD_BOT_TOKEN set, on
   every platform, every cold process. Now non-blocking: memory cache ->
   24h disk cache -> permissive default + one background detection that
   seeds the disk cache for the next process. The permissive default is
   pinned per-process so tool schemas never flip mid-conversation
   (prompt-cache safety); it mirrors the existing detection-failure
   fallback (all actions exposed, 403s enriched at call time).

2. Ollama /api/show probe (~0.3s): get_model_context_length step 5e
   POSTed to <base_url>/api/show for KNOWN providers (openrouter etc.),
   got a 404, and never cached the miss - so every fresh process paid a
   full HTTP round-trip. Known non-Ollama providers now skip the probe;
   local/custom/unknown endpoints keep the exact previous behavior.

3. env_probe subprocess sweep (~0.5s): the Python-toolchain probe ran
   4-8 subprocess calls inside the FIRST system prompt build. Now warmed
   off-thread during agent init; the prompt build hits the cache (same
   lock, so a mid-flight warm just joins instead of recomputing).

4. tools.mcp_tool import (~0.4s): the between-turns MCP refresh in
   build_turn_context imported the whole mcp package even with zero MCP
   servers configured. MCP tools can only exist if tools.mcp_tool was
   already imported (discovery/reload paths), so gate the import on
   sys.modules membership - no behavior change for MCP users.

CLI additionally pre-imports run_agent + openai off-thread during the
idle banner window (same pattern as the /model picker prewarm), hiding
the remaining ~1.5s of module imports while the user types. Fixes 1-4
apply to every interaction layer (CLI, gateway, TUI, desktop, cron).

Measured cold first turn (submit -> request dispatched, openrouter,
discord token set): 4.3s before -> 0.9s after CLI prewarm (~80%); the
agent-side non-import cost drops 2.9s -> 0.36s (init) + 0.27s (turn
prologue).
2026-07-05 21:37:33 -07:00
teknium1
e8b0e38a2e docs+test(mcp): document skip_preflight and cover the bypass with a test
Docs harvested from PR #56251 by @huangdihd (duplicate of #55203,
submitted two days later, better documented). Test added by us.
2026-07-05 21:36:19 -07:00
Guillaume Nodet
32c1c47eef fix(mcp): add POST probe fallback in preflight content-type check
Some MCP servers (e.g. DocuSeal) serve their web UI on HEAD/GET but
speak Streamable HTTP only via POST.  The preflight probe now tries a
lightweight JSON-RPC `initialize` POST before rejecting endpoints
whose HEAD/GET returns a non-MCP content type (e.g. `text/html`).

If the POST returns `application/json` or `text/event-stream` with a
2xx status, the endpoint is accepted.  Otherwise the original rejection
behaviour is preserved.

Adds 5 new test cases covering the POST probe path:
- POST rescues HTML HEAD with JSON response
- POST rescues HTML HEAD with event-stream response
- POST still rejects when it also returns HTML
- POST still rejects on non-2xx status
- POST not attempted when HEAD already returns valid MCP content type
2026-07-05 21:36:19 -07:00
teknium1
6d359e0681 test(mcp): initial-connect exhaustion now parks — update awaiting tests
Two pre-existing tests awaited run() to return after initial-connect
retry exhaustion; with #57477's parking that await hangs (CI: 300s
SIGKILL on slices 4 and 6). Assert the new contract instead: the task
stays alive (parked) and exits on shutdown.
2026-07-05 19:10:31 -07:00
teknium1
b80b0b682a test(mcp): parked server self-probe revival + AUTHOR_MAP for #54139 salvage 2026-07-05 19:10:31 -07:00
yoma
2ea03d8c6b fix(mcp): park after initial connect failures 2026-07-05 19:10:31 -07:00
liuhao1024
e334700809 fix(mcp): reset reconnect retry counter after successful session establishment
The local retries variable in MCPServerTask.run() accumulated across
transient disconnections — each transport exception incremented it, but
only clean transport returns (auth recovery / manual refresh) or
park-wake reset it. Five transient blips over a long-uptime gateway
would permanently park the MCP server.

Promote retries to instance attribute _reconnect_retries and reset it
at all 4 session-establishment sites in _run_stdio / _run_http, so only
consecutive failures without successful reconnection count toward the
parking budget.

Fixes #57604
2026-07-05 19:10:31 -07:00
Teknium
e2fe529efb
feat(approvals): user-defined deny rules that block commands even under yolo (#59164)
Adds approvals.deny to config.yaml — a list of fnmatch globs matched
against terminal commands. A match blocks unconditionally, BEFORE the
--yolo / /yolo / approvals.mode=off bypass, making it the user-editable
counterpart to the code-shipped hardline blocklist.

- Checked in both command gates (check_dangerous_command and
  check_all_command_guards), after the hardline floor and sudo-stdin
  guard, before the yolo bypass and permanent allowlist.
- Matching runs over the same normalized/deobfuscated command variants
  as the dangerous-pattern detector, case-insensitive.
- Opt-in: empty/absent list is a no-op; behavior unchanged.

Supersedes the trust-engine approach from #21500 with a minimal
config-native design: the only capability the existing stack lacked
was deny-that-beats-yolo. Allow already exists (command_allowlist),
ask already exists (session approvals).
2026-07-05 14:48:40 -07:00
teknium1
f514132ff3 fix: disclose mid-line clamp in truncation hint
When a single line exceeds the entire char budget, its tail is
unreachable via offset pagination (offsets are line-granular). Tell
the model so it doesn't assume it saw the full line.
2026-07-05 14:42:05 -07:00
teknium1
25f0cecf5e Port from nearai/ironclaw#5029: graceful char-budget truncation for read_file
read_file previously hard-rejected any read whose formatted output exceeded
the ~100K char safety limit, returning an error with zero content. A file
with few but very long lines (logs, wide CSV rows, minified data) sails past
the line-count limit and then trips the char guard, so the model gets nothing
and must guess a smaller limit — wasting a full round-trip.

Now the read is trimmed to the last complete line that fits the budget and
returns the partial content plus truncated_by="bytes" and a next_offset, so
the model paginates forward instead of starting over. A single line larger
than the whole budget is clamped on a code-point boundary (never empty) and
the cursor still advances. Applies at both read paths (normal + extracted
documents).

Adapted from IronClaw's Rust dual line/byte cap to hermes's Python tool-layer
char guard, which is the single uniform chokepoint over the gutter-rendered
content for every backend.
2026-07-05 14:42:05 -07:00
teknium1
3167dbaee2 fix(docker): widen docker_network to file/code-exec paths + guard container reuse
Follow-up to the salvaged toggle commit:

- file_tools.py / code_execution_tool.py: carry docker_network in their
  container_config dicts so those environment-creation paths honor the
  lockdown instead of silently defaulting back to bridge (the probe/exec
  asymmetry class reported on #46358).
- docker.py: cross-process reuse now inspects HostConfig.NetworkMode when
  docker_network=false and removes a mismatched (networked) container
  before starting a fresh air-gapped one. Fails closed when inspect fails.
  Default-network config never churns containers, so operators using
  docker_extra_args --network=none are unaffected.
- tests: AST invariant that every container_config site carrying
  docker_run_as_host_user also carries docker_network, plus three reuse
  guard tests (reject bridge under lockdown / keep matching none /
  no inspect when network enabled).
- docs: configuration.md gains terminal.docker_network + env var row.
2026-07-05 14:41:51 -07:00
Teknium
cd2b360d64 feat: add Docker terminal network toggle
Port from qwibitai/nanoclaw#2713: expose Hermes' existing Docker network isolation primitive through terminal config so operators can opt out of container egress.
2026-07-05 14:41:51 -07:00
kshitijk4poor
74cc9ee3f0 Revert "Merge pull request #58698 from kshitijk4poor/feat/pre-tool-call-approve-escalation"
This reverts commit 368e5f197e, reversing
changes made to abf9638f4e.
2026-07-06 02:36:52 +05:30
Teknium
eab208db70
feat(hooks): spill oversized hook-injected context to disk (#20468)
Port from openai/codex#21069 ("Spill large hook outputs from context").

Both shell hooks and Python plugins can return {"context": "..."} from
pre_llm_call, which gets appended to the current turn's user message on
every subsequent API call. A plugin that emits a large blob inflates
every turn and blows out the prompt cache prefix.

- tools/hook_output_spill.py: shared helper that writes oversized
  context to $HERMES_HOME/hook_outputs/<session_id>/<uuid>.txt and
  returns a head/tail preview plus the saved path. Never raises.
- agent/turn_context.py: apply the cap at the pre_llm_call aggregation
  site (moved here from run_agent.py since the original PR), covering
  both Python plugins and shell hooks.
- agent/shell_hooks.py: reserve output_spill as a sub-key under hooks:
  so the config block doesn't emit unknown-hook-event warnings.
- Docs: document the cap + config in build-a-hermes-plugin.md.

Config (behaviour-preserving when absent):
  hooks.output_spill: enabled/max_chars/preview_head/preview_tail/directory

Tests: 14 unit tests; shell_hooks (56) and plugins (100) suites green.
E2E validated with isolated HERMES_HOME (spill, passthrough, traversal
sanitisation, reserved-key skip).
2026-07-05 13:51:26 -07:00
Teknium
55e3ee1ab8
fix: remove dead f-string prefixes via ruff F541 (216 sites) (#52336)
ruff check --fix --select F541 . on current main. Pure prefix removals;
adjacent-string concatenations keep the f only on interpolating fragments.
No string content or live placeholder altered.
2026-07-05 13:42:46 -07:00
teknium1
e01f58ff1f feat(mcp): adopt mcp__server__tool naming convention
Port from anomalyco/opencode#33533. Native MCP tools now register as
mcp__<server>__<tool> (double-underscore delimiter) instead of
mcp_<server>_<tool>, aligning with the convention used by Claude Code,
Codex, and OpenCode.

The double-underscore delimiter disambiguates the server/tool boundary
even when either component contains underscores (the single-underscore
form was ambiguous, which is why is_mcp_tool_parallel_safe already had to
track provenance in a side-map). It also unifies native registration with
the Anthropic-OAuth wire form (_MCP_TOOL_PREFIX = 'mcp__'), so the
single->double promotion that path performed is now a no-op for native
tools while still handling legacy replayed names.

- tools/mcp_tool.py: add MCP_TOOL_NAME_PREFIX + mcp_prefixed_tool_name()
  helper; route _convert_mcp_schema, utility schemas, refresh stale-set,
  and the parallel-safe prefix gate through it
- agent/transports/codex_event_projector.py: mirror convention in the
  deterministic call_id input for MCP server-executed tool calls
- tests: update produced-name assertions to the new convention
2026-07-05 13:40:21 -07:00
kshitij
368e5f197e
Merge pull request #58698 from kshitijk4poor/feat/pre-tool-call-approve-escalation
feat(plugins): pre_tool_call approve action escalates to human gate (closes #51221)
2026-07-05 22:04:23 +05:30
Adrian Lastra
519ec7b3b3 fix(computer_use): parse (label) and = "value" AX element label forms
The SOM/AX element list dropped labels for two extremely common cua-driver
render forms, leaving the model unable to target elements by name:
  - [79] AXButton (Dark)              -> parenthesised label
  - [4]  AXStaticText = "Wi-Fi"       -> = "value" form
  - [92] AXPopUpButton = "Automatic"  -> = "value" form
The old regex only matched quoted "label" and id=Label, so System Settings
buttons/text/popups all surfaced with empty labels. That's why selecting the
macOS Appearance 'Dark' button by element index required guessing — the
labels weren't available to aim with.

Fix: extend _ELEMENT_LINE_RE to capture all four label forms (= "value",
"quoted", (parenthesised), id=Label), skipping a pure-digit (N) order number
in favour of the id= label. Verified live against System Settings: the
Appearance buttons now surface as Auto/Light/Dark.

Adds a regression test covering all label forms. Full suite: 84 passed.
2026-07-05 05:36:31 -07:00
Adrian Lastra
13b75e73ff fix(computer_use): re-fetch via CLI when MCP returns silent-empty captures
The first fix handled the EAGAIN McpError path. But the persistent MCP
session (long-running gateway/desktop worker) has a second failure mode:
list_windows or get_window_state 'succeed' over MCP yet return a
degenerate/empty payload (no windows, or no screenshot + blank tree)
WITHOUT raising — typically when the bridge reconnected mid-call and
dropped the heavy response. That surfaced to the model as a silent 0x0
capture with no error and no fallback firing (0.00s empty return).

Fix: detect empty results in capture() and re-fetch over the CLI
transport before giving up:
  - empty list_windows -> CLI re-fetch the window list
  - empty get_window_state (som/ax) -> CLI re-fetch the AX tree + screenshot
  - empty screenshot (vision) -> CLI re-fetch get_window_state for the PNG

Adds 2 regression tests. Full suite: 83 passed.
2026-07-05 05:36:31 -07:00
Adrian Lastra
7af9abd174 fix(computer_use): fall back to CLI transport when cua-driver MCP bridge hits EAGAIN
The cua-driver MCP stdio bridge intermittently (and on some machines
persistently) fails to forward heavier calls like get_window_state to
the daemon with POSIX EAGAIN — 'daemon transport error forwarding
get_window_state: Resource temporarily unavailable (os error 35)'.
The wrapper surfaced this as an empty 0x0 capture, so computer_use
returned blank screenshots even though the display, permissions, and
the daemon were all healthy (the direct 'cua-driver call' CLI path
worked fine throughout).

Fix: when the MCP path raises the transient/transport error, fall back
to the 'cua-driver call' subprocess transport, which talks to the
daemon over a different socket. The CLI fallback routes get_window_state
screenshots to a temp file via screenshot_out_file (tiny JSON response
instead of a multi-MB base64 blob that congests the socket), reads the
PNG back, retries with backoff, and remaps the JSON into the same
{data, images, structuredContent, isError} shape the MCP path produces
so capture()/_action() are transport-agnostic.

Adds _is_transient_daemon_error() classifier and 3 regression tests.
Verified live: captures that returned 0x0 now return full
1567x905 screenshots with the AX element tree.
2026-07-05 05:36:31 -07:00
Teknium
de4310c8f6
fix(computer-use): report the wedged startup phase in the session ready-timeout error (#58801)
The 'never reached ready' error (issue #57025) was undiagnosable — doctor
and MCP test pass while the wrapper times out, with no hint where startup
stalled. Track a phase marker through _lifecycle_coro (binary-check →
manifest-discovery → mcp-initialize → capability-discovery → ready) and
include it in the timeout RuntimeError plus a pointer to doctor and the
agent.log phase timings.

Complements the 15s→30s bump + success-path phase timing log from #58760.
2026-07-05 05:36:09 -07:00
Teknium
ebfc49c4d9
fix(approval): require exact ./.. segments in the root-collapse hardline token (#56179)
Follow-up to #56236: the broadened root token /[/.]*\** treats any run of
dots after the root slash as a collapse spelling, so a literal root-level
directory named '...' (rm -rf /...) was unconditionally hardline-blocked
with no approval path. Tighten the token to /(?:(?:\.\.?)?/)*(?:\.\.?)?\**
so each inter-slash segment must be exactly '.' or '..' — all real collapse
spellings (//, /., /./, /.., //*, ///, /../..) stay on the hardline floor
while literal dot-run dirs fall through to the softer DANGEROUS_PATTERNS
rules like every other real path.
2026-07-05 02:24:00 -07:00
Teknium
cb6c47af08
feat(approvals): /deny <reason> relays denial reason to the agent (port nanoclaw#2832) (#54518)
* feat(approvals): /deny <reason> relays denial reason to the agent

Port from qwibitai/nanoclaw#2832 (reject with reason).

Gateway /deny now accepts an optional trailing reason (/deny <reason>
or /deny all <reason>). The reason rides on the per-session approval
entry through resolve_gateway_approval -> _await_gateway_decision and is
appended to the BLOCKED tool result the agent receives, so a declined
agent can adapt instead of only hearing 'denied'.

Adapted to hermes-agent's synchronous single-command /deny model: no DB
state, no second-message capture step, no migration. Reason is capped at
280 chars and threaded through both the terminal-command guard and the
execute_code guard. Plain /deny and the approve paths are unchanged.

- tools/approval.py: _ApprovalEntry.reason; resolve_gateway_approval gains
  optional reason; _await_gateway_decision returns it; both gateway BLOCKED
  messages include it
- gateway/slash_commands.py: parse leading 'all' + trailing reason
- locales/en.yaml: deny.denied_reason_{singular,plural}
- hermes_cli/commands.py: /deny args_hint '[all] [reason]'
- tests: 3 new (with-reason, all+reason, plain-deny regression)

* fix(ci): localize deny-reason keys across all locales + update interrupt-path assertions

CI surfaced two enforced invariants broken by the deny-with-reason change:
- test_i18n catalog-parity requires every locale to carry the same keys as
  en.yaml with matching placeholders. Added deny.denied_reason_singular/plural
  (with {count}/{reason}) to all 15 non-English locales.
- test_approval_interrupt asserts the exact dict from _await_gateway_decision,
  which now carries a 'reason' key (None on the interrupt/timeout paths).
2026-07-05 02:22:08 -07:00