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
Persists as the server's connect_timeout in config, which the probe
now honors. CLI-flag portion of PR #54494; the probe-wrapper portion
was superseded by resolving connect_timeout inside _probe_single_server.
_reauth_oauth_server (hermes mcp login / reauth) called
_probe_single_server without a timeout, so it always used the 30s
probe default — far too short for a human browser OAuth round-trip
(open → sign in → consent → loopback redirect). The server-level
connect_timeout in config.yaml was silently ignored, so login timed
out at ~40s no matter what the user configured.
Pass the server's configured connect_timeout through, with a 180s
floor for the interactive login path. Update the two TestMcpLogin
probe mocks for the new kwarg and assert the login path propagates a
>=180s timeout.
Follow-up to the salvaged #54938: the bounded reader gives a proper 413 +
anomaly telemetry for oversized chunked bodies; client_max_size makes
aiohttp enforce the same 1 MiB cap on every other read path
(#58536/#58902/#59180 pattern). Test fixture's fake Application now
accepts kwargs.
The salvaged #25296 fixture's _FakeRequest.read() calls json.dumps but the
test module never imported json — the NameError was swallowed by the
handler's generic except → 400, failing 10 payload tests.
_handle_webhook() called request.read() with no size guard. Since the
endpoint is publicly reachable, an attacker can send an arbitrarily large
POST body to exhaust gateway memory.
Add _TWILIO_WEBHOOK_MAX_BODY_BYTES (64 KiB — well above any real Twilio
payload) and gate on both Content-Length and actual read size, returning
HTTP 413 with an empty TwiML Response on oversized requests. Mirrors the
guard already present in the Raft adapter.
A `hermes update` that bumps the spectrum-ts pin rewrites the Photon
sidecar's package-lock.json but never reinstalls node_modules. The sidecar
then spawns against the old install and the v8 postinstall patch throws
"@spectrum-ts/imessage dist not found", so the gateway retries the photon
platform every 300s forever without ever repairing the deps. Observed in
the wild: a June pin bump to spectrum-ts 8.0.0 left node_modules at 3.1.0,
and inbound/outbound iMessage stayed dead for days with the reconnect loop
faithfully restarting into the identical broken state.
_start_sidecar only checked that node_modules exists, not that it matches
the lockfile, so restart never became repair. Detect the skew with the same
signal npm ci uses: the top-level package-lock.json being newer than npm's
node_modules/.package-lock.json install marker. When stale, reinstall
(npm ci, falling back to npm install) before spawning. The reinstall runs
via asyncio.to_thread so a cold install can't block the event loop and stall
every other platform's traffic; worst case it heals on the next reconnect
tick instead. First-run "deps not installed" behavior is unchanged, and a
missing/unreadable marker fails safe to "not stale" so start is never
blocked.
Reuses the existing npm ci -> npm install fallback from
`hermes photon install-sidecar`. Adds unit tests for the staleness signal
(stale / fresh / missing-marker).
Follow-up to the #55911 salvage: inherit model.api_key only when the aux
base_url resolves to the same hostname as the main model's base_url
(runtime override or config). A misconfigured aux endpoint on a different
host keeps the fail-safe no-key-required placeholder instead of leaking
the main credential cross-host.
When an auxiliary task is configured with provider=custom and an explicit
base_url but an empty api_key, the custom_key fallback chain in
resolve_provider_client() jumped straight to the no-key-required
placeholder without consulting model.api_key from config.yaml. Users
on self-hosted gateways who share the same endpoint and credentials for
both the main model and auxiliary tasks got 401 auth errors.
Add _read_main_api_key() following the same pattern as _read_main_model()
and _read_main_provider(): checks _RUNTIME_MAIN_API_KEY (runtime override)
first, then config.yaml model.api_key. Insert it into the fallback chain
before no-key-required so real credentials are used when available, while
local servers without auth still get the placeholder.
Overlap-invariant regression test from PR #58686 — no toolset in the
blank-slate disabled_toolsets may share a tool with a kept toolset,
since the subtraction happens at tool granularity (#57315, #58281).
The disabled_toolsets subtraction loop in _compute_tool_definitions
preserved shared core tools only for hermes-* platform bundles (#33924),
subtracting bundle_non_core_tools(); every other name took the else
branch and got a full resolve_toolset() subtraction. The `coding`
toolset is a posture toolset (posture: True) that re-lists the shared
_HERMES_CORE_TOOLS it does not own, so disabled_toolsets=["coding"]
stripped those core tools from the whole schema (34 tools collapsed to a
handful; terminal/read_file/write_file/web_search/execute_code gone).
Extend the core-preserving branch to also match posture toolsets, so
they subtract only the non-core delta. Only `coding` carries
posture: True, so atomic toolsets stay fully removable. The
bundle-misconfiguration info log is gated to hermes-* names, since its
wording is bundle-specific and disabled_toolsets=["coding"] is a
legitimate config written by older `hermes setup` runs.
Adds a regression test (TestDisabledToolsetsPostureToolset) alongside
the existing #33924 bundle tests.
Blank Slate's _blank_slate_minimal_toolsets() adds every TOOLSETS entry
to agent.disabled_toolsets except file and terminal. The coding
posture toolset (session-level, selected by agent/coding_context.py)
slips through because the loop only skips hermes-* composites and
includes-only groups.
At runtime, model_tools.get_tool_definitions() resolves coding and
subtracts its tools — terminal, read_file, write_file, patch,
search_files, process — erasing the entire Blank Slate minimal surface.
The agent ends up with only cronjob.
Skip posture toolsets in the disabled-list computation. Posture
toolsets are not user-facing capabilities to disable; they are
per-session selections that should never appear in agent.disabled_toolsets.
Fixes#57315
Assert the "Test server" probe skips prompts/list when tools.prompts is false,
skips both families when the server advertises neither capability (the Unreal
MCP server case), probes both when advertised and enabled, and falls back to
the legacy always-try behaviour when no capability info was captured.
* fix(docker): heal pairing-dir ownership after `docker exec` writes (#10270)
The official Docker image runs the gateway as the unprivileged `hermes`
user (uid 10000) via `gosu`, but `docker exec` defaults to root. Approval
files written by `docker exec <container> hermes pairing approve <code>`
end up as `-rw------- root:root`, and the post-gosu gateway process
cannot read them. The approval is silently ignored — the user keeps
hitting 'Unauthorized user' on every message.
The entrypoint's existing top-level chown is gated on the top-level
$HERMES_HOME being mis-owned, so on warm boots (where /opt/data is
already hermes:hermes) the recursive chown is skipped — meaning a
container restart does NOT self-heal the bug either.
Three-part fix:
1. docker/entrypoint.sh: chown the platforms/pairing/ (and legacy
pairing/) subtree on every container start, regardless of the
top-level decision. The directory is tiny (a few JSON files), so
the unconditional chown is effectively free. Container restart
now self-heals.
2. gateway/pairing.py: PairingStore._load_json was swallowing
PermissionError under its bare 'except OSError' branch, which is
what made this a silent failure. Split it out: log a WARNING that
names the file, the gateway's uid, the file's owner/mode, and the
exact docker exec -u hermes workaround. Still falls back to {} so
the gateway stays up.
3. website/docs/user-guide/security.md: add a Docker tip to the
pairing-CLI section pointing users at `docker exec -u hermes …`
up front.
Reproduced end-to-end in a containerized harness — before the fix
the gateway sees 0 approved users after `docker exec` + restart;
after the fix it sees the expected 1, and the file on disk goes
from `root:root 600` back to `hermes:hermes 600` on next start.
Fixes#10270
* fix(pairing): gate os.geteuid for Windows in PermissionError warning
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).
Sibling sweep from the #58902 raft review found aiohttp servers still
running on the implicit 1 MiB default with no explicit body cap:
- bluebubbles webhook (127.0.0.1): 1 MiB explicit cap — events are small
JSON/form payloads; attachments arrive via the REST API
- teams Bot Framework listener (0.0.0.0 bind — most exposed): 1 MiB cap;
activities are JSON well under that
- hermes proxy server: 10 MB cap mirroring api_server's MAX_REQUEST_BYTES
(chat-completion payloads can be large, but must stay bounded)
client_max_size bounds every read path including chunked transfer-encoding
requests that carry no Content-Length (#58536/#58902 pattern).
Deliberately excluded: feishu, whatsapp_cloud, sms, line, wecom, msgraph —
open contributor PRs (#54938, #54944, #54620, #54931, #54934, #25296)
already cover those; reviewing them separately preserves their credit.
3 regression tests pin the wiring.
PR #58889 fixed the CLI-fallback transport; review of that fix found the
same leak class at four sibling spawn sites of the third-party cua-driver
binary:
- _resolve_mcp_invocation (cua-driver manifest): no env= at all — full
parent environment inherited
- cua_driver_update_check (check-update --json): telemetry env but no
secret sanitization
- doctor._drive_health_report (<binary> mcp Popen): telemetry env only
- permissions._run (every macOS/Linux permission probe): telemetry env only
All now route through _sanitize_subprocess_env(cua_driver_child_env()),
matching the sanctioned MCP spawn and the #53503/#55709/#58889 strip-by-
default policy for non-terminal spawns. Sanitization degrades gracefully
(falls back to the telemetry env) so doctor/permission probes never break
on an import error.
4 regression tests covering each site.
Skill bundles load their member skills via _load_skill_payload directly,
bypassing the scan-time disabled filter in get_skill_commands(). PR #58888
closed this gap for stacked slash-skill invocations, but /<bundle> dispatch
in the gateway had the same class of bypass: a skill an operator disabled
for a platform via skills.platform_disabled still got its full content
injected when referenced by a bundle.
build_bundle_invocation_message() now accepts a platform kwarg, filters
members against get_disabled_skill_names(platform=...), and reports skipped
skills in the bundle header. Gateway dispatch passes the event's platform
explicitly (env-var resolution can't be trusted in the multi-platform
gateway process, same reasoning as the #58888 gate).
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.
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.
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.
Port from qwibitai/nanoclaw#2713: expose Hermes' existing Docker network isolation primitive through terminal config so operators can opt out of container egress.
_resolve_task_provider_model returns early on an explicit provider arg,
which skips the config block that consults auxiliary.<task>.base_url /
api_key. Any caller passing provider explicitly (e.g.
resolve_vision_provider_client(provider="custom", ...)) bypasses the
configured custom endpoint and falls through to main-runtime resolution,
silently routing the task to the wrong backend.
Adopt the task's configured base_url/api_key before the early returns,
but only when no explicit base_url was given and the config targets the
same provider (or names none) — a caller forcing a *different* provider
keeps full explicit-arg priority, and an explicit base_url still wins
over config.
Fixes#58515
The load_config() cache is keyed on config file mtime/size only, so a
load_config() that runs before load_hermes_dotenv() populates the process
environment caches the unexpanded ${VAR} literal and serves it for the
life of the process — auxiliary.<task>.api_key/base_url env refs reach the
provider client verbatim (auth failure / silent fallback), while
providers.* appear to work because provider credential resolution re-reads
the environment at call time.
Record a snapshot of every ${VAR} name referenced in the raw config
(user + managed) with its os.environ value at expansion time, and treat
the cache as stale when any of those values change. Covers both the late
.env load and in-process key rotation; an unchanged environment still
takes the cache-hit path.
Fixes#58514
Moves gateway routing metadata (display_name, origin_json, expiry_finalized)
into state.db, making SQLite the single source of truth for gateway session
discovery. Eliminates the dual-file (sessions.json + state.db) polling
dependency that caused the mcp_serve new-conversation race (#8925).
- hermes_state.py: schema v18 (3 new sessions columns + sessions.json
backfill migration), record_gateway_session_peer gains
display_name/origin_json, new set_expiry_finalized(),
list_gateway_sessions(), find_session_by_origin()
- gateway/session.py: peer recorder persists display_name + full origin
JSON; new SessionStore.set_expiry_finalized() single write-path
- gateway/run.py: expiry watcher success + give-up paths use the store
helper so the flag lands in both sessions.json and state.db
- mcp_serve.py: routing index reads state.db first (sessions.json fallback
for pre-migration DBs); _poll_once collapses to a single state.db mtime
check — the #8925 race is structurally impossible now
- gateway/mirror.py, gateway/channel_directory.py, hermes_cli/status.py:
query state.db first, sessions.json fallback
Closes#9006
Port from openclaw/openclaw#95108: an unbounded response.read() on a
non-OK *streaming* response can balloon memory (huge body) or hang the
agent forever (body opens then stalls with no further bytes). The
diagnostic body is only ever shown truncated, so reading megabytes or
blocking indefinitely buys nothing.
Add agent/bounded_response.read_streaming_error_body() which caps the
read at a byte limit and enforces a hard wall-clock deadline (run on a
worker thread so it can interrupt a socket read that stalls mid-chunk,
which a between-chunk wall-clock check cannot). Wire it into all three
streaming error-body sites that previously did a bare response.read():
native Gemini, Gemini Cloud Code, and Antigravity Cloud Code. The
existing error builders now accept an optional pre-read body_text so
classification (status code, RESOURCE_EXHAUSTED, free-tier guidance,
Retry-After) is preserved unchanged.
Tests use a real in-process socket server (no mocks): oversize body is
capped, stalled body hits the deadline with partial text preserved,
normal error envelope reads intact and parses.
_redact_env already skips redaction when a KEY=value assignment's value is
a programmatic env lookup (os.getenv(...), os.environ[...], process.env.X)
per issue #2852 — masking it would corrupt a code snippet, not redact a
secret. _redact_json (JSON "key": "value" syntax) and _redact_yaml
(unquoted key: value syntax) are separate closures in the same function
and never got the same check, so the identical code-snippet-in-config-
syntax case still gets mangled:
{"apiKey": "os.getenv('OPENAI_API_KEY')"} -> {"apiKey": "os.get...EY')"}
api_key: os.getenv("OPENAI_API_KEY") -> api_key: os.get...EY")
Fix: apply the same _ENV_LOOKUP_VALUE_RE.match(value) check in both
closures before masking, mirroring _redact_env exactly. Real secret
values in JSON/YAML syntax are still redacted (verified live and via new
tests) — this only skips the case where the "value" already look like a
code snippet.
a0a3c716f fixed the exact same failure mode for Telegram (#58563):
post-#48648, oversized mid-stream edits truncate to a one-message preview
instead of splitting. Once a long streamed reply grows past that cap, every
subsequent progressive edit truncates to the SAME preview text — re-sending
an identical edit every tick still counts against the platform's edit rate
limit for the rest of the stream.
Discord's edit_message() has the identical architecture (mid-stream
truncate-in-place, both pre-flight and reactive-after-50035 truncation
paths) and this file's own docstring already calls out "the Telegram #48648
lesson" it's built on — but the saturated-preview dedup fix itself was never
ported over.
Fix: track the last truncated preview per (chat_id, message_id), mirroring
a0a3c716f exactly. Skip the edit call when the new truncation is identical;
still deliver when the visible content actually changes (e.g. the
chunk-count marker crosses (1/2) -> (1/3) as the stream grows). State
clears on finalize and when content shrinks back under the cap, so dedup
can never mask a real edit.
11b4a21a5 cleared the per-session _last_resolved_model cache on /new and
the compression-exhausted auto-reset, so a resumed/reset conversation
resolves the model from current config instead of a stale cached value
(#58403). Three other sites documented as the same "full conversation
boundary" treatment — pop _session_model_overrides, clear the reasoning
override, pop _pending_model_notes — still missed _last_resolved_model:
- _session_expiry_watcher's permanent finalization block (gateway/run.py):
a session that goes idle and is finalized, then resumed, could serve a
model cached before it went idle on a transient config-cache miss.
- The daily/idle/suspended auto-reset cleanup (_was_auto_reset handling,
gateway/run.py): same failure mode, different trigger.
- /resume (gateway/slash_commands.py), whose own comment already says
"conversation boundary just like /new" for the sibling dicts it clears.
Fix: pop the session's _last_resolved_model entry in all three, mirroring
the exact pattern 11b4a21a5 established.
_handle_wake() and _handle_activity() enforced max_body_bytes only via
the Content-Length header. A Transfer-Encoding: chunked request
(content_length=None) or a spoofed small Content-Length bypassed the
cap entirely, letting the actual read be bounded only by aiohttp's
implicit 1 MiB client_max_size default (64x the 16 KB default) — the
same pattern ec29590a0 just fixed for gateway/platforms/webhook.py.
Fix: web.Application(client_max_size=self._max_body_bytes) so aiohttp
enforces the cap on every read path including chunked bodies, catch
HTTPRequestEntityTooLarge -> 413 on both endpoints (was swallowed into
a generic 400), and re-check the actual bytes read as defense in depth.
Exposure here is narrower than the webhook adapter (binds to 127.0.0.1
by default and requires the bridge token), but the bypass is otherwise
identical.
_redact_telegram_error_text() strips bot tokens from api.telegram.org
URLs embedded in transport-error text, and is already applied across the
send/edit transient-error paths. Four sites still built their message
from the raw exception:
- connect()'s fatal-error handler is the most severe: the raw text is
passed to _set_fatal_error(), which persists it via
write_runtime_status() to a dashboard/admin-facing runtime status
file, not just a log line. A transient network error during startup
commonly embeds the request URL
(https://api.telegram.org/bot<TOKEN>/getMe), so this could leak the
live bot token into that surface.
- disconnect(), send_document(), send_video() build the same unredacted
pattern into a warning log line (lower blast radius, but the same
leak class).
Fix: route all four through the existing _redact_telegram_error_text()
helper before building the message/log line, mirroring the send/edit
paths exactly. Also drops exc_info=True from the two logger.error/
logger.warning calls that had it — exc_info prints the exception's own
traceback (including its unredacted message) separately from the format
string, which would otherwise defeat the redaction; the already-redacted
sibling call sites in this file follow the same convention.
_CuaDriverSession._call_tool_via_cli() (the EAGAIN/silent-empty MCP
fallback transport) invokes `cua-driver call <tool> <json>` via
subprocess.run() with no env= argument, so the third-party cua-driver
binary inherits the full, unsanitized parent environment. The primary
MCP spawn site (_lifecycle_coro) already applies
_sanitize_subprocess_env(cua_driver_child_env()) before opening the
stdio client, per the same policy #53503/#55709 established for other
subprocess spawn points — this fallback path, added alongside the
EAGAIN/silent-empty-capture hardening, missed it.
Fix: apply the same env=_sanitize_subprocess_env(cua_driver_child_env())
to the subprocess.run() call in _call_tool_via_cli(), mirroring the
sanctioned spawn site exactly (telemetry policy applied first, then
Hermes-managed secrets filtered).
_handle_message() re-checks a slash-skill command's per-platform disabled
status before dispatch, because get_skill_commands() only applies the
global disabled list at scan time. That check only covered the leading
skill: split_stacked_skill_commands() resolves additional /skill tokens
that follow it (stacked invocations, up to 5 skills, #57987), and
build_stacked_skill_invocation_message() loads every one of them via
_load_skill_payload() with no disabled-status check of any kind.
A message on a platform with skills.platform_disabled configured for a
given skill could still get that skill's full SKILL.md content injected
into the agent's context for the turn, as long as it was typed after an
allowed skill: `/allowed-skill /disabled-skill do X`.
Fix: after computing the stacked extra_keys, look up each one's skill
name and re-check it against the same get_disabled_skill_names(platform=)
set already used for the leading skill. If any stacked skill is disabled
for the platform, reject the whole invocation with the same style of
message the leading-skill check already returns, instead of partially
loading it.
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).
Feishu adapter's disconnect() cancelled WSS-thread tasks but never
called the lark_oapi client's _disconnect() coroutine, so no
WebSocket CLOSE frame was sent. Feishu's server kept routing
messages to the stale endpoint for minutes (CLOSE-WAIT timeout),
silencing the channel across every shutdown path — systemd restart,
hermes update, hermes gateway restart, and the --replace takeover
during 'hermes dashboard' invocations.
Schedule ws_client._disconnect() on the WSS thread loop via
run_coroutine_threadsafe with a 5s timeout before the existing
task-cancel + loop-stop sequence. Defensive hasattr guard + broad
except keeps disconnect() resilient if lark_oapi's internals shift.
Fixes#10202
Port from Kilo-Org/kilocode#11555: GLM-5.2 exposes a native
reasoning_effort knob with two enabled levels (high / max) on its
OpenAI-compatible endpoints. Previously the zai profile (direct Z.AI
/api/paas/v4) used the base ProviderProfile and emitted nothing, and the
OpenCode Go profile only handled Kimi K2 / DeepSeek — so a user's effort
preference for GLM-5.2 was silently dropped on both routes.
- zai: ZaiProfile maps effort onto high/max (xhigh/max -> max, lower -> high)
- opencode-go: same mapping for GLM-5.2, alongside existing Kimi/DeepSeek
- alias spellings recognized (glm-5.2 / glm-5-2 / glm-5p2, vendor-prefixed)
- disabled / no effort leaves the server default untouched
The google-antigravity and google-gemini-cli OAuth providers were removed
in #50492. They were the only producers of a cloudcode-pa:// base_url, so
the account-level-quota early-returns in _pool_may_recover_from_rate_limit
and _credential_pool_may_recover_rate_limit are now unreachable.
- Drop the dead cloudcode-pa:// checks and the now-unused provider/base_url
params on _pool_may_recover_from_rate_limit (only caller updated).
- Prune the obsolete CloudCode-specific regression tests; keep the live
single/multi-entry pool-rotation invariants (#11314).
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.
TestMcpParallelToolBatch seeded provenance under old-style
mcp_<server>_<tool> names, which no longer pass the
is_mcp_tool_parallel_safe() prefix gate after the naming change.
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
Defense-in-depth alongside the interpreter-shutdown guard: run_job closed
the cron agent's async resources (agent.close + cleanup_stale_async_clients)
in its finally block BEFORE run_one_job called _deliver_result, so a live
delivery could race a torn-down async client. run_job now accepts an optional
defer_agent_teardown holder; when set it hands the live agent back instead of
closing it, and run_one_job tears it down (via the extracted _teardown_cron_agent
helper) only AFTER delivery — in a finally so a failed run never leaks. Default
path (holder=None) is unchanged, so every existing caller keeps inline teardown.
Reorder approach based on #58777 by @LavyaTandel; reworked to keep a single
delivery site in run_one_job and add regression coverage.
Co-authored-by: LavyaTandel <lavya@loom.local>
Pins `_interpreter_shutting_down()` (finalizing flag + shutdown-error-text
fallback) and asserts the standalone delivery path skips gracefully without
scheduling a send when the interpreter is finalizing, while the normal
non-finalizing path still delivers. Source guardrails keep the guard wired
into both the dispatch (`_submit_with_guard`) and standalone-delivery sites.
Assert _await_thread_exit lets a coroutine scheduled onto the running loop by a
blocked worker thread complete (the #58818 deadlock a synchronous join caused),
returns False when the thread outlives the timeout, and handles None/dead
threads.
Phase-2 review follow-ups on the unreadable-config chokepoint work:
- hermes_cli/xai_retirement.py apply_migration() is a full-file config.yaml
rewriter (ruamel round-trip + plain open("w")) that lives outside the
atomic_yaml_write path, so the chokepoint didn't cover it. It reads the
file first (which already fails closed on an unreadable file), but add
require_readable_config_before_write() right before the write as a
backstop for the read-then-write window, and a regression test asserting
the original bytes survive an unreadable config.
- Drop the unnecessary "Path" string quotes on atomic_config_write's
annotation — Path is imported eagerly at module top, no forward ref needed.
auth.py _update_config_for_provider / _reset_config_provider intentionally
keep their standalone require_readable_config_before_write guard + bare
atomic_yaml_write: the guard must fire BEFORE the read (fail-fast) at those
read-then-write sites, and a test pins the atomic_yaml_write call. Both are
already fully guarded against the bug; routing them through the wrapper
would move the check to write time for no benefit.
The unreadable-config-overwrite bug (an existing config.yaml that reads as
{} on a permission/IO error gets replaced with only defaults or the edited
section) is not limited to save_config / config set / auth. The same
read-then-atomic_yaml_write pattern lives at ~7 other independent write
sites that don't route through those functions:
- gateway/slash_commands.py: _save_config_key, memory/skills write_approval
toggles, tool_progress toggle, runtime_footer toggle, personality set
- hermes_cli/doctor.py --fix (stale root-key migration)
- gateway/platforms/yuanbao.py auto-sethome
- plugins/platforms/telegram/adapter.py topic thread_id persistence
- tui_gateway/server.py _save_cfg
- agent/onboarding.py mark_seen
Rather than sprinkle require_readable_config_before_write() at each site,
add a single fail-closed chokepoint, atomic_config_write(), that runs the
guard then delegates to atomic_yaml_write, and route every config.yaml
write through it. Root cause remains that read_raw_config() can't tell an
absent file from an unreadable one (returns {} for both) — read-only
callers correctly stay fail-open, but any full-file replacement now fails
closed in one enforced place instead of relying on each caller to remember
the guard.
save_config / set_config_value / auth keep the contributor's original
guard calls (their commit); this commit widens the fix to the sibling
call paths and adds a regression test on the chokepoint (fails closed on
unreadable existing file + still creates a genuinely absent file).
httpx ignores the client-level `limits` kwarg when a custom `transport`
is supplied. The #31599 keepalive fix injected limits via
`httpx_kwargs[limits]`, but the fallback-IP branch also passes a
custom `TelegramFallbackTransport` — so the limits were silently
discarded and the inner AsyncHTTPTransport instances ran with httpx
defaults (keepalive_expiry=5.0), leaking CLOSE_WAIT fds.
Pass the tuned limits directly into `TelegramFallbackTransport`
via `transport_kwargs` so its inner transports honour keepalive_expiry.
Only affects the fallback-IP branch; proxy and direct-DNS branches
continue to use `_with_limits()` as before.
Fixes#58790
The two TestSourceGuardrail tests asserted the presence of literal
strings ("#58753", "_user_survives") in context_compressor.py. Those
are change-detector tests that break on any refactor without catching a
real regression. The four behavioral tests in
TestCompressAlwaysKeepsAUserTurn already exercise the real compress()
path and fully cover the invariant (user turn survives, summary pinned
to user, no consecutive user roles, surviving tail user untouched).
Regression coverage for the kanban-worker crash where compression left a
transcript with no user-role messages, triggering a non-retryable
`400 No user query found in messages` from vLLM/Qwen.
Exercises the real `compress()` path with the reporter's shape (no system
prompt in the list, a re-compaction with the only user turn in the
compressed middle) and asserts the output always keeps >=1 user turn,
never introduces consecutive user roles, and leaves a surviving tail user
message untouched. A source guardrail pins the guard so a future refactor
cannot silently drop it.
Add an opt-in toggle (require_admin_for_exec_approval, default false) that
restricts who can click Approve/Deny on a dangerous-command prompt to admins
listed in allow_admin_from. Off by default, so the v0.16-restored user-scope
behavior is unchanged. When on, the clicker must pass the normal admission
check AND be an admin; fails closed (logged) when no admins are configured.
Only ExecApprovalView is gated — model picker / clarify / update-prompt stay
user-scope.
coerce_tool_args only repaired the outermost value, so JSON-encoded
*elements* of array properties (and nested object sub-fields) were left
as strings. Three core tools have array<object> schemas — todo.todos,
delegate_task.tasks, memory.operations — so a model emitting
{"todos": ["{...}"]} would pass raw JSON strings into the tool and fail
downstream on item["id"]/item["goal"] access.
Adds a schema-guided recursive pass (_normalize_json_strings_for_schema)
that parses JSON-string array items and nested object fields only when
the matching schema position expects an array/object, preserving
legitimate JSON-looking string fields (type: string).
Adapted from cline/cline#11803 to hermes-agent's existing coercion layer.
Follow-up to the salvaged #58696 (devatnull) + #41343 (annguyenNous)
commits: instead of fully suppressing commentary/analysis-phase stream
deltas, fire on_reasoning_delta so the CLI/gateway display them like
thinking text. Matches Codex CLI semantics where commentary is never
the turn's final answer, while keeping the narration visible in the
reasoning display. Adds devatnull to AUTHOR_MAP.
- bridge: only enqueue poll_update events for polls Hermes itself created
(tracked via recentlySentIds when /send-poll returns) so arbitrary human
polls in group chats don't inject agent-visible messages on every vote
- update test_already_whatsapp_italic for the new markdown-italic mapping
- AUTHOR_MAP entry for @devatnull (PR #58704 salvage)
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.
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.
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.
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.
Replaces the POSIX `/bin/bash -c "$(curl …)"` invocation with a
download-then-exec flow: curl the upstream install.sh into a mkstemp
temp file (unpredictable name, 0600) and run it as a plain argv list.
No shell=True, no command substitution. The temp script is removed in
a finally block; download failures return cleanly without exec.
Salvages the intent of #34974 by @ErnestHysa. His original patch
targeted a fixed /tmp/cua-driver-install.sh path (symlink/TOCTOU-prone
on multi-user hosts) and predates Windows/Linux installer support;
this version uses mkstemp and keeps the powershell path untouched.
Co-authored-by: ErnestHysa <takis312@hotmail.com>
Follow-up to #57987: after /skill-a the completer previously went silent
for a second /skill token. Now, while the leading tokens form an unbroken
skill chain (each token a distinct installed skill, under the 5-cap) and
the word under the cursor starts with '/', the completer keeps offering
the remaining skill commands, and SlashCommandAutoSuggest ghost-suggests
the rest of the next skill name. Instruction text, path-like tokens, and
broken chains get no suggestions. The TUI's complete.slash RPC reuses
SlashCommandCompleter, so it inherits the behavior with no changes.
* fix(cli): unwedge cua-driver installer timeouts — group-kill on timeout, stale-lock pre-clear, 660s ceiling
The cua-driver refresh in hermes update could wedge permanently:
subprocess timeout (300s) killed only the outer shell, orphaning the
curl|bash grandchildren and the upstream installer's concurrent-install
lock (~/.cua-driver/packages/.install.lock.d). The installer only
reclaims a stale lock after 600s of waiting — longer than our old
ceiling — so every subsequent run was killed before recovery could
fire: 'always times out'.
- Run the installer in its own process group (start_new_session) and
SIGKILL the whole group on timeout, so no lock-holding orphans survive.
- Pre-clear a provably-stale lock (dead holder pid, or pid-less and
older than the upstream 600s window) before invoking the installer.
- Raise the ceiling to 660s (> upstream LOCK_STALE_AFTER_SECONDS=600).
- Timeout message now names the lock path and the manual re-run command.
Fixes#58762
* chore: suppress windows-footgun lint on platform-gated kill calls
Both sites are POSIX-only: _clear_stale_cua_install_lock early-returns
on win32, and os.killpg sits in the 'not is_windows' branch.
The custom ``httpx.HTTPTransport(socket_options=[SO_KEEPALIVE, ...])``
in ``_build_keepalive_http_client()`` was introduced to fix CLOSE-WAIT
socket accumulation on long-lived connections (#10324).
That approach broke streaming for providers behind reverse proxies
(OpenResty, Cloudflare, etc.) because the custom socket options
conflict with the proxy's chunked-transfer handling (#54049, #12952).
It also stripped TCP_NODELAY, stalling TLS handshakes and SSE encoding.
Narrow per-provider bypasses were added for Copilot (#50298), Codex
(#36623, #12953), but the root cause remained.
The fix moves connection lifecycle management from the socket layer to
the HTTP pool layer:
- ``httpx.Limits(keepalive_expiry=20.0)`` tells httpx to close idle
pooled connections at 20 s, before a reverse proxy's typical 30-60 s
timeout drops them and causes CLOSE-WAIT accumulation.
- The default httpx transport preserves OS TCP defaults (including
TCP_NODELAY), so TLS handshakes and SSE chunked encoding work
correctly.
- ``trust_env=False`` prevents httpx from double-dipping on env vars
(we handle proxy detection ourselves via ``_get_proxy_for_base_url``
which respects NO_PROXY).
- The Copilot host bypass (line 3632) is no longer needed since all
providers now use the same standard httpx.Client.
Closes#54049. Supersedes #12010, #36623, #12953, #50298.
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.
* 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).
Inspired by Claude Code v2.1.199 (July 2, 2026): stacked slash-skill
invocations load all leading skills (up to 5), not just the first.
- agent/skill_commands.py: split_stacked_skill_commands() consumes leading
/skill tokens (stops at the first non-skill token so slash-path arguments
are never swallowed); build_stacked_skill_invocation_message() composes
the multi-skill turn reusing the existing bundle scaffolding markers so
extract_user_instruction_from_skill_message() keeps memory providers
storing the user's instruction, not N skill bodies.
- cli.py + gateway/run.py: dispatch the stacked path on both surfaces.
- 11 new tests + docs section in skills.md.
Port from anomalyco/opencode#34529: MCP servers can emit
notifications/message logging notifications (RFC 5424 levels), but the
MCP SDK's default logging_callback silently discards them — server-side
warnings/errors during tool calls were invisible.
- tools/mcp_tool.py: pass a logging_callback to every ClientSession
(stdio, SSE, streamable HTTP old+new API paths via the shared
sampling_kwargs sites), mapping the 8 MCP log levels onto Python
logging levels and tagging entries with [server/logger] origin.
- JSON-serialize non-string payloads, cap at 2000 chars so a chatty
server can't flood agent.log, never raise from the handler.
- Gated on SDK support (_check_logging_callback_support) mirroring the
existing message_handler gate for old SDK versions.
- tests/tools/test_mcp_server_log_notifications.py: 10 tests covering
level mapping, origin tagging, JSON payloads, truncation, and the
never-raise contract.
Inspired by Claude Code v2.1.199 (July 2, 2026): SSL certificate errors
(TLS-inspecting proxies, missing CA bundles, expired certs) no longer
burn retries before showing actionable guidance — they fail immediately
with the fix hint.
- agent/error_classifier.py: new FailoverReason.ssl_cert_verification +
_SSL_CERT_VERIFY_PATTERNS, checked BEFORE the transient-SSL patterns
(cert-verify messages also contain '[SSL:' and previously retried
forever as timeout). Non-retryable, no compression, no fallback churn.
- agent/conversation_loop.py: dedicated status line + per-cause fix
hints (corporate proxy CA bundle, certifi refresh, self-signed local
endpoints) on the non-retryable abort path.
- 7 new tests incl. regression guards (transient alerts still retry,
large-session cert failure doesn't trigger compression).
`hermes profile export default` crashed with `shutil.Error` when
HERMES_HOME pointed outside ~/.hermes (common in Docker deployments)
and the workspace contained broken symlinks. Two root causes:
1. `copytree` defaults to `symlinks=False` and follows link targets;
broken ones crash. #58397 (liuhao1024) drafted a minimal
`symlinks=True` flag fix; this PR adopts that change.
2. `copytree` was invoked against the entire HERMES_HOME root (which
doubles as cwd in Docker layouts). The post-hoc blacklist at
`_DEFAULT_EXPORT_EXCLUDE_ROOT` is a fixed-length enumerate-and-pray
list that can't anticipate every unrelated sibling directory
(`x11-dev/`, etc.). Replaced with a positive allow-list at
`_DEFAULT_EXPORT_INCLUDE_ROOT` enumerating the known Hermes profile
artifacts (config, persona, skills, cron, scripts, sessions,
plugins, memories, knowledge, preferences). Sensitive runtime
surfaces (`state.db`, `logs/`, auth files, other profiles) are
intentionally not in the allow-list so the export stays a
portable, credential-free snapshot of the user-facing surface —
which means the existing `test_export_default_excludes_infrastructure`
regressions remain green.
Adds two regression tests:
* test_export_default_uses_allowlist_for_unrelated_dirs — >x11-dev<
sibling directories must not leak into the archive.
* test_export_default_handles_broken_symlinks — symlinks inside
allowed artifacts survive instead of crashing the export.
closing that PR as superseded once this lands.
Closes#58394
shutil.copytree() defaults to symlinks=False which follows symlinks and
crashes on broken ones. In Docker/custom HERMES_HOME deployments,
unrelated directories may contain stale symlinks that break export.
Add symlinks=True to both copytree() calls in export_profile() so
broken symlinks are preserved as symlink entries in the archive.
Fixes#58394
video_analyze_tool's local-path branch read raw bytes via
_detect_video_mime_type (extension-only, no magic-byte check) with no
call to agent.file_safety.raise_if_read_blocked, unlike the image-gen
and video-gen provider plugins that already route local inputs through
that shared chokepoint (#57698). A model could point video_url at a
credential store (e.g. .env, auth.json) renamed or symlinked to a
video-like extension and have its raw bytes base64-encoded and sent to
the vision provider.
vision_analyze_tool and its native fast path (_vision_analyze_native)
had the same gap in their local-file branches; they were only
incidentally protected by the image magic-byte sniff rejecting
non-image content, not by the intended read guard.
Add raise_if_read_blocked() to all three local-file branches, mirroring
the existing plugins/image_gen and plugins/video_gen call sites.
* fix(cli): set correct x-initiator header per Copilot turn
copilot_default_headers() always hardcoded x-initiator: agent, but
GitHub Copilot billing requires "user" for user-initiated prompts and
"agent" for tool/follow-up calls. This caused premium requests to never
be consumed correctly, risking billing issues or account bans.
Adds is_agent_turn param to copilot_default_headers() and injects
extra_headers={"x-initiator": "user"} on the first API call of each
user turn when targeting Copilot URLs. The flag flips to False after
injection so subsequent calls (tool use, streaming fallback) default
back to "agent".
Fixes#3040
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(release): add AUTHOR_MAP entry for @tjp2021 (PR #4097 salvage)
---------
Co-authored-by: Tim <tim@iteachyouai.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Both gateway compression entry points (session-hygiene auto-compress in
run.py; manual /compress in slash_commands.py) filtered the transcript
to user/assistant-only, content-bearing messages before calling
_compress_context. That starved the compressor:
- tool results are usually the bulk of the context, and
_prune_old_tool_results never saw them
- short filtered histories tripped the protect-first/last early-return,
so compression became a no-op even on huge sessions
- assistant tool_calls stubs (content=None) were dropped, so even the
summary lost the tool activity
Pass user/assistant/tool messages through intact, matching what the
agent loop itself feeds _compress_context.
Port of PR #3854 onto current main (the manual-compress handler moved
from run.py to slash_commands.py since the PR branched); regression test
asserts tool messages reach the compressor.
Authored-by: David Zhang <david.d.zhang@gmail.com> (@Git-on-my-level)
Co-authored-by: David Zhang <david.d.zhang@gmail.com>
_do_reconnect() succeeded but never called
YuanbaoAdapter.set_active(adapter), leaving get_active()
permanently returning None after any WS disconnect/reconnect
cycle. This caused cron delivery to silently fail because
_send_yuanbao() checks get_active_adapter() and gives up
immediately when it returns None.
Fix: call set_active(adapter) after successful reconnect,
matching the pattern in connect().
Fixes#58363
Post-#48648, oversized mid-stream edits truncate to a 4096-char preview
instead of splitting. But when rich messages raise the consumer's overflow
budget to 32k, the consumer keeps accumulating past 4096 and keeps issuing
progressive edits every edit_interval — each one truncating to the SAME
preview text. Telegram counts every one of those no-op requests against the
flood budget: a long streamed reply fires ~1 identical edit per 0.8s for
the rest of the stream, trips flood control (200s+ penalties), and the
final delivery hangs behind inline flood sleeps. Users see the bot stuck
'streaming' and the chat unresponsive.
Fix at the chokepoint: track the last truncated preview per
(chat_id, message_id) and skip the API call when the new truncation is
identical. Previews still update when the visible prefix actually changes
(e.g. chunk-count marker 1/2 → 1/3). State clears on finalize and when
content shrinks back under the cap, so dedup can never mask a real edit.
Live repro: 19,956-char streamed reply, transport=edit, rich available —
4x flood-control hits within ~700ms, 250s penalties, hung final delivery.
E2E harness on the same stream: 14 edit calls on main vs 7 with the fix
(the delta is pure no-op duplicates; scales with stream length).
Extend the pre_tool_call plugin hook return contract with a new directive:
{"action": "approve", "message": "why this needs human confirmation"}
Previously a pre_tool_call hook could only veto a tool call (action: block)
or allow it silently. It could not escalate to the existing human-approval
flow. This unlocks user-defined runtime approval rules on ANY tool (HTTP
writes, file writes to sensitive paths, email sends), enforced at runtime —
resolving #51221 as a pure plugin, with no core approval.py rule schema.
Mechanism:
- get_pre_tool_call_directive() returns (action, message) for block|approve;
get_pre_tool_call_block_message() kept as a block-only back-compat shim.
- resolve_pre_tool_block() is the single dispatch-site chokepoint: fetches
the directive and, for approve, invokes the human gate; fail-closed to a
block on denial, timeout, or gate exception. ALL FOUR tool-dispatch sites
now call it: tool_executor (concurrent + sequential), agent_runtime_helpers,
and model_tools.handle_function_call.
- request_tool_approval() escalates via the SAME machinery as Tier-2
dangerous commands: session/permanent allowlist, prompt_dangerous_approval
(CLI) / submit_pending (gateway), [o]nce/[s]ession/[a]lways/[d]eny,
timeout fail-closed, approvals.cron_mode for cron contexts.
Architecture: extracted the shared decision core into _run_approval_gate(),
called by BOTH check_dangerous_command() and request_tool_approval() so the
fail-closed / cron / gateway / yolo / persist policy lives in ONE place and
cannot drift. Fixed a latent divergence — the plugin path now honors --yolo.
Approval grain: [a]lways is keyed on tool_name + a hash of the reason (an
explicit plugin rule_key overrides), so distinct reasons on the same tool
persist independently instead of one 'always' blanketing the whole tool.
Non-interactive: cron honors approvals.cron_mode (parity with commands); any
other non-interactive non-gateway context fails CLOSED for the plugin path
(the command path keeps its historical fail-open default, unchanged).
No new config schema, no new env vars, no new hook events.
On Windows every Hermes process (gateway, serve, TUI/slash workers, MCP
servers, CLI commands) writes the shared rotating logs through
concurrent-log-handler's cross-process rotation lock. When the emitting
thread is an asyncio event loop, a lock wait blocks the loop — stalling it
for seconds and dropping WebSocket clients (the 'gateway keeps going down'
symptom seen in #58265).
Route every file handler through a single QueueListener on a dedicated
thread: loggers only enqueue (non-blocking); the listener does the file I/O
and rotation-lock wait off the hot path. The QueueHandler funnels via the
root logger; per-handler levels and component filters are preserved by
respect_handler_level + handler.handle on the listener thread. An atexit
hook stops the listener before logging.shutdown closes the file handlers.
- _NonFormattingQueueHandler passes the raw record (in-process queue) so
target handlers apply their own RedactingFormatter/filters.
- flush_log_queue() drains synchronously (shutdown + tests).
- rotating_file_handlers() exposes the handlers now behind the listener;
tests updated to use it.
Extends the #58265 fix: the provider-key warn-storm was one amplifier of
this contention; this takes the contention off the event loop entirely.
_normalize_custom_provider_entry() runs on every load_picker_context()
call (per picker/inventory request) and warned each time for (a) the
redundant `provider` key that Hermes' own config writer emits into
provider entries and (b) any other unknown key. On Windows the serve
launcher+worker pair share one rotating log via concurrent-log-handler's
cross-process lock, so that per-load warning volume drove 'Cannot acquire
lock after 20 attempts' retries that pegged a core, stalled the event
loop ~14s, and dropped every desktop/TUI WebSocket while /health stayed
green (gateway looked down; dashboard looked fine).
- Accept `provider` as a known key (silently ignored) so self-written
legacy configs don't warn.
- Deduplicate the normalizer's warnings per (provider, signature) so a
static config quirk is surfaced once, not on every inventory load.
Adds regression tests for both.
Fixes#58265
After a config change (e.g. switching model provider), the /new command
must clear the per-session _last_resolved_model cache so the next turn
resolves the model from the updated config instead of falling back to
the stale cached value.
Without this fix, if a transient config-cache miss occurs on the first
post-/new turn, the #35314 recovery path serves the old model from the
cache — the user sees the old model being used even though they changed
config.yaml and explicitly ran /new.
Fix applies to both call sites that reset session model state:
- GatewaySlashCommandsMixin._handle_reset_command (slash_commands.py)
- GatewayRunner compression-exhausted auto-reset (run.py)
Fixes#58403
Per-session /model overrides supplied api_key and provider but omitted
credential_pool, so billing rotation never ran on HTTP 402. Wire the pool
on fast override, rehydrate, and apply paths; backfill from provider for
legacy persisted overrides. Regression tests in tests/gateway/.
_try_anthropic() hard-failed (return None, None) when the anthropic
credential pool was present but had no selectable entry — e.g. the pooled
OAuth token expired and its refresh_token had gone stale, so
_select_pool_entry("anthropic") returned (True, None). This wedged every
auxiliary task routed to Anthropic (goal judge surfaced "no auxiliary
client configured") even when a perfectly valid ANTHROPIC_TOKEN /
credentials-file token was available. The main session stayed healthy
because it resolves the env token directly.
The openrouter path (_try_openrouter) and codex path already fall through
to their standalone credential on (True, None); anthropic was the only
provider that hard-failed. Make _try_anthropic fall through to
resolve_anthropic_token() on that branch so the three paths are symmetric:
a temporarily dead pool entry must not block auxiliary tasks when a valid
standalone credential exists.
Adds a regression test covering: (1) pool present + no entry + valid env
token -> client built from the env token, (2) pool present + no entry + no
resolvable token -> clean (None, None), (3) base_url defaults correctly
when falling through with pool_present=True.
The webhook adapter enforced max_body_bytes only via the Content-Length
header; a Transfer-Encoding: chunked request (content_length=None) or a
spoofed small Content-Length bypassed the cap entirely and read the full
body (bounded only by aiohttp's implicit 1 MiB default, above any
operator-configured smaller limit).
- web.Application(client_max_size=max_body_bytes): aiohttp enforces the
cap on every read path, chunked included
- catch HTTPRequestEntityTooLarge -> 413 (was swallowed into generic 400)
- post-read length re-check as defense in depth
- chunked-upload regression test
Manual port of PR #3955 by @Gutslabs onto current main (handler had
been restructured since); authorship preserved.
The container exec-read piped the whole file through base64 with no size
guard — the 50MB cap was only enforced host-side AFTER the full payload
had already streamed into host memory. A prompt-injected read of a huge
container file (or /dev/zero) could balloon the gateway process.
head -c (cap+1) bounds the read inside the sandbox; the +1 byte lets the
host distinguish at-cap from over-cap and reject with SourceTooLarge.
Input redirect replaces 'base64 --' (no argv exposure at all for
leading-dash paths). Docker integration tests re-verified live.
- _normalize_to_supported_image: ensure cache/vision exists before writing
the converted PNG (fresh HERMES_HOME had no dir -> FileNotFoundError).
- resolver: SVG passes through as image/svg+xml instead of erroring; the
call sites rasterize to PNG via the salvaged normalize step (cairosvg /
svglib / rsvg-convert / inkscape, best-effort with actionable error).
- normalization offloaded via asyncio.to_thread at both call sites.
- tests: resolver pass-through + rasterization + no-converter error paths.
- AUTHOR_MAP: jonathan@mintrx.com -> JAlmanzarMint (PR #52688 salvage).
vision_analyze embedded SVG (and BMP/TIFF) tool-results into conversation
history with media_type image/svg+xml. Anthropic only accepts jpeg/png/
gif/webp, so the request fails with a non-retryable 400. Because the image
is baked into immutable history and re-sent every turn, the session is
permanently wedged on resume — retries re-send the same bad bytes.
Add _normalize_to_supported_image(): SVG is rasterized to PNG (best-effort
via cairosvg/svglib/rsvg-convert/inkscape), other non-supported raster
formats are re-encoded to PNG via Pillow, and if conversion is impossible
the tool returns an actionable error instead of a session-wedging payload.
Wired into both the native-vision fast path and the auxiliary-API path so
the whole bug class is covered, not just the one call site.
All 99 existing vision tests pass.
- resolve_image_source: bare cwd-relative filenames ('pic.png') resolve
again (main accepted them; the path-shape gate regressed them — review
by egilewski). Unknown explicit schemes (ftp://, s3://) still rejected.
- Local backend: nonexistent path now raises a clean 'image file not
found' instead of a misleading sandbox-fallback message.
- W4: remove the now-dead path-based _detect_image_mime_type (suffix-trust
SVG acceptance) so future callers can't reintroduce it.
- W3: SVG sources rejected with a dedicated actionable message + test.
- Polish: host read_bytes / temp-file write_bytes offloaded via
asyncio.to_thread (matches the container exec-read); unused
ResolveContext.cfg/extra_roots fields dropped; duplicate policy check
documented as intentional pre-flight short-circuit.
Salvage of #35362, evolved to also close the vision sandbox-escape
(GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image
bytes host-side while every other tool reads through the terminal backend —
so one resolver fixes both the delivery gaps and the escape.
Delivery (from #35362, re-authored against current main since the branch was
4140 commits stale and vision_tools.py had been rewritten on both sides):
- tools/image_source.py: one resolver for data:/http(s)/file/local/container
image sources, returning raw bytes through a single magic-byte-sniff +
50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source'
for every source type (#7571, #25118, #29643, #22328, #32709, #9077).
- tools/credential_files.py: from_agent_visible_cache_path, the container->host
cache reverse-map (inverse of the existing forward twin).
- tools/vision_tools.py: both vision sites route through the resolver with
task_id threaded from the handler; resolved bytes are materialized to a temp
file so main's evolved encode/resize/embed-cap pipeline is reused verbatim
(kept over the PR's older bytes-core resize to avoid touching browser_tool /
conversation_compression callers).
Security (fills #35362's deliberately-stubbed _within_allowed_roots seam):
- Under a non-local terminal backend the file tools are confined to the sandbox
(SECURITY.md 2.2), but vision read host-side — a prompt-injected
vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even
redirects the model to vision_analyze for image paths. The resolver now
enforces the same boundary: local backend reads any host path (chosen
posture); non-local backend host-reads ONLY the media caches under
HERMES_HOME (where the gateway/download media lives) and routes every other
path to an in-sandbox base64 exec-read — which reads the CONTAINER's file,
the same one 'cat' would, never the host's. Paths are resolve()-d so a
symlink can't escape a cache; fail-closed when no sandbox env exists.
This closes the escape AND delivers container-only images (#32709) with the
same mechanism.
Tests: unified resolver + confinement model (tests/tools/test_image_source.py,
incl. proof a non-cache host path under Docker yields container bytes not the
host secret); existing vision tests updated to the resolver boundary; Docker
integration test verified green against a real daemon (exec-read of a tmpfs
/workspace file, a root-owned mode-600 file, and the host-secret invariant).
Fixes GHSA-gpxw-6wxv-w3qq.
Co-authored-by: banditburai <promptsiren@gmail.com>
'KEY=os.getenv(...)' / 'os.environ[...]' / 'process.env.X' values are
variable-name references in code snippets, not leaked secrets. Masking
them corrupted pasted code in prose/log contexts (issue #2852):
ha_token=os.getenv('HOMEASSISTANT_TOKEN') -> ha_token=os.get...EN').
Skip these values inside _redact_env, which covers all three passes that
share the closure (_ENV_ASSIGN_RE, _CFG_DOTTED_RE, _CFG_ANCHORED_RE).
Real secret values are still masked.
Salvage of PR #2852-fix #2854 — the PR's own placement (an unconditional
pass before the code_file gate) would have reintroduced the code-file
false-positive class; the skip is applied inside the existing gated pass
instead. Tests adapted from the PR.
Co-authored-by: crazywriter1 <sampiyonyus@gmail.com>
Add unit tests for resolve_placeholder_terminal_cwd and extend the config
bridge simulation for docker mount-on vs mount-off vs local fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
The conflict-retry ladder schedules a background recovery task via
loop.create_task(self._handle_polling_conflict(...)) on each failed
start_polling. test_polling_conflict_becomes_fatal_after_retries never
cancelled the last one, so under a loaded scheduler a leaked task could
get a turn, re-drive the counter into the fatal branch, and fire
_notify_fatal_error a second time — breaking assert_awaited_once()
non-deterministically. The bounded updater.stop() guard added in this
salvage introduced an extra await/scheduling yield that surfaced the
latent leak in CI slice 3. Cancel the leaked task before the fatal
assertions so the test is deterministic regardless of scheduler timing.
The salvaged fix (#58272) guarded the primary network-error reconnect path.
Issue #58270's Scope section names three more unguarded await updater.stop()
sites that can hang identically on a CLOSE-WAIT socket:
- conflict handler (before the retry back-off sleep)
- conflict-retries-exhausted teardown (before the fatal notify)
- disconnect() teardown (would hang gateway shutdown/restart)
Each is now wrapped in asyncio.wait_for(..., _UPDATER_STOP_TIMEOUT) with a
warning on timeout, matching the primary path, so no reconnect/teardown ladder
can wedge on a dead socket.
Also hoist the shared 15.0s bound to a single module constant
_UPDATER_STOP_TIMEOUT (self-documenting + DRY across all 4 sites), and update
the CLOSE-WAIT regression test to patch that constant instead of monkeypatching
asyncio.wait_for process-wide.
Fatal-notify idempotency: bounding the conflict-exhausted teardown stop() adds
an await AFTER _set_fatal_error, which yields the loop and lets a concurrent
retry task (scheduled by an earlier conflict, already suspended past the entry
guard) reach the fatal branch too — double-firing the fatal handler (surfaced
as a Python 3.11 CI failure in test_polling_conflict_becomes_fatal_after_retries).
Snapshot the pre-transition fatal state and only notify on the first transition.
When the TCP connection enters CLOSE-WAIT the PTB polling task is blocked
on epoll on a dead socket and never wakes. updater.stop() awaits that task
and therefore hangs indefinitely.
Consequence: _polling_error_task stays alive-but-blocked forever; every
subsequent heartbeat probe sees it as "in-flight" and skips triggering a
new reconnect; the gateway silently drops messages for hours until a manual
restart. Field incident: 11-hour outage on 2026-07-04 UTC despite the
heartbeat loop firing a reconnect at 01:11 — stop() blocked the entire
ladder.
Fix: wrap the updater.stop() call inside asyncio.wait_for(timeout=15).
On TimeoutError log a warning and continue to _drain_polling_connections()
+ start_polling() — same recovery path, just unblocked.
The heartbeat loop (PR #48496) correctly detects the dead socket and fires
_handle_polling_network_error. This commit is the missing second half:
ensuring the reconnect itself always completes.
Test: test_handle_polling_network_error_updater_stop_timeout() simulates
a hang by making stop() sleep forever and verifies that drain + start_polling
are still reached after the timeout.
Fixes#58270
Strict providers (DeepSeek) reject a payload where the same tool_call_id
appears more than once with HTTP 400 'Duplicate value for tool_call_id'.
The issue was filed as an 'orphaned tool message' compression bug, but the
pasted error is a DUPLICATE tool_call_id — orphans are already handled on
main; duplicates were not. Reproduced live on main: both shapes leaked
through repair_message_sequence and sanitize_api_messages.
Two chokepoints, two shapes:
- repair_message_sequence: consume the id from known_tool_ids on first
match so a SECOND tool result reusing it falls into the drop branch
(duplicate tool-result shape). This is @Robinlovelace's kernel from
#55436 (applied manually — that PR was ~800 commits stale and bundled
an unrelated duplicate-DB-write change for #860, which is dropped here).
- sanitize_api_messages (final pre-API pass): add a dedup pass covering
BOTH (a) duplicate tool_calls sharing an id WITHIN one assistant message
(the message[6] shape) and (b) later tool result messages reusing an
already-seen id. #55436 covered neither of these at this chokepoint.
Tests: duplicate-tool-result dedup at both functions, duplicate-assistant-
tool_call-id collapse, and a negative control proving distinct ids are
never dropped (no over-dedup).
Credit: @Robinlovelace (#55436) for the repair_message_sequence dedup kernel.
Closes#58327.
22c5048d9 restored Anthropic-style cache_control for two of MoA's three
call paths: the acting aggregator (MoAChatCompletions.create, the
persistent `provider: moa` model) and the advisor fan-out (_run_reference).
aggregate_moa_context() -- the /moa <prompt> one-shot command's synthesis
call -- is the third, independent call path and was never covered: its
call_llm(task="moa_aggregator", ...) sent a single undecorated user message
containing the full joined reference output, re-billing the entire input on
every invocation even when the resolved aggregator slot is a cache-honoring
route (Claude on OpenRouter/native Anthropic, MiniMax, Qwen/DashScope).
- Generalize _maybe_apply_advisor_cache_control to
_maybe_apply_moa_cache_control (it never had advisor-specific logic --
same policy function, same breakpoint layout as the main loop, judged
purely on the passed-in runtime) and reuse it in aggregate_moa_context
the same way _run_reference already does.
- Compute _slot_runtime(aggregator) once and reuse it for both the
decoration call and the call_llm kwargs, instead of calling it twice.
Mutation-verified: reverting the moa_loop.py change makes the new
regression test fail by asserting a plain string aggregator-message
content where the cache-honoring case expects native cache_control
content blocks.
Follow-up to @msh01's wall-deadline init-timeout fix.
- Resource leak: on timeout the initialize() task is abandoned without
awaiting its (shielded, possibly-never-completing) cancellation, so the
half-built PTB app's httpx client / connection pool was never closed —
up to 8x across the retry ladder. Add an optional on_abandon cleanup to
_await_with_thread_deadline that best-effort app.shutdown()s the abandoned
app, run detached + exception-swallowed so it can never re-block or re-hang
the ladder (mirrors _close_client_on_timeout in agent/auxiliary_client.py).
- Cover the helper itself: the salvaged test monkeypatched out the real
_await_with_thread_deadline, so its abandonment/cleanup path was untested.
Add direct tests for happy-path return, prompt-timeout-with-cleanup, and
cleanup-error-swallowed; the wedged coroutines swallow cancellation for a
bounded window (proving the helper returns before cancellation completes,
the #58236 shielded-scope behavior) without leaving an immortal task that
would wedge pytest teardown. Widen the salvaged stub to accept on_abandon.
- Attribution: add yingwaizhiying@gmail.com -> msh01 to AUTHOR_MAP (bare
gmail does not auto-resolve the check-attribution gate).
Known follow-up (not addressed here): the retry ladder reuses the same
self._app across all 8 attempts; a fresh app per attempt would fully close
the coherence risk if an abandoned initialize() completes in the background.
That is a larger restructure of the ~130-line builder+handler setup, left
for a separate change.
Follow-up to @srojk34's basename-denylist widening. Two gaps the
basename-only guard left, both covered by the two canonical guards it
mirrors:
- Directory-tree stores mcp-tokens/ (live MCP OAuth tokens) and pairing/
are denied as whole trees by gateway.platforms.base._ROOT_CREDENTIAL_DIRS
and agent.file_safety, but the dashboard files API descends into subdirs,
so mcp-tokens/<server>.json (non-canonical basename) stayed
listable/readable/downloadable. Add _is_sensitive_path(), a path-aware
check that blocks any path with a credential-directory component, and
route all three call sites (list/read/download) through it.
- Add .git-credentials to the basename set (agent.file_safety blocks it too).
- Correct the docstring: it now says it mirrors the credential-FILE basenames
of the canonical guards, with the directory trees handled by the new
path-aware helper (the prior wording overstated parity).
Scope stays on the read/list/download exfil surface (#57505); the write
endpoints (upload/mkdir/delete) are a separate threat and out of scope.
Tests: dir-tree descent blocked (mcp-tokens/pairing per-server files),
.git-credentials blocked, plus a positive control that a benign subdir file
stays browsable. Mutation-checked (neuter _is_sensitive_path -> new tests
fail). 39 web_server_files + fs tests pass, ruff clean.
_is_sensitive_filename() only blocked .env / .env.<suffix>, but the
dashboard Files tab's managed root is operator-configurable and, per the
docker-mount scenario #57505 was filed against, can point directly at
HERMES_HOME — where the canonical credential stores enforced elsewhere
in the codebase (gateway.platforms.base._ROOT_CREDENTIAL_FILES,
agent.file_safety.get_read_block_error) all live: auth.json, OAuth
token stores, webhook HMAC secrets, the Bitwarden disk cache. None of
those basenames were blocked, so the Files tab could still list, read,
and download them. .envrc (direnv) also slipped past the old check
since it doesn't equal ".env" or start with ".env.".
Widen the basename set to mirror both existing guards so the dashboard
doesn't lag behind them.
Both wired against features the iron-proxy author (@mslipper) confirmed on
PR #30179 — and both verified present in the pinned v0.39.0 source.
Header-auth providers (match_headers):
- New _HEADER_AUTH_PROVIDERS: Anthropic native (x-api-key), Azure OpenAI
(api-key on *.openai.azure.com / *.cognitiveservices / *.services.ai),
Gemini (x-goog-api-key + ?key= query param via match_query).
- TokenMapping grows match_headers + alias_env_names; per-provider header
sets flow into the secrets rules; mappings.json roundtrips them
(legacy files load with the Authorization default).
- GEMINI_API_KEY / GOOGLE_API_KEY collapse into ONE mapping (two
require-rules on the same host would reject each other); the sandbox
gets the token under both names, and the proxy child env mirrors the
alias into the canonical name when only the alias is set.
- Docker backend injects alias env names alongside canonical ones.
- The fail-closed tier is now empty, so fail_on_uncovered_providers and
discover_blocked_providers are deleted (dead toggle otherwise);
_NON_BEARER_PROVIDERS shrinks to genuinely-unswappable signature auth
(AWS SigV4, GCP service-account OAuth) — warn-only, as before.
Management API (hot reload):
- Generated proxy.yaml enables the v0.39 management listener: loopback
only at tunnel_port+2, bearer key from HERMES_IRON_PROXY_MGMT_KEY.
- Key minted at setup (management.token, 0600); start_proxy injects it
(v0.39 refuses to start when api_key_env is empty).
- hermes egress reload -> POST /v1/reload: re-reads proxy.yaml and
atomically swaps the pipeline; 422 leaves the running ruleset
untouched; actionable errors for not-running / pre-management config /
key mismatch. Secrets changes still require restart (daemon env is
read at spawn) — the CLI says so.
Validation: 218/218 unit+CLI+docker tests; 3/3 gated live E2E against the
real v0.39.0 binary (Authorization swap, x-api-key swap, live reload with
token rotation on the same pid). Docs updated.
Two review findings on the #57922 salvage:
1. Stale inline comment at the login-exchange site still claimed the token
endpoint uses the claude-code/ UA prefix and 404s claude-cli/ — now
contradicts the axios/ fix. Repointed it at _OAUTH_TOKEN_USER_AGENT.
2. The inherited Path.home test isolation on the three TestRefreshOauthToken
tests only stubbed the ~/.claude *file* source, not the macOS Keychain.
_refresh_oauth_token re-reads read_claude_code_credentials() (keychain
first) in its adopt-already-refreshed branch, so on any macOS dev/CI runner
with real Claude Code creds the branch short-circuits and the 3 tests fail.
Stub read_claude_code_credentials -> None so the tests are hermetic.
(The remaining TestResolveAnthropicToken/TestResolveWithRefresh/TestRunOauthSetupToken
failures on macOS are the same pre-existing keychain-leak class on origin/main,
unrelated to this OAuth-UA fix, and pass in CI — left out of scope.)
hermes auth add anthropic fails 100% at token exchange with HTTP 429 while
Claude Code /login succeeds through the same client_id/redirect/scope. The
discriminator is the User-Agent on the /v1/oauth/token request.
Verified live against platform.claude.com (throwaway code, nothing burned):
claude-code/2.1.200 (external, cli) -> 429 rate_limit (Hermes, blocked)
Mozilla/5.0 -> 429 rate_limit
axios/1.7.9 -> 400 invalid_grant (reached validation)
node / empty / SDK-style UAs -> 400 invalid_grant
Anthropic now rate-limits token-endpoint requests whose UA starts with
claude-code/ (the anti-abuse net for Max-sub-as-API-key). This is the same
prefix-block shape that #48534 first hit on claude-cli/, then #56263 dodged by
switching to claude-code/ — which held ~2 weeks and is now blocked too. Bumping
_CLAUDE_CODE_VERSION_FALLBACK cannot help; the gate is prefix-based.
Fix: shared _OAUTH_TOKEN_USER_AGENT (axios/) on the token endpoint only — the
two refresh POSTs (refresh_anthropic_oauth_pure) and the login exchange POST
(run_hermes_oauth_login_pure). The real Claude Code CLI exchanges the auth code
with a bare axios client, NOT its claude-code/ inference UA.
The INFERENCE client (build_anthropic_kwargs, /v1/messages) is deliberately left
on claude-code/ + x-app: cli — that fingerprint is required there and is NOT
throttled on the messages API. Two endpoints, opposite UA requirements.
Also isolate two _refresh_oauth_token tests from live ~/.claude creds and update
the UA regression tests to assert the split (token endpoint uses a
non-claude-code UA while inference keeps claude-code/).
Verified E2E: Hermes' own login path now returns 400 (past the 429 wall)
instead of 429, using the real _OAUTH_TOKEN_USER_AGENT constant against the live
platform.claude.com token endpoint.
Salvaged from #57922 (authorize-host + scope changes dropped as non-load-bearing;
they only add a redirect hop back to claude.ai and the UA fix alone clears 429).
repair_message_sequence Pass 1 registered only tc.get("id") when building
the set of known assistant tool_call ids, then matched tool results against
it by tool_call_id. In the Codex Responses format an assistant tool_call
carries both id (fc_...) and a distinct call_id (call_...); a tool result's
tool_call_id may be keyed on either depending on which builder produced it.
Registering only id made a valid tool result whose tool_call_id matched
call_id look orphaned, so the pass dropped it and left the assistant
tool_call unanswered -- producing HTTP 400 on strict providers (DeepSeek,
Kimi): 'Messages with role tool must be a response to a preceding message
with tool_calls'. Long-running sessions that persisted such a sequence were
permanently broken, re-sending the orphan every turn.
Register both id and call_id for each assistant tool_call so a result
matching either key is recognized, consistent with
AIAgent._get_tool_call_id_static and the compressor's _sanitize_tool_pairs.
Apply the same call_id||id precedence to the corrupted-args sanitizer's
existing-result scan / stub insertion, which had the identical mismatch.
Adds 3 regression tests covering the codex id!=call_id case (match on
call_id, match on only call_id, match on id when both present).
On X11 a window's PID comes from the optional _NET_WM_PID property, so
cua-driver's list_windows legitimately returns pid: null for windows that
don't set it (desktop root, panels, override-redirect popups). capture()
and focus_app() coerced every entry via int(w["pid"]) inside a list
comprehension, so a single null-pid window raised TypeError and aborted
the whole enumeration before any screenshot — capture was impossible on
any X11 desktop with even one such window.
Route both ingestion sites through a new _ingest_windows() helper that
skips entries lacking a usable pid/window_id (uncapturable anyway) and
coerces the rest.
Adds tests/tools/test_computer_use_null_pid_windows.py covering the
helper's filtering/coercion and an end-to-end capture() regression that
reproduced the crash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #22773 heuristic classified any telegram:<positive_chat_id>:<numeric_thread_id>
cron target as a Bot API channel Direct-Messages topic and routed it via
direct_messages_topic_id, which nulls message_thread_id. A normal forum-style
topic inside a private chat has the identical shape, so every such cron
delivery landed in General instead of the target thread (#52060). It also
means the only way to address a genuine channel DM topic was this same
ambiguous guess.
Disambiguate with the real runtime signal instead: probe the live adapter's
get_chat_info once and route via direct_messages_topic_id only when the chat
is actually a channel; everything else (private forum topic, forum supergroup,
group) and any probe failure fall back to message_thread_id — parity with
0.16.0 and with live reply routing. This fixes forum-topic delivery AND makes
genuine channel DM-topic cron delivery work correctly.
PR #57601's original branch added a top-level reasoning_effort emit to the
LEGACY build_kwargs path (agent/transports/chat_completions.py), but
provider=custom resolves to CustomProfile (plugins/model-providers/custom/),
so chat_completion_helpers takes the profile path and returns early — the
added branch was unreachable dead code for every custom endpoint.
Move the fix to its real site, CustomProfile.build_api_kwargs_extras(), and
follow the DeepSeek/Zai profile precedent:
- disabled -> extra_body.think = False (unchanged)
- enabled + effort -> TOP-LEVEL reasoning_effort (the OpenAI-compatible
format GLM-5.2/ARK expect), passed through verbatim
incl. max/xhigh
- enabled + no effort -> omit, so the endpoint's server default applies
(avoids silently forcing 'medium' as the original
branch did)
Deliberately does NOT force think=True on enable — that flag is Ollama-only
and risks a 400 on GLM/vLLM endpoints that don't recognize it; thinking is
already server-default-on for these backends.
Verified end-to-end through the real profile dispatch (temp HERMES_HOME):
custom+high -> reasoning_effort=high; custom+max -> reasoning_effort=max;
custom+none -> think=False; custom+unset -> nothing; num_ctx composes.
Adds tests/plugins/model_providers/test_custom_profile.py (13 cases).
Addresses the custom-provider half of #55276.
Co-authored-by: huanshan5195 <huanshan5195@users.noreply.github.com>
Follow-up to salvaged #57601. Adding "max" to VALID_REASONING_EFFORTS
made parse_reasoning_effort("max") valid, so:
- test_unknown_levels_return_none no longer lists "max" (it is now valid;
auto-covered by test_each_valid_level which iterates the tuple).
- test_known_supported_levels_are_documented and the parse_reasoning_effort
docstring now include "max" so the doc-sync guard actually protects it.
Follow-up to the salvaged SSH-tilde-cwd fix. The predicate
"backend == ssh and (cwd == ~ or cwd.startswith(~/))" was inlined at
each expanduser guard site, which is how the test simulator drifted from
production (it grew an SSH guard on a top-level-alias branch that has no
production counterpart).
- Add tools/terminal_tool._is_ssh_remote_tilde_cwd(backend, cwd) as the
single source of truth (case/whitespace-tolerant).
- Use it in _get_env_config and the gateway config bridge.
- Test simulator imports the real helper instead of re-implementing the
predicate; revert the phantom SSH guard on the top-level-alias branch
(production maps top-level cwd: to a plain env var, not TERMINAL_CWD via
an SSH-guarded path — that branch tested nothing real).
Follow-up to the salvaged #58000 fix.
- Extract _raise_if_non_interactive(lead) so the shared 'hermes mcp login'
next-step wording lives in one place across both OAuth boundaries
(_redirect_handler, _wait_for_callback), rather than two copy-pasted
inline raises. Boundary-specific lead sentences preserved verbatim, so
existing message-match tests stay green.
- Add a positive-control test asserting the guard does NOT over-fire on the
interactive path (valid/refreshable tokens keep working), satisfying an
explicit regression-coverage line from #57836.
Add TestNonInteractiveFailFastAtCallbackBoundary: the callback boundary must
reject before binding a listener and without entering the poll loop, the guard
must hold even when a (stale) token file exists on disk, the redirect handler
must not print a URL or open a browser, and both boundaries must point users at
`hermes mcp login`.
Mark the existing timeout test and the SSH-hint redirect tests interactive so
they exercise their intended paths rather than short-circuiting on the new
non-interactive guard.
The salvaged max-output/pressure fix set conversation_history=None after
the new pre-API compaction. That is only correct for legacy session-
rotation. Under the default in-place compaction (compression.in_place:
True), archive_and_compact inserts the compacted rows into the session DB
directly without stamping them with the intrinsic persisted-marker, so a
subsequent flush with conversation_history=None re-appends them — doubling
the active context and retriggering compression (the early-persist
duplicate-row trap).
Use conversation_history_after_compression(agent, messages), matching the
two existing compaction sites (post-response should_compress and the
turn-prologue preflight), which returns None for rotation and
list(messages) for in-place so the compacted dicts are skipped by identity.
Adds a regression test with a real SessionDB + real archive_and_compact
that asserts the compacted summary row is persisted exactly once (fails
with 2 copies on the None variant).
Follow-up to the salvaged #57845 fix. _can_carry_marker used
any(isinstance(part, dict)) but _apply_cache_marker only marks the LAST
content part, so a list whose last element is a non-dict passed the carrier
gate yet received no marker — wasting one of the four breakpoints. Tighten
the predicate to require content[-1] to be a dict (mirroring the apply
logic) and add a regression test. Flagged by a 3-agent review.
Gateway users can now search resumable sessions from messaging surfaces:
/sessions search <query> (alias: find) matches titles and session ids —
including every title/id in a row's forward compression chain, so a
compressed-away title still surfaces its live tip — plus a
punctuation-normalized variant so 'an94' matches 'AN-94'.
Implemented by generalizing the existing id_query chain-filter in
SessionDB.list_sessions_rich into a combined SQL-level filter (search
stays ORDER BY last-active + LIMIT at SQL level), threading a
search_query through the shared query_session_listing helper, and
teaching parse_session_listing_args to split off a search query.
Search results pass through the existing _resume_row_visible guard
unchanged: origin scoping, admin-only 'all', and the fail-closed
legacy-row posture from the July 1 hardening are preserved exactly.
Over-fetch (50) before the visibility cut so origin-invisible matches
can't starve the page.
Salvages the feature direction of PR #57595 by @GodsBoy with a minimal
implementation that keeps the resume authorization surface untouched.
Post-merge follow-ups + several review rounds + a hub-search rework, folded together.
Merge-scuff restores (a stale-base refactor had reverted two live-on-main fixes):
- gateway: SessionStore compression-tip healing + its regression test.
- desktop: messaging session/transcript polling in desktop-controller
(MESSAGING_POLL / ACTIVE_MESSAGING_SESSION_POLL, refreshMessagingSessions,
refreshActiveMessagingTranscript, the richer sameCronSignature) so inbound
platform traffic updates live again instead of freezing until manual refresh.
Profile-switch isolation (epoch/close/guard on every profile-scoped async):
- Hub store clears + in-flight runHubAction bails (and swallows the post-switch
404 instead of a phantom toast); hub preview/scan/search/sources profile-scoped.
- MCP: probe/auth epoch guards, dirty-draft reset, sidebar mutations blocked
until config resettles AND every persist re-checks the epoch post-await;
profilePending clears on config settle incl. error; logs re-key on profile.
- Model settings reload on switch and epoch-guard setModelAssignment /
saveMoaModels / API-key activation.
- Config draft resets + cancels its autosave on switch; skill editor/archive and
star-map node dialogs close on switch; openSkillEditor / star-map openEdit
discard stale fetches; tool-usage analytics loads are profile-guarded/keyed.
Correctness + UX:
- Unique per-skill action names for hub install AND uninstall; hub/catalog rows
flip only on a clean exit_code; catalog install polls the background bootstrap
to completion, reconciles the mcp.json draft (no dropped server), and fails
loudly on non-zero exit; MCP catalog query keyed by profile.
- /test reports needs-auth for anonymous auth:oauth servers; /auth snapshots +
restores tokens on a failed re-auth and clears the full 300s callback window.
- config-settings shows a retry on load failure; CodeEditor/JsonDocumentEditor
go read-only while saving so edits typed mid-save aren't dropped.
- Deep-link highlighter deletes its param only after a successful scroll.
- Restored the PageSearchShell trailing slot → Artifacts refresh button/spinner.
- /settings?tab=mcp redirect keeps server=.
Progressive hub search: fan out one query per backend-searchable source
(index-covered API sources stay unsearchable → no ~70-call GitHub re-hammer),
merge/dedupe by trust as each lands, per-source spinner overlaid on the dimmed
chip — results stream in without blocking on the slowest, no layout shift.
test(web): /api/skills list carries usage + provenance (CI contract).
from a live blocked-sites pass (no PII):
- posture shift: blind opt-out is the DEFAULT, not a fallback -- submit on every site with an
accessible removal channel even without first confirming a listing (own identifiers to the broker's
own official channel = still least-disclosure). guided flows double as the authoritative search.
- blocked-form rule: when a form is automation-hostile (hard captcha / cloudflare / datadome /
slide-to-verify), default to the broker's CITED rights-email rather than recording blocked.
- captcha policy clarified: never defeat behavioral/token/slider challenges; ok to read a static
distorted-text or plain-arithmetic captcha on the subject's own opt-out; stop if the whole
submission is rejected after a correct answer (fingerprinting the automation, not grading it).
- intelius/peopleconnect: delete-wipes-suppression is field-confirmed -- a deletion-complete email
means the suppression is gone and the subject re-lists cluster-wide; re-run suppression and verify
the Control step reads "suppressed". guided-mode session persists; DOB is an <input type=date>.
- new records: addresses.json (intelius front-end, cluster-covered) and socialcatfish.json
(cited rights-email lane + automation-hostile form).
- new references/site-playbooks.md: per-site game-plan matrix (8 blocked-tail sites), the meta-search
no-op skip-list (idcrawl/lullar/yasni/webmii/namesdir/itools/skipease), and the infopay /
peopleconnect backend clusters. OSINT-list triage taxonomy added to methods.md.
- state-machine.md: fixed doc drift + documented submitted->not_found illegal (resolves as
awaiting_processing), blocked->submitted via action_selected, operator_manual_check, --evidence & pitfall.
tests: standalone 99, PR 97 (+1 cluster-coverage regression); ruff + windows-footguns clean.
Route the app off its hand-rolled helpers onto lib/{text,time,format,json-format}
and the new primitives, plus assorted small tidy-ups:
- compactNumber for counts/tokens; normalize/capitalize/asText at the many
filter/label sites; shared Intl date/time formatters; row-hover + framed
editor adoption; scrollbar-gutter + padding parity on list surfaces.
- Messaging/Artifacts/Cron search hints + narrow-viewport tab dropdown;
floating-pet adopts useOnProfileSwitch; number formatting in statusbar,
command-center, agents.
- Electron: native overlay width + backend spawn tidy.
- Settings > Keys: credential fields read as plain subtext (all-unset) until
the group is focused or expanded, then take full input chrome with no
horizontal/vertical shift; inline Remove (trash) + Save mirror SearchField's
trailing-clear pattern instead of a floating hint that overlapped the card;
Esc still cancels. Drops the now-dead or/escToCancel i18n keys.
- Shared TabDropdown/ResponsiveTabs (components/ui): PageSearchShell and the
Command Center log file/level filters reuse the one narrow-width collapse.
- OverlayNav: data-driven pane nav — persistent rail on wide, a single dropdown
riding the titlebar strip on narrow; Settings and Command Center adopt it, and
the mobile dropdown carries the same section icons as the rail. Fixes narrow
vertical centering, redundant mobile section titles, gateway-status wrap, and
Panel master/detail stacking.
- OverlayIconButton is now the titlebar ghost button, matching the close X at
every size. Settings sub-view nav opens section + sub-view in one navigate so
API-keys/accounts actually open on narrow.
- Settings > Model: cube icon (was the {} namespace glyph) and a DOM-shaped
skeleton in place of the centered spinner.
- Command palette / session switcher clear the macOS traffic lights on small
screens.
- Prettier/eslint sweep across the touched files.
phase-2 work (sending webmail, clearing session-bound gates like peopleconnect guided-mode) needs
the operator's own logged-in browser, not a cloud browser. new `pdd.py cdp`:
- finds chrome/chromium/brave/edge (macos/linux/windows), launches it detached on a dedicated debug
profile ($HERMES_HOME/chrome-debug) with --remote-debugging-port, waits for the port, prints the
CDP endpoint (webSocketDebuggerUrl)
- `--check`: report whether a debug browser is already live (never double-launches)
- `--print`: emit the exact command for the operator to run themselves
- doctor, SKILL.md, and methods.md all point at it
- windows-safe detach (start_new_session on posix, DETACHED_PROCESS on windows); stdlib only
tests: standalone 98, PR 96 (+6 cdp); ruff + windows-footguns clean.
from a live run (NY subject, 43 brokers):
- fanout default 8->5 (8+ batches time out)
- setup/doctor read $HERMES_HOME/.env so creds hermes already loads are detected
- new `show <subject> <broker>`: reads back case state+evidence for cheap parent re-verify
- intelius: requires.dob + 5-step guided-mode gate; planner pre-warns when dob is missing
- rehold.json: property-record != PII (an address-only match is not_found, not removable)
- tps/fps: match_signal_notes tell the scanner to ignore SEO-templated titles
- methods.md: browser backends (scan vs execute + operator chrome over CDP), property/SEO callouts
- doctor: warn when browser email-mode pairs with a cloud scan backend (needs operator chrome/CDP)
- ledger: found->not_found retract (false-positive), blocked->human_task_queued
- autopilot: indirect-exposure web-form fallback; drop a stray f-string
tests: standalone 92 pass; ruff clean.
A plugin-registered WebSearchProvider with no built-in provider credentials
must light up web_search / web_extract and be discoverable by the backend
selectors. Covers check_web_api_key(), _get_backend(), _is_backend_available()
registry delegation, per-capability extract selection (#32698), and that the
web_search / web_extract tool registry entries are not filtered out.
Tests contributed by @m0n5t3r (PR #28652, issue #28651).
Asserts the behavior contract that run_one_job installs a profile secret
scope around run_job under multiplexing (so resolve_runtime_provider's
get_secret does not fail-close with UnscopedSecretError) and tears it
down afterward. Mutation-verified: fails on unmodified main with the
exact UnscopedSecretError, passes with the fix.
Fold the xAI video credential-read guard into the same shared
agent.file_safety.raise_if_read_blocked chokepoint this PR introduces for
the image providers, so the whole image+video bug class is covered by one
enforced boundary. Consolidates the parallel salvage of #57695 (xAI
image+video) into this PR; #57727 is now redundant and will be closed.
- video_gen/xai: guard _image_ref_to_xai_url and _video_ref_to_xai_url
(the video image + video byte-read chokepoints) via the shared helper.
- Regression tests: symlinked auth.json with .png/.mp4 names are blocked
across both video read paths (mutation-checked).
Follow-up to the per-provider guards. Three improvements from review:
1. Extract agent.file_safety.raise_if_read_blocked() as a single shared
chokepoint and route the OpenAI, OpenRouter, and (newly) xAI image
providers through it, replacing the 3x-duplicated inline try/except.
Fixes the whole bug class: xai/_xai_image_field read a model-supplied
local path via open() with no guard — the same vulnerability the PR
fixed for OpenAI/OpenRouter, in a sibling provider it missed.
2. Strengthen the regression tests from pass-on-any-ValueError to true
security invariants: spy open()/read_bytes() and assert the blocked
credential is NEVER read; add negative controls (legit local image
still loads; remote/data: URIs pass through unguarded) so a
block-everything regression can't pass.
3. Guard is best-effort by design (defense-in-depth, not a security
boundary) — documented on the shared helper.
- agent/file_safety.py: raise_if_read_blocked()
- plugins/image_gen/{openai,openrouter,xai}: route through helper
- tests: no-read spies + negative controls across all three providers
Five follow-ups to #57659 from post-merge review:
1. install.ps1: gateway scheduled-task re-enable now runs in a finally
(a thrown Remove-Item/uv venv failure previously stranded the user's
gateway autostart disabled), and tasks that were already disabled
before the install are no longer blindly re-enabled.
2. The venv-python holder guard is no longer bypassed by plain --force
(which the desktop bootstrap passes on every update while its lock
probe only checks hermes.exe/app.asar). New explicit --force-venv is
the escape hatch; --force keeps bypassing only the hermes.exe shim
guard.
3. _detect_venv_python_processes now also catches uv/base-interpreter
trampolines whose exe is outside the venv, via cmdline (venv path or
'-m hermes_cli.main' tied to this install root) and cwd.
4. Missing venv python is now UNHEALTHY on managed installs
(.hermes-bootstrap-complete / .update-incomplete markers) so the
repair lane runs instead of 'Already up to date!'; the repair branch
recreates the venv first when it's gone entirely. Dev checkouts keep
reporting healthy.
5. install.ps1 comment no longer claims a Startup-folder disarm the
code doesn't perform (logon-only, not a mid-install respawner).
The salvaged tests from #53754 predate _handle_vision_analyze becoming
async and the native fast path; await the handler and force the legacy
aux path so the model-resolution assertion is actually exercised.
_handlers for vision_analyze and video_analyze read model name from
config.yaml (auxiliary.vision.model / auxiliary.video.model) before
falling back to AUXILIARY_VISION_MODEL / AUXILIARY_VIDEO_MODEL env
vars. Matches the existing config-first pattern for timeout and
temperature in the same file.
Fixes#53749
A custom:<name> main provider resolves at runtime to the bare provider id
"custom". In the vision auto-detect chain, the main-provider branch called
resolve_provider_client("custom", ...) WITHOUT explicit_base_url/api_key,
so it returned (None, None) ("no endpoint credentials found") and the whole
chain fell through to OpenRouter/Nous. A user on a custom endpoint with no
aggregator configured then got "No LLM provider configured for task=vision
provider=auto" on every image, even though their main model fully supports
vision.
Recover the live endpoint that set_runtime_main() records each turn
(_RUNTIME_MAIN_BASE_URL/_API_KEY/_API_MODE) and forward it to Step 1, with
a fallback to _resolve_custom_runtime() for non-gateway callers. Mirrors the
existing explicit-base_url branch directly above.
Adds TestResolveVisionCustomProvider covering custom, custom:<name>, and the
no-runtime fallback path.
When model.provider is set to custom:<name>, _supports_vision_override()
previously tried only the runtime provider key ('custom') and the raw
config value ('custom:my-proxy'). It did not try the stripped name
('my-proxy'), which is the actual key under providers: in config.yaml.
This caused native image routing to fall back to text mode even when the
user explicitly declared supports_vision: true on the named provider's
model entry.
Fixes#39963
A user with tts.openai.model set to a direct-OpenAI model (e.g. tts-1-hd)
but no VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY (or with tts.use_gateway)
routes TTS through the managed Nous audio gateway, which only proxies
gpt-4o-mini-tts. The request 400s with:
VALIDATION_ERROR: Unsupported managed OpenAI speech model
{'model': 'tts-1-hd', 'supportedModels': ['gpt-4o-mini-tts']}
_resolve_openai_audio_client_config now reports whether it resolved the
managed gateway; _generate_openai_tts coerces the model to a
managed-supported one (logging a warning that points at the direct-key
escape hatch) unless the user redirected base_url to their own endpoint.
Direct-key users keep their tts-1/tts-1-hd preference unchanged.
Follow-up to #57507: .ENV / .Env.local on case-insensitive filesystem
mounts slipped past the guard. Lowercase the name before matching and
add a regression test. Addresses egilewski's open review note.
`_dispatch_sync` gathers the mautrix per-event handler tasks with a bare
`asyncio.gather(*tasks)`. Without `return_exceptions=True`, the first handler
that raises aborts the gather, so the sibling events in the same sync response
are dropped unprocessed — the exception propagates up to the sync loop, which
logs a single "sync error" and moves on. The invite/redaction gathers a few
lines above already use `return_exceptions=True`.
Use `return_exceptions=True` and log each failing handler, so one bad event no
longer takes out the rest of its batch and per-event failures stay visible.
Regression test: a batch with one failing and one succeeding handler no longer
raises, the good handler still runs, and the failure is logged (mutation-
verified — reverting re-raises RuntimeError out of _dispatch_sync).
Source: https://github.com/NousResearch/hermes-agent/pull/52346
Related prior work: https://github.com/NousResearch/hermes-agent/pull/39462
Related prior work: https://github.com/NousResearch/hermes-agent/pull/27426
Maintainer direction: https://github.com/NousResearch/hermes-agent/pull/52346#issuecomment-4854881612
Remove acp_command and acp_args from the model-facing delegate_task schema and
dispatch paths. Child agents can still use ACP subprocess transport when it
comes from trusted delegation config or parent inheritance, but a model tool
call can no longer choose the command or arguments that reach child
construction.
This is salvageable because the risky boundary is model control over child ACP
transport, not ACP itself. The patch follows the maintainer direction from the
source discussion by preserving trusted ACP configuration and prior integration
work while removing the untrusted tool-call fields from both top-level and
per-task delegate inputs.
Reproduced on main by passing acp_command through delegate_task and observing it
reach _build_child_agent. Verified after the fix that model dispatch strips the
hidden top-level fields and per-task hidden fields are ignored before child
construction.
Co-authored-by: Carlosian <claudlos@agentmail.to>
Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com>
Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
Replace the exact-filename frozenset with _is_sensitive_filename()
that matches .env plus any .env.<suffix> variant. This covers
shorthand suffixes like .env.prod that the previous enumeration
missed.
Add test_sensitive_env_suffix_variants_blocked regression test
covering .env.prod, .env.dev, .env.staging.local, and .env.ci.
Addresses review feedback from egilewski on PR #57507.
The dashboard Files tab could list, read, and download .env files
containing API keys when running with a bind-mounted Hermes home
directory (e.g. docker run -v ~/.hermes:/opt/data).
Add _SENSITIVE_FILENAMES frozenset and filter these from
list_managed_files(), read_managed_file(), and download_managed_file().
Return 403 for direct read/download attempts on sensitive files.
Fixes#57505
_resolve_media_to_data_urls's ad-hoc _MEDIA_TAG_RE matched any bare
token after MEDIA: (no absolute-path anchor) and read the resolved
path directly with no denylist. A relative/traversal path like
MEDIA:../../../../etc/passwd.png slipped through, and any image-
suffixed file the process could read (including under ~/.ssh, ~/.aws,
etc.) was base64-inlined into the API response if its path merely
appeared in the model's own final reply text.
Every other platform adapter's MEDIA: handling already goes through
two shared primitives in gateway/platforms/base.py:
- MEDIA_TAG_CLEANUP_RE, which anchors the path to ~/, /, or a
Windows drive letter plus a known deliverable extension.
- validate_media_delivery_path, which resolves symlinks and rejects
paths under the credential/system-path denylist.
Reuse both here instead of the local unanchored pattern and naive
Path().expanduser() resolution.
browser_cdp's frame_id (OOPIF) path returned early via
_browser_cdp_via_supervisor before _browser_cdp_private_guard ever ran,
unlike the stateless path a few lines below. A model that navigated a
cloud browser to a private/internal URL could still read page content
by passing frame_id, bypassing the same SSRF/private-page boundary
already enforced on Runtime.evaluate, Page.navigate, and other raw CDP
calls.
Apply the same guard call used by the stateless path before dispatching
to the supervisor, so both routing modes share one boundary.
Root-causes the July 2026 Windows incident chain (locked _brotlicffi.pyd /
_sodium.pyd during install, then 'No module named annotated_doc' with
'hermes update' insisting 'Already up to date!'):
- hermes update: probe venv core imports even when the checkout is current;
a half-updated venv (dep sync killed mid-flight by a locked .pyd) is now
detected and repaired instead of being reported as up to date
- hermes update (Windows): after pausing gateways, refuse to mutate the venv
while other processes run from the venv interpreter (the Desktop backend
runs as python.exe so the hermes.exe shim guard never saw it); --force
keeps the old behavior
- install.ps1 venv stage: disarm gateway autostart Scheduled Tasks before
the kill sweep (they respawn the gateway inside the kill->delete window),
make the sweep a bounded loop requiring 3 clean passes, and rename-then-
delete the old venv (a rename succeeds even with mapped DLLs) with stale-
dir cleanup on the next run
- desktop updater: 'venv shim still locked after 15s' now ABORTS the update
hand-off (restarting our backend, surfacing the holder to the user)
instead of 'proceeding anyway (force)' into guaranteed venv corruption;
the unlock wait also re-kills respawned backends each poll tick
A Cursor-style MCP manager inside Capabilities, plus the backend it needs.
- Server list with brand/favicon avatars + live status dot and a capability
summary (N tools, M prompts, K resources); Servers | Catalog views.
- Catalog: one-click install of Nous-approved servers with required-env prompts.
- GUI OAuth: Authenticate opens the system browser from the TTY-less backend and
verifies a token actually lands; header/API-key servers are never pushed down
OAuth; a dirty mcp.json can't drop a freshly-persisted auth field.
- Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent
LogTail; probes cached 5m and keyed by (profile, config) so revisiting never
respawns the fleet or shows a stale probe.
- Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick
(the generic /api/config deep-merge could not remove keys).
- perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio
spawn can't stall every other request into a 15s timeout.
- per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
The routing-heal added to get_or_create_session calls
SessionDB.get_compression_tip; the stale-guard suite's bare MagicMock db
returned a Mock the heal then assigned as session_id, failing JSON
serialization. Model the real contract (a non-compressed session's tip is
itself) so the heal is a correct no-op.
Inbound Telegram/WeChat/Discord messages are written by the background
gateway, not the desktop websocket that drives local chats. Without
explicit polling the messaging sidebar and the open transcript stay
frozen until the user manually refreshes.
Desktop:
- MESSAGING_POLL_INTERVAL_MS (10 s): interval poll of the messaging
session list so new platform sessions surface automatically.
- ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS (5 s): poll the currently-
viewed messaging transcript and re-hydrate the chat state when the
FNV-1a signature changes (hash covers role + timestamp + content).
- sameCronSignature now compares lineage_root_id / source / profile /
preview / message_count / last_active / ended_at so stale previews
and activity times are no longer silently ignored.
- sessionMatchesStoredId helper de-dups the id / _lineage_root_id check.
- refreshMessagingSessions exposed from useSessionListActions so the
controller can use it in the poll effect.
Gateway:
- SessionStore._compression_tip_for_session_id: look up the latest
compression continuation for a session id.
- SessionStore._heal_compression_tip_locked: rewrite a stale entry to
the compression child before returning it, so a restart or failed send
no longer leaves the store pinned to the compressed parent.
Co-authored-by: lawyer112 <lawyer112@users.noreply.github.com>
Follow-up to the salvaged early-exit retry fix (#35617): the debug-browser
launch path was fire-and-forget (stderr to DEVNULL, no logging), so every
platform failure — Windows singleton forward to an existing instance, bad
profile dir, missing shared libraries, policy blocks — collapsed into the
same unactionable 'port 9222 isn't responding yet' message and debug
reports contained nothing.
- launch_chrome_debug() returns a structured ChromeDebugLaunch with
per-candidate attempts (state, exit code, stderr tail)
- browser stderr is captured to <hermes_home>/chrome-debug/launch-stderr.log
- clean exit (code 0) without the port opening is detected as Chromium's
single-instance forward and produces a targeted user hint to close all
running instances of that browser
- crash exits surface the stderr tail (e.g. missing libnspr4.so)
- every spawn/exit is logged to agent.log so hermes debug share captures it
- CLI (/browser connect) and TUI/desktop (browser.manage) both print the hint
* feat(desktop): CLI/dashboard parity — skills hub browser, MCP test/toggle/catalog, maintenance ops, log filters
Brings desktop GUI to parity with hermes skills/mcp/doctor/backup/debug-share/
curator/memory CLI commands and the dashboard's System + Skills-hub pages:
- Skills page: new Browse Hub tab (search official/GitHub/community sources,
preview SKILL.md, security scan verdicts, install/update with live action log)
- MCP settings: connection test (tool listing), per-server enable/disable
toggle, and a Catalog tab installing Nous-approved MCP servers with env prompts
- Command Center: new Maintenance section (doctor, security audit, backup,
debug share links, curator status/pause/run, memory file status + reset)
- Command Center system logs: file (agent/errors/gateway/desktop), level, and
substring filters instead of a fixed agent.log tail
- hermes.ts API client + types for all the above; en/zh locale strings (ja and
zh-hant inherit via defineLocale)
* feat(desktop): backend model catalogs in toolset config — hermes tools parity
Completes the `hermes tools` parity gap: after picking an image/video
generation backend the CLI runs a model picker (e.g. FAL's multi-model
catalog with speed/strengths/price); the desktop toolset drawer now has the
same flow as a radio-card list.
- web_server: GET /api/tools/toolsets/{name}/models (catalog + current +
default for the active or named provider row) and PUT .../model
(validated write to image_gen.model / video_gen.model), reusing the CLI's
plugin catalog helpers so GUI and `hermes tools` stay in lockstep
- desktop: ModelCatalogPicker in ToolsetConfigPanel — per-model cards with
speed/strengths/price, in-use + default badges, disabled until the
backend is the active one; provider selection now mirrors is_active
locally so the catalog unlocks without a refetch
- tests: 3 backend endpoint tests (catalog shape invariants, persist +
validation), 2 component tests, 2 API-contract tests; en/zh strings
OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without
/v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI
chat completions (base URL WITH /v1). The runtime stripped /v1 for
anthropic-routed models, and the TUI/desktop + gateway persisted that
stripped URL to model.base_url. Every later chat_completions model then
POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the
marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed.
- New normalize_opencode_base_url(): symmetric /v1 normalization —
strip for anthropic_messages, re-append for chat_completions /
codex_responses on opencode.ai hosts (heals persisted stripped URLs;
custom proxy overrides untouched)
- Applied at all three former one-way strip sites (resolve_runtime_provider
x2, switch_model)
- opencode_model_api_mode: all Qwen models on Go AND Zen now route via
/v1/messages per current published endpoint tables (previously only
qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way)
- Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2,
kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2,
kimi-k2.7-code, minimax-m3, qwen3.7-plus
Reported by IndieSuperhuman on X: opencode-go 404s for any model other
than minimax.
A single-model Hermes agent never sends temperature; the provider default
applies. MoA hardcoded reference_temperature=0.6 / aggregator_temperature=0.4,
and the coercion float(preset.get(key, 0.6) or 0.6) made unset IMPOSSIBLE to
express: absent, null, empty, and even an explicit 0 all collapsed to the
baked-in default. Every MoA advisor and aggregator therefore ran at 0.6/0.4
while the same model running solo used the provider default — silently
skewing solo-vs-MoA comparisons and overriding provider-tuned defaults.
- moa_config normalization: temperatures coerce to None when absent/blank/
invalid (new _coerce_float_or_none); explicit values incl. 0 honored.
- moa_loop: _preset_temperature() resolves preset values; None flows to
call_llm, which already omits the parameter when None (same contract as
max_tokens). Aggregator still inherits the acting agent's own configured
temperature when the preset doesn't pin one.
- conversation_loop (context-mode MoA): same resolution, no more hardcoded
0.6/0.4 at the call site.
- DEFAULT_CONFIG preset + web_server payload models + docs updated: unset
is the default, pinning stays available.
Follow-up to the per-site strips from the review gate. The two copy-site
strips are correct but positional — a copy site added after the assembly
loops would re-leak _db_persisted into the child-session flush. Add a single
terminal sweep (_strip_persistence_markers) run once on the fully-assembled
compressed list so the invariant 'no compacted message leaves compress()
carrying a persistence marker' is structural, not dependent on copy-site order.
- agent/context_compressor.py: _strip_persistence_markers() called before
compress() returns; helper docstring notes the sweep is the authoritative guard
- tests/agent/test_context_compressor.py: structural regression — neuter the
per-site helper to a leaking copy, assert the terminal sweep still strips
- tests/run_agent/test_compression_persistence.py: pin the fixture assumption
behind the exact-equality row-count assertion
Shallow messages[i].copy() during context compression propagated the
_db_persisted marker from cached gateway incremental flushes into the
post-rotation compressed list. _flush_messages_to_session_db then skipped
every row when writing to the new child session, so gateway restarts
lost the compacted transcript (severe amnesia).
Strip the marker in _fresh_compaction_message_copy() and add regression
tests for rotation flush + compressor assembly.
Fixes#57491
The reaction-guard regression test defined a local _should_react lambda and
asserted it against itself — a tautology that would stay green even if the
production guard at _handle_slack_message reverted to (is_dm or is_mentioned),
re-introducing the unmentioned-MPIM reaction spam this PR fixes.
Replace it with a shared _reaction_guard helper plus a source-introspection
test that pins the production expression: asserts (is_one_to_one_dm or
is_mentioned) is present and (is_dm or is_mentioned) is absent. Mutation-checked
— reverting the adapter guard now fails the test.
Follow-up self-review finding on the salvage of #57339.
Group DMs (MPIMs) were classified as DMs and thereby exempted from every
operator control that shared surfaces are supposed to honor: allowed_channels,
require_mention, strict_mention, free_response_channels, and the reaction
guard. Symptom: the bot added 👀/✅ to unmentioned MPIM
messages and still invoked the agent (which then returned NO_REPLY) instead of
the gateway dropping the event before model execution. Removing an MPIM from
allowed_channels did not disable it.
Root cause is the DM classification at adapter.py:
is_dm = channel_type in {"im", "mpim"}
used for BOTH routing exemptions and reaction gating. An MPIM is a shared
surface (multiple humans can see and trigger the bot), not a private 1:1 DM,
so it must be gated like a channel.
This behavior was introduced/reinforced by a trail of Slack group-DM PRs:
- #4633 fix(slack): treat group DMs (mpim) like DMs + reaction guard
- #54632 fix(slack): subscribe to message.mpim + mpim scopes so group DMs work
- #54663 fix(slack): group DMs work OOTB + reinstall nudge
#54632/#54663 correctly made MPIM messages *reachable*; #4633 over-reached by
giving them the DM mention/reaction *exemptions*. This corrects only that
over-reach.
Fix (minimal): introduce `is_one_to_one_dm = channel_type == "im"` and key the
two EXEMPTION sites off it instead of `is_dm`:
- mention/allowlist gating block (`if not is_one_to_one_dm and bot_uid:`)
- reaction guard (`(is_one_to_one_dm or is_mentioned)`)
`is_dm` is intentionally retained for session/thread scoping and chat_type
labeling, where treating an MPIM as a persistent multi-party conversation is
correct — only the mention/reaction exemptions were wrong.
Docs: slack.md now distinguishes 1:1 DMs (mention-exempt) from group DMs
(shared surface; obey require_mention/strict_mention/allowed_channels/
free_response_channels; reactions only when @mentioned).
Tests: +7 in test_slack_mention.py (MPIM unmentioned dropped under
require_mention and strict_mention; MPIM mentioned processed; MPIM off
allowed_channels dropped; MPIM in free_response opted in; 1:1 IM still exempt;
reaction guard drops unmentioned MPIM). Updated _would_process to model the
is_one_to_one_dm gating + strict_mention. 72 passed.
PeopleConnect is the exception to deletion-beats-suppression: "DELETE MY
USER DATA" also deletes suppressions on file, and deletion does not stop the
people-search sites from showing you (public records re-list). Suppression
is the effective lever and must be maintained.
- intelius.json: deletion.prefer=false; playbook/quirks/notes rewritten with
the verbatim privacy-center language; delete is the data-purge-only path.
- autopilot: honor deletion.prefer -> prefer_suppression when false.
- methods.md / SKILL.md / README: exception called out.
- tests updated + prefer-flag routing test (86 tests).
unbroker finds where a consenting person's info is exposed across data
brokers and people-search sites and files the removals, running as far as
each site allows and handing only genuinely human-only steps (hard CAPTCHA,
gov-ID, phone, fax) back as an end-of-run digest.
- Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent,
the broker DB, tier planning, the ledger, email, and the autonomous
action queue; the agent scans/submits with native tools (web_extract,
browser_*, delegate_task, cronjob, terminal).
- Verify-before-disclose, least-disclosure (never volunteers SSN), consent
gate, opaque ids, optional age-at-rest encryption, file-locked ledger.
- Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers
the state registry (~545) in a single request; BADBOOL + curated
people-search coverage; scheduled re-scan for re-listing.
- No CAPTCHA-solving services or anti-bot bypass; browser email mode needs
no stored password.
- 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via
injected fakes, registry via CSV fixtures). Ships placeholder data only.
Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).
hermes debug share reads os.getenv — the invoking terminal's environment — but
launchd/systemd and the desktop-spawned `serve` backend load credentials from
~/.hermes/.env, not the login shell. A key exported in the shell but absent
from .env is invisible to the backend, yet the dump printed a bare "set",
sending support down a phantom "the key is configured" path.
This was the actual trap behind a "Desktop has no web_search / no tools"
report: FIRECRAWL_API_KEY was a shell export (so `debug share` in a terminal
read "firecrawl set") but not in .env, so the launchd backend's
check_web_api_key returned False and web_search was gated off — which a
contributor then misdiagnosed as a missing `desktop` platform registration.
The dump now annotates any key set in-process but missing from ~/.hermes/.env
with "(shell only — not in .env; managed/desktop backend may not see it)" so
the mismatch is obvious instead of hidden behind "set".
The merged webhook session-close fix (#57370, salvaging #57322) wrapped
handle_message in a try/finally — but BasePlatformAdapter.handle_message
is fire-and-forget: it spawns _process_message_background and returns
before the agent run starts. The finally-close therefore ran BEFORE
get_or_create_session created the session row, found no session_id, and
silently no-op'd — the ghost-session leak persisted on the real path.
(The shipped test masked this by stubbing handle_message with a fake
that created the row synchronously.)
Move the close to an on_processing_complete override — the lifecycle
hook the base class fires at the TRUE end of the run, on the success,
failure, and cancellation paths alike. Empirically verified through the
real fire-and-forget pipeline: before, ended_at stayed NULL; after,
ended_at is set with end_reason=webhook_complete and the row is
prunable.
Tests now stub only the runner-side _message_handler (the seam the live
gateway injects) so handle_message / _process_message_background /
on_processing_complete all run for real; adds an AsyncSessionDB-facade
coverage test for the coroutine-await branch.
Follow-up to @helix4u's #57336 salvage. Two review findings:
- W1: model-picker grouped custom-provider rows by
(api_url, credential, api_mode) but NOT extra_headers. Entries sharing a
URL+credential+api_mode yet declaring different headers (e.g. per-tenant
routing behind one proxy) collapsed into one row and probed /models with
whichever header set was seen first (order-dependent). Fold a canonical
header identity into group_key so distinct header-authed endpoints stay
separate; drops the now-dead first-non-empty merge branch.
- W2: the extra_headers stringify+None-filter comprehension existed in 5
copies (config.py x2, runtime_provider.py, model_switch.py, models.py).
Extract one shared hermes_cli.config.normalize_extra_headers primitive;
all sites now call it.
Tests: +normalize_extra_headers unit tests, +regression test proving two
same-endpoint entries with different headers stay distinct and each probes
with its own headers. 223 targeted tests pass; ruff clean.
Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:
1. Memory-leak regression for mode='none' sessions. _is_session_expired()
returns False forever for the 'none' reset policy, so the naive guard would
never idle-evict those agents — reopening the unbounded-cache leak the idle
sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
(a public predicate: will the expiry watcher EVER finalize this session?) and
gate the deferral on it. mode='none' agents fall through to soft eviction as
before.
2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
(_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
_release_evicted_agent_soft, which by design does NOT fire on_session_end.
If cache pressure evicts a finalizable-but-not-yet-expired agent before it
expires, the watcher later finds no cached agent and the hook is skipped.
Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
finalizable and not yet expired, commit end-of-session extraction via the
live agent's own (fully-scoped) memory manager using commit_memory_session()
— extraction WITHOUT provider teardown, so the eviction stays soft and a
resumed turn keeps working. Skipped for mode='none' (no missed boundary to
compensate) and expired sessions (the watcher tears those down directly).
This closes#11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).
Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.
The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.
Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).
Fixes: #11205
Self-review (ruff+ty lint diff = 0 net-new; 2-agent deep review) surfaced one
Warning + comment-accuracy nits; no Critical:
- W1: the local-probe TTL cache memoized None (probe failure) for 30s, so a
probe that failed during a startup race would suppress a legit retry once
the server came up. Cache only positive results — still fully bounds the
hot-path probe rate (reachable servers cache their value) while an
unreachable one re-probes on the next call. Add a regression test asserting
a None result is NOT cached (retry re-probes); mutation-verified.
- Tighten the platform-guard comment: gateway/TUI/cron already construct with
quiet_mode=True (gated by `not agent.quiet_mode`), so the guard's active job
is CLI dedup vs show_banner, not "filling the gateway/TUI gap" as originally
worded.
Verified not-issues (per review): positive-value 30s cache does not break the
reconcile-after-restart freshness contract (restart = fresh process, empty
cache); cache key is collision-safe; platform guard is correct in both
directions (no runtime path leaves platform None on a non-CLI surface).
Tests: 149 passed. ruff clean; ty 0 net-new vs base.
Desktop/dashboard WebSocket connections drop during long agent operations
(delegate_task subagents, large model outputs) when the uvicorn event loop is
GIL-starved for minutes. Root cause: uvicorn's ws keepalive ping runs on the
SAME event loop as agent turns. A single synchronous GIL-holding call on a
worker thread (a regex/scrub over a large output, or a long subagent turn)
freezes the loop, so it cannot process the incoming pong within ws_ping_timeout
and uvicorn closes an otherwise-healthy connection (#53773: 'event loop stalled
226.3s'; #48445/#50005). Loosening the timeout only raises the threshold — a
multi-minute stall sails past any finite window.
The keepalive ping exists to detect half-open connections (reverse-proxy 524,
dropped tunnels), which cannot happen on loopback: there is no network or proxy
in the path, and a dead local client tears the socket down with a real FIN/RST
that starlette surfaces as WebSocketDisconnect regardless of the ping. So on
loopback the ping provides ~no liveness value while actively killing
recoverable stalls — disable it entirely (ws_ping_interval/timeout=None).
Non-loopback (public) binds sit behind a Cloudflare Tunnel where half-open IS a
real failure mode, so the ping stays at 20/20 to detect it.
Empirically verified (real uvicorn + websockets peer): with ws_ping=None the
server never closes a silent peer during an 8s window; with the pre-fix 2s/2s
window uvicorn closes it. A genuinely-dead client still fires the
WebSocketDisconnect reap path regardless of the ping.
Note: this fixes the local Desktop case (the OP's scenario). A remote Desktop
over an authenticated public dashboard route (McCalebTheSecond's comment) keeps
the ping and needs the deeper GIL-hotspot fix — tracked separately.
Closes#53773
Salvage review of #56431 surfaced one Critical + two Warning issues; fix
them on top of the contributor's cherry-picked commits:
1. Critical — duplicate non-agentic warning on the interactive CLI. The new
agent_init warning fires on every platform, but cli.py show_banner()
already warns on CLI (richer output + /model hint), so a CLI user saw the
warning twice per startup. Guard the agent_init emit to skip platform=="cli"
— it now fills exactly the gateway/TUI gap the PR intended, no duplication.
2. Warning — vLLM error-parse regex under-matched. The patterns required a
literal space before the number, so "max_model_len: 32768", "=32768",
"(32768)", and "... is 32768" all returned None. Broaden both patterns to
accept :/=/(/ 'is' delimiters. Add a parametrized test over all delimiter
variants.
3. Warning — per-call live probe latency on local endpoints. The new
reconcile-on-hit + pre-defaults step-7 probe made every local resolution
fire a synchronous network probe (banner + /model switch + compressor
update_model each within one startup). Add a 30s in-process TTL cache
keyed by (model, base_url) around _query_local_context_length so back-to-
back resolutions reuse one round-trip; not persisted to disk, so the
reconcile freshness contract (re-probe after restart) is preserved. Add an
autouse fixture clearing the cache between tests + TTL coverage.
Tests: 148 passed (was 138). ruff clean.
Replace the webhook delivery-close path's direct reach into private
SessionStore._entries (which also bypassed the store lock) with a public,
lock-held peek_session_id(session_key) accessor. Mirrors the existing
lookup_by_session_id inverse helper. Keeps a getattr fallback for older
stores / test doubles. Adds a unit test for the accessor.
Webhook deliveries created a unique one-shot session (delivery_id baked into
the session key at gateway/platforms/webhook.py:668) but the adapter fired
handle_message via asyncio.create_task WITHOUT ever ending the session
(webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires
the agent per session_key but never calls end_session for the webhook path,
and _end_session_on_close teardown doesn't run for these fire-and-forget tasks.
SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE
ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL ->
unprunable -> unbounded state.db growth. This was the primary driver of the
SQLite lock-contention gateway outage.
Fix: wrap the delivery in _run_delivery_and_close, which awaits
handle_message and then (in finally, so failures still reap) calls
_end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete').
This mirrors how cron closes its session with 'cron_complete'
(cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an
already-ended row, so it never clobbers a compression/agent_close reason.
Adds tests/gateway/test_webhook_session_close.py asserting the invariant
(a completed webhook session has ended_at set + is prunable), including the
error-path case, against a real SessionStore + SessionDB.
Add regression tests for vLLM max_model_len error parsing, stale local
cache reconciliation, live probes over llama defaults, and the 64K minimum
guard on persistent cache writes.
(cherry picked from commit 1cb47ef437de7ce289cb358e8d6b89e9194b43ed)
Refine the blank-line handling so a blank line only continues a list run
when the next non-blank line is another list item. This keeps a list ->
paragraph -> list sequence as three separate blocks and matches the
contiguous-list layout for mixed/nested lists (one rich_text block, split
into sub-lists by (indent, ordered)), rather than emitting a separate
block per item.
Adds regression tests for the mixed blank-separated layout and the
list->paragraph->list boundary.
When a Markdown ordered list has blank lines between items (common in
LLM-authored content), the list run loop breaks on each blank line.
Slack numbers each rich_text_list independently, so N items produce N
lists each starting at 1.
Skip blank lines inside the list run as soft separators instead of
breaking, so ordered items stay in one rich_text_list and Slack renders
the correct numbering.
Fixes#57076
setup.runtime_check and setup.status are polled by the Desktop frontend on
connect and periodically (use-status-snapshot → evaluateRuntimeReadiness), but
neither was in _LONG_HANDLERS — so dispatch() ran both inline on the WS reader
thread. Under GIL pressure from concurrent agent turns (terminal I/O, large
output, background-process completions) either can block for seconds:
- setup.runtime_check → resolve_runtime_provider() (config read, auth check,
may probe the provider endpoint)
- setup.status → _has_any_provider_configured() (provider config + credential
scan)
While either blocks the reader thread the WS read loop can't service later
requests; the frontend RPC timeout fires, the client drops the socket, and the
lost setup.runtime_check response reads as ready=false — a false "needs setup"
/ "Settings failed to load" even though the provider is configured.
Route both to the RPC pool (same precedent as #55545's session.list/pet.info/
process.list). The handlers are read-only and pool writes go through the
lock-guarded write_json, so there's no ordering or safety concern.
Test asserts all 5 frontend-polled RPCs are pool-routed.
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
delete_profile stopped only the process named in gateway.pid, but a Desktop
app spawns a headless `serve`/`dashboard` backend per profile that holds the
profile's SQLite connection open and keeps writing sessions/WAL/sandbox files.
That backend is never in gateway.pid, so a CLI `hermes profile delete` run
while the Desktop app is up left it writing into the tree — rmtree's final
rmdir then failed with ENOTEMPTY (#47368 "Bug 2"), and pre-guard it also
resurrected the directory.
- _profile_bound_backend_pids(): find running Hermes backends bound to this
profile via a `--profile <name>` selector or a HERMES_HOME env resolving to
the profile dir. Tightly scoped — current-user only, backend subcommands
(serve/dashboard/gateway) only so an interactive chat is never killed, and
never this process or its ancestors.
- _stop_profile_backends(): terminate them (graceful, then force), best-effort
so it can never make delete worse.
- _rmtree_with_retry(): a few spaced retries absorb the ENOTEMPTY / Windows
file-lock race from a just-terminated writer's in-flight -wal/-shm/sandbox
writes instead of failing the whole delete on a race the next attempt wins.
Complements the recreation guard (deleted profiles no longer reappear) and the
Desktop teardown-before-delete flow; this is the CLI-side convergence fix for a
delete run while a Desktop-managed backend is live.
Part of #47368.
A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:
- _session_info reported reasoning_effort "" for disabled reasoning,
indistinguishable from unset — the desktop adopted it after the first
turn, wiping its sticky "thinking off" pick so every later chat
reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
config.yaml, so every desktop model-menu selection (preset.effort ??
'medium') clobbered the user's configured value. Now session-scoped
like the messaging gateway's /reasoning, landing on
create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
to "" by every loader's `str(x or "")`, silently re-enabling thinking.
parse_reasoning_effort now treats False/"false"/"disabled" as
{"enabled": False}; loaders (tui gateway, gateway, cli, cron,
delegate) pass the raw value through. The desktop config reader also
crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
defaults to thinking ON server-side — so disabling reasoning was a
silent no-op on direct Z.ai, the actual token burner. The profile now
emits extra_body.thinking {"type": "enabled"|"disabled"} for
thinking-capable GLM models, mirroring the DeepSeek profile.
Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.
The terminal-refresh quarantine filtered in-memory entries on
source == "device_code" but built removed_ids from the deleted
"loopback_pkce" source name, so the revoked device-code entry was
never pruned from the persisted pool in auth.json. Also restores the
_print_loopback_ssh_hint test suite scoped to Spotify (the helper's
remaining caller) instead of deleting it wholesale.
Replace the loopback/PKCE-callback server and manual-paste fallback with
the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The
flow works in headless/SSH/container sessions with no 127.0.0.1 listener,
shrinking the local attack surface.
- Poll the token endpoint with server-provided interval, honoring
slow_down and expires_in; store tokens with auth_mode
oauth_device_code.
- Adaptive proactive refresh skew for short-lived device-code JWTs;
rotated tokens sync back to auth.json, the global root store, and the
credential pool (no refresh-token replay).
- Clear source suppression on successful re-login (CLI + dashboard) and
drop the duplicate dashboard pool entry so exactly one seeded
device_code entry exists.
- Use the shared device_code source name for consistency with the
nous/codex device-code providers.
- Desktop: remove the loopback OAuth flow states and dead type variants;
pkce providers' sign-in URL selection is unchanged.
- Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted
--manual-paste flag from documented commands.
MSYS_NO_PATHCONV is honored by Git for Windows bash only. _find_bash's
final shutil.which fallback can return MSYS2-proper or Cygwin bash,
which ignore it and honor MSYS2_ARG_CONV_EXCL instead. Set both so argv
path conversion stays disabled regardless of which bash flavor spawns.
Also subsumes the cmd /c mangling in #56147.
Per-session /model overrides (_session_model_overrides) were in-memory only,
so a gateway restart silently reverted every session to the global default
model. Persist the non-secret parts (model/provider/base_url ONLY — never
api_key) into the session entry in sessions.json and lazily rehydrate them
on first use after a restart, re-resolving credentials through the normal
runtime provider resolution.
- gateway/session.py: SessionEntry.model_override field with
sanitize_model_override() (allowlist: model/provider/base_url) applied on
both serialization and deserialization; SessionStore.set_model_override /
get_model_override accessors. reset_session() already creates a fresh entry,
so /new keeps its clear-on-reset semantics — a restart cannot resurrect an
override the user reset away.
- gateway/slash_commands.py: write-through at both /model set sites (text
command + picker) after storing the in-memory override.
- gateway/run.py: _rehydrate_session_model_override() called from
_resolve_session_agent_runtime(); in-memory state always wins, credentials
are re-resolved per provider (credential-less fallback on failure). Session
expiry finalization also drops the persisted override.
- tests/gateway/test_session_model_override_persistence.py: restart
round-trip, /new clearing, api_key-never-serialized (including tampered
sessions.json), rehydration + live-state precedence + credential-failure
degradation.
Salvaged from #3659 by @Git-on-my-level, narrowed to the restart-persistence
gap confirmed in triage.
Named providers / custom_providers entries in config.yaml now accept an
extra_headers dict scoped to that endpoint — for reverse proxies, API
gateways, and custom auth schemes (e.g. Cloudflare Access service tokens).
- hermes_cli/config.py: normalize extra_headers on provider entries
(_normalize_custom_provider_entry + providers-dict translation), add
get_custom_provider_extra_headers /
apply_custom_provider_extra_headers_to_client_kwargs helpers keyed on
base_url (case/trailing-slash insensitive, no substring bypass —
mirrors the TLS helpers)
- hermes_cli/runtime_provider.py: surface extra_headers in the resolved
runtime for named custom providers (providers dict, legacy
custom_providers list, and the credential-pool path)
- run_agent.py / agent/agent_init.py: merge per-provider extra_headers
onto the OpenAI client default_headers at construction and on every
_apply_client_headers_for_base_url re-application (credential swaps,
rebuilds), most-specific level wins; OpenAI-wire only (native
Anthropic/Bedrock scoped out)
- agent/auxiliary_client.py: accept model.extra_headers as an alias of
model.default_headers for the global variant
- cli-config.yaml.example: documented commented example
- Header values are treated as secrets and never logged
Salvaged from PR #3526 by @jneeee, reimplemented against current main.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Adds a no-code routing layer to the OpenAI-compatible API server so one
Hermes deployment can map different API clients to different
model/provider backends. Clients pick a backend by sending a configured
alias as the OpenAI 'model' field; unmatched values fall back to the
global model. Configured aliases are listed by GET /v1/models.
Precedence (highest first): session /model override > model_routes
route > global config. Route provider credentials resolve through
_resolve_runtime_agent_kwargs_for_provider (same seam as
channel_overrides); per-route api_key/base_url are upstream provider
credential overrides — never caller auth, never logged.
Salvaged and rebased from PR #3176 by @Mibayy onto current main.
Salvaged from PR #3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).
- /compact is now a first-class alias of /compress (CLI, gateway,
Telegram/Slack/Discord command lists, autocomplete) — also fixes the
dangling '/compact' references in gateway error messages
(gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
counts, token estimate, 'here [N]' boundary) without touching the
transcript. Flags coexist with the existing 'here [N]' / focus-topic
args on both the CLI and gateway surfaces via shared pure helpers in
hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
implemented: it would need its own transcript-persistence branch
outside the guarded _compress_context rotation machinery (#44794
data-loss class). The flag is recognized and returns an explanatory
message pointing at '/compress here [N]' and /undo instead of being
mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.
Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.
Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>
tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.
Salvage of the surviving hunk of #3296 by @Mibayy. The PR's gateway
_handle_provider_command hunk targets code removed on main (/provider was
absorbed into /model + /status, which already read model.base_url); the
hermes status mislabel was the remaining live symptom:
_effective_provider_label() only checked the legacy OPENAI_BASE_URL env var,
so a custom endpoint configured canonically in config.yaml still displayed
as OpenRouter.
Replace the plugin-local _IMAGE_MAGIC_MIME table + _sniff_image_mime
body with a delegation to agent.image_routing._sniff_mime_from_bytes,
the canonical magic-byte sniffer already used across the codebase, then
gate its result to the raster formats gpt-image-2's Responses
input_image actually accepts (png/jpeg/gif/webp).
The shared sniffer also recognizes SVG/TIFF/ICO; without the allowlist
those would pass local validation and be rejected server-side with an
opaque HTTP 400. Gating locally fails them cleanly as invalid_image_input.
Adds a regression test for SVG rejection.
Follow-up on top of @CrazyBoyM's #55828.
Follow-up to #56874, which added the Camofox private-page SSRF guard
(_camofox_current_page_private_url) but wired it only into the Camofox
eval path (_camofox_eval). The other Camofox content-read tools —
camofox_snapshot, camofox_get_images, and camofox_vision — still read the
current page's accessibility tree / images / screenshot without the
guard, so on a non-local Camofox backend they can return the content of
an intranet or cloud-metadata page (e.g. 169.254.169.254) that the
terminal itself can't reach.
Apply the same guard, gated on _eval_ssrf_guard_active (non-local
backend, not a local sidecar, allow_private_urls unset) and fail-open on
probe failure, matching the eval-path guard and the main-browser
snapshot/vision guards. camofox_back is intentionally not changed: its
target is unknown until navigation completes, and the subsequent content
read is already guarded.
Adds regression tests covering the three read tools blocking on a private
page, the public-page pass-through, and the guard-inactive no-probe path.
Three CLI reliability fixes:
1. Interrupt reliability: chat() only re-queued the user's interrupt
message when the turn result carried interrupted=True. When the agent
thread raced past its last interrupt check (or finished) before the
interrupt landed, the message was silently dropped — and the stale
_interrupt_requested flag left on the agent instantly aborted the
NEXT turn. Un-acknowledged interrupt messages are now re-queued as
the next turn and the stale flag is cleared (only when the agent
thread actually exited). The clarify-race path also parks the message
in _pending_input instead of dropping it.
2. Slow exit (5+ min): stdlib ThreadPoolExecutor workers are non-daemon
and joined unconditionally by concurrent.futures' atexit hook — even
after shutdown(wait=False). One wedged tool worker (abandoned after
interrupt/timeout) held the process open forever. Promoted
async_delegation's daemon executor to a shared tools/daemon_pool
module and adopted it in tool_executor (concurrent tool batches),
memory_manager (background sync), delegate_tool (child timeout wrapper
+ batch fan-out), and skills_hub (source fan-out). Added a 30s exit
watchdog (HERMES_EXIT_WATCHDOG_S) armed at _run_cleanup start as a
backstop for wedged cleanup steps.
3. Exit jank: after prompt_toolkit tears down the input/status bars the
terminal sat silent for the whole cleanup window, looking hung. Print
'Shutting down… (finalizing session)' immediately at exit start.
E2E: live PTY interrupt of a foreground 'sleep 120' terminal tool now
aborts in ~1s and the typed message runs as the next turn; wedged-worker
+ wedged-cleanup subprocess exits in 5.8s (watchdog) instead of hanging.
Salvage of the surviving piece of #2696 by @tarunravi. The PR's other two
changes (tool progress streaming, SSE None-sentinel fix) were independently
superseded on main by the structured hermes.tool.progress SSE events and the
rewritten queue-drain loop.
Remote OpenAI-compatible frontends can't read server-local file paths, so
MEDIA:<path> tags (browser screenshots, generated images) were dead text.
_resolve_media_to_data_urls() now inlines small (<=5MB) local images as
markdown data URLs across all four response surfaces: chat completions
(non-streaming), session chat, session chat stream final event, and the
Responses API. Non-image, missing, or oversized paths pass through
untouched.
Salvage of #2794 by @CharmingGroot, ported to the relocated
plugins/platforms/email/adapter.py:
- Guard raw_email = msg_data[0][1] against IndexError/TypeError and
non-bytes payloads. UIDs are added to _seen_uids before fetch, so an
exception mid-batch permanently skipped every remaining message in
the batch — now the bad message is logged and skipped instead.
- Message-ID domain generation falls back to 'localhost' when
EMAIL_ADDRESS lacks '@' (now via a shared _message_id_domain() helper
covering all 3 send paths; the PR fixed 2 of 3).
- ChannelOverride + channel_overrides on PlatformConfig
- Resolve model/runtime: session /model, then channel_overrides, then global
- Thread/parent channel lookup; bridge discord.channel_overrides from YAML
- Drop unrelated test and delegate_tool changes from PR scope
delegation.max_concurrent_children is now the single cap for both a
batch's parallelism and concurrent background delegation units.
- _get_max_async_children() delegates to _get_max_concurrent_children();
a leftover max_async_children key logs a one-time deprecation warning
- config v32→33 migration removes the stale key, folding a raised
max_async_children into max_concurrent_children (max wins, no lost
headroom)
- capacity error messages now point at max_concurrent_children
- pool-at-capacity sync fallback now attaches an explanatory note so
the model/user know why the call blocked instead of dispatching async
Previously users who raised max_concurrent_children (e.g. to 15) still
hit the invisible default-3 async cap: the 4th background delegate_task
silently ran inline, blocking the turn with no signal.
CLAUDE_CODE_OAUTH_TOKEN is set and owned by the user's Claude Code
install (subscription OAuth), not a Hermes-managed inference
credential — Claude subscription auth is not a working Hermes provider
path. Blocklisting it broke agent-spawned claude CLIs: with no token in
the child env, claude fell through to the shared macOS Keychain /
~/.claude/.credentials.json store and, on auth failure, cleared it —
logging the user out of their interactive Claude sessions and the
desktop app.
Exempt it from _HERMES_PROVIDER_ENV_BLOCKLIST (it arrives via the
anthropic registry entry, so discard explicitly with rationale).
ANTHROPIC_API_KEY / ANTHROPIC_TOKEN and every other provider credential
remain stripped, and the GHSA-rhgp-j443-p4rf fail-closed passthrough
guard is unchanged for everything still on the blocklist.
Fixes#55878
Follow-up on the salvaged #56392 guard. The cherry-picked change matched
custom:<name> pool entries against the primary by raw base_url string
equality, which (a) can't disambiguate two named custom providers sharing
one gateway base_url and (b) left a latent bare-"custom" entry bypass.
Route the match through get_custom_provider_pool_key(rt[base_url]) compared
against the entry's custom:<name> key, mirroring the sibling guard in
recover_with_credential_pool. Use CUSTOM_POOL_PREFIX instead of the literal.
Add regression tests for the custom same-endpoint (swap) and cross-endpoint
(skip) branches, plus the plain-provider fallback-pool case from #56885.
Two related hardening fixes for auxiliary calls (which include MoA reference
advisors — a pinned-model path where provider fallback is not a meaningful
recovery):
1. Transient-transport retries: the same-provider retry on a connection reset /
timeout / 5xx / 408 was a single attempt, then fallback. For a pinned aux
call a second blip silently loses the call (root of the run2 double-advisor
'Connection error' collapse — a genuine upstream blip). Now retries N times
with exponential backoff, N = auxiliary.transient_retries (default 2 -> 3
total attempts, clamped [0,6]). Compression-on-timeout fast-fail carve-out
preserved.
2. Per-model client-cache isolation: _client_cache_key excluded the model, so
two concurrent auxiliary calls to the same provider/base_url/key but
different models (e.g. an opus + gpt-5.5 MoA fan-out) shared one cache entry
and could race each other's client lifecycle. Model now participates in the
key -> distinct clients, no cross-call races. Same-model reuse unchanged.
- agent/auxiliary_client.py: _transient_retry_count() + backoff loop; model in
_client_cache_key and both call sites.
- hermes_cli/config.py: auxiliary.transient_retries default (2).
- tests: new retry/isolation tests; updated 2 stale-expectation tests to the
corrected behavior (per-model resolve; N-retry escalation).
Backoff base is overridable (_TRANSIENT_RETRY_BACKOFF_BASE) so tests don't sleep.
_get_platform_tools reverse-maps a platform composite to configurable
toolsets with an all-tools subset test. Because get_toolset() merges
registry-registered tools into a toolset, a tool added to a toolset
(delegate_cli -> delegation; desktop-only read_terminal -> terminal) that the
static composite never listed made the subset test fail, silently dropping the
entire toolset on api_server and other inference-based platforms. Compare the
toolset's static membership at all three reverse-map sites.
Fixes#49622.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds include_registry=True kwarg to resolve_toolset/get_toolset. When False,
returns only the static TOOLSETS view with no registry-merged tools — the
composite-authored membership platform reverse-mapping must compare against.
Default True preserves all existing behavior; this is the enabling half of
the api_server toolset-drop fix (#49622).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the browser private-network eval guard to the Camofox backend.
On main, _browser_eval() returned early in Camofox mode before running the
shared private-URL literal pre-scan and before re-checking the page URL
after eval, leaving Camofox as a sibling backend that could execute
browser_console(expression=...) against private/internal targets.
- move the eval private-URL literal pre-scan before the Camofox early return
- add a Camofox current-page private-URL probe via the evaluate endpoint
- withhold Camofox eval results when the page is now private/internal
Follow-up to browser private-network hardening in #56173, #56526, #56664.
Salvage of #56764 by @rayjun (rayoo), cherry-picked to preserve authorship.
The fake _SessionStore tracked peer_records but no test read it, leaving
#55300's peer-record behavior unasserted. Add a positive assertion on the
persist path and negative (== []) assertions on the two stale/moved-binding
skip paths, so the peer-record side effect is bound.
Mutation-verified: removing the production _record_gateway_session_peer call
makes the positive assertion fail.
Co-authored-by: João Vitor Cunha <jvsantos.cunha@gmail.com>
The #55300 peer-recording call now fires on the failed-turn compression
split path; the fake _SessionStore in test_compression_failure_session_sync
(carried in with #55721's test changes) lacked that method. Add a
call-tracking no-op so the combined salvage's tests pass.
Co-authored-by: João Vitor Cunha <jvsantos.cunha@gmail.com>
Manual /compress built a temporary AIAgent without the originating
platform / stable gateway session key, so an external context engine
ingested the retained transcript tail as source=cli during /compress
and again as the real platform on resume (duplicate cli,telegram rows).
Pass platform=_platform_config_key(source.platform) + the in-scope
gateway_session_key, mirroring the normal gateway turn. Assigned into
runtime_kwargs (single-valued, authoritative) so they neither collide
into a duplicate-kwarg TypeError nor lose to a stale resolver value.
Fixes#50422.
MoA per-turn latency is dominated by advisor GENERATION: turn wall time
correlates ~0.88 with output tokens and ~-0.03 with input tokens (measured over
52 turns). Each turn waits for the slowest advisor to finish writing, and
advisors were uncapped — writing multi-thousand-token essays the aggregator
only needs the gist of.
Add an opt-in per-preset reference_max_tokens knob (mirrors reference_temperature)
that caps ADVISOR output only; the acting aggregator is never capped. Default
None = uncapped, so existing presets are byte-for-byte unchanged (no regression).
Wired through both MoA execution paths (MoAChatCompletions.create and
aggregate_moa_context).
E2E: same task, closed preset uncapped vs reference_max_tokens=600 -> 59s to 33s
(~44% faster), final answer identical/correct.
- hermes_cli/moa_config.py: _coerce_int_or_none helper + reference_max_tokens
in _normalize_preset/_default_preset/flattened view
- agent/moa_loop.py: read preset.reference_max_tokens, pass to reference fan-out
- agent/conversation_loop.py: pass reference_max_tokens on the per-turn path
- tests + docs
Ben caught that the initial approach (widening _NOUS_PORTAL_ALLOWED_HOSTS to
include the staging host) was the wrong fix -- env vars are supposed to
override the allowlist, mirroring how NOUS_INFERENCE_BASE_URL already
bypasses _ALLOWED_NOUS_INFERENCE_HOSTS via _nous_inference_env_override().
The actual bug: both resolve_nous_access_token and
resolve_nous_runtime_credentials read
`_optional_base_url(state.get("portal_base_url")) or os.getenv(...) or ...`
-- a plain `or` chain where the STORED state value wins first (short-circuits
before the env vars are even read), and then whichever value won gets run
through the same _NOUS_PORTAL_ALLOWED_HOSTS gate regardless of its source.
So a hosted agent stamped with HERMES_PORTAL_BASE_URL=<staging> in its env
AND a staging portal_base_url already persisted to auth.json would still
get silently rewritten to prod on every refresh, because the env var never
even got a chance to be consulted.
Revert the previous _NOUS_PORTAL_ALLOWED_HOSTS widening entirely --
staying prod-only preserves the allowlist's actual job (rejecting an
untrusted network-provided portal_base_url persisted to auth.json by a
compromised Portal response).
Add _nous_portal_env_override() (mirrors _nous_inference_env_override())
and restructure both call sites so the env override is checked FIRST and,
when set, wins outright and skips the allowlist gate entirely -- the
allowlist only ever runs against the fallback (stored-state-or-default)
path now.
Rewrote tests/hermes_cli/test_nous_portal_staging_allowlist.py to test the
actual fix: the helper function, and an end-to-end
resolve_nous_access_token proof that the env override wins even when state
ALSO has the staging host stored (the exact incident shape), that it wins
over a stored PROD host too, and that the allowlist's heal-to-prod
behaviour for an untrusted stored value is preserved when no override is
set.
* fix(streaming): handle completed responses with empty/None choices
The streaming fallback guard added in #55932 recognized a completed
response object only when its `choices` was a non-empty list. But an
adapter can return a completed response whose `choices` is `None` or an
empty list (an error / content-filter / terminal frame) — still a whole,
non-iterable response, not a token stream. Those shapes fell through to
`for chunk in stream` and crashed with
'types.SimpleNamespace' object is not iterable
which is exactly issue #55933 (MoA `openai-codex` aggregator on
TUI/Desktop, where a stream consumer forces the streaming path).
Broaden the guard to discriminate on the PRESENCE of a `choices`
attribute (a genuine provider Stream object exposes none), disable
streaming for the session, and return the completed object so the outer
loop's normal invalid-response validation handles empty/None choices via
its retry path instead of iterating.
Based on the diagnosis in #56525 by @spiky02plateau (that PR normalized
the MoA aggregator return with a one-shot chunk iterator; the common
text/tool-call crash was already fixed at this seam by #55932, so this
extends the existing guard to cover only the remaining empty/None-choices
gap).
Fixes#55933
* refactor(streaming): simplify empty-choices guard body and parametrize tests
Post-review cleanup (no behavior change):
- Inline the single-use `response_choices` local and drop the redundant
`if first_choice is not None else None` guard (getattr(None, ...) already
returns the default safely).
- Collapse the two near-identical empty/None-choices regression tests into
one `@pytest.mark.parametrize` case.
Mutation-verified: reverting the guard to the old non-empty-list condition
still makes both parametrized cases fail with the historical
'types.SimpleNamespace' object is not iterable.
---------
Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
Follow-up to the salvaged fix: the regression test asserted a frozen
max_tokens == 128_000 literal, coupling it to the Opus-4-8 model table.
Assert against _get_anthropic_max_output("claude-opus-4-8") plus > 2000
instead, so the test survives model-table churn while still catching a
regression to the old `or 2000` fallback.
agent/vertex_adapter.py resolved VERTEX_CREDENTIALS_PATH,
GOOGLE_APPLICATION_CREDENTIALS, VERTEX_PROJECT_ID, and VERTEX_REGION via raw
os.environ.get() instead of the profile-scoped get_secret() every other
credential lookup in hermes_cli/runtime_provider.py uses. In a multiplex
gateway serving several profiles from one process, os.environ still holds
whichever profile's .env python-dotenv loaded at boot — so a raw read here
let one profile's turn silently mint a Vertex OAuth2 token from, and get
billed against, a different profile's GCP service account. No error, no
fail-closed guard: the multiplex UnscopedSecretError protection was bypassed
entirely because these reads never went through get_secret().
- _resolve_credentials_path/_resolve_project_override/_resolve_region now
call agent.secret_scope.get_secret(), matching the _getenv() pattern
already used for every other provider's credentials.
- get_vertex_credentials()'s ADC fallback (google.auth.default()) reads
GOOGLE_APPLICATION_CREDENTIALS from os.environ internally, bypassing
get_secret() entirely — closed with a narrow guard: when multiplexing is
active and this profile's scope has no Vertex credentials of its own, but
os.environ still carries a value (left by a different profile's boot-time
dotenv load), refuse ADC rather than silently authenticate as a stranger.
- Zero behavior change for single-profile installs: get_secret() falls
through to os.environ transparently whenever multiplexing is off.
Same bug class as the already-fixed _HERMES_OAUTH_FILE/_AUTH_JSON_PATH/
HOOKS_DIR cross-profile leaks, now closed for Vertex's OAuth2 credential
path.
The salvaged fix wired per-provider ssl_ca_cert / ssl_verify (and
HERMES_CA_BUNDLE) into the MAIN OpenAI client. This follow-up:
- Auxiliary client parity: process_bootstrap.build_keepalive_http_client
accepts and forwards verify; auxiliary_client._resolve_aux_verify mirrors
the main-client TLS resolution (via load_config_readonly, the read-only
fast path) so compression/vision/web_extract/title-gen/session_search
honor the same per-provider CA. Without this, chat worked against a
private-CA endpoint but every auxiliary call still failed APIConnectionError.
- switch_model now reads custom_providers from live config (load_config_readonly)
instead of the init-time agent._custom_providers snapshot, so ssl_ca_cert /
ssl_verify edits are honored on mid-session model switch — matching the
context-length reload (#15779).
- Drop the dead client-level verify= where a custom httpx transport is used
(httpx ignores it there); verify lives on the transport. Fix docstrings.
Applies to both run_agent._build_keepalive_http_client and process_bootstrap.
- resolve_httpx_verify: add CURL_CA_BUNDLE to the env chain (consistency with
agent/ssl_guard._CA_BUNDLE_ENV_VARS) and emit a loud logger.warning naming
the endpoint whenever ssl_verify:false disables verification.
- get_custom_provider_tls_settings: case-insensitive base_url match (config
dedup already lowercases; scheme/host are case-insensitive) so a mixed-case
entry doesn't silently drop its CA. Exact match preserved — no prefix bypass.
- Demote best-effort except Exception: pass in agent_init/switch_model to
logger.debug(exc_info=True).
- Tests for aux verify forwarding, _resolve_aux_verify, case-insensitive
match, and prefix-bypass rejection.
Wire ssl_ca_cert and ssl_verify through custom_providers config and env
vars into the keepalive httpx client, fixing APIConnectionError against
mkcert/self-signed Ollama proxies behind HTTPS.
Desktop chat bubbles render plain text, but a worker-routed command that
builds its own Rich Console (e.g. /journey) picks up truecolor from the
gateway's inherited COLORTERM and leaks raw escapes into the bubble. Strip
ANSI at the single worker-return choke point so every command renders cleanly.
The TUI opens /journey as an overlay, so it never travels this path.
In the interactive CLI, /journey dispatched straight to `args.func(args)`,
letting Rich write ANSI to stdout — which patch_stdout's StdoutProxy passes
through as literal `?[38;2;…m` garbage. Route the read-only views (default +
`list`) through a captured, force-color Console and re-emit via `_cprint`
(prompt_toolkit's ANSI parser), matching the `ChatConsole` idiom.
`delete`/`edit` stay on real stdio since they prompt / open `$EDITOR`.
parse_frontmatter's malformed-YAML fallback stores every value as a string,
so a skill's `metadata` can be a str. `_category`/`_related` chained
`.get("metadata", {}).get("hermes", {})` and blew up with `'str' object has
no attribute 'get'`, taking down `build_learning_graph()` (and thus /journey
and `hermes journey`) whenever any installed skill had bad frontmatter.
Extract a `_hermes_meta()` helper that returns the nested dict only when it
really is one. Fixes the whole class, not just the two call sites.
Two Hermes bots sharing a channel could volley replies at each other
indefinitely. Root cause: Discord reply-pings (allowed_mentions
replied_user=true) add the replied-to bot to message.mentions without a
literal <@bot> token in the body, so the existing bot-admission gate
treated a reply chip as an explicit @mention and re-triggered the peer.
Adds opt-in discord.bots_require_inline_mention (default false; env
DISCORD_BOTS_REQUIRE_INLINE_MENTION). When enabled, bot-authored
messages must carry a raw inline <@id>/<@!id> mention in the content;
reply-ping-only mentions no longer admit the message. Human messages and
all existing defaults are unchanged.
The new _self_is_raw_mentioned helper deliberately ignores the resolved
message.mentions list (which reply-ping populates) and checks only the
raw content token via the shared _raw_mentioned_user_ids primitive.
Vertex AI authenticates via OAuth2 (service-account JSON path / ADC), not
PROVIDER_REGISTRY, and VERTEX_CREDENTIALS_PATH is declared with
password=False (it's a path, not a bare key) under category="provider" —
a category the registry-derived blocklist loop never checks. Both it and
GOOGLE_APPLICATION_CREDENTIALS (the ADC fallback the adapter also reads)
fell through every existing blocklist source and leaked the on-disk
location of a GCP service-account key into every spawned subprocess
(terminal, codex/copilot app-server, browser workers) — the same leak
class already closed for every other provider's credentials in #53503.
Follow-up review fixes on the salvage of #54872 (原作者 张满良/@zmlgit):
1. [HIGH] Adapter selection now goes through the shared
_authorization_adapter chokepoint (gateway/authz_mixin.py) instead of a
local inline lookup that fell back to the DEFAULT profile's same-platform
adapter when the owning profile had a registry entry but no adapter for
that platform. That fallback re-introduced the exact cross-profile
mis-delivery ([230002] Bot can NOT be out of the chat) this change exists
to fix. Adds a mutation-verified guard test
(test_notifier_owning_profile_adapter_no_default_fallback).
2. [HIGH→documented] The creator-wake SessionSource cannot faithfully
reconstruct a DM/thread creator's session key because chat_type is neither
persisted on the subscription nor carried on the session-context bridge.
Documented the limitation inline; behavior degrades to a fresh group
session (never an exception). The end-to-end fix (stamp + persist
chat_type) is a scoped follow-up, not bundled into this salvage.
3. [MED] Documented that archived/unblocked are intentionally claimed (cursor
hygiene) but silent, and excluded from wake kinds.
4. [MED] Wake-injection failure now logs at WARNING with exc_info=True (the
cursor has already advanced, so a broken wake must not be a silent no-op).
The /codex-runtime slash command short-circuits with "openai_runtime
already set" when invoked with the same value as the current config,
and crucially skips the entire migration block below. The check
conflates two things: (a) "the config value is correct" and (b) "the
world state (managed block in ~/.codex/config.toml, hermes-tools MCP
callback, plugin discovery) is converged".
Common footgun this exposes: a user who pre-sets
`model.openai_runtime: codex_app_server` directly in config.yaml
(reasonable thing to do) and then runs /codex-runtime codex_app_server
to trigger migration sees "already set" and silently gets no migration.
~/.codex/config.toml never receives the managed block, the hermes-tools
MCP callback never registers, and codex falls through to its default
runtime instead of the app-server one — visibly successful but
functionally partial setup.
The migration is idempotent by design (it replaces its own managed
block in place between MIGRATION_MARKER and MIGRATION_END_MARKER), so
re-running it is safe and cheap. Fix the short-circuit to fall through
to migration when re-applying codex_app_server while skipping the
config persist (no value-level change needed). The disable case
(re-applying "auto") still short-circuits because disabling doesn't
touch ~/.codex/config.toml at all.
The user-visible message changes to "openai_runtime already set to
codex_app_server — re-applying migration" so re-runs surface what
happened.
Regression test (test_reapply_codex_app_server_runs_migration) asserts:
- migrate() was called when re-applying
- persist_callback was NOT called (no config write on no-op transitions)
- migration output (MCP servers, sandbox default) surfaces in the
user-visible message
- requires_new_session is True so callers know to /reset
Verified RED→GREEN: the test fails on origin/main with
"migration must run on reapply, not just first enable" and passes with
this fix. Full test_codex_runtime_switch.py suite: 31 passed.
Self-review follow-up on the salvaged approval-routing fix.
The initial adaptation re-read os.getenv("HERMES_YOLO_MODE") at session-build
time. That diverges from the repo's security invariant: HERMES_YOLO_MODE is
frozen into tools.approval._YOLO_MODE_FROZEN at import time precisely so a skill
running mid-process cannot set the env var and instantly flip the approval
bypass (a prompt-injection escalation path). A live re-read re-opened that hole
for the codex routing path.
- Add tools.approval.is_approval_bypass_active() — the canonical three-source
bypass check (frozen --yolo/HERMES_YOLO_MODE + session /yolo + approvals.mode
off) in one place. This is the 4th inline copy of that OR-chain (the three
sites in approval.py and tui_gateway/server.py:3121 all use the same idiom);
the helper is the shared chokepoint they can collapse onto.
- codex_runtime.py now calls is_approval_bypass_active() instead of the
hand-rolled mode-or-session check plus a runtime env re-read.
- Update the env-yolo test to patch _YOLO_MODE_FROZEN (the canonical test
pattern, e.g. tests/tools/test_yolo_mode.py) rather than setenv, which is
dead-on-arrival against the frozen constant.
Fail-closed default preserved on every branch; 28 integration + 77 session/yolo
tests pass; E2E confirms the real exec decision flips decline->accept only when
bypass is active.
On gateway/cron/non-CLI contexts the codex app-server runtime has no UI to
surface codex's exec/apply_patch approval requests, so they fail closed
(silently decline) — the bot appears responsive but cannot write files, with
no approval prompt anywhere ("patch rejected by user").
When the user has explicitly opted out of Hermes approvals (approvals.mode: off,
the /yolo session toggle, or HERMES_YOLO_MODE=1), collapse to codex's own
sandbox permission profile (~/.codex/config.toml) as the policy gate by passing
_ServerRequestRouting(auto_approve_exec=True, auto_approve_apply_patch=True) to
the session. Defaults (manual/smart/unset) preserve the current fail-closed
behavior — a no-op for users who have not opted out.
Reads the mode via the canonical tools.approval._get_approval_mode() (which
already normalizes the YAML-1.1 bare-'off'->False case) at session-build time,
so a mid-session /yolo toggle is honored too.
5 integration tests: each opt-out mechanism (config off, YAML False, env var,
session yolo) plus the default fail-closed regression guard.
Closes#26530
Co-authored-by: snav <jake@nousresearch.com>
Every other content-returning browser tool entry point
(browser_snapshot/vision/console/eval, and click/type/press via
_blocked_private_page_action) re-checks window.location.href against the
private/internal/cloud-metadata floor after the page could have changed --
because a redirect chain or client-side navigation can land on an address
the initial browser_navigate preflight never saw. browser_back was the one
navigation-triggering entry point missing this: it called
_run_browser_command(..., "back", []) and returned the resulting URL
straight to the model with no re-check.
On a cloud/CDP (non-local) backend, if browser history contains a
private/internal address (e.g. a prior redirect touched an internal host),
browser_back would navigate the live browser there and hand the URL back
to the model with no guard -- the exact class of gap the private-page
guard exists to close, just on the one entry point it hadn't reached yet.
Re-check happens after the navigation succeeds (not before, unlike
click/type/press) since it's the resulting page -- not the one being left
-- whose safety matters. A failed back navigation (no history) skips the
check entirely since nothing changed. Verified live: the new regression
test fails (returns the private URL instead of a blocked payload) on the
pre-fix code and passes after.
With the default busy_input_mode=interrupt, a burst of rapid gateway
messages arriving while context compression is in flight could interrupt
the current turn and start a fresh turn against the pre-rotation parent
session. Because compression is interrupt-immune (#23975), the still-
running compression later rotates the id out from under that new turn,
and if the new turn also grew past the compression threshold it started
its own uncancellable compression on the same stale parent — forking
multiple orphaned one-shot sibling continuations (#56391).
While a state.db compression lock is held for the session, demote
'interrupt' busy-input mode to 'queue' semantics (mirroring the subagent
protection in #30170), so the follow-up message waits for the in-flight
compression + its id rotation to land instead of racing a new turn
against the stale parent. Ack copy explains the compression demotion.
Fixes#56391.
Two independent MoA auxiliary-call fixes:
#53866 — auxiliary.moa_reference.timeout and auxiliary.moa_aggregator.timeout
were 600s while moa_agent was 120s. Raise both to 900s so a genuinely long
reference/aggregator turn (mixed providers, deep reasoning, long tool chains)
has headroom instead of being cut mid-generation.
#53735 — _CodexCompletionsAdapter (the Codex/Responses auxiliary path used by
the MoA acting-aggregator, compression, web_extract, session_search, etc.)
never set prompt_cache_key, so it stayed cache-cold while the MAIN Responses
transport (agent/transports/codex.py) was warm. Derive the same
content-addressed key via the shared _content_cache_key(instructions, tools)
helper and set it on the aux Responses request, with the same host guards the
main transport uses (xAI carries the key in extra_body; GitHub/Copilot opts out
of cache-key routing).
Tests: 5 new prompt_cache_key cases (set+prefixed, stable across identical
prefix, differs on different instructions, skipped for xai/github hosts).
tests/agent/test_auxiliary_client.py 279 pass; tests/hermes_cli/test_config.py
130 pass.
On the MoA path agent.model/provider are the virtual preset name (e.g.
"closed") and "moa", which have no pricing entry. estimate_usage_cost()
returned None for the aggregator turn, so the `if amount_usd is not None`
guard skipped it and the session's estimated_cost_usd reflected only the
advisor fan-out — a ~50% undercount when the aggregator does the full acting
loop (verified: $0.91 advisor-only vs $1.96 true, aggregator = 54%).
MoAChatCompletions.create() now stashes the resolved aggregator slot as
last_aggregator_slot (exposed via MoAClient); conversation_loop reads it to
price the aggregator turn at its real model/provider. cost_source flips from
'none' to 'provider_models_api'.
Follow-up to the END-MARKER reorder: moving the summary prefix after the
[PRIOR CONTEXT] wrapper meant _is_context_summary_content (prefix-at-start)
no longer recognized a merged-tail summary. That silently broke three
consumers — the last-real-user anchor (would pick the merged summary as a
real user turn, causing active-task loss), the carry-forward summary find,
and the auto-focus skip. _strip_summary_prefix would also carry the wrapper
+ stale tail content forward as the next summary body.
Extract the two delimiter strings into _MERGED_PRIOR_CONTEXT_HEADER /
_MERGED_SUMMARY_DELIMITER constants (writer + detector stay in sync), teach
_is_context_summary_content and _strip_summary_prefix to look past the
delimiter, and add a regression test. Standalone summaries unchanged.
When the compression summary is merged into the first tail message
(the alternation corner case where a standalone summary role would
collide with both head and tail), the old format was
SUMMARY + END_MARKER + OLD_TAIL_CONTENT — so the preserved tail content
appeared AFTER the end marker and the model could read it as a fresh
message to respond to.
Reorder so the END MARKER is always last: old tail content is wrapped in
[PRIOR CONTEXT ...][END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]
delimiters, then the summary, then the END MARKER. _append_text_to_content
handles both string and multimodal-list content.
Salvaged from #56372 by @Gromykoss. Only the END-MARKER reorder half is
carried over. The PR's second change (a post-compaction pass that strips
user-role messages before the first summary marker on compression_count>=2)
was dropped: on 2nd+ compactions the protected head decays to system-only
(_effective_protect_first_n -> 0, #11996) so the targeted 'ghost head user'
does not occur, and where the strip does fire it deletes legitimate recent
tail user turns (data loss) and can leave consecutive assistant messages
(role-alternation violation).
The persisted (DB-fallback) branch of _resume_target_allowed() compared only
sessions.user_id against source.user_id, but build_session_key() keys the
participant on `user_id_alt or user_id` (Signal/Feishu carry the canonical
participant in user_id_alt). The sessions table has no user_id_alt column, so a
per-user row a caller shares the user_id of — but not the user_id_alt — maps to a
DIFFERENT live session key, yet the row's user_id matched both participants:
a co-member could resume/enumerate another member's persisted per-user group or
no-chat_id DM session (IDOR, CWE-639).
The live-origin guard (_same_origin_chat) already compares user_id_alt; the
persisted fallback couldn't. Fail closed on both identity-bearing per-user
branches (non-DM per-user group, no-chat_id DM) whenever the caller carries a
user_id_alt. Shared group/thread sessions (no participant scoping) and DMs keyed
on a present chat_id are unaffected; callers keyed on user_id (e.g. Telegram)
still resume their own rows; admin --all override still applies.
Regression: tests/gateway/test_resume_command.py::
test_resume_persisted_fallback_fails_closed_on_user_id_alt.
Addresses egilewski follow-up on PR #52355: the persisted-row fallback required
row_uid == caller_uid for every identity-bearing caller, which wrongly blocked a
legitimately SHARED non-DM group session. With group_sessions_per_user=False,
build_session_key resolves every participant of a chat to one session key, so a
co-member (different user_id) in the same chat shares Bob's session — but the
guard returned "/resume blocked".
Mirror is_shared_multi_user_session() in the fallback, exactly as the live-origin
branch (_same_origin_chat) already does: for a non-DM caller, first require the
same platform + chat + thread provenance (unchanged — blank/mismatching chat
still fails closed), then allow without user-id equality when the session is
shared, and keep requiring the same owner for per-user group/thread sessions.
DM scoping is unchanged (always per-user).
Adds a regression: shared group → co-member allowed; per-user group → blocked;
different chat → blocked even when shared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses egilewski/CodeRabbit follow-up on PR #52355: the identity-bearing
persisted fallback compared row_chat == caller_chat, which SUCCEEDS when both
normalize to "" — so a legacy row with no stored chat provenance could still be
resumed by a caller that also has no chat_id (probe: a group caller with
chat_id=None resuming a NULL-chat telegram row on matching user_id).
A non-DM session (group/channel/forum/thread) is keyed by chat_id in
build_session_key, so a blank chat on either side is NOT proof of same-chat.
Require both row and caller chat_id to be non-blank and equal for non-DM
callers; a legacy NULL-chat row (or a caller missing its chat_id) now fails
closed. DMs are unchanged: they are keyed on user_id, so a no-chat_id DM row
stays resumable by the same user (and a mismatching chat_id, when present, is
still rejected).
Adds the blank-caller-chat group probe and a DM no-chat_id same-user/other-user
regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the egilewski/CodeRabbit and teknium1 reviews on PR #52355.
1) Persisted-row chat scope (egilewski/CodeRabbit). The sessions table stored
only source + user_id, so an identity-bearing caller could resume/list an
INACTIVE persisted row that matched source+user_id but belonged to a
DIFFERENT chat (probe: same user moves `same_user_chat_b` into chat-a).
Persist the messaging origin and compare it:
- schema: sessions gains origin_chat_id / origin_thread_id (declarative
auto-migration via the existing column reconciler).
- SessionDB._insert_session_row accepts + writes the two columns.
- the gateway records them at every origin-bearing creation: both
SessionStore create paths (get_or_create_session + reset/switch) and the
/title path that materializes a store-only session into the DB.
- _resume_target_allowed's identity branch now also requires
origin_chat_id AND origin_thread_id to match the caller. Legacy rows with
NULL origin (created before this change) cannot prove chat origin and
fail closed — resume them via a live session or an admin --all override.
The /sessions listing inherits the fix (non-Matrix rows route through the
same helper).
2) DM key-contract mirror (teknium1). _same_origin_chat's DM branch only
compared user_id and allowed when either side was missing, diverging from
build_session_key (no-chat_id DM keys are built from user_id_alt or
user_id). It now: treats an equal non-blank chat_id as sufficient (the DM
key IS the chat_id when present), and otherwise compares the effective
participant id (user_id_alt or user_id), failing closed on a
missing/different participant so two no-chat_id DM origins are never
conflated.
Tests: add same-user/different-chat (e2e + unit) and chat-scope unit cases;
add DM no-chat_id / user_id_alt / no-identity / same-chat_id cases; update
existing fixtures to record origin_chat_id like the gateway does; make the
cross-room `/resume --all` listing test run as admin (cross-room listing is
admin-gated) and give the boundary-state resume runner a live same-origin so
its post-resume clearing assertions exercise an authorized resume.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses egilewski (Codex/CodeRabbit) follow-up on PR #52355: the no-identity
branch of _resume_target_allowed() returned True after only checking that the
row's source didn't mismatch the caller platform. The sessions table has no
chat_id, so same-platform alone is not ownership proof — a Telegram group
caller in chat-a with user_id=None could resume (and /sessions could list) a
persisted row owned by another chat/user (e.g. victim_chat_b_uid,
source=telegram, user_id=victim).
Fail closed: an identity-less caller can no longer bind to or enumerate a
persisted session by id/title. A legitimate same-chat resume of an ACTIVE
session still works via the live-origin branch (which compares chat_id), and an
operator can use the admin --all override. The listing path inherits the fix
because _resume_row_visible() routes non-Matrix rows through the same helper.
Adds an end-to-end no-identity probe (resume blocked) and a unit-level
persisted-fallback assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses egilewski (Codex) CR on PR #52355: the Matrix direct /resume <id>
guard (and the Matrix listing guard) used _same_matrix_room(), which compared
only platform + chat_id. But build_session_key() appends thread_id for every
chat type when present, and Matrix scopes the model's turn to the current
room/thread — so a live session in another thread of the SAME room is a
DIFFERENT session. A caller in thread A could resume a target whose live origin
was in thread B (switch_session fired on the victim session).
Add a thread_id equality check to _same_matrix_room so room scoping also
enforces the thread boundary. Non-threaded rooms have empty thread_id on both
sides ("" == ""), so existing room-level sharing is preserved unchanged; only
cross-thread access is newly blocked. This mirrors the thread handling already
in _same_origin_chat for the non-Matrix adapters.
Adds regressions replaying the reviewer's thread-a -> thread-b probe (direct
guard + listing path), plus same-thread-shared and thread-vs-no-thread cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses egilewski (Codex) CR on PR #52355: the persisted-row fallback in
_resume_target_allowed() skipped the platform/source check when sessions.source
was blank (the row_src guard only rejects a *mismatching* non-blank source),
then accepted the row on user_id equality alone. A legacy/malformed row with a
blank source but a matching user_id was therefore resumable — an identified
caller could bind to a transcript whose origin it can't prove.
Now an identity-bearing caller is allowed only when the row proves BOTH the
same owner (non-blank user_id match) AND the same platform/origin (non-blank
source match). A blank/legacy source fails closed, exactly like a missing
user_id. No-identity (single-user) callers are unaffected.
Adds a regression replaying the reviewer's blank-source same-uid probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/resume resolved a persisted session id/title with no ownership check on any
adapter except Matrix, so an authorized caller could bind their gateway session
to another user's/room's transcript and read it. The titled-session listing and
numeric index were also globally enumerable on non-Matrix platforms, exposing
the ids and previews needed to target the IDOR.
Generalize the Matrix-only room guard to an adapter-agnostic ownership check
(live origin when active; DB row source + user_id for persisted-only sessions,
the only fields available), applied to the direct-id/title path and the
listing/numeric paths on every platform. An explicit admin --all override is
honored. The Matrix path is preserved unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Windows _quote_cwd_for_cd override only reached _wrap_command; the
snapshot bootstrap cd in init_session still used a bare shlex.quote(),
so on Windows the bootstrap cd failed and pwd -P captured the login
shell's dir instead of terminal.cwd. Route it through _quote_cwd_for_cd
too, and add -- for hyphen-safety to match _wrap_command.
The model could pass `toolsets` (top-level and per-task) to delegate_task,
letting it choose which toolsets a subagent got. Toolset selection is a
capability-scoping decision the model should not control; subagents inherit
the parent's enabled toolsets, period.
- Remove `toolsets` from the delegate_task() signature, the registry handler,
the top-level + per-task JSON schema, and the live dispatch path
(run_agent._dispatch_delegate_task — this forwarded it on every model call).
- Single-task and per-task child builds now pass toolsets=None so
_build_child_agent resolves to pure parent inheritance.
- Drop the now-dead _SUBAGENT_TOOLSETS / _TOOLSET_LIST_STR schema-hint block.
- _build_child_agent keeps its internal toolsets param + intersection helpers
(internal API; fed the inherited value only).
- Tests: schema assertions flipped to assertNotIn; added a regression test
proving the dispatch path never forwards a smuggled model `toolsets`.
- Docs: update delegate_task signature refs in the autonomous-ai-agents skill.
Consolidates the pairing/allowlist authorization model. Reverses the
read-side AND-ing from #56346 (which made a paired user require ALSO
being in the allowlist) and restores pairing as a first-class grant:
- authz_mixin: a pairing-store entry authorizes regardless of the
allowlist (union). approve_code is reachable only by the trusted
operator (CLI / authenticated dashboard), never by an inbound sender,
so it is not an attacker-controlled path — the #23778 bypass was the
inbound message/approval-button gate, fixed separately.
- pairing: when an allowlist IS already configured for the platform,
operator approval also appends the user to that allowlist env var
(option i) and revoke removes them, keeping a single operator-visible,
editable source of truth instead of an opaque approved.json. On an
open gateway (no allowlist) approval is a no-op on the env var so we
never silently lock an open gateway; the pairing store remains the
grant record, honored by the union.
- auto-resume authz (0de67ad60) now honors paired users automatically
via the same union — a legitimately-paired session survives restart.
Replaces the now-incorrect AND-ing tests with union + mirror + revoke
coverage. E2E verified: locked-gateway approve/revoke round-trips
through the allowlist; open-gateway approval stays open.
test_references_run_in_parallel asserted elapsed < 0.9 for two 0.5s
sleeps that run concurrently. On a loaded CI runner, thread-pool
startup pushed the wall time to 0.9001s — a 0.14ms miss — flaking the
shard. Loosen to < 0.95, which still sits well below the 1.0s serial
floor, so a genuine serialization regression (>=1.0s) still fails hard.
Delegate to hermes_bootstrap.harden_import_path() instead of the inline
'', '.' sys.path filter, matching entry.py/acp_adapter/entry.py after #51693.
The shared helper also relocates the Hermes source root ahead of an absolute
cwd path on sys.path (venv/PYTHONPATH case), which the inline filter missed.
Test static check rewritten to assert the shared guard runs before import cli.
The slash-command worker is spawned as `-m tui_gateway.slash_worker` and
inherits the user's CWD. A local package in that CWD (e.g. a project shipping
its own `utils/`, `proxy/`, or `ui/`) shadows the installed hermes module, so
`import cli` crashes the worker with:
ImportError: cannot import name 'atomic_replace' from 'utils'
The child then exits 1 in a crash loop. #15989 added this sys.path guard to the
sibling entrypoint tui_gateway/entry.py but not to this worker, which is spawned
as a separate process and so starts with CWD back on sys.path.
Apply the same guard (insert HERMES_PYTHON_SRC_ROOT, strip ''/'.') before the
first non-stdlib import. Add a regression test that imports the worker from a
CWD containing colliding packages.
Fixes#51286
Adds Vertex AI as a first-class provider for Gemini models via Vertex's
OpenAI-compatible endpoint. Vertex authenticates with short-lived OAuth2
access tokens (service-account JSON or ADC), not a static API key — the
missing piece behind the recurring requests (#13484, #12639, #56259).
- agent/vertex_adapter.py: OAuth2 token minting + refresh-on-expiry
(5-min margin), ADC->service-account fallback, global vs regional
endpoint URLs. Config precedence: env var > config.yaml > default.
- plugins/model-providers/vertex/: provider profile (auth_type=vertex),
reuses Gemini's extra_body.google.thinking_config translation.
- runtime_provider: vertex short-circuit BEFORE the credential pool so a
credentials-file path is never mistaken for a static API key; mints a
fresh token + computes base_url per resolve.
- run_agent + conversation_loop: _try_refresh_vertex_client_credentials()
re-mints the token and rebuilds the client on a mid-session 401, so a
long-lived gateway agent survives token expiry (~1h).
- auxiliary_client: vertex auth_type branch for side-LLM tasks.
- config.yaml: vertex.project_id / vertex.region (non-secret, bridged to
env); credential path stays in .env (VERTEX_CREDENTIALS_PATH).
- setup wizard + model picker: dedicated _model_flow_vertex; curated
google/gemini-* model list; --provider choices.
- pricing/metadata: Vertex prices off the gemini docs snapshot; endpoint
host auto-maps to the vertex provider (no probe spam).
- lazy_deps + pyproject [vertex] extra: google-auth, opt-in only.
- docs: guides/google-vertex.md + providers page; tests for adapter +
runtime resolution.
Salvages and modernizes #8427 by @slawt onto current main: rewired from
the legacy PROVIDER_REGISTRY path to the provider-profile architecture,
moved non-secret config out of .env into config.yaml, and added the
per-turn 401 token-refresh the original lacked.
The patch tool's strategy 7 (unicode_normalized) matches ASCII old_string
against a file containing real Unicode (em-dashes, smart quotes, ellipsis,
non-breaking spaces). Writing new_string verbatim silently replaced the
file's Unicode with the LLM's ASCII equivalents.
_preserve_unicode_in_replacement() diffs old_string->new_string and applies
only the actual edits to the file's original Unicode text, preserving
unchanged characters.
Salvaged from #50540 by @aj-nt. Only the Unicode-preservation half is
carried over; the write_file line-number-strip half was dropped (the
existing _looks_like_read_file_line_numbered_content reject guard already
covers its target case, and the strip's looser threshold risks silently
mutating legitimate pipe-delimited content).
The in-chat /restart command was leaving the gateway dead on systemd
deployments using Restart=on-failure (the default for many
operator-managed and tutorial-style unit files). The gateway drained,
exited cleanly (code 0), and was never revived — the only recovery was
a host reboot.
Root cause was a multi-layer assumption mismatch:
1. gateway/run.py:_stop_impl assumed all systemd units use
Restart=always, so the Linux/systemd branch returned exit code 0
and relied on a `systemd-run` transient helper to restart the unit
immediately. Units with Restart=on-failure never see a clean exit
as a trigger, so nothing revived the process.
2. gateway/run.py:_launch_systemd_restart_shortcut hardcoded
`--user` scope, so it could not even locate the unit PID on
system-level deployments (the common case for
/etc/systemd/system/hermes-gateway.service). It silently returned
without launching the helper.
3. Even after the scope detection was fixed, the helper could not
actually start: non-root gateway units (User=ubunutu) hit a Polkit
denial on `systemd-run --system` ("Interactive authentication
required"), and `--user` requires a D-Bus user session that is
typically absent on headless servers.
The fix is two-fold:
* `_stop_impl` now always exits with GATEWAY_SERVICE_RESTART_EXIT_CODE
(75 / EX_TEMPFAIL) on service-managed restarts, regardless of
platform. Combined with RestartForceExitStatus=75 in the unit file,
systemd treats the planned restart as a controlled failure and
revives the gateway via Restart=on-failure, with RestartSec as the
only delay. The planned-restart helper is still attempted (for
RestartSec=0 setups that want sub-second restarts) but is no longer
load-bearing.
* `_launch_systemd_restart_shortcut` now probes both system and user
scopes via MainPID equality and uses whichever scope actually owns
the gateway process. It bails out safely if neither matches.
StartLimitBurst in the unit file still bounds accidental restart
loops, and the macOS launchd path is unchanged.
Verified end-to-end on Ubuntu 24.04 with hermes-gateway as a
/etc/systemd/system/... service running under User=ubunutu. The
unit uses Restart=on-failure, RestartSec=30, RestartForceExitStatus=75,
StartLimitIntervalSec=600, StartLimitBurst=5. /restart from Feishu now
drains cleanly, exits 75, and the gateway is back online ~30s later
without manual intervention.
Tests: tests/gateway/test_gateway_shutdown.py renamed the affected
case to test_gateway_stop_systemd_service_restart_uses_tempfail and
now asserts exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE.
14/14 tests in this module pass.
browser_navigate's always-blocked cloud-metadata floor (169.254.169.254,
metadata.google.internal, ECS/Azure/GCP IMDS) was gated on
`not _is_local_backend()`, contradicting both the adjacent comment and the
is_always_blocked_url docstring ("denied regardless of backend"). A default
local headless Chromium on a cloud VM — or an off-host CDP browser — could
navigate to IMDS and read instance credentials into the model context. Make the
floor unconditional on the initial-nav and post-redirect paths.
Also: _is_local_backend() ignored a CDP override while _is_local_mode() honors
it, so an off-host CDP browser was treated as "local" and skipped the broader
private/internal SSRF check too. Treat a CDP override as non-local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolve_nous_runtime_credentials / resolve_nous_access_token now read via
_load_provider_state_with_source (and write via _save_provider_state_to_source).
TestEnvOverrideWins mocked only the old _load_provider_state, so the real
(empty) state was read → AuthError. Mock the new boundary too, returning
(state, None) so the write-through helper treats it as the active store.
- Track auth store source path on Nous state reads and write rotated
OAuth refresh tokens back to the same store, preventing stale-token
replays when Hermes falls back to a global/root auth.json.
- Skip Nous fallback entries locally when no access/refresh token is
present, suppressing repeated failed resolution attempts within a
session.
- Sync session model metadata after fallback switches so the gateway
DB reflects the backend that actually served the latest turn.
Follow-up on the salvaged #49830 hardening. The contributor's sensitive
query-param set included bare English words (code, key, auth, session,
sig) that double as ordinary page facets — ?code= on promo/challenge
pages, ?key= as a search facet, ?session= on blogs — so web_extract and
cloud browser_navigate would refuse a large slice of normal browsing.
Narrow the set to unambiguously credential-named params (access_token,
authorization, client_secret, password, token, x-amz-signature, ...).
Prefix-based vendor-key redaction (is_safe_url) still catches recognizable
key shapes; this set is the belt-and-suspenders for opaque secrets carried
under an explicit credential-named parameter.
Also fixes two intra-PR-staleness test breakages surfaced by salvaging onto
current main:
- web_extract_tool() no longer accepts use_llm_processing= (signature
changed since the PR was authored) — dropped the invalid kwarg.
- agent.redact now fully masks keyed 'token=<secret>' to 'token=***'
instead of partial 'sk-...'; the console-redaction test now asserts the
real invariant (secret body gone) rather than the exact mask format.
Added a regression test that generic English-word query params are NOT
blocked by the credential guard.
Add policy gates and output redaction for browser/CDP surfaces, strengthen session ownership tracking, and block credential-like query parameters before third-party browser/web backends receive URLs.
Inspired by the agbrowse review: keep local browser magic-link flows possible while preventing cloud reader/browser escalation from receiving opaque token, code, signature, or key query parameters.
The persist user-message override was applied in place to the live messages
list. On the early crash-resilience persist (which runs BEFORE api_messages is
built), that stripped observed group-chat context off the live user message and
silently dropped it when observe_unmentioned_group_messages was enabled.
Fix at the single chokepoint: _flush_messages_to_session_db resolves the
override (idx/content/timestamp) locally and applies it ONLY to the row written
to the DB — the live dict is never mutated, so EVERY persist caller (early
persist, mid tool-loop flush, /resume, /branch) is protected uniformly. This
supersedes the earlier shallow-copy approach, which broke the intrinsic
_DB_PERSISTED_MARKER idempotency (copies never propagated the marker back to
the live dicts → duplicate rows) and closes the sibling class tracked in #56303.
Trailing empty-response scaffolding is still dropped from the live list in
_persist_session (unchanged behavior).
Salvaged from #48817; chokepoint reworked to coexist with the marker-based
dedup (#50372).
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
The salvaged PR guarded only resolve_nous_access_token; the primary
resolve_nous_runtime_credentials path also POSTs the refresh token to
portal_base_url on refresh with no allowlist check. Mirror the guard
there so a poisoned host can't receive the bearer, and drop the stray
duplicated allowlist comment. Adds a sibling-site regression test.
Add tmp_path symlink regression tests for both generate_systemd_unit and
generate_launchd_plist (~/.local/bin/node -> profile node install must not
leak the profile target into the generated unit PATH). Register
jearnest11's AUTHOR_MAP entry for the salvage cherry-pick.
A user who tapped Always on an approval button gets a pairing-store entry.
_is_user_authorized() checked the pairing store BEFORE the allowlist and
returned True unconditionally, so a paired-but-not-allowed user permanently
bypassed TELEGRAM_ALLOWED_USERS (or equivalent) even after being removed from
the allowlist (#23778).
Record pairing membership but only honor it in the no-allowlist branch. When
an allowlist IS configured, the paired user must appear in the canonical
allowed_ids set (the same set that resolves WhatsApp aliases, SimpleX names,
group allowlists, and the '*' wildcard), so pairing grants no extra access.
Cherry-picked/rebased from #47736 (#23805) by ygd58; membership check rewritten
to reuse the existing allowlist logic. Adds regression tests.
The MCP serve event bridge polls two files to decide whether there is new
conversation activity to surface to MCP clients: the gateway sessions.json
index and state.db. Its skip-when-unchanged guard was self-defeating — it
refreshed self._sessions_json_mtime with the current value *before*
comparing against it, so the sessions.json term was always true and the
guard collapsed to a state.db-only check.
The impact is silent message loss on the event stream. The gateway commonly
persists a message to state.db on one tick and registers the owning
conversation in sessions.json a moment later. On that later tick only
sessions.json has changed, so the broken guard takes the early return and
never processes the freshly-registered chat. Its messages are withheld from
every connected MCP client (events_poll / events_wait) until state.db
happens to change again — which, for an otherwise-idle conversation, may be
never. A polling bridge that quietly swallows new conversations is exactly
the failure mode this watcher exists to prevent.
The fix is minimal and low-risk: capture the previously-seen sessions.json
mtime before the cache refresh and compare against that, so the guard skips
only when NEITHER file changed since the last poll. The hot-path mtime
optimization is fully preserved (a genuinely idle tick still short-circuits),
and all existing EventBridge polling tests continue to pass unchanged.
## What does this PR do?
Fixes a logic error in `EventBridge._poll_once` (`mcp_serve.py`) where the
"nothing changed, skip this poll" guard compared `sj_mtime` against
`self._sessions_json_mtime` *after* that attribute had already been
overwritten with `sj_mtime`. The comparison was therefore always true,
reducing the intended "skip only if both files are unchanged" check to a
state.db-only check and discarding any tick in which only sessions.json
changed. The guard now compares against the mtime observed on the previous
poll, restoring the intended behavior.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)
## Changes Made
- `mcp_serve.py`: in `EventBridge._poll_once`, snapshot
`prev_sessions_json_mtime = self._sessions_json_mtime` before refreshing the
cached index, and use it in the skip guard
(`sj_mtime == prev_sessions_json_mtime`) so a sessions.json-only change no
longer triggers the early return. Added a comment explaining the seam.
- `tests/test_mcp_serve.py`: added
`TestEventBridgePollE2E::test_poll_picks_up_new_conversation_when_only_sessions_json_changed`,
a regression test that reproduces the boundary state (state.db unchanged,
sessions.json newly updated) and asserts the new conversation's message is
emitted.
## How to Test
1. Reproduce the failure on the old code: with the guard comparing against
`self._sessions_json_mtime`, the new test fails — the freshly-registered
conversation yields `0` events instead of `1`.
2. Apply the fix and run `pytest tests/test_mcp_serve.py -q` — all 46 tests
pass (40 skipped require the optional `mcp` SDK), including the three
pre-existing `TestEventBridgePollE2E` polling tests and the new regression
guard.
3. `ruff check mcp_serve.py tests/test_mcp_serve.py` and
`python scripts/check-windows-footguns.py mcp_serve.py` both report clean.
## Checklist
### Code
- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/test_mcp_serve.py -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
`pathlib.Path('~user').expanduser()` raises RuntimeError when the
tilde-expansion can't resolve the user (e.g. `~500-700` where the LLM
meant "approximately 500-700" rather than a path). The hint walker's
existing `except (OSError, ValueError):` clauses do not catch
RuntimeError, so it escapes through the tool dispatcher and surfaces
in the conversation loop as a misleading
Error during OpenAI-compatible API call #N:
Could not determine home directory.
Reproduced across three unrelated models (openai/gpt-5-mini,
openai/gpt-5.1-codex, deepseek/deepseek-v4-flash) on terminal-tool
commands containing literal tildes in non-path contexts — common in
LLM output ("~500 agencies", "~45,000 CVEs", "~80/hr blended rate").
Reproduction (one-liner):
>>> from pathlib import Path
>>> Path("~500-700").expanduser()
RuntimeError: Could not determine home directory.
Fix: extend the three `except` clauses in
agent/subdirectory_hints.py to also catch RuntimeError:
line 138 (_add_path_candidate's outer catch around the Path().expanduser() call)
lines 198+202 (_load_hints_for_directory's nested catches around hint_path.relative_to(Path.home()))
Tests: tests/agent/test_subdirectory_hints_tilde.py adds three cases
covering: tilde-as-approximately in heredoc commands, ~unknown_user paths,
and a regression guard that legitimate ~/path expansion still works.
Root cause: gateway spawns LSP servers (jdtls/pyright/yaml-ls) and
slash_worker without start_new_session=True, so they inherit the
gateway process group (= TUI parent PID). When mcp_tool
_snapshot_child_pids() races with these spawns during stdio MCP
server startup, non-MCP children leak into _stdio_pgids with the
TUI parent PGID. shutdown_mcp_servers() then killpg(tui_parent_pid,
SIGTERM), killing the TUI itself.
Evidence: tui_gateway_crash.log shows recurring SIGTERM stacks:
shutdown_mcp_servers -> _kill_orphaned_mcp_children ->
_send_signal -> killpg(pgid, sig) -> SIGTERM received
Fix (3 layers):
1. agent/lsp/client.py: add start_new_session=True to LSP server
spawn so each LSP server gets its own process group/session.
2. tui_gateway/server.py: same fix for slash_worker spawn, the
symmetric root-cause patch so no gateway direct child shares
the TUI parent pgid.
3. tools/mcp_tool.py: add _filter_mcp_children() defense-in-depth
that drops non-MCP children (slash_worker, jdtls/eclipse LSP)
from the PID delta before they can poison _stdio_pgids.
Two tests for the auto-resume authorization gate: an unauthorized session
owner is skipped without claiming a _running_agents slot or persisting one,
and a raising auth check fails closed (session skipped, not resumed).
git's and sudo's option parsers resolve unambiguous long-flag prefixes, so
`git reset --har`, `git branch --delete --force`, and `sudo --stdi`/`--ask`
execute identically to their full-flag forms while evading the exact-string
DANGEROUS_PATTERNS regexes that gate them. Verified live against real git
and sudo binaries. Widen the patterns to accept unambiguous abbreviations,
scoped narrowly enough to avoid colliding with sibling flags (--help,
--soft/--mixed/--merge/--keep, --shell/--set-home).
Follow-up widening the archived-history fix to the sibling save paths the
original PR did not cover. Model switches (_cmd_model, set_session_model) and
_restore mint a fresh AIAgent with _session_db_created=False, so the
agent-owns-persistence guard evaluates False and the blind full-history
replace_messages() fired — DELETEing the durable active=0/compacted=1 rows on
any compressed ACP session (same data-loss class the PR fixes, different
trigger).
- hermes_state.replace_messages: add active_only=True to delete/reinsert only
the live (active=1) rows, leaving soft-archived rows untouched (idea adopted
from the competing PR #50306 by @mrparker0980, credited).
- hermes_state.has_archived_messages: cheap existence probe for active=0 rows.
- acp_adapter._persist: when the agent doesn't own persistence but the session
already has archived rows on disk, replace active-only; otherwise the
destructive full replace stays (fresh create/fork has nothing to lose).
- Regression test: model-switch save on a compacted session keeps the archived
turn discoverable via get_messages(include_inactive=True) + search_messages.
ACP's SessionManager._persist() called db.replace_messages() on every
save. That delete-then-reinsert is destructive by design. The agent
backing each ACP session already persists to the same SessionDB itself:
it flushes turns incrementally via append_message and, on context
compression, preserves pre-compaction turns non-destructively through
archive_and_compact() as searchable active=0/compacted=1 rows.
So the per-save replace_messages() was a redundant double-write that
deleted exactly those archived rows (and their FTS entries). Worse,
after a compression-driven id rotation the agent's live head no longer
equals the ACP session id, so the replace overwrote the ended parent
transcript while new turns flowed to the new id — split-brain corruption
of one conversation. Any ACP conversation (VS Code / Zed / JetBrains)
long enough to compress lost history.
Now _persist skips the destructive replace when the agent owns
persistence to this DB (its _session_db is this db and its row exists),
relying on the agent's own incremental + archival flush. It still falls
back to the atomic replace when the agent is not self-persisting — test
agent factories, and fresh create/fork sessions whose copied history the
agent has not flushed yet — so the #13675 rollback guarantee holds.
## What does this PR do?
Fixes silent history loss in ACP editor sessions. ACP _persist no longer
destroys the compression-archived transcript the agent already wrote.
Long enough conversations compress; that compression archives old turns
non-destructively; ACP then hard-deleted them on the next save. After an
id rotation it also clobbered the ended parent and split the
conversation across two ids. This change defers to the agent's own
persistence when it owns the DB and only uses the destructive replace
when nothing else is writing the transcript.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)
## Changes Made
- `acp_adapter/session.py`: in `SessionManager._persist`, guard the
`db.replace_messages()` call. Skip it when the agent owns persistence
to this DB (`agent._session_db is db` and `agent._session_db_created`);
otherwise keep the destructive atomic replace as the fallback.
- `tests/acp/test_session.py`: add a regression test proving archived
(active=0/compacted=1) rows survive a save when the agent self-persists
and stay FTS-searchable; add a test confirming the replace path still
runs for agents that do not own DB persistence.
## How to Test
1. Run `pytest tests/acp/test_session.py -q` — 43 pass.
2. `test_save_session_preserves_agent_archived_history`: archive a turn
via `archive_and_compact`, save, and confirm it survives and is found
by `search_messages` (fails before this fix — replace_messages deleted
it).
3. `test_save_session_still_replaces_when_agent_not_self_persisting`:
confirm history still overwrites cleanly for non-self-persisting
agents.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Rework follow-up on the Windows destructive-shell detection. The PowerShell
pattern required an explicit -Command/-c before the verb, but PowerShell runs
the verb as the DEFAULT POSITIONAL arg — so `powershell Remove-Item -Recurse
-Force C:\x` (no -Command) slipped through, the exact case the PR body claims
to close. Also missing the canonical `ri` alias.
Anchor the verb to the command position (after the shell name + any leading
-Flag switches + optional -Command/-c) so bare invocations are caught while a
benign path arg containing 'del'/'rm' (e.g. -File c:\del-logs\run.ps1) is not.
Add ri to the verb list. Mutation-verified regression tests for the bare
invocation, ri alias, and the benign-path negative.
Register the Matrix room-message, reaction, and invite handlers with
mautrix's wait_sync=True. mautrix's handle_sync() only returns the tasks
for handlers registered as sync-awaited; non-waited handlers are
fire-and-forget via background_task.create() and are NOT returned. Since
_dispatch_sync() awaits only the returned tasks (await asyncio.gather),
the inbound handlers previously had no completion point, so Tuwunel/
mautrix homeservers connected and completed initial sync but dispatched
zero inbound messages.
Fixes#46142.
Co-authored-by: Zeheng Huang <153708448+hunjaiboy@users.noreply.github.com>
Follow-up correcting the salvaged fix's persistence approach to avoid a
duplicate user-message write (verified via E2E — the #860/#42039 bug class
the original diff aimed to avoid).
Root cause: in gateway mode the AIAgent is built WITH a session_db, so the
inbound user turn is already flushed at turn start (turn_context.
_persist_session). The original fix returned agent_persisted=False, making the
gateway re-write the whole new-message slice via append_to_transcript ->
append_message (a raw INSERT with no dedup), duplicating the already-flushed
user turn.
Corrected approach (single writer): run_codex_app_server_turn now flushes its
OWN projected assistant/tool messages via _flush_messages_to_session_db (which
dedups the already-persisted user turn through _DB_PERSISTED_MARKER) and
returns agent_persisted=True so the gateway skips its write. Net result:
session_search/distill see the full codex conversation, each message persisted
exactly once.
Adds regression coverage asserting exactly-once persistence on a real
SessionDB, agent_persisted=True, FTS visibility, and standard-runtime skip-db
behaviour preserved.
Co-authored-by: Lubos Buracinsky <lubos@komfi.health>
compress_context() and /new already flush un-persisted messages before
calling end_session() (fixed in #47202), but /resume and /branch still
call end_session() directly. When a turn is interrupted mid-flight and
the user immediately runs /resume or /branch, messages generated during
that turn have not yet been written to state.db and are silently lost on
session rotation.
Add the same best-effort _flush_messages_to_session_db() call before
end_session() in both _handle_resume_command and _handle_branch_command,
mirroring the pattern established in cli.py:new_session().
Regression tests verify the flush is called when an agent is present.
Phase 2c review flagged that only 2 of the 4 structurally-identical
resolve_provider_client routing dead-ends were demoted. Complete the bug-class:
also demote+dedup the external-process ('not directly supported') and OAuth
('not directly supported, try auto') fall-throughs, keyed by provider name, so
none of the four dead-ends spam WARNING on a retry loop.
Add direct tests for the unhandled-auth_type and OAuth dedup paths via a
monkeypatched PROVIDER_REGISTRY (the review noted these were unverified).
Mutation-checked: reverting either sibling demotion fails its test.
The two fall-through branches in resolve_provider_client (unknown provider,
unhandled auth_type) logged at WARNING on every retry of a misconfigured
provider, spamming logs during retry loops. Demote both to logger.debug with
per-process dedup: the first occurrence still surfaces (a provider-name typo or
PROVIDER_REGISTRY/auth_type-drift bug is worth seeing once), while identical
repeats are suppressed for the process lifetime.
Salvaged from #56283 (extracting only the stated auxiliary_client fix; the
original PR also bundled ~2800 lines of unrelated changes across 10 other
files, which are dropped).
Reworks @valenteff's #53277 fix per review (Teknium's 3 findings):
- Route refresh_launchd_plist_if_needed's bootstrap through the existing
_launchctl_bootstrap() EIO-recovery helper (canonical since #56256),
wrapped in a wall-clock retry loop, instead of an ad-hoc 5x2s loop.
- Window sized to agent.restart_drain_timeout (default 180s), not a fixed
~10s: the failure happens while the old gateway is still draining (finding 1).
- Retry on subprocess.TimeoutExpired too, not just CalledProcessError — a
bootstrap timeout after bootout otherwise escapes and leaves the service
unloaded (finding 2).
- Confirm success with launchctl list, not a bare bootstrap exit 0 (finding 3);
mirror verify+drain-window in the detached-helper bash path.
- Shared helpers _launchd_reload_log_path / _append_launchd_reload_log /
_launchctl_label_registered / _retry_launchctl_bootstrap_until_registered.
3 new tests cover retry-until-listed, TimeoutExpired-retried, deadline-exhaust.
E2E: real reload log + mocked launchctl — retries CalledProcessError+TimeoutExpired,
verifies via launchctl list, logs failures.
Think-enabled models (MiniMax M2.7, DeepSeek, etc.) emit inline
<think>...</think> reasoning even for simple prompts like title
generation, and the raw XML was leaking into session titles. Route the
title-model response through the canonical strip_think_blocks scrubber
before cleanup so every tag variant — closed pairs, unterminated blocks,
orphan closes, mixed case — is handled, not just a single literal
<think> pair.
- 2 regression tests: closed <think> pair stripped, unterminated block
at start yields no title.
Salvaged from PR #44126 by @shawchanshek.
Hardens the salvaged #53997 tests per review: the positive-resolution and
reconnect-recovery tests now assert query_keys is awaited with the REAL
resolved device id ({mxid: [<id>]}) and never [None] — the [null] body the
homeserver rejects (the actual bug), plus await_count==2 to prove
verification genuinely re-runs after resolution rather than just the flag
looking right.
Per review feedback on #53997 from @teknium1: the flag was set True
on failed device_id resolution but never reset, so a same-adapter
reconnect that successfully resolves a real device_id would keep
skipping server-side key verification indefinitely.
Reset now happens at the top of connect(), before resolution runs,
so every connect() attempt starts clean. A repeat failure re-sets
the flag (unchanged behavior); a recovery correctly clears it.
Adds TestDeviceIdRecoveryOnReconnect to cover the transition.
- Resolve device_id via query_keys({mxid: []}) when whoami() returns None
- Guard _verify_device_keys_on_server and _reverify_keys_after_upload
against None/unverified device_id to prevent 'device_keys values must
be a list of strings' serialization failure
- Disconnect existing client before reconnect to prevent dual OlmMachine
instances on the same crypto store
Re-targeted from #39779 (legacy gateway/platforms/matrix.py) onto the
migrated plugins/platforms/matrix/adapter.py path following the
2026-06-20 adapter migration. Logic unchanged from original fix.
242 tests passing (233 upstream + 9 new).
/health/detailed leaked runtime state (gateway state, connected
platforms, active-agent counts, PID, exit reason) with no auth. Gate it
behind the same Bearer auth as other API routes; plain /health stays
open for liveness probes.
Also refuse to start on a placeholder/too-short (<16 char) API_SERVER_KEY
regardless of bind address — a guessable key on a terminal-capable
endpoint is RCE-adjacent even on loopback, since any local process can
reach it. The required-key check was already unconditional; this extends
the strength floor to loopback binds too. Startup guards are hoisted
above app/background-task creation so a rejected start leaves no partial
state.
Salvaged from #44073 (external-surface hardening), split into a focused
PR per maintainer request.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
The execute_code sandbox exposed its tool-call RPC (AF_UNIX socket and
remote file-poll transports) without any caller check, so any local
process that could reach the socket / rpc dir could dispatch
terminal-capable tool calls through the parent. Mint a per-session
HERMES_RPC_TOKEN, pass it to the sandboxed child, and require a
timing-safe match on every request in both _rpc_server_loop and
_rpc_poll_loop. Empty/missing/wrong token fails closed.
Salvaged from #44073 (per-session RPC token). Added timing-safe
secrets.compare_digest comparison and fail-closed regression tests.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
MoA full-turn traces (moa.save_traces) recorded the aggregator's acting
output only on the non-streaming path, where it's captured inline at
call time. On the streaming path — which every hermes chat --query run
and every live gateway/CLI turn takes — the aggregator's raw token
stream is handed to the live consumer, so the trace left output=null and
only pointed at the session-db assistant row. An offline audit of a
benchmark run (HermesBench drives --query) then couldn't see what the
aggregator produced without hand-joining to state.db.
Capture the resolved streamed acting text at trace-flush time (the agent
already holds it in _current_streamed_assistant_text) and fold it into
the trace, so the record is self-contained in both modes. New
output_location value inline_from_stream marks a streamed turn whose text
was captured this way; a genuinely empty acting turn (pure tool call)
still points at the session db, matching state.db exactly.
Touches only the trace side-channel — no change to the acting path,
message history, role alternation, or prompt cache.
- agent/moa_loop.py: consume_and_save_trace(..., aggregator_output_fallback)
on both the facade and the MoAClient wrapper; prefer inline capture,
fall back to the resolved streamed text.
- agent/moa_trace.py: embed the fallback; add inline_from_stream location.
- agent/conversation_loop.py: pass _current_streamed_assistant_text at flush.
- tests: 5 cases across streaming / non-streaming / empty-fallback / no-double-write.
Follow-up on the salvaged #47491 commits:
- Register _plugin_api_runtime_gate BEFORE the auth middlewares so it
executes AFTER them, and add an explicit auth check: unauthenticated
requests to /api/plugins/<name>/ fall through to auth's 401 instead of
this gate's 404. Prevents the gate from becoming a plugin-name oracle
(an unauthenticated caller could otherwise fingerprint installed/enabled
plugins by status code). Keeps test_non_kanban_plugin_route_requires_auth
green.
- Enable the 'example' user plugin in the _install_example_plugin test
fixture so the auth / static-asset-allowlist tests still reach the real
serving paths now that user plugins are gated on plugins.enabled.
- Mark the runtime-gate unit-test scopes as authenticated so they exercise
the enabled/disabled policy under the new auth-first ordering.
Address two residual bypasses identified in review:
1. Add _plugin_api_runtime_gate middleware that checks plugins.enabled/
plugins.disabled on every request to /api/plugins/{name}/... routes.
Previously, disabling a plugin at runtime had no effect on its already-
mounted API routes until a restart.
2. Extend serve_plugin_asset to check plugins.disabled for bundled plugins.
Previously, only user plugins were gated — a bundled plugin in
plugins.disabled would still serve assets from the unauthenticated
/dashboard-plugins/{name}/... endpoint.
Both fixes ensure the enabled/disabled policy is evaluated live at request
time, not just at startup.
Adds regression tests covering:
- Middleware blocks disabled user plugin API routes (404)
- Middleware blocks user plugin removed from enabled set (404)
- Middleware passes enabled user plugin API routes
- Middleware blocks disabled bundled plugin API routes (404)
- Bundled plugin assets return 404 when disabled
- Bundled plugin assets served normally when not disabled
- User plugin asset gating still works correctly
Slack Workflow Builder posts (and other app/bot messages) arrive as
subtype=bot_message with user=None. _is_user_authorized rejected them at
the `if not user_id: return False` guard, which runs *before* the #4466
{PLATFORM}_ALLOW_BOTS bypass — so @mentioning the bot from a Slack
workflow silently did nothing, even with SLACK_ALLOW_BOTS (or
SLACK_ALLOW_ALL_USERS) set. The chat-scoped allowlist for Telegram/QQ
already runs before that guard for the same reason (channel broadcasts
with no from_user); Slack was both missing from the bot-bypass map and
had the bypass running too late.
- gateway/authz_mixin: move the {PLATFORM}_ALLOW_BOTS bypass ahead of the
no-user-id guard and add Platform.SLACK -> SLACK_ALLOW_BOTS.
- plugins/platforms/slack/adapter: set is_bot=True on inbound
bot_message events so the gateway can identify workflow/app senders
(they carry no user_id to match against the allowlist).
Tested: new tests/gateway/test_slack_bot_auth_bypass.py plus the existing
Discord/Feishu bot-auth and gateway authz/gating suites all pass.
Follow-up on the salvaged resume_pending fix: the empty-turn safety net
now emits the same reason-aware recovery note as the _is_resume_pending
branch (reason phrase + 'session restored' guidance + no-re-execute
instruction) instead of a second, differently-worded note. Also adds the
AUTHOR_MAP entry for the salvaged commit.
A session interrupted by a gateway restart is flagged resume_pending and
auto-continued on startup via _schedule_resume_pending_sessions(), which
dispatches an empty-text internal MessageEvent. The recovery system note
that should fill that empty turn is gated, in _run_agent(), on
_interruption_is_fresh — the age of the LAST PERSISTED TRANSCRIPT ROW.
For an active thread returned to after >1h of silence, that transcript
clock is stale even though the interruption (last_resume_marked_at) is
seconds old. The gate evaluates False, the note is not prepended, and the
model receives a genuinely blank user turn — replying with confused
'that message came through blank' noise.
Fix (two parts, both default-on, behavior unchanged for healthy turns):
1. resume_pending freshness now also considers last_resume_marked_at (the
restart watchdog's own stamp). The branch fires when EITHER the
transcript clock OR the resume mark is fresh, so the startup scheduler's
freshness decision and the per-turn injection agree.
2. Empty-turn safety net: if the user turn is still blank after all
injections AND the session is resume_pending, backfill a recovery note
so a blank turn can never reach the model. Scoped to resume_pending so
ordinary empty turns (e.g. uncaptioned image) are untouched.
Adds 3 regression tests; the two core ones fail on the pre-fix logic.
Follow-up to the salvaged fail-closed defaults. The own-policy default flip
(open -> pairing) and the email dispatch-level deny broke sibling tests
across the suite that relied on the old fail-open behavior:
- test_email.py: dispatch-mechanics tests now opt into EMAIL_ALLOW_ALL_USERS
(they test formatting/attachments/threading, not authz); the two auth
contract tests are rewritten to assert the new fail-closed behavior
(no allowlist + no allow-all => sender dropped at the adapter).
- test_whatsapp_cloud.py / test_whatsapp_formatting.py / test_whatsapp_from_owner.py:
autouse fixture opts into WHATSAPP_ALLOW_ALL_USERS so dm_policy: open
dispatch-mechanics tests still flow (open now requires an explicit
allow-all opt-in, SECURITY.md 2.6).
- _adapter_for_source: use getattr for source.platform/profile so bare
SimpleNamespace test fixtures without .profile don't crash the busy/queue
ingress path (AGENTS.md pitfall #17).
Full tests/gateway/ + yuanbao pipeline: 8555 passed, 0 failed.
Aligns runtime behaviour with SECURITY.md 2.6: externally reachable
messaging adapters must fail closed unless access is explicitly
configured. Closes the confirmed multiplex authorization bypass a
secondary profile's open dm/group policy no longer inherits the default
profile's allowlist trust.
- Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default
dm_policy/group_policy to pairing/allowlist instead of open; open now
requires an explicit GATEWAY_ALLOW_ALL_USERS or per-platform allow-all.
- Startup guard (_own_policy_open_startup_violation) refuses to boot when
an enabled adapter is open without the allow-all opt-in; the guard now
runs for every secondary profile in multiplex mode too.
- Profile-aware own-policy authorization: _authorization_adapter /
_adapter_for_source resolve the live adapter via SessionSource.profile,
so _is_user_authorized and the ingress/pairing/busy/queue paths read the
originating profile's adapter policy, not the default profile's.
- Fail-closed intake for Email, Feishu P2P, and Discord (blank-principal
denial, empty-allowlist deny, missing-interaction.user deny).
Salvaged from #44073 (external-surface hardening), split into a focused
gateway-authz PR per maintainer request. Follow-up fix by Hermes Agent:
the Discord slash-auth channel bypass now matches DISCORD_ALLOWED_CHANNELS
by the same name-inclusive keys (id + name + #name + parent) the on_message
scope gate uses, so a name-form channel allowlist authorizes slash
interactions consistently (was id-only, breaking #name matching).
Co-authored-by: Hermes Agent <agent@nousresearch.com>
Discord's _fetch_channel_context backfills recent channel/thread activity
(from any member who can post there, not just the allowlisted user) into
the agent's context with no sender-trust distinction. Slack's equivalent
_fetch_thread_context was fixed to prefix non-allowlisted senders with
[unverified] and add LLM guidance not to act on their content, mitigating
indirect prompt injection from third parties in shared channels/threads.
Port the same mechanism to Discord using the already-wired
_is_sender_authorized/set_authorization_check plumbing.
Phase 2c mutation-check found the salvaged tests covered only the pure helpers
(_is_background_review_harness_message / _strip_background_review_harness) — the
two integration WIRINGS had zero coverage: removing the _persist_disabled guard
in _flush_messages_to_session_db, or the _strip call in
get_messages_as_conversation, left all 13 tests green.
Add:
- TestPersistDisabledHardStop: a _persist_disabled agent's flush writes nothing
to a live SessionDB (guards the run_agent hard-stop).
- TestGetMessagesAsConversationStripsHarness: a session with stray harness rows
resumes clean end-to-end through get_messages_as_conversation (guards the
hermes_state load-time wiring).
Mutation-checked: each new test fails when its wiring is reverted.
The forked skill/memory review agent shares the parent's session_id for
prompt-cache warmth. Without isolation it wrote its harness turn ('Review the
conversation above and update the skill library…') plus its curator-mode reply
straight into the user's REAL session in state.db; the next live turn re-read
that injected user message as a standing instruction and the agent 'became' the
curator, refusing the actual task.
Root fix: a _persist_disabled flag on the fork that hard-stops every DB write
and lazy-open path (_flush_messages_to_session_db, _ensure_db_session,
_get_session_db_for_recall) — the review writes only to the skill/memory stores
via its tools. Defense-in-depth: _strip_background_review_harness drops any
stray harness message (and the assistant reply that followed) at load time in
get_messages_as_conversation, so an already-polluted session resumes clean.
Salvaged from #50296.
Co-authored-by: arminanton <29869547+arminanton@users.noreply.github.com>
The standalone thread-pool fallback in _deliver_result() runs inside the
`except RuntimeError:` block (taken when asyncio.run() sees a running loop).
When future.result() raised there (SMTP ConnectionError, timeout, etc.), the
exception was NOT caught by the sibling `except Exception:` — it escaped
_deliver_result() and crashed the whole delivery loop, silently skipping every
remaining target. Multi-target delivery (e.g. deliver: 'email:a,email:b') is a
documented feature, so this broke a promised contract.
Wrap the fallback in its own try/except so a per-target failure is logged with
exc_info and the loop continues to the next target.
Fixes#47163
Phase 2c review follow-up on the id()-reuse persistence fix:
- test_recycled_id_in_dedup_set_still_persists_new_message seeded an EMPTY
dedup set, so it never injected a collision and passed under id-based dedup
too (couldn't distinguish the designs). Replace with
test_stale_seed_id_from_prior_flush_cannot_suppress_new_message, which asserts
the durable invariant: the seed is empty after every flush (mutation-checked:
removing the post-flush reset now fails BOTH id-reuse tests).
- Refresh the _flush_messages_to_session_db docstring: it still described the
old per-session identity tracking; document the intrinsic-marker mechanism,
that _flushed_db_message_ids is now a one-shot seed, and the shared-dict
mutation safety note.
_flush_messages_to_session_db deduped persisted messages with a retained
{id(msg)} set (_flushed_db_message_ids) kept across turns. Once a flushed dict
is dropped from the live list (scaffolding rewind / in-place compaction) and
GC'd, CPython recycles its address onto a new assistant/tool dict whose id()
collides with the stale entry — so the real turn is silently never written to
state.db.
Replace the retained id-set with an intrinsic _DB_PERSISTED_MARKER stamped on
each dict. The id-set is demoted to a one-shot seed (valid only while the
caller's objects are alive) that is translated to markers and cleared after
every flush, so no id() outlives a flush to alias a future message. The marker
is _-prefixed so the wire sanitizers strip it before any request leaves.
Preserves the existing _is_ephemeral_scaffolding skip. Salvaged from #50372.
Co-authored-by: rrevenanttt <290873280+rrevenanttt@users.noreply.github.com>
Review nit (helix4u): the fix covers 500/502/503/529 but the positive tests
only asserted 500 and 503. Parametrize over all four so 502/529 are covered
too; keep the plain-5xx negatives.
Local inference servers (llama.cpp/llama-server, vLLM/Ollama behind a
Cloudflare/Tailscale hop) report context overflow with HTTP 500/502/503/529
instead of 400/413. _classify_by_status returned server_error/overloaded and
retried blindly, then dropped the turn with no compaction. Route explicit
_CONTEXT_OVERFLOW_PATTERNS matches on those 5xx codes to context_overflow
(should_compress=True); plain 500 stays server_error, plain 503 overloaded.
hermes -w locks each worktree (reason 'hermes pid=<pid>'). git worktree
remove --force (single -f) refuses a locked tree, so a crashed session's
lock was never released and its worktree accumulated forever — a real
contributor to .worktrees/ bloat.
_prune_stale_worktrees now classifies each lock via _worktree_lock_is_live:
a live-owner pid is skipped at any age; a dead-owner (or foreign) lock is
unlocked first so the aggressive age-based cleanup can actually reap it.
The >72h reap tier is kept (that cleanup is intentional) but now guarded so
dirty/unpushed work is preserved, and branch deletion is gated on
git worktree remove succeeding. New fail-safe helpers _worktree_is_dirty
and _worktree_lock_is_live (pid liveness via gateway.status._pid_exists,
Windows-safe).
A transient Bot API network error during gateway bootstrap (deleteWebhook
or the initial start_polling) currently raises out of connect() and marks
the Telegram adapter fatal, restart-looping the whole gateway even though
the right behavior is to degrade the Telegram channel and let the existing
reconnect ladder recover in the background.
- _delete_webhook_best_effort(): swallow only transient network errors and
continue to polling; non-network errors (e.g. auth failures) still raise.
- _start_polling_resilient(): on a transient conflict/network error at
bootstrap, schedule background recovery and return degraded instead of
raising; non-transient errors still propagate.
- Track the polling error-callback recovery tasks in _background_tasks so
they can't be garbage-collected mid-flight.
- Add a second Telegram Bot API seed fallback IP (149.154.166.110).
Reconnect keeps its existing 10-retry -> supervisor-restart semantics; this
change only fixes the bootstrap raise, it does not alter the retry ladder.
Close a recovery/fallback final_response with an assistant transcript entry before session persistence so durable history cannot end at a tool/user message after the caller receives a final answer.
Adds a regression for a tool-tail transcript with a non-empty final_response. Related to #46071 / #46053, but covers the adjacent case where the assistant message was never appended before persistence.
Follow-up to #54111. That PR routed the early SystemExit exit paths
(clean-fatal-config #51228, startup-aborted-before-running) through
_exit_after_graceful_shutdown / os._exit. Those paths raise right after
runner.start() without going through _stop_impl, so they relied on atexit
to release the PID file + runtime lock — and os._exit bypasses atexit,
leaking both.
Release them explicitly in the backstop (the single guaranteed cleanup
chokepoint). Both calls are idempotent: no-op on the normal _stop_impl
path, actual cleanup on the early-exit paths. Corrects the now-inaccurate
docstring claim that teardown always ran first. Adds a guard test plus the
missing str-code->1 coverage.
E2E: real PID file written + lock acquired, _exit_after_graceful_shutdown(78)
exits code 78 AND removes the PID file (leak confirmed closed).
The streaming think-tag suppressors in cli.py (_stream_delta) and
gateway/stream_consumer.py (_filter_and_accumulate) matched tag names
with case-sensitive str.find(), so only the exact-case literals in the
tag tuples were caught. Mixed-case variants a model may emit — <Think>,
<ThInK>, <REASONING>, <Thought> — slipped through and leaked raw
reasoning into the user-visible stream.
Match against a lowercased view of the buffer with lowercased tag names
at all three sites (open-tag boundary search, partial-tag hold-back,
close-tag search) in both paths. Only KNOWN tag names are matched — no
substring matching — and the block-boundary gating that protects prose
mentions of <think> is preserved.
- 6 parametrized case-insensitive regression tests in each of
tests/gateway/test_stream_consumer.py and
tests/cli/test_stream_delta_think_tag.py.
Salvaged from PR #27289 by @YLChen-007.
get_messages_as_conversation ordered rows by (timestamp, id). append_message
stamps each row with time.time(), which is not monotonic — on WSL2, after an
NTP step, or when a VM/laptop resumes from sleep the clock can jump backwards
mid-conversation. A later row then carries an earlier timestamp than its
predecessor, so ORDER BY timestamp sorts an assistant tool_calls row after its
tool response, orphaning the tool call and triggering an HTTP 400 on the next
completion. Order by the AUTOINCREMENT id (true insertion order) instead.
This is the sibling path to c03acca50, which already fixed get_messages but
missed get_messages_as_conversation.
Salvaged from #50356.
Co-authored-by: pprism13 <290877921+pprism13@users.noreply.github.com>
Builds on the salvaged force-exit fix:
- Route the start_gateway() SystemExit paths (clean-fatal-config #51228,
planned-restart, service-restart) through the same os._exit backstop. Those
paths previously fell through to normal interpreter finalization, leaving
them vulnerable to the SAME wedged-non-daemon-thread hang the boolean-return
paths now avoid. main() catches SystemExit and converts its code (None->0,
int->code, str->1) to os._exit. Every exit path is now wedge-proof.
- Document in the helper why bypassing atexit is safe (remove_pid_file +
release_gateway_runtime_lock are performed explicitly in start_gateway
teardown) and why logging is not flushed (synchronous RotatingFileHandlers).
- Tests: assert termination via os._exit not SystemExit (adapted from
@AgenticSpark's PR #53122, a duplicate of #53121), plus SystemExit(78) is
routed through os._exit(78) and SystemExit(None) maps to os._exit(0).
On macOS, `launchctl bootstrap` of a label still registered in the domain
fails with 5: Input/output error (EIO). That is the *already loaded* case — a
stale registration from an interrupted restart or a bootout that didn't settle
— recoverable by booting the leftover out and bootstrapping again, and distinct
from the domain being genuinely unmanageable.
launchd_install and launchd_start (both bootstrap paths) treated exit 5 as
'launchd cannot manage this macOS version' and silently degraded to a detached
process, losing auto-start at login and crash-restart. Centralize bootstrap in
_launchctl_bootstrap(), which on EIO boots the stale label out and retries once;
only if the retry also fails does the error propagate so callers apply their
existing _launchctl_domain_unsupported fallback for a genuinely broken domain.
launchd_restart already boots out before bootstrapping (its drained job is
almost always still registered, so a plain bootstrap would hit EIO on the common
path), so it keeps its explicit pre-bootout rather than routing through the
bootstrap-first helper. Corrected the stale exit-5 comment that claimed it
always meant an unmanageable domain.
Adds TestLaunchctlBootstrapEioRetry covering clean bootstrap (no bootout),
EIO -> bootout -> retry success, persistent EIO re-raise, and non-EIO re-raise
without a spurious bootout.
Command-menu registration (set_my_commands), the status-indicator, and
DM-topic setup make Bot API calls that can stall for certain bot tokens.
They ran inside connect() before/after _mark_connected() but still within
the coroutine the gateway wraps in a connect timeout, so one slow call blew
the whole connect and the adapter never came up — even though polling/webhook
was already live (getMe works via curl). Fixes#46298.
- mark connected as soon as polling/webhook startup succeeds
- move command-menu, status-indicator, and DM-topic setup into a cancellable
background housekeeping task (_run_post_connect_housekeeping)
- cancel that task during disconnect so it can't fire into a torn-down client
- harden scope-name lookup with getattr fallback
Salvaged onto the relocated plugin adapter (plugins/platforms/telegram/
adapter.py) since the original PR #46404 targeted the pre-migration
gateway/platforms/telegram.py path.
Co-authored-by: Hermes Agent <teknium@nousresearch.com>
When text compression can't reduce a 413 request further, evict base64
image parts from tool messages and retry once instead of dead-ending
with 'Payload too large and cannot compress further.'
A 413 is a request-body byte-size limit, not a token limit. browser_vision
screenshots (2-5MB base64 each) keep the HTTP body oversized even after
aggressive summarization. The strip pass passes remember_model=False so a
413 does not poison _no_list_tool_content_models — that set is for providers
that reject list-type tool content, a distinct failure mode.
Cherry-picked from #47397 by Tranquil-Flow; placed onto main's current
token-aware 413 recovery else branch.
vLLM (and other OpenAI-compatible servers) report context overflow with
both the window and the prompt in tokens:
"This model's maximum context length is 131072 tokens. However, you
requested 65536 output tokens and your prompt contains at least 65537
input tokens, for a total of at least 131073 tokens."
parse_available_output_tokens_from_error() already classified this as an
output-cap error (the "requested N output tokens" gate), but none of the
extraction patterns matched the "prompt contains [at least] N input
tokens" phrasing, so it returned None. The recovery path then
misclassified the failure as prompt-too-long and looped through
compression — which frees little while each retry keeps requesting the
same oversized max_tokens — terminating in "cannot compress further"
even though simply lowering the output cap would have succeeded.
Add an extraction branch for the token-based phrasing: available output
= window - reported input. When the input alone is at or over the
window it still returns None, so the caller correctly falls through to
compression.
Relates to #43547.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A crash-interrupted session marked resume_pending is returned by
get_or_create_session so its transcript reloads intact. The idle/daily
reset policy (#54442) keys on updated_at, which is bumped to now on every
message — so a zombie session that keeps receiving messages never trips
it and resumes stale context forever (context bleed reported on Telegram
and Feishu).
Gate the resume_pending branch on last_resume_marked_at (set once at
resume-mark, never bumped per-message) against the auto-continue freshness
window. If resume has been pending past the window, fall through to
auto-reset with reason "resume_pending_expired". A window <= 0 disables
the gate (opt-out for the pre-fix always-fresh behaviour).
Also hoist auto_continue_freshness_window() into gateway/session.py as the
single source of truth; gateway/run._auto_continue_freshness_window() now
delegates to it (keeps the existing import/patch surface).
Fixes#46934
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
The in_channel surface DOES add a seed: _seed_cron_channel_session CREATES
the flat (platform, chat_id, None) session and mirrors the brief into it,
because mirror_to_session only APPENDS to an existing session and the flat
channel row is otherwise absent for a chat_postMessage delivery. Correct
the scheduler thread-skip comment and the test class docstring, which still
described the earlier 'let the existing mirror seed it' design.
Live DM testing showed a reply to a DM cron brief did NOT continue the job.
Root cause: for a 1:1 DM the governing knob is dm_top_level_threads_as_sessions
(default True), NOT reply_in_thread / cron_continuable_surface. Under the
default, each top-level DM keys to a per-message session (…:dm:<chat>:<ts>),
so a reply mints a new ts and can never converge with the flat …:dm:<chat>
session the cron seed creates.
A 1:1 DM has no thread-vs-timeline split, so "in_channel" has no coherent
meaning for a DM — cron_continuable_surface is a channel concept and is a
no-op for DMs. DM continuation is governed entirely by
dm_top_level_threads_as_sessions:
- false → all top-level DMs share …:dm:<chat> → seed + reply converge → works
- true (default) → per-message sessions → no continuation (cron or interactive)
Option A (chosen): document the requirement; no code change (the flat-DM seed
from the prior commit already lands correctly when the knob is false). Adds a
":::note 1:1 DMs" admonition to cron.md + the zh-Hans mirror.
Verification (real inbound handler, not a hard-coded assumption — the mistake
that made the earlier DM E2E falsely pass): tests/manual/cron_inchannel_dm_e2e.py
drives the REAL _handle_slack_message for a top-level DM under both knob values
and asserts false→converges (…:dm:D_TESTDM == seed), true→diverges
(…:dm:D_TESTDM:<ts>). See decisions.md D9.
Live testing exposed a real bug: an in_channel continuable cron delivered
flat to the channel (✅) but the reply did NOT continue the job — the bot
had no brief in context and confabulated the answer.
Root cause: mirror_to_session only APPENDS to a session that already
exists (_find_session_id → no-op when none matches); it never CREATEs one.
A flat (slack, chat_id, None) row is only created when a human posts a
top-level message the bot processes — a cron chat_postMessage delivery
never goes through the inbound handler, so the row is absent and the brief
is silently dropped. The prior impl relied on the bare mirror (F5/OQ-1
concluded "deletion only" — wrong).
Fix: _seed_cron_channel_session mirrors _seed_cron_thread_session —
get_or_create_session FIRST (chat_type = "dm" if is_dm else "group",
thread_id=None), keyed to the ORIGIN USER'S id, then mirror. The channel
session key embeds user_id (…:group:<chat>:<user>), so a system:cron id
would key the seed away from the reply; the origin user's id makes seed
key == inbound reply key. DM key ignores user_id but needs chat_type=dm
to match the prefix. Wired into the in_channel branch after delivery;
suppresses the generic mirror to avoid double-write.
DM validated (per request): the seeded key equals the inbound DM reply key
for a 1:1 DM; continuation works there too.
Tests:
- Rewrote the in_channel tests to use a real _session_store and the origin
user_id; assert get_or_create_session is called with the flat, correctly-
keyed source. Prove-fail: (a) reverting the create step and (b) seeding
with system:cron each turn a targeted test RED; restore → GREEN.
- +2 direct _seed_cron_channel_session unit tests asserting the KEY-MATCH
invariant (seed key == inbound reply key) via build_session_key, for both
channel and DM.
- Rewrote tests/manual/cron_inchannel_e2e.py to drive a REAL SessionStore +
real mirror_to_session + real _find_session_id + real build_session_key
(no session-layer mocks — the old mocked E2E is exactly why the bug
shipped). Asserts the brief lands in the transcript and the reply resolves
to the same session, for BOTH channel and 1:1 DM.
Full relevant sweep: 283 passed.
Add a per-platform `cron_continuable_surface` extra key
(`thread` default | `in_channel`) so a continuable cron job can deliver
FLAT into a Slack channel — no dedicated thread — and still be
replied-to. In `in_channel` mode the scheduler skips the thread-open
branch (leaves `thread_id=None`); the shipped origin-mirror then seeds
the `(slack, chat_id, None)` shared-channel session — the same bucket
`reply_in_thread: false` routes inbound channel replies to — so a plain
channel reply continues the job in context.
Design: specs/cron-inchannel-continuable (D1–D7, F5). Model B
(shared-channel session), NOT anchoring to the delivery `ts` — on Slack
replying to a specific message IS threading, so a `ts` anchor would only
relocate the thread, never deliver true threadless continuable.
- gateway/platforms/base.py: `supports_inchannel_continuable` capability
flag (default False → unsupported platforms fail SAFE to `thread`).
- plugins/platforms/slack/adapter.py: flag=True; `_cron_continuable_surface()`
resolver (coerces to the two-value enum); `_warn_if_inchannel_without_flat_reply`
connect-time warning (D5: warn, not hard-require — the misconfig fails safe).
- gateway/config.py: shared-key bridge line (top-level OR nested config).
- cron/scheduler.py: read the key generically from platform config, gate
the `in_channel` branch on the adapter capability flag, skip thread-open.
No new seed function (reuses the existing mirror — G6).
Pairing (docs): `in_channel` + `reply_in_thread: false` +
`require_mention: false` (or a free-response channel). Missing
`reply_in_thread: false` fails safe to a threaded continuation.
Gateway-side config flag — `/restart` to apply; NO Slack app reinstall.
Tests (from inside the worktree, PYTHONPATH=$PWD):
- +6 cron scheduler tests (in_channel skips thread-open; seeds flat
channel session with thread_id=None; thread-mode regression;
fail-safe on unsupported platform; value coercion). Prove-fail:
removing the `and not in_channel_surface` guard turns the two
load-bearing tests RED; restore → GREEN.
- +10 slack resolver/capability/warning tests; +2 config-bridge tests.
- tests/manual/cron_inchannel_e2e.py: offline E2E driving BOTH real
legs (delivery seed + inbound reply keying) → both converge on
(slack, C, None).
- No regressions: test_slack.py 216 passed alone; broader sweep green
(4 pre-existing cross-file-ordering failures reproduce identically on
pristine origin/main).
Docs: cron.md + slack.md + zh-Hans mirrors of both.
Review of the #50531 salvage found the cross-session HERMES_SESSION_* leak also
survives on the non-terminal spawn helper hermes_subprocess_env (added by #56202
after #50531 was written), which does os.environ.copy() without the guard. Of
its six callers, five re-bind the session identity explicitly (slash_worker/ACP
via --session-key argv) and are safe by accident; but tui_gateway cli.exec
(server.py) spawns a fresh CLI with NO --session-key under the engaged TUI host,
so it inherits a possibly-foreign HERMES_SESSION_* from the last-writer-wins
global and would stamp Kanban rows / telemetry with another session's id.
Route hermes_subprocess_env through the same _inject_session_context_env
chokepoint, restoring the single-uniform-policy-across-every-spawn-surface
invariant the codebase already claims for the internal-secret filter. Safe for
all six callers: bound ContextVars win (re-binders unaffected), _UNSET strips
(closes cli.exec). Adds 3 guard tests; mutation-checked.
Session vars (HERMES_SESSION_*) have a process-global os.environ mirror written
last-writer-wins as a CLI/cron fallback and never cleared. Under a concurrent
multi-session host (messaging gateway, ACP adapter, API server, TUI) that global
belongs to whichever turn wrote it last. A subprocess spawned from a task whose
session ContextVar is _UNSET (a sibling task that never bound, or one that
inherited another session's context) inherited the FOREIGN global and acted on
another session's identity.
Add a session_context_engaged() latch (set once any host calls set_session_vars)
and route both terminal spawn paths through a single _inject_session_context_env
chokepoint: once engaged, a bound ContextVar (incl. "") is authoritative and an
_UNSET var is STRIPPED rather than inheriting the possibly-foreign global. Pure
single-process CLI/one-shot (never engaged) keeps the inherited fallback.
Salvaged from #50531 (supersedes #49922). local.py hunk re-applied by intent
onto the current hermes_subprocess_env refactor.
Co-authored-by: PolyphonyRequiem <3107779+PolyphonyRequiem@users.noreply.github.com>
Review follow-up on the anthropic_adapter batch salvage:
1. #52145 shipped no behavior test for the adjacency rewrite. Add
test_strips_tool_use_when_result_not_immediately_adjacent (a tool_use whose
result appears later but NOT in the immediately-following user message must
be stripped — the exact case the old global id-match got wrong) plus an
adjacent-pair control. Mutation-checked: reverting to a global match fails
the non-adjacent test.
2. test_token_refresh_ua_prefix was vacuous — it bound to _refresh_oauth_token
(a wrapper with no urllib.request.Request), so its assert never ran and it
did NOT guard the real refresh UA site. Retarget it at
refresh_anthropic_oauth_pure (:1048) with the header-scoped check. Mutation-
checked: reverting :1048 to claude-cli/ now fails it.
The salvaged test_token_exchange_ua_prefix did a naive whole-function substring
check for 'claude-cli/', which false-positives on an explanatory comment that
references the old (blocked) UA. Scope it to actual User-Agent header lines —
mirroring the sibling test_no_claude_cli_in_source — so a comment documenting
why claude-cli/ is avoided doesn't trip it. Mutation-checked: an actual
claude-cli/ UA header still fails the test.
Anthropic's OAuth endpoints 404 for the claude-cli/ User-Agent prefix. Switch
all three OAuth UA sites (build_anthropic_client, refresh_anthropic_oauth_pure,
run_hermes_oauth_login_pure) to the claude-code/ prefix Anthropic expects.
Salvaged from #51948.
Co-authored-by: DhivinX <20087092+DhivinX@users.noreply.github.com>
Only classify files below cron/output/ as disposable cron output.
The cron/output directory itself is a durable container for retained
job history and should not be tracked or deleted wholesale.
Add regression coverage for both category detection and cleanup of a
stale tracked entry pointing at the output root.
Rework follow-up on the per-job TERMINAL_CWD readers-writer lock.
The lock was acquired BEFORE the try: whose finally: is the only release
site, with the env-override statements (os.environ[TERMINAL_CWD] = workdir;
logger.info) sitting in the unprotected window between acquire and try. Any
exception there — a raising log handler, an os.environ error, a thread
interrupt — propagated out of run_job WITHOUT running the finally, leaking
the lock. A leaked writer permanently deadlocks the whole scheduler (every
future cron job blocks on acquire_*); a leaked reader blocks all writers.
- Snapshot _prior_terminal_cwd before the acquire (so the finally can always
restore env even if the body raises before the override).
- Open the try: immediately after acquire and move the env-override lines
inside it, so the existing finally always releases the lock.
- Add a mutation-verified regression test: a workdir job whose in-window
logger.info raises must still release the writer lock (a subsequent
acquire_write must not block).
A cron job with a per-job `workdir` overrides the process-global
`os.environ["TERMINAL_CWD"]` for the entire duration of its agent run and
restores it afterwards. The scheduler dispatches workdir jobs on a
single-thread sequential pool and workdir-less jobs on a separate parallel
pool, and the in-code comments claimed this made the override safe.
That only prevents two workdir jobs from overlapping each other. The two
pools run concurrently in the same process and share `os.environ`, so while
a workdir job has `TERMINAL_CWD` pointed at its project directory, any
workdir-less job firing in the same window reads that same global through the
terminal, file, and code-exec tools and runs its commands in the wrong
directory. The corruption window spans the whole workdir-job run, and a file
write or delete can land in another job's tree.
This serializes the override with a writer-preferring readers-writer lock.
Workdir jobs acquire it as writers (exclusive for their whole run); workdir-
less jobs acquire it as readers, so they still run in parallel with each
other but never alongside a workdir job's override. The guarantee is based on
run overlap rather than tick boundaries, so it also holds when a workdir job
spans ticks.
## What does this PR do?
Fixes a directory-isolation bug in the cron scheduler: a workdir cron job's
process-global `TERMINAL_CWD` override could be observed by a concurrently
running workdir-less cron job, causing that job's shell/file/code-exec
commands to execute in the wrong directory.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)
## Changes Made
- `cron/scheduler.py`: add `_ReadWriteLock` (writer-preferring) and the
module-global `_terminal_cwd_lock`.
- `cron/scheduler.py`: in `run_job`, acquire the lock as a writer for workdir
jobs and as a reader for workdir-less jobs, spanning the `TERMINAL_CWD`
override and its restore in the `finally` block.
- `cron/scheduler.py`: correct the stale comments in `run_job` and `tick` that
claimed the sequential pool alone made the override safe.
- `tests/cron/test_terminal_cwd_lock.py`: new tests for reader concurrency,
writer exclusion, and the no-cross-observation regression.
## How to Test
1. `python -m pytest tests/cron/test_terminal_cwd_lock.py -q` — the regression
test `test_reader_never_observes_writer_override` fails without the lock and
passes with it.
2. `python -m pytest tests/cron/test_cron_workdir.py tests/cron/test_parallel_pool.py -q`
— confirms the existing `TERMINAL_CWD` set/restore and pool behaviour are
unchanged.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix
- [x] I've run the affected `tests/cron/` suites and all tests pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (docstrings/comments) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture — N/A
- [x] I've considered cross-platform impact (Windows, macOS) — uses stdlib `threading` only
- [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A
_plugin_override_policy is keyed by the plugin package root
(e.g. hermes_plugins.allowed), but the lookup used caller_mod
(the exact leaf module string). A call from hermes_plugins.allowed.cleanup
would evaluate _plugin_override_policy.get("hermes_plugins.allowed.cleanup")
→ False and raise PermissionError even when the plugin registered opt-in
under its package root.
Switch the policy lookup to caller_root (.join of the first two segments)
so submodule callers inherit the package-level allow_tool_override grant.
Adds a focused regression test for the opted-in submodule case.
When a model emits an inline <think>...</think> block but the opening
tag is dropped upstream (thinking-mode toggle, truncated stream, or
incomplete upstream filtering), the bare </think> close tag leaked
through to the user in the live progressive edit. The agent-side final
scrubber (agent/think_scrubber.py) already had _strip_orphan_close_tags;
this ports the same logic into GatewayStreamConsumer so the streaming
display stays clean too.
- _filter_and_accumulate: strip orphan close tags before appending the
'no-opening-tag' branch text to _accumulated.
- _flush_think_buffer: same on stream end for held-back partials.
- 14 regression tests (TestStripOrphanCloseTags): all 6 close-tag
variants, multi-tag, partial-tag-untouched, trailing whitespace,
and end-to-end through _filter_and_accumulate / _flush_think_buffer.
Only strips KNOWN close-tag names (case-insensitive) — never arbitrary
tag-shaped substrings — so comparison operators and unrelated prose are
preserved.
Salvaged from PR #43192 by @testingbuddies24.
Wrapping a catastrophic command in a bare subshell or brace group walked
straight past the unconditional hardline floor -- even under --yolo,
/yolo, approvals.mode=off, and cron approve mode. The command-substitution
forms were already caught; the bare paren / brace-group forms were the gap.
Rather than add the paren and brace openers to the flat _CMDPOS pattern
class (which cannot tell a real subshell opener from one sitting inside a
quoted argument, and would false-positive on ordinary prose such as a PR
title that merely mentions the trigger word), teach the existing
QUOTE-AWARE command-start tokenizer (_iter_shell_command_starts) to treat
the paren and brace openers as command starts, then emit a detection
variant that marks each real command start with a newline (already a
_CMDPOS separator). Openers inside quotes never register as starts, so
quoted arguments are left untouched while real subshell/brace bypasses now
anchor. One place covers every _CMDPOS rule (shutdown/reboot/init/
systemctl/telinit and the rm root/home/system floor).
Tests: subshell/brace bypasses added to the hardline-block, root-wipe, and
yolo-bypass sets; a regression set asserts quoted paren/brace prose is NOT
blocked (guards our own gh-pr-create workflow).
The messaging extra and platform.slack pin aiohttp==3.14.0, but several
lazy messaging features listed only their SDK and let aiohttp come in
transitively. Each of those SDKs caps aiohttp loosely enough that a
vulnerable already-installed aiohttp still satisfies the range, so the
eager extras got the patched floor while the lazy paths did not:
- discord.py (aiohttp>=3.7.4,<4)
- mautrix / aiohttp-socks (aiohttp>=3,<4 / aiohttp>=3.10.0) [Matrix]
- microsoft-teams-apps (aiohttp<4) [Teams]
(Teams additionally shipped an explicit but *stale* aiohttp==3.13.4 in
both the pyproject `teams` extra and platform.teams.)
- tools/lazy_deps.py: add aiohttp==3.14.0 to platform.discord, platform.matrix;
bump the stale platform.teams pin 3.13.4 -> 3.14.0.
- pyproject.toml: add aiohttp==3.14.0 to the matrix extra; bump the teams extra
3.13.4 -> 3.14.0 (homeassistant/sms/messaging already at 3.14.0).
- tests/test_packaging_metadata.py: test_security_pins_present_in_mirrored_lazy_features
now covers platform.discord/slack/matrix/teams. The existing agree-guard only
compares packages pinned in BOTH sources, so it can't catch a lazy feature
that omits a pin entirely; this guard is an explicit coverage contract
(security package -> lazy features that must carry it) and fails with
'platform.matrix: aiohttp=MISSING' if a floor is dropped again.
- uv.lock: regenerated, zero drift (aiohttp 3.14.0).
Adds two checks to tests/test_packaging_metadata.py:
1. No package is exact-pinned to two different versions across
pyproject.toml's [project.dependencies] / extras.
2. Every package pinned in BOTH the pyproject extras and the LAZY_DEPS
allowlist in tools/lazy_deps.py uses the same version.
This is the regression guard for the drift the rest of this PR fixes: the
two pin sources are hand-maintained mirrors (lazy_deps even documents
"update both this map AND the corresponding extra"), and they have silently
diverged on aiohttp and anthropic. Run against the pre-fix tree, check (2)
fails on `anthropic: pyproject=['0.86.0'] lazy_deps=['0.87.0']`.
The lazy_deps side is parsed via AST (not imported) so the test stays free
of tools/lazy_deps.py runtime imports; only exact `==` pins are compared.
Completes the #30719 restart-loop defenses. Defenses 1-2 (the
_HERMES_GATEWAY guard on `hermes gateway stop|restart` + terminal_tool,
and the cron-creation lifecycle filter) already landed on main, but two
gaps remained:
- The agent's `cronjob` model tool calls cron.jobs.create_job directly,
bypassing the hermes_cli.cron.cron_create CLI filter, so lifecycle
commands scheduled via the model tool were only blocked at execution
time (terminal_tool), not at creation. Moved the filter to a shared
cron/lifecycle_guard.py enforced at create_job — the single chokepoint
every job-creation path hits (CLI + model tool). Re-exported
_contains_gateway_lifecycle_command from hermes_cli.cron so
terminal_tool's import keeps working.
- No breaker for the auto-resume loop itself. Defenses 1-2 cover the
cron/CLI/terminal paths, but any other SIGTERM source (e.g. a raw
terminal("launchctl kickstart ai.hermes.gateway")) still triggers the
boot->auto-resume->re-run cycle. Added gateway/restart_loop_guard.py:
counts restart-interrupted boots in a rolling window (config
gateway.restart_loop_guard, default 3 boots / 60s) and skips
auto-resume for that boot once tripped. The gateway still comes up and
serves real inbound messages; it just stops replaying the session that
keeps killing it, putting a human back in the loop.
Also tightened the lifecycle regex over main's version: dropped
`hermes gateway start` (benign), required the gateway identifier on the
launchctl/systemctl branches (so `launchctl unload
ai.hermes.update-checker.plist` and `systemctl restart
hermes-meta.service` no longer false-positive), added the inverse
pkill token order, and fixed the binary-script bypass (decode with
errors='replace' instead of swallowing UnicodeDecodeError). The
create_job guard resolves relative script paths under HERMES_HOME/scripts
the same way the scheduler does, so a bare script name is scanned as the
file that actually runs.
Design and much of defense-2 originate from PR #33395 (@kshitijk4poor),
which itself salvaged #30728 (@SimoKiihamaki). Rebuilt against current
main since defenses 1-2 had already landed under different names.
Closes#30719.
Co-authored-by: SimoKiihamaki <simo.kiihamaki@gmail.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
The original cross-session contamination fix (#38788) only cleared
_previous_summary in on_session_end(), but on_session_reset() clears
14+ per-session variables. When a session ends (cron exit, gateway
expiry, session-id rotation) and the compressor instance is reused,
the surviving stale state causes:
- _ineffective_compression_count surviving → next session skips
compression prematurely (anti-thrashing guard misfires)
- _summary_failure_cooldown_until surviving → next session blocks
summary generation for an unrelated transient error
- _last_compress_aborted surviving → callers think compression is
still aborted
- _last_aux_model_failure_* surviving → stale error warnings shown
- _last_summary_dropped_count / _last_summary_fallback_used
surviving → misleading user warnings
- _context_probed / _context_probe_persistable surviving → stale
context-probe state
Also fix on_session_reset() which was missing _last_compress_aborted
clearing — a /new or /reset would inherit the aborted flag from the
prior conversation.
Add 6 targeted tests covering the leak vectors and a parity test
ensuring on_session_end and on_session_reset always clear the same
surface.
rm -rf //, /., /./, /.. and //* all resolve to / in the shell but slipped
past the root-filesystem hardline pattern, whose target group only matched
the literal / and /* tokens. They fell to the softer DANGEROUS_PATTERNS
'delete in root path' rule, which --yolo / approvals.mode=off / cron
approve-mode are designed to bypass — leaving the one unconditional floor
open to a full root wipe under yolo.
Broaden the root token from '/|/\\*|/ \\*' to '/[/.]*\\**' inside
_hardline_rm_path so any root-anchored path whose components collapse back
to / (repeated slashes plus ./.. segments) with an optional trailing glob
is caught. A trailing real segment (/tmp, /home, /.ssh) still fails to
match and stays with the softer rules.
Co-authored-by: kernel-t1 <214165399+kernel-t1@users.noreply.github.com>
Add an interactive Raft setup flow for hermes gateway setup. The wizard follows the existing platform adapter setup pattern, persists RAFT_PROFILE to the Hermes env file, preserves an existing profile when the user declines reconfiguration, and registers the flow via setup_fn.
Add focused Raft adapter coverage for saving RAFT_PROFILE, keeping an existing profile, and registering setup_fn.
Signed-off-by: skyzh <skyzh@mail.build>
Signed-off-by: HaoHao <HaoHao@mail.build>
The credential-pool Codex refresh path synced tokens from auth.json and
then POSTed the refresh_token to OpenAI's token endpoint without holding
the cross-process auth-store lock across the whole read->POST->write-back
sequence. Because Codex refresh tokens are single-use, two concurrent
Hermes processes could both adopt the same on-disk token and both POST
it; the loser got refresh_token_reused / invalid_grant.
Wrap the Codex OAuth branch of _refresh_entry in the existing shared
_auth_store_lock (reentrant, cross-process flock) using the same
extended-timeout pattern resolve_codex_runtime_credentials() already
uses. A waiting process now blocks on the lock and, once inside, the
in-lock re-sync picks up the rotated token the winner persisted and
skips its own POST. Also send User-Agent: hermes-cli/<version> on the
refresh request.
Credit @cooper-oai (#34820) for identifying the concurrent-refresh
reuse race; this ships the narrow lock-serialization fix without the
separate Codex auth-store partition.
Matrix outbound image downloads validated only the final URL after
following redirects, so a public URL that 302-redirects to loopback /
private-network / cloud-metadata endpoints had already connected to the
unsafe hop before the check ran.
Re-validate every redirect hop before following it:
- aiohttp path resolves redirects manually with allow_redirects=False,
validating each Location via is_safe_url (aiohttp can't use the httpx
response event hook).
- httpx fallback installs the shared _ssrf_redirect_guard event hook.
Regression tests cover per-hop blocking of an unsafe redirect, following
a safe redirect chain, and httpx guard wiring.
auxv leaks AT_RANDOM (stack canary seed) + AT_BASE/AT_PHDR load
addresses — an ASLR oracle on par with maps. pagemap exposes
virtual->physical translation. Both slipped through the endswith
tuple alongside the maps family covered by the salvaged commit.
Adds regression coverage for auxv/pagemap and for the per-thread
/proc/<pid>/task/<tid>/<file> alias form (endswith catches both).
Follow-up on #32238, closes#34430.
PR #4609 blocked /proc/*/maps to prevent ASLR layout leakage, but the
endswith("/maps") check does not match /proc/*/smaps or
/proc/*/smaps_rollup — both expose the same virtual-address layout and
bypass the guard. /proc/*/numa_maps carries the same data with NUMA
annotations and is equally bypassed. /proc/*/mem (raw process memory)
is added as defence-in-depth; it requires address knowledge to exploit
but is blocked for consistency.
Extends the endswith tuple in _is_blocked_device_path() to cover all
four variants and adds regression assertions for all new paths to
test_proc_sensitive_pseudo_files_blocked.
Partially addresses #4427.
An invite to a room with no remaining members surfaces as "no servers
in the room have been provided" or "room not found" on join. The pending
invite was never cleared, so every gateway startup re-attempted the join
and re-emitted the warning indefinitely.
Detect that specific failure mode by narrow error-message match and call
leave_room to decline the invite; transient/network errors leave the
invite untouched for the next sync. Adds 5 tests.
Reimplements the matrix portion of #33953 onto the current plugin adapter
(gateway/platforms/matrix.py was relocated to
plugins/platforms/matrix/adapter.py since the PR was opened). The two
gateway/status.py fixes from that PR (wrapper-subcommand rejection,
psutil start-time fallback) already landed on main independently.
Reported by @Bougey; original patch authored by @KiraKatana.
_maybe_wrap_untrusted() only wrapped str-typed tool outputs. When a
high-risk tool (web_extract, browser_*) returns a multimodal content
list ([{type:text},{type:image_url}]) — which _tool_result_content_for
_active_model() produces by unwrapping the _multimodal envelope for
vision-capable providers — the text part reached the model completely
unguarded. An attacker page that ships one image bypassed the entire
untrusted-data wrapper.
Extend the wrapper to handle list content: each {type:text} part is run
through the same string-wrapping path (min-char threshold, delimiter
neutralization, one well-formed block), image/video parts pass through
untouched so the list stays valid for vision adapters. Recursing into
the existing string branch means the list path inherits the delimiter
defang and the no-forgeable-fast-path hardening from #56172 for free.
The outer list is rebuilt (not returned by identity), so callers compare
by value.
## What does this PR do?
Closes a critical bypass of the dangerous-command approval system. The
normalizer that every command passes through before pattern matching
(`_normalize_command_for_detection`) already strips ANSI, null bytes,
fullwidth Unicode, backslash escapes and empty-quote token splits — but
it did nothing about the shell `IFS` variable. In any POSIX shell `$IFS`
and `${IFS}` expand to whitespace, so a command written as
`rm${IFS}-rf${IFS}/` is executed by the live shell as `rm -rf /` while
the detection regexes — which anchor on literal `\s` between a command and
its arguments — never fire.
The impact is severe: this evades BOTH layers at once. It slips past every
entry in `DANGEROUS_PATTERNS` (so `curl${IFS}...|sh`, `sed${IFS}-i`
against `~/.hermes/config.yaml`, sudo privilege flags, etc. auto-run with
no approval prompt) AND the unconditional hardline floor that is
documented as un-bypassable "not even with --yolo" (`rm -rf /`, `mkfs`,
`dd` to a raw block device, `shutdown`/`reboot`, fork bomb). A
prompt-injected or malicious instruction could wipe the host filesystem or
power the box off while the approval system reports nothing. Confirmed at
runtime before the fix: `detect_hardline_command('rm${IFS}-rf /')` returned
`(False, None)`.
The fix mirrors the shell's own expansion: it collapses `$IFS` / `${IFS}`
(including the bash substring form `${IFS:0:1}`) to a single space inside
the existing de-obfuscation block, so the whitespace-anchored patterns
match exactly as they do for the un-obfuscated command. It is deliberately
narrow and safe — a `\b` word boundary keeps it from touching unrelated
variables like `$IFSACONFIG`, so it cannot introduce false positives on
legitimate commands.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/approval.py`: in `_normalize_command_for_detection`, substitute
`$IFS` / `${IFS}` (and `${IFS:...}`) expansions with a literal space
before dangerous/hardline pattern matching, alongside the existing
backslash and empty-quote de-obfuscation.
- `tests/tools/test_approval.py`: add `TestIFSWhitespaceBypass` covering
the brace, bare and substring IFS forms against both
`detect_hardline_command` and `detect_dangerous_command`, plus
regression guards that a look-alike variable (`$IFSACONFIG`) and plain
safe commands are not flagged. Import `detect_hardline_command`.
## How to Test
1. Reproduce the hole (pre-fix): `detect_hardline_command('rm${IFS}-rf /')`
returns `(False, None)` and `detect_dangerous_command(...)` returns
`(False, ...)`, i.e. a host-destroying command is auto-approved.
2. With the fix applied, both now flag the command: hardline match
"recursive delete of root filesystem" and dangerous match "delete in
root path".
3. Run the suite: `pytest tests/tools/test_approval.py
tests/tools/test_hardline_blocklist.py -q` — the new
`TestIFSWhitespaceBypass` cases pass and nothing else regresses.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix (no unrelated commits)
- [x] I've run the relevant tests and they pass (two pre-existing failures
are environmental: missing optional deps in the minimal venv, not
caused by this change)
- [x] I've added tests for my changes
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — the change is a
pure string transform with no platform-specific behavior; footgun gate passes
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
`@file` / `@folder` context-reference expansion enforced its own narrow
deny-list (`_ensure_reference_path_allowed` in `agent/context_references.py`)
that only covered `~/.ssh` keys, a handful of shell dotfiles, `~/.hermes/.env`,
and `skills/.hub`. It never blocked the credential stores that the canonical
read guard (`agent/file_safety.get_read_block_error`) protects: provider API
keys (`~/.hermes/auth.json`), Anthropic OAuth tokens
(`~/.hermes/.anthropic_oauth.json`), MCP OAuth material (`~/.hermes/mcp-tokens/`),
webhook HMAC secrets, and project-local `.env` files.
This matters because the messaging gateway feeds **untrusted** remote text
straight into reference expansion: `gateway/run.py` calls
`preprocess_context_references_async(..., allowed_root=_msg_cwd)` where
`_msg_cwd` defaults to the operator's HOME when `TERMINAL_CWD` is unset. A chat
peer (Telegram/Discord/Slack/...) could send `@file:~/.hermes/auth.json`, pass
the `allowed_root` check (it resolves under HOME), slip past the narrow list,
and have the operator's live keys read into the agent's context — where the
model would typically echo or act on them.
Rather than duplicate and re-sync a second secret list, this routes the guard
through the existing single source of truth. A reviewer might ask "why not just
add `auth.json` to the local list?" — because the local list has already drifted
once (a prior commit had to add `.config/gh`); anchoring to
`get_read_block_error` means every future addition there protects this path too.
The narrow checks are kept as a fallback since they also cover dirs that guard
does not (`.aws`, `.gnupg`, `.kube`, etc.), and the canonical lookup is wrapped
so it can never crash reference expansion.
N/A
- [x] 🔒 Security fix
- `agent/context_references.py`: `_ensure_reference_path_allowed` now also
consults `agent.file_safety.get_read_block_error` after its existing checks
and refuses the reference when that canonical guard flags the resolved path.
The lookup is wrapped so guard-resolution failures fall back to the explicit
checks instead of breaking expansion.
- `tests/agent/test_context_references.py`: added
`test_blocks_canonical_read_denylist_credential_stores`, asserting that
`@file` attaches for `auth.json`, `.anthropic_oauth.json`, `mcp-tokens/*`, and
a project-local `.env` are all refused and their secret bodies never reach the
expanded message.
- `scripts/release.py`: added the contributor email to `AUTHOR_MAP` (release
gate).
1. `scripts/run_tests.sh tests/agent/test_context_references.py` — all 15 tests
pass, including the new credential-store case.
2. Regression proof: stash `agent/context_references.py`, run the suite with
`-- -k canonical`, and confirm the new test fails (secrets leak into the
message) without the fix; restore and confirm it passes.
3. `ruff check agent/context_references.py tests/agent/test_context_references.py`
and `python scripts/check-windows-footguns.py agent/context_references.py
tests/agent/test_context_references.py` both pass.
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix (plus the AUTHOR_MAP release gate)
- [x] I've run the test suite for the touched area and all tests pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Follow-up to the #55780 dead-target not_found blast-radius fix (merged in
#56225). classify_send_error and is_chat_level_not_found each built their own
lowercased error blob, but divergently: classify_send_error appended the
exception CLASS NAME while is_chat_level_not_found did not. A caller passing
exc= to both could get inconsistent answers on the same failure.
- Extract _error_blob(exc, error_text) as the single source of truth both
classifiers use (str(exc) when non-empty + class name; no stray leading
space).
- Align is_chat_level_not_found's signature to (exc, error_text), matching
classify_send_error, removing the swapped-positional footgun; update the
sole caller and the three tests to keyword form.
- Add a regression guard asserting _error_blob keeps the class name.
Surfaced by the hermes-pr-review Phase 2c structured review of #56225.
#55115 added the dead-target registry so confirmed-dead delivery targets are
short-circuited. Its documented scope (gateway/dead_targets.py) is deliberately
narrow: only *whole-chat* deaths -- the `forbidden` and chat-level `not_found`
(`chat not found`) kinds -- should be recorded; "Thread/topic-level not_found is
NOT recorded here ... a deleted topic does not mean the parent chat is dead."
But the implementation doesn't honor that scope. classify_send_error collapses
chat-level "chat not found" AND thread/message-level not_found ("thread not
found", "topic_deleted", "message_id_invalid", "message to edit/reply not
found") into one "not_found" kind, _DEAD_ERROR_KINDS contains "not_found"
wholesale, and deliver()'s except marks the PARENT chat_id dead. So a single
deleted Telegram topic or edited-away message permanently marks the entire chat
(and every future scheduled / cron / agent delivery to it) dead -- silently. The
adapter self-heal the docstring relies on only covers the non-private-group
thread retry; named-DM-topic and message-level failures propagate to deliver()'s
except and wrongly kill the whole chat.
Add is_chat_level_not_found() (factoring the not_found substrings into chat-level
vs sub-chat-level constants) and gate the delivery dead-path: a "not_found" only
marks the target dead when it is chat-level. classify_send_error's public
contract is unchanged (still returns "not_found" for every shape); only the
mark_dead decision is refined, restoring the registry's documented scope.
Cross-platform: telegram/slack/discord delivery all flow through
classify_send_error -> mark_dead. Adds regression tests through the real
deliver() path plus helper/classifier units.
The three salvaged PRs (#46647, #54583, #55013) were authored against a
tree where _refresh_agent_cache_message_count was sync and _session_db was
the raw SessionDB. On current main the helper is async and awaits the
AsyncSessionDB facade, and _run_agent was split into _run_agent_inner.
- Wrap test _session_db in AsyncSessionDB so the awaited get_session works
- Make refresh-calling tests async + await the helper
- Point the placement-guard test at _run_agent_inner (recursion lives there
post-mixin-extraction)
- Relocated production call sites now correctly await the async helper
The cross-process coherence guard (#45966) compares the session's
on-disk message_count against the snapshot stored next to the cached
agent, and rebuilds the agent on a mismatch. The guard is correct
when the cache snapshot and the live count both refer to the same
DB row. But the agent cache is keyed by session_key, which can
group multiple conversation threads (different session_ids) under
the same key — and the message_count values belong to DIFFERENT
DB rows.
When the user switches from session A to session B under the same
session_key, the cache hit returns A's cached agent. The guard then
compares A's snapshot count (A.message_count) against B's live count
(B.message_count) — they are NEVER equal because they track
different conversations — and invalidates the cache. Every session
switch busts the prompt cache and forces a fresh agent build. The
post-turn re-baseline (#46237) made it worse: it reads the live
count from the CURRENT session_entry.session_id, so each switch
overwrites the original snapshot with the new session's count,
causing the very next switch BACK to the original session to fire
the guard again.
This is the bug from #54947 (P0, sweeper:risk-session-state,
sweeper:risk-caching).
Fix:
* Record the snapshot's session_id alongside the message_count in
the cache tuple: (agent, sig, mc, session_id) — a 4-tuple. The
cache build at the AIAgent construction site stores the active
session_id.
* The cache-hit guard skips the cross-process count comparison
when the active session_id differs from the snapshot's
session_id — the comparison is meaningless across different DB
rows, so the agent is REUSED without invalidation. The cross-
process guard still fires when the session_id matches and the
live count differs (genuine cross-process write on the SAME
session).
* _refresh_agent_cache_message_count checks the snapshot's
session_id: when it differs from the current session_id, the
snapshot is intentionally left untouched (overwriting it would
corrupt the original conversation's baseline and cause the
switch-back to fire the guard). The legacy 3-tuple shape (no
session_id) is still re-baselined as before.
* Backward-compat:
- 2-tuple (agent, sig) — unchanged, opts out of the guard.
- 3-tuple (agent, sig, mc) — unchanged behavior, standard
cross-process check.
- pending sentinel — unchanged, untouched by re-baseline.
- new 4-tuple (agent, sig, mc, session_id) — full session_id-
aware guard with skip on mismatch.
Tests:
* tests/gateway/test_session_id_cache_coherence.py — 7 tests
covering L1-L5 from LAYERS.md:
- L1 session_id switch must REUSE
- L2 cache tuple records snapshot's session_id
- L3 re-baseline skips when session_id differs
- L4 same-session_id turns still re-baseline (#46237 holds)
- L5 legacy 2-tuples and pending sentinels untouched
- legacy 3-tuple (no session_id) still guarded (#45966 holds)
- 3-tuple transitions to 3-tuple (not 4-tuple) on re-baseline
No regressions in 70 existing tests in test_agent_cache.py or 137
related session tests. Co-authored with #52197 (deferred cleanup
of evicted agents); both fixes compose cleanly.
The cross-process cache-coherence guard (#45966) compares a session's
on-disk message_count against a snapshot stored next to the cached agent,
rebuilding the agent on a mismatch so a foreign writer (e.g. the dashboard
backend) can't leave the in-memory transcript stale.
On a fresh gateway conversation the post-turn re-baseline
(_refresh_agent_cache_message_count) ran BEFORE the first-turn `session_meta`
marker row was appended to the transcript. That append goes through
append_to_transcript -> append_message, which increments message_count
unconditionally. So the snapshot was left exactly one short of the live
count, and on turn 2 of every fresh conversation the guard mistook this
process's own session_meta write for a foreign write, evicting and rebuilding
the cached agent — silently busting the per-conversation prompt cache the
cache exists to protect.
Move the re-baseline to after the turn's full transcript persistence block
(including the session_meta append and the compression session_id swap). The
snapshot now matches the live count, so the guard fires only on genuinely
foreign writes. This also makes the call honor its own documented contract of
using the compaction-updated session_id.
Adds a regression test that drives the real _handle_message_with_agent
against a real SessionDB and asserts the invariant: after a fresh first turn,
snapshot == live message_count, so the next turn's guard reuses the cached
agent. Fails before this change, passes after.
Review follow-up on the concurrent-tool deadline salvage. timed_out_indices is
snapshotted from not_done at the deadline; a worker can still finish and write
results[i] in the window before the post-execution result loop reads it. The
loop unconditionally replaced results[i] with a fabricated 'timed out' message
for any snapshotted index, discarding a genuinely-successful (just-late) result.
Gate the timeout message on 'and r is None' so a real result always wins. Add a
regression test that forces the snapshot-vs-result-loop race deterministically
(mutation-checked: reverting the guard fails it). Also document the intentional
detached-worker leak at the executor abandon site.
A tool with no internal interrupt check (read_file, web_search, or a wedged
terminal backend) that never returns keeps the concurrent-tool poll loop alive
forever: the loop only breaks when all futures finish or an interrupt is
requested, and the 30s heartbeat resets the gateway idle monitor so idle-kill
never fires. The ThreadPoolExecutor was also used as a context manager, so its
__exit__ joined the hung worker with wait=True.
Add a wall-clock batch deadline (HERMES_CONCURRENT_TOOL_TIMEOUT_S, default 420s
— above the 360s web_extract timeout; 0/negative disables). When it fires:
cancel pending futures, signal an interrupt to the worker threads, abandon the
executor (shutdown wait=False, cancel_futures=True) so hung threads aren't
joined, and return a per-tool 'timed out' result for the unfinished calls while
still surfacing the finished ones. Also fixes the latent futures.index(f)
lookup (ambiguous with duplicate futures) by tracking a future->index map.
Salvaged from #54562.
Co-authored-by: Gustavo Mendes <87918773+gustavosmendes@users.noreply.github.com>
verify_on_stop / pre_verify append a synthetic assistant "done" plus a
synthetic user nudge to keep the agent going one more turn before it can
claim completion. Both were flagged (_verification_stop_synthetic on the
nudge only), but the flags were never registered in
_EPHEMERAL_SCAFFOLDING_FLAGS, so the central _is_ephemeral_scaffolding()
filter that guards both persistence sinks (SQLite flush + JSON snapshot)
let them through. The resumed transcript then inherited loop-only
scaffolding, invalidating the prompt-prefix cache on later turns.
- add _verification_stop_synthetic and _pre_verify_synthetic to
_EPHEMERAL_SCAFFOLDING_FLAGS (the single chokepoint both sinks use)
- flag the blocked attempt assistant message too, not just the nudge, so
the whole synthetic pair drops together and persistence does not keep a
premature done with the nudge stripped (assistant to assistant adjacency)
The API-payload leak claimed in the report is already handled: the
chat_completions transport strips every underscore-prefixed message key
before the wire, so the marker never reaches strict providers.
Reported by patppham.
End-to-end regression coverage for #32243 that asserts every runtime
branch resolving an Anthropic endpoint returns
`api_mode == "anthropic_messages"`:
* `_resolve_explicit_runtime` — the path used when a Hermes
subcommand passes an explicit `--api-key` / `--base-url`. Pins
that a stale persisted `model.api_mode: chat_completions` from a
prior provider migration cannot override the anthropic pin.
* `_resolve_runtime_from_pool_entry` — the path triggered by
`hermes auth add anthropic --type oauth` (the exact flow from the
issue). Same stale-api_mode regression pinned here.
* `_try_resolve_from_custom_pool` — the user-defined
`providers:` / `custom_providers:` path that depends on the
URL detector fix landed in the prior commit. Asserts both the
detector fallback fires for `api.anthropic.com` and that an
explicit `api_mode_override` still wins (so users who DELIBERATELY
pointed a chat_completions transport at api.anthropic.com for
OpenAI-compat experiments aren't hijacked).
Co-locates the three contracts so a future refactor of one branch
cannot silently diverge from the others and re-introduce the
"out of extra usage" 400 on fresh OAuth Pro/Max credentials.
Add a dedicated `TestDirectAnthropicHost` class to
`test_detect_api_mode_for_url.py` covering the native Anthropic host
shape (bare, trailing slash, /v1 suffix, uppercase host) plus the
two negative-space regressions that matter for security: lookalike
subdomains (`api.anthropic.com.attacker.test`) and path-segment
spoofing (`https://proxy.example.test/api.anthropic.com/v1`) must
NOT be classified as native — leaking an Anthropic OAuth token to
either would be the worst case.
Refs #32243.
_strategy_exact advanced its scan cursor by pos+1 instead of
pos+len(pattern), so self-overlapping patterns (e.g. "aa" in "aaaa")
matched at overlapping offsets. _apply_replacements works in reverse
order, so the second replacement operated on already-modified content
using stale offsets — corrupting the file and reporting the wrong count
under replace_all=True. Advancing by len(pattern) matches str.replace()
semantics.
When two features register a post-delivery callback for the same session
(e.g. background-review release + /goal continuation), the second
registration is composed with the first via a `_chained` wrapper. That
wrapper was `def _chained()` — a sync function calling each callback
via `_prev()` / `_new()` and discarding the return value.
For sync callbacks that's fine. For async callbacks (such as the
`_deliver()` coroutine the /goal feature registers to inject the
continuation prompt) the returned coroutine was silently dropped:
RuntimeWarning: coroutine '_deliver' was never awaited.
Outer invoker in `_handle_message` already checks
`inspect.isawaitable(_post_result)` and awaits — but only sees the
wrapper's return value, which was `None`.
Fix: make `_chained` async, iterate over chained callbacks, await any
that return an awaitable. Outer invoker already handles awaitable
wrappers, so no other change is needed.
Tested:
* Added two regression tests in test_post_delivery_callback_chaining.py
covering an async callback chained behind sync (and vice versa).
* Updated existing chaining tests + test_run_cleanup_progress.py to
await the popped callback when it's awaitable.
* 62 tests pass across the touched suites.
Live-validated on Discord: /goal continuations now arrive after the
first turn's response is delivered (previously silent).
Refs: NousResearch/hermes-agent#31922
Subprocesses spawned by the terminal tool, execute_code, Docker backend, and
the codex app-server could inherit Hermes-internal secrets that the name-based
`_HERMES_PROVIDER_ENV_BLOCKLIST` can't enumerate, because they're injected into
`os.environ` at runtime under dynamic names:
- `AUXILIARY_<TASK>_API_KEY` / `AUXILIARY_<TASK>_BASE_URL` — per-task side-LLM
credentials bridged from `config.yaml[auxiliary]` by gateway/run.py and cli.py
(vision, web_extract, approval, compression, plugin-registered tasks). Often
separate, higher-spend keys plus base URLs pointing at private endpoints.
- `GATEWAY_RELAY_*_SECRET` / `_KEY` / `_TOKEN` — relay-auth material provisioned
by gateway/relay.
Additionally, agent/transports/codex_app_server.py built its spawn env from a
raw `os.environ.copy()`, bypassing the centralized `hermes_subprocess_env()`
helper entirely — handing every codex subprocess the full Tier-1 secret set
(GH_TOKEN, gateway bot tokens, Modal/Daytona infra tokens, dashboard session
token) unfiltered. This is the #29157 sibling spawn-site gap; copilot_acp_client
already routes through the helper.
Fix — single chokepoint:
- Add `_is_hermes_internal_secret(key)` in tools/environments/local.py as the
single source of truth for the dynamic secret patterns. Matches
AUXILIARY_*_API_KEY / _BASE_URL and GATEWAY_RELAY_*_SECRET/_KEY/_TOKEN; leaves
non-secret AUXILIARY_*_PROVIDER/_MODEL and GATEWAY_RELAY routing hints visible.
- Wire the predicate into every spawn path unconditionally (ignores skill
env_passthrough opt-in AND inherit_credentials — a model-driving CLI never
needs these): `_sanitize_subprocess_env` (both loops), `_make_run_env`
(foreground), `hermes_subprocess_env` (Tier-1), and the Docker forward filter.
- Add the static GATEWAY_RELAY_* names to `_HERMES_PROVIDER_ENV_BLOCKLIST` so the
exact-match path catches them independently of the predicate.
- Add the GATEWAY_RELAY_ID/_SECRET/_DELIVERY_KEY triplet to `_ALWAYS_STRIP_KEYS`
(Tier-1) so it is stripped unconditionally on EVERY spawn surface — including
the codex/copilot `inherit_credentials=True` path that skips the Tier-2
blocklist. `_SECRET`/`_DELIVERY_KEY` are already predicate-matched; `_ID` has
no secret suffix, so enumerating it here is what closes its leak on the
inherit path (self-review W1).
- Defense in depth: env_passthrough.py `_is_hermes_provider_credential()` now
consults the same predicate, so a skill can't register these names as
passthrough and tunnel them into an execute_code / terminal child.
- Route codex_app_server through `hermes_subprocess_env(inherit_credentials=True)`
— strips Tier-1 + dynamic-internal secrets while provider creds (which codex
needs to authenticate) still flow.
Consolidates PRs #53715 (necoweb3 — the _is_hermes_internal_secret backbone +
Docker filter), #53503 (srojk34 — env_passthrough guard), and #55709 (srojk34 —
codex routing). Retires #52348 (claudlos): its copilot half is already on main,
and its codex half used the full-strip `_sanitize_subprocess_env` which would
break codex provider auth — the correct tier is `inherit_credentials=True`.
Tests: TestHermesInternalDynamicSecrets (terminal + predicate + passthrough
override), TestInternalDynamicSecrets (hermes_subprocess_env both tiers),
TestSpawnEnvSecretStripping (codex spawn env), plus env_passthrough
defense-in-depth cases.
Co-authored-by: necoweb3 <sswdarius@gmail.com>
Co-authored-by: srojk34 <286497132+srojk34@users.noreply.github.com>
Co-authored-by: claudlos <claudlos@agentmail.to>
AIAgent.run_conversation() promises a dict with final_response, but 16
terminal-failure branches returned dicts that either omitted the key or
set it to None. Callers that index result['final_response'] directly
(run_agent.py chat() + the __main__ printer) turn a real provider/context
failure into an opaque KeyError instead of surfacing the actionable error.
Every offending branch already carried usable 'error' text, so this
mirrors that text into final_response for all 16 sites (8 that omitted the
key, 8 that returned None). Adds an AST regression test that fails if any
run_conversation() dict return omits final_response or sets it to a literal
None, and tightens the invalid-response test to assert final_response == error.
A delayed fatal-error notification from an adapter instance that has
already been replaced by a successful reconnect (a different adapter
object now owns the platform slot) was still processed: it overwrote
the platform's runtime status back to retrying/fatal and could
re-queue an already-healthy platform for reconnection.
Snapshot the current owner of the platform slot at the top of
_handle_adapter_fatal_error and bail out before any side effect when
it belongs to a different, already-installed adapter.
_handle_polling_network_error's chained retry never updated
self._polling_error_task, so the reentrancy guard shared with the
heartbeat loop and the pending-updates probe went stale mid-recovery,
letting more than one recovery attempt run concurrently against the
same adapter. Combined with a TOCTOU window in
_handle_adapter_fatal_error (the adapter was only removed from
self.adapters in a finally block after awaiting disconnect()), two
concurrent fatal notifications for the same adapter could both pass
the "still installed" check and call disconnect() twice, which is
where the reported "'NoneType' object has no attribute 'updater'"
originates once self._app is cleared by the first call.
- Reassign the chained retry task to self._polling_error_task so the
guard reflects an in-flight recovery.
- Capture self._app in a local variable across the stop/start_polling
sequence instead of re-reading self._app between awaits.
- Claim (pop) the adapter from self.adapters before awaiting
disconnect() in _handle_adapter_fatal_error, not after, closing the
TOCTOU window for a concurrent notification on the same adapter.
Path(raw).name reduces '..'/'.'/'' to themselves, so basename
extraction alone still let a Graph-provided display_name of '..' or
'../' escape the temp recording directory (tmp_dir / '..' resolves to
the parent). Reject the dot-only basenames explicitly and fall back to
the artifact id. Extends @outsourc-e's regression coverage with the
dot-only cases.
The MoA aggregator received the per-turn reference block merged into the most
recent `user` message. In an agentic tool loop that message is the original
task near the top of the context (everything after it is assistant/tool turns),
so injecting text that changes every iteration diverges the prompt prefix early.
The server's KV cache then cannot be reused and the entire conversation
re-prefills on every tool-loop step — full prefill each step, which dominates
latency on long contexts.
Append the reference block at the end of the prompt instead (merging into the
last message only when it is already a trailing user turn, i.e. plain chat).
This keeps the [system][task][tool-history] prefix stable and cache-reusable so
only the new block re-prefills, and gives the aggregator the references with
recency. Extracted as `_attach_reference_guidance` with unit tests.
Measured on a local llama.cpp aggregator over a long agentic task: KV-cache
reuse on follow-up steps went from ~0.3% to ~93-95% and per-step prefill on an
~80k-token context dropped from ~44s to <1s, with no change to output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_maybe_wrap_untrusted` is the architectural defense against indirect
prompt injection. It wraps attacker-controllable tool output
(web_extract, web_search, browser_*, mcp_*) in
`<untrusted_tool_result>...</untrusted_tool_result>` so the model treats
it as data. The content was interpolated verbatim, so the boundary was
forgeable.
Two holes. A poisoned page that embeds `</untrusted_tool_result>` closes
the block early — everything after it reads as trusted instructions. And
the `startswith("<untrusted_tool_result")` re-entrancy guard returned
content that merely started with the opening tag completely unwrapped, so
an attacker just prefixed the tag to drop all data framing.
Fix neutralizes any embedded delimiter token (case-insensitive) before
interpolation and drops the forgeable fast-path, so content is always
sealed in exactly one well-formed block. Re-wrapping an already-wrapped
forward is harmless — it stays framed as data.
## What does this PR do?
Closes an indirect prompt-injection bypass in the untrusted-tool-result
wrapper. Attacker content can no longer break out of, or forge, the
trust boundary.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `agent/tool_dispatch_helpers.py`: add `_neutralize_delimiters` (case-insensitive defang of the `untrusted_tool_result` token); `_maybe_wrap_untrusted` now always neutralizes then wraps, and the forgeable `startswith` re-entrancy guard is removed.
- `tests/agent/test_tool_dispatch_helpers.py`: replace the double-wrap test (it encoded the bypass) with regression tests for embedded closing tag, leading opening tag, and a cased closing tag.
## How to Test
1. `scripts/run_tests.sh tests/agent/test_tool_dispatch_helpers.py` — 29 pass.
2. Embedded `</untrusted_tool_result>` mid-content: real closing delimiter appears once, at the end; payload trapped inside.
3. Content starting with the opening tag: data framing is applied, not skipped.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix
- [x] I've run the affected tests and they pass
- [x] I've added tests for my changes
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (docstrings) — or N/A
- [x] cli-config.yaml.example — N/A
- [x] CONTRIBUTING.md / AGENTS.md — N/A
- [x] Cross-platform impact — N/A (pure-Python, stdlib `re`)
- [x] Tool descriptions/schemas — N/A
A literal "rm -rf /" carried as DATA inside another command's quoted
argument — a PR title, a git commit -m message, an echo/printf arg —
tripped the unconditional root-filesystem hardline and could not run at
all. `gh pr create --title "block rm -rf / spellings"` was blocked
outright, because the bare rm path branch matched the mid-string "rm"
(via \brm) with the space after "/" satisfying its (\s|$) terminator.
Anchor the shared _RM_FLAG_PREFIX to _CMDPOS so the rm hardline rules
fire only when rm is an actual command word (start of line, after a
separator ; && || |, after a subshell opener $()/backtick, or after
sudo/env/exec wrappers) — not when the string appears as an argument
value. Broaden the bare-path terminator to also accept shell
metacharacters ) ` ; | & so a real wipe inside a command substitution
is still caught.
The quoted-path branch is unchanged, so quoted root/HOME paths stay
blocked. Adds regression tests for both directions: data-arg false
positives must NOT block, real wipes at every command position must block.
Review follow-up on the batch salvage: normalize the inter-class spacing to two
blank lines (PEP8) between the three new test classes, and add an explicit
assertion in test_sanitizer_strips_orphaned_preserves_text_content that the
'(tool call removed)' placeholder does NOT overwrite existing assistant text.
No production change.
After the first compaction protect_first_n decays, so on a later compaction
the only protected head message can be the system prompt. Adapters like
Anthropic and Bedrock send the system prompt as a separate parameter, so the
summary becomes the first message in messages[] — and Anthropic rejects any
request whose first message is not role=user (HTTP 400). Pin the summary to
role=user when the head is system-only, and stop the collision-flip logic from
reverting it back to assistant.
Salvaged from #52167.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
compress() eagerly reset _last_summary_auth_failure and
_last_summary_network_failure at the top of every call. On a second
compress() during the failure cooldown, _generate_summary() returns None from
the cooldown early-return WITHOUT re-asserting those flags, so the abort guard
saw False and fell through to the destructive static-fallback that drops the
middle window — the data-loss #29559/#25585 describe. Stop resetting them
eagerly; a successful summary already clears both, so letting them persist
across calls is safe and keeps the cooldown abort protection intact.
Salvaged from #52056.
Co-authored-by: srojk34 <286497132+srojk34@users.noreply.github.com>
_sanitize_tool_pairs inserted stub role="tool" results for orphaned
tool_calls. The pre-API repair_message_sequence() tracks known call IDs by
tc.get("id") while this sanitizer keys on call_id||id; when they disagree
(Codex Responses API: id != call_id) the stubs are silently dropped by the
repair pass, re-exposing the original orphans. Strip the orphaned tool_calls
at the source instead (preserving any text content, adding a placeholder for
an otherwise-empty assistant turn) to avoid the mismatch class entirely.
Salvaged from #51225.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Addresses egilewski (Codex) CR on PR #52351: the run_job() credential-exfil
backstop caught every exception around _validate_cron_base_url() and set
err = None, so an unexpected validator/import error let an unvetted stored
provider/base_url pair reach resolve_runtime_provider() — the very sink this
checkpoint exists to guard. A synthetic validator-exception probe with a
legacy custom:legit + off-host base_url job slipped through (validator_exception
ALLOW).
Now fail closed: if the validator raises and the job carries a base_url
override (the exfil precondition), refuse the run. A job with no base_url
override can't exfiltrate via this path — the validator would return None — so
it still runs, keeping the common no-override jobs from wedging on an unrelated
error. Operator fallback providers come from config, not the job, so they are
unaffected.
Adds two regressions: validator-exception + base_url -> blocked;
validator-exception without base_url -> still allowed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The model-facing cronjob tool accepts free-form provider + base_url. On fire,
the scheduler pairs the named provider's stored credential with the job's
base_url, so a prompt-injected job (e.g. provider=anthropic,
base_url=https://attacker/v1) sends the real API key to an attacker endpoint. A
base_url with no provider inherits the default provider's key for the same
effect.
Add a fail-closed guard at the tool boundary: a base_url override is allowed
only for the custom/BYOK sentinel, a configured custom_providers entry, or when
the override host matches the named provider's own endpoint; an override without
an explicit provider is rejected. The trust boundary is the caller, so
operator-configured base_urls for named providers are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `prompt.submit` handler in the TUI gateway lets a client trim the
conversation back to a chosen user turn via `truncate_before_user_ordinal`.
It validated only the upper bound (`ordinal >= len(user_indices)`) and never
the lower one. A negative ordinal therefore sailed straight past the guard and
fell into Python's negative indexing: `user_indices[-1]` resolves to the *last*
user turn, so the history was silently sliced to everything before it and that
truncated list was immediately committed to disk with `db.replace_messages`,
which deletes and reinserts the whole row in one transaction.
The impact is severe and unrecoverable: a single out-of-range value — from a
client bug, a hidden/real user-message desync, or any present or future
frontend that emits a relative ordinal — permanently destroys the user's
conversation on disk instead of returning the intended `4018` error. Because
the gateway is deliberately frontend-agnostic, it cannot assume the value is
well-formed; it must validate it.
The fix is minimal and safe: extend the existing guard to reject negatives on
the very same error path the upper bound already uses. No in-memory history is
mutated and no DB write happens for an invalid ordinal, so a bad value now
fails closed with no data loss. The valid-ordinal path is untouched.
N/A
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- `tui_gateway/server.py`: in the `prompt.submit` handler, change the
ordinal guard from `if ordinal >= len(user_indices)` to
`if ordinal < 0 or ordinal >= len(user_indices)` so a negative ordinal is
rejected with error `4018` before any history slice or `replace_messages`
write occurs. Added a comment explaining the negative-indexing hazard.
- `tests/test_tui_gateway_server.py`: add
`test_prompt_submit_rejects_negative_truncate_ordinal`, which submits a
`truncate_before_user_ordinal` of `-1` and asserts the handler returns
`4018`, leaves the in-memory history intact, never marks the session
running, and never calls `replace_messages`. Added the `pytest` import used
by the new test's fail-fast guards.
1. Check out this branch and run
`scripts/run_tests.sh tests/test_tui_gateway_server.py -- -k negative_truncate`
— the new test passes.
2. Reproduce the bug: temporarily revert the guard to the old
`if ordinal >= len(user_indices)` and rerun — the test fails because the
handler truncates the history and starts a turn instead of returning `4018`.
3. Full file run: `scripts/run_tests.sh tests/test_tui_gateway_server.py`
(the only failure is the pre-existing, environment-dependent
`test_browser_manage_connect_default_local_reports_launch_hint`, which also
fails on clean `main` when a Chromium browser is installed locally).
- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5.0)
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Upstream #52270 added `_nous_inference_env_override()` but wired it into
only `resolve_nous_runtime_credentials`. Three sibling resolution paths
still ignored the override, so a self-hosted Nous inference endpoint set
via `NOUS_INFERENCE_BASE_URL` was silently dropped whenever credentials
arrived through any of them:
- the credential-pool path (`_resolve_runtime_from_pool_entry`)
- the explicit-provider path (`_resolve_explicit_runtime`)
- the auxiliary side-LLM client (`_pool_runtime_base_url`)
Route all three through the same auth-layer reader so every
`NOUS_INFERENCE_BASE_URL` read shares one normalization path
(trailing-slash stripping, blank -> empty) and the documented
trusted-bypass intent stays in one place. The override is live-only: it
wins for the base URL returned this run but is never persisted to
auth.json or the credential pool, so an ephemeral dev/staging value
cannot poison durable auth state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What does this PR do?
A single, perfectly valid `.env` line was being silently corrupted on read
and write. When a secret's value happened to contain a known Hermes env var
name followed by `=` — for example a webhook or proxy base URL carrying a
query parameter like `OPENAI_BASE_URL=https://proxy.example.com/v1?TAVILY_API_KEY=sk-...`
— `_sanitize_env_lines()` treated the embedded `KEY=` as a second entry. It
truncated the real secret at the inner match and fabricated a bogus second
variable. A related path silently dropped any text before the first matched
key. Because this runs on every `load_env()`, `save_env_value()`,
`remove_env_value()` and `sanitize_env_file()`, the damage was written back to
`~/.hermes/.env` and re-applied on every read — persistent loss/corruption of
the canonical secrets store.
The concatenation splitter now only acts when the line actually begins with a
known `KEY=` (so leading text is never dropped) and when every value that
precedes a boundary is a plain token. If a preceding value looks structured —
a URL/query string (`://`, `?`, `&`) or contains whitespace — the embedded
`KEY=` is understood to be part of that value, and the line is kept verbatim.
Genuine concatenations of plain-token secrets still split as before.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `hermes_cli/config.py`: added `_looks_like_structured_value()` helper and
reworked the split logic in `_sanitize_env_lines()` to anchor splits to the
line start and skip splitting when a preceding value looks like a URL/query
string or holds whitespace.
- `tests/hermes_cli/test_config.py`: added two regression tests — a value that
embeds a known `KEY=` is preserved verbatim, and leading text before the
first key is not dropped.
## How to Test
1. Run the sanitizer tests: `pytest tests/hermes_cli/test_config.py -k anitize -q`.
2. Confirm the new cases reproduce the bug on the old code and pass on the new:
`OPENAI_BASE_URL=https://proxy.example.com/v1?TAVILY_API_KEY=sk-embedded`
is returned unchanged instead of being split into a truncated value plus a
fabricated `TAVILY_API_KEY` entry.
3. Run the full file: `pytest tests/hermes_cli/test_config.py -q` (97 passed).
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
The TestRunJobSessionPersistence run_job tests shared a helper that returned
a positional list of patches; callers applied a hardcoded slice
(patches[0..N]). When the BSM-seam fix split one env patch into two, the list
grew and every caller's slice silently dropped resolve_runtime_provider off
the end. The tests still passed locally — a dev machine has ambient provider
state (seeded via the cron delivery-routing path's plugin discovery) that let
the real resolver succeed — but failed on CI's clean HOME where nothing seeds
a provider, so run_job raised AuthError and AIAgent was never constructed.
Fix: _run_job_patches is now a contextmanager that enters the whole patch
bundle via ExitStack and yields (fake_db, mock_agent_cls). A caller can no
longer drop a patch by index, so a future seam change can't reintroduce the
local-green/CI-red split. Behaviour and assertions unchanged; 577 cron tests
pass.
When /compress rotates the session, the handler repointed the live
session entry onto the new (empty) continuation session_id and _save()d
that BEFORE writing the compressed transcript — and rewrite_transcript
swallowed DB write failures at DEBUG. A transient write failure (SQLite
lock under concurrent writes, ENOSPC, disk/IO error) left the session
pointing at an empty id while the handler still reported a cheerful
'Compressed: N → M' success. The active conversation vanished from view.
- gateway/session.py: rewrite_transcript now returns bool (True on write
success or no-DB, False on canonical write failure). /retry, /undo, and
yuanbao recall ignore the result, so their behavior is unchanged.
- gateway/slash_commands.py: _handle_compress_command persists the
compressed transcript FIRST and treats a write failure as fatal (raises
into the outer handler's 'compress failed' banner). Only repoints +
_save()s the session on a successful write. Widened beyond the original
rotation case to also cover in-place compaction (#38763): a failed
in-place write would otherwise leave the DB untouched while still
reporting success.
- tests: regression tests for both the rotation and in-place write-failure
paths — assert a failure banner, unchanged session_id, and no _save().
Co-authored-by: Hermes Agent <agent@nousresearch.com>
The message triggers append one FTS5 segment per insert into both the
porter and trigram indexes. Nothing ever called the existing
optimize_fts() maintenance helper, so on a long-lived state.db these
segments accumulate without bound (observed: ~34k trigram segments for
~27k messages). Every MATCH then has to scan all segments, and every
insert pays a growing automerge cost that lengthens the WAL write-lock
hold time. Because the gateway and cron agents are separate processes
sharing one state.db, those longer holds exhaust the 1s-timeout x 15-retry
budget in _execute_write and surface as repeated:
Session DB creation failed (will retry next turn): database is locked
Session DB append_message failed: database is locked
Wire optimize_fts() into the write path on a coarse cadence
(_OPTIMIZE_EVERY_N_WRITES = 1000), alongside the existing every-50-writes
checkpoint. 'optimize' is effectively free once the index is already
merged, so steady-state cost is negligible; only the first merge of a
neglected index is expensive. The call is best-effort and never fails the
surrounding write.
Tests: cadence fires on the write path; a failing optimize never breaks
the write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 583647b56e207a9b0accfd05efa2b9b251630984)
Command-name obfuscation bypassed the dangerous-command denylist: the
executable name could be spelled with shell tricks that survive regex
matching but still resolve to a blocked command at runtime —
$(echo rm), ${0/x/r}m, backticks, and printf substitutions.
Adds a non-executing shell-word scanner that deobfuscates only at
command positions (start, after ;|&&||, inside $(...), after
sudo/env/exec/... wrappers) and feeds the resulting variants through
the existing HARDLINE_PATTERNS / DANGEROUS_PATTERNS — no second
blocklist. Scoping to command words keeps ordinary arguments
(echo $(echo rm) -rf /) from being promoted into command names.
Co-authored-by: egilewski <1078345+egilewski@users.noreply.github.com>
`_normalize_command_for_detection` strips backslash-escapes before matching
DANGEROUS_PATTERNS and HARDLINE_PATTERNS, but the strip rule was
`re.sub(r'\\([^\n])', r'\1', ...)` — its `[^\n]` class deliberately skips
newlines. A backslash immediately followed by a newline is a POSIX line
continuation: the shell removes BOTH characters and joins the tokens, so
`rm -rf \<newline>/` executes as `rm -rf /`. With the dangling backslash left
in place, the structured rm/dd/mkfs patterns no longer match because a literal
`\` sits wedged between the tokens they expect to be adjacent.
The worst consequence is on the HARDLINE floor. The dangerous-command layer
still fired here only by accident (the generic `\brm\s+-[^\s]*r` "recursive
delete" rule needs no path), and that layer is bypassed by `--yolo` /
`approvals.mode=off`. The hardline blocklist — the unconditional floor reserved
for catastrophic, unrecoverable commands and meant to hold even under yolo —
anchors the root path directly after the flags, so `rm -rf \<newline>/`,
`rm -r\<newline>f /`, and `rm -rf \<newline>~` all slipped past it entirely.
A yolo session could therefore wipe the root filesystem.
The fix collapses line continuations (`\` + `\n` or `\r\n`) to nothing,
mirroring the shell, before the existing escape strip runs. This was the gap
left by 621bf3a87, which added the escape strip but only for non-newline chars.
## What does this PR do?
Closes a shell line-continuation bypass in the dangerous-command detector.
Before: `rm -rf \<newline>/` normalized to `rm -rf \<newline>/`, so the
hardline root-delete patterns did not match and the command could run under
`--yolo`. After: line continuations are collapsed first, the command
normalizes to `rm -rf /`, and the hardline floor blocks it unconditionally.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/approval.py`: in `_normalize_command_for_detection`, add
`command = re.sub(r'\\\r?\n', '', command)` ahead of the existing
backslash-escape strip so shell line continuations (`\`+newline, LF or CRLF)
are removed exactly as the shell would, instead of leaving a stray backslash
that breaks the structured patterns.
- `tests/tools/test_hardline_blocklist.py`: add a parametrized
`test_hardline_blocks_line_continuation` covering the root, in-flag, home,
CRLF, and mkfs continuation forms, plus
`test_line_continuation_root_wipe_cannot_bypass_hardline` asserting the
continuation root wipe stays blocked even with `HERMES_YOLO_MODE=1`.
## How to Test
1. Reproduce: stash the `tools/approval.py` change and run
`scripts/run_tests.sh tests/tools/test_hardline_blocklist.py` — the new
line-continuation cases fail (`rm -rf \<newline>/` is not flagged hardline,
and leaks past the floor under yolo).
2. Restore the change and rerun the file — all 106 tests pass.
3. Regression: `scripts/run_tests.sh tests/tools/test_approval.py` (the
existing fullwidth/ANSI/null-byte normalization and multiline cases still
pass).
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5.0)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — handles both LF and CRLF line endings
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
# Conflicts:
# tools/approval.py
When an Anthropic Claude Pro/Max OAuth subscription hits the "out of extra
usage" 400 (now classified as billing), surface actionable guidance pointing
at claude.ai/settings/usage and the cycle-reset option instead of the generic
"add credits with that provider" line — which does not apply to a
subscription. Folds in the UX from #40073 (@harsh-matchmyflight) without the
extra FailoverReason enum; the billing reclass already provides the recovery
behavior.
Anthropic returns HTTP 400 with "You're out of extra usage. Add more at
claude.ai/settings/usage and keep going." when the account's extra-usage
allowance is depleted. The existing _BILLING_PATTERNS list did not
include this wording, so classify_api_error fell through to generic
format_error — non-retryable and should_fallback=False — causing the
agent to abort instead of engaging the configured fallback chain.
Add the pattern and a regression test covering the exact Anthropic body.
A finite one-shot cron job whose side effect kills the tick (gateway
suicide, OOM, segfault, hard-timeout) re-fired forever: mark_job_run —
which increments repeat.completed and removes the job — runs AFTER the
job, so an abrupt tick death never records completion and every
supervisor relaunch re-dispatches the job (#38758).
Commit the dispatch BEFORE the side effect:
- claim_dispatch() increments repeat.completed under the cross-process
jobs lock and persists it before run_job(), converting finite
one-shots from at-least-once to at-most-times.
- Called from run_one_job (the shared body used by BOTH the built-in
ticker and the external Chronos fire_due path) before run_job.
- mark_job_run skips the increment for pre-claimed one-shots (no
double-count) and still removes at the limit.
- get_due_jobs drops a stale one-shot already at its dispatch limit so
a job claimed-but-not-cleaned-up after a crash stops appearing as due.
- No-op for recurring jobs (advance_next_run) and infinite/no-repeat
one-shots; a handed-in job dict absent from the store proceeds.
Closes#38758
The salvaged write-target boundary included `#` in its char class, so a
`#` glued to the redirect/tee path (`echo x > .env#backup`) matched as a
comment boundary and flagged the write as dangerous. But the shell writes
to the distinct file `.env#backup`, not `.env` — a false positive, same
class as the config.yaml.bak case the PR already excluded. Drop `#` from
the boundary; a real trailing comment is always whitespace-preceded (\\s).
Adds regression tests for .env#backup, config.yaml#backup, and
tee .env#backup staying out of the deny.
The dangerous-command approval gate has rules that flag a shell command
when it overwrites a project `.env` or `config.yaml` — these files hold
API keys, DB passwords, and (for `config.yaml`) the approval policy
itself, so a write to them should require user approval. The matching
`write_file`/`patch` deny on the file-tools side was paired with these
terminal-side rules so neither path is an open door.
The redirection and `tee` rules anchored the sensitive path with
`_COMMAND_TAIL` (`(?:\s*(?:&&|\|\||;).*)?$`), which only tolerates the
rest of the line being empty or a command separator. The problem: in
POSIX shell the redirection target is fixed regardless of what trails it.
`echo secret > .env extra` still truncates `.env` (the `extra` is just
another argument to `echo`), and `echo secret > .env # note` does too
(the `#` starts a comment). Because neither tail is a separator, the old
anchor failed to match and the command sailed through approval — a
prompt-injected step could overwrite a project `.env`/`config.yaml`
unprompted. The system-path redirection rule one line above never had
this restriction and already caught these forms.
The fix introduces `_WRITE_TARGET_BOUNDARY`, a lookahead that only
requires the path token to END at a shell word boundary (whitespace,
quote, separator, redirection operator, `#`, or EOL) rather than
demanding the rest of the line be empty. It is applied to the two
stream-write rules (redirection and `tee`) where the sensitive path is
always a write target. The `cp`/`mv`/`install` rule deliberately keeps
`_COMMAND_TAIL`: there the sensitive file is only a target when it is the
LAST argument (the destination), so requiring end-of-line is correct and
keeps `cp config.yaml backup.yaml` (config.yaml as the source) out of the
deny.
## What does this PR do?
Closes a bypass in the dangerous-command approval gate where a trailing
argument or `#` comment after a `>`/`>>`/`tee` write target let a command
overwrite a project `.env` or `config.yaml` without triggering approval,
even though the shell still overwrites the file.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/approval.py`: add `_WRITE_TARGET_BOUNDARY` (a word-boundary
lookahead) and use it instead of `_COMMAND_TAIL` in the two
project-env/config stream-write patterns ("overwrite project env/config
via tee" and "via redirection"). `_COMMAND_TAIL` is kept and still used
by the `cp`/`mv`/`install` rule, where end-of-line anchoring is the
correct semantics.
- `tests/tools/test_approval.py`: add regression tests for
`> .env extra`, `> .env # note`, `>> config.yaml foo`, and
`tee .env backup` (now flagged), plus `> config.yaml.bak` (must stay
safe — different file).
## How to Test
1. Reproduce: before the fix,
`detect_dangerous_command("echo secret > .env extra")` returns
`(False, None, None)` — the overwrite is not flagged.
2. Apply the fix; the same call now returns the "overwrite project
env/config via redirection" detection.
3. Run `pytest tests/tools/test_approval.py -q` — the new cases pass and
the existing `cp config.yaml backup.yaml` / `config.yaml.bak`
false-positive guards still hold.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix
- [x] I've run the relevant tests and they pass
- [x] I've added tests for my changes
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, docs/, docstrings) — or N/A
- [x] I've updated cli-config.yaml.example if I added/changed config keys — or N/A
- [x] I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Review follow-up on the private-page action guard:
- Add test_guard_inactive_does_not_block_or_probe: when the SSRF guard is
inactive (local backend / allow_private_urls), click/type/press must proceed
WITHOUT probing the page URL. This is the branch most likely to silently
regress if the guard condition is inverted; a mutation check (flipping the
condition) confirms the test fails as designed.
- Add test_camofox_short_circuits_before_guard: camofox mode returns from the
dedicated camofox_* path before the guard runs; guards never consulted.
- Fix PEP8: 3 -> 2 blank lines before _blocked_private_page_action.
When the primary provider's auth fails (expired token / 429 quota cap),
_resolve_runtime_agent_kwargs() falls through to the fallback provider
chain, whose runtime dict carries its own 'model' key. api_server's
_create_agent then did AIAgent(model=model, **runtime_kwargs), colliding
on 'model' and 500ing every /v1/chat/completions request while a fallback
was active. Pop the runtime model and let it override the config model,
mirroring the native gateway path (_resolve_session_agent_runtime).
Salvaged from #35716 by @ryo-solo (earliest submitter); the PR's second
half (Mistral reasoning_content strip) is already handled on main and
dropped.
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
hermes doctor's final 'configure missing API keys' summary counted every
toolset with unmet key requirements, including default-off and explicitly
disabled ones. Filter the summary to toolsets actually enabled for the CLI
platform, with a graceful fallback to prior behavior when config resolution
fails.
Fixes#11336
Bare print() output is swallowed by patch_stdout while an interactive
prompt_toolkit Application owns the terminal, so /sessions and /history
rendered nothing. Route those emissions through _cprint (prompt_toolkit's
native renderer) when an app is running, and fall back to print otherwise.
Fixes#36815
## What does this PR do?
Closes a critical hole in the hardline command floor. HARDLINE_PATTERNS is
the unconditional last line of defense: detect_hardline_command runs BEFORE
every yolo / approvals.mode=off / cron approve-mode bypass, so it is the only
gate standing between the agent (or a prompt-injected instruction) and an
irrecoverable disk wipe. The three rm rules anchored on a bare path token,
and _normalize_command_for_detection never strips shell quotes — so the
ordinary, recommended shell idioms slipped straight through:
rm -rf "/" rm -rf '/' rm -rf "/etc"
rm -rf "$HOME" rm -rf ${HOME} rm -rf "${HOME}"
All of these returned NO hardline match. A leading quote pushes the path out
of reach of the flag group, a trailing quote breaks the `(\s|$)` terminator,
and the `${HOME}` brace form was never listed at all. Under --yolo,
approvals.mode=off, or cron approve-mode the dangerous-command layer is also
skipped, so these commands reached execution with zero gate — exactly the
unrecoverable data loss the floor is documented to make impossible. Because
quoting paths and `${HOME}` are normal shell usage, not exotic obfuscation,
this is a high-severity, easily-triggered bypass.
The fix makes the rm path matcher quote- and brace-tolerant while staying
conservative: a path is matched when it is either fully wrapped in its own
matching quote pair (`"/"`) or bare with a whitespace/end terminator. The
matching-quote requirement is deliberate so the change adds no new false
positives — a dangerous-looking string that is merely an argument to another
command (e.g. `git commit -m "rm -rf /"`) has a closing quote but no opening
quote of its own around the path, so neither branch fires.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/approval.py`: added `_hardline_rm_path()` (matches a destructive
path either fully quoted or bare-with-terminator), factored the protected
system-dir list into `_HARDLINE_SYSTEM_DIRS` and the rm flag prefix into
`_RM_FLAG_PREFIX`, and rebuilt the three rm `HARDLINE_PATTERNS` on top of
them, adding the `${HOME}` brace form. Kept as plain concatenation so regex
backslashes never land inside an f-string field (Python 3.11 floor).
- `tests/tools/test_hardline_blocklist.py`: added quoted (`"/"`, `'/'`,
`"/etc"`, `"$HOME"`, ...) and brace (`${HOME}`, `"${HOME}"`) cases to the
must-block set, a dedicated `_QUOTED_BRACE_BYPASS` regression parametrization,
no-false-positive guards (`git commit -m "rm -rf /"`), and extended the
yolo-cannot-bypass integration test to cover the quoted/brace forms.
## How to Test
1. Reproduce the bypass on `main`: `detect_hardline_command('rm -rf "/"')`
returns `(False, None)` — the floor lets it through.
2. With this change it returns `(True, "recursive delete of root filesystem")`;
the same holds for `'/'`, `"/etc"`, `"$HOME"`, `${HOME}`, `"${HOME}"`.
3. Run the suite: `scripts/run_tests.sh tests/tools/test_hardline_blocklist.py`
— 125 passed, including the new bypass and no-false-positive cases.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix (no unrelated commits)
- [x] I've run the relevant tests and they pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — pattern-only change, ruff + footgun gate pass
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Cron's job runner was the last entry point still reading
fallback_providers/fallback_model as an either/or, silently dropping the
legacy fallback_model when fallback_providers was set. Every other entry
point (cli, gateway, oneshot, fallback_cmd, tui_gateway, auxiliary_client)
already merges both keys via get_fallback_chain(). This aligns cron with
them at both call sites: the auth-fallback resolution loop and the
AIAgent(fallback_model=...) argument.
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
The forum-topic skill-binding lookup assumed config.extra['group_topics']
was always a list of {chat_id, topics} entries. When an operator writes the
natural mapping shape ({"-100...": [...]}), iterating yields string keys and
chat_entry.get(...) raises AttributeError, breaking dispatch for that group.
Normalize both shapes to a common iterator and guard non-dict/non-list
entries so malformed config falls through cleanly instead of crashing.
mcp-tokens/ holds live MCP OAuth access tokens (<server>.json) and
dynamically-registered OAuth client credentials (<server>.client.json),
layout per tools/mcp_oauth.py. This is the same credential class as
auth.json/credentials/, which _media_delivery_denied_paths() already
blocks. The write side already denies this dir (file_tools
_check_sensitive_path), but the media-delivery (read/exfil) side did
not, leaving an unpaired half-door.
Without it, a prompt-injection MEDIA: tag emitting
~/.hermes/mcp-tokens/<server>.json would, in default (non-strict)
mode, pass the denylist and exfiltrate a live OAuth bearer token to
the same untrusted channel. Sibling follow-up to commit 4ec0adebe
(config.yaml media-delivery denylist).
mcp-tokens is a directory and _path_under_denied_prefix already does
containment matching, so the whole subtree (.json/.client.json/
.meta.json) is denied, mirroring credentials/.
A context-compaction handoff banner is inserted with role="user" when the
protected head ends in an assistant/tool message. On a resumed or
multi-compaction session, _find_last_user_message_idx would return that
banner as the latest user turn, so _ensure_last_user_message_in_tail anchored
the tail to the summary and rolled the genuine last user message into the
next compaction — the exact active-task loss the anchor exists to prevent
(#10896/#22523).
Reuse the existing _is_context_summary_content helper to skip summary banners
when locating the last real user message.
Salvaged from #36626 by Frank Song (issue #36624). The PR's other two changes
(demoting completed tool results inside the protected tail; a preflight
compression_exhausted result) are superseded on current main by the min_tail
floor (#39170), the no-op compression counting (#40803), and the existing
413/disabled terminal-error paths.
Inside httpx AsyncClient response event hooks, response.next_request is
often None even for a genuine redirect, so guards keyed on
`if response.is_redirect and response.next_request` silently never fire.
A public URL that 302s to http://169.254.169.254/ was followed anyway,
defeating the pre-flight is_safe_url() check.
Resolve the redirect target from the Location header (via urljoin, so
relative Locations work too), falling back to next_request only when no
Location is present. Extracted as tools.url_safety.redirect_target_from_response
and wired into every SSRF redirect guard:
- gateway/platforms/base.py (shared image + audio download for all platforms)
- tools/vision_tools.py (two download hooks)
- plugins/platforms/slack/adapter.py
Original fix by @zapabob (PR #35940), which targeted the since-refactored
gateway/platforms/slack.py; reconstructed onto the current shared sites and
widened to the whole bug class.
Review of the salvage found the timeout-message redaction left the more
common failure mode unguarded: when the first websockets.connect(cdp_url)
fails (bad URI / refused / TLS), the raw websockets exception -- which
embeds the full cdp_url incl. ?token= and user:pass@ -- is stashed as
_start_error and re-raised verbatim by start(), and two reconnect
logger.warning sites log the same raw exception.
Add a module-level _redact_cdp_error_text() chokepoint (delegating to
agent.redact.redact_cdp_url) and route all four supervisor egress points
through it:
- start() TimeoutError message (already covered; kept)
- start() _start_error re-raise -> now raises a redacted RuntimeError with
'from None' so no secret leaks via message OR traceback cause chain
- connect-failed and session-dropped reconnect warnings
Guard tests assert the re-raised message is redacted for both token and
userinfo, the raw cause is suppressed, and the helper preserves non-secret
context (host/reason). Verified with a mutation check: reverting to the raw
'raise err' fails the new tests. Correct the redact_cdp_url docstring to
scope its guarantee to direct-URL redaction and point exception callers at
the supervisor helper.
The session-log fix (browser_tool._sanitize_url_for_logs) and the
supervisor attach-timeout fix (CDPSupervisor.start) both composed the
same three redactors (redact_sensitive_text -> _redact_url_query_params
-> _redact_url_userinfo) to mask CDP endpoint credentials. Two copies of
one policy drift: tune one site (e.g. add fragment masking) and the other
silently re-leaks.
Promote that composition to a single public helper redact_cdp_url() in
agent/redact.py -- the one place the CDP-URL redaction policy lives -- and
route both call sites through it (_sanitize_url_for_logs becomes a thin
wrapper; the supervisor imports the helper instead of re-composing the
private redactors). Add direct unit tests for the seam covering query
tokens, multiple credentials, userinfo passwords, plain-URL passthrough,
non-string/exception coercion, and None.
No behavior change at the call sites; both leak paths remain closed.
PR #54851 added _sanitize_url_for_logs() and wired it into the three log
sites inside _resolve_cdp_override(). A fourth site was missed:
_create_cdp_session() logs the already-resolved cdp_url unconditionally,
and CDPSupervisor.start() interpolates the raw cdp_url[:80] into the
attach-timeout TimeoutError (which _ensure_cdp_supervisor() logs with %s).
Both leak query-string credentials (e.g. ?token=secret from hosted CDP
providers) into Hermes logs.
Sanitize the URL at both remaining sites. The raw URL is preserved
unmodified in the returned session dict and used for the real connection;
only the logged/error representation is redacted.
Salvaged from #55883.
Co-authored-by: srojk34 <286497132+srojk34@users.noreply.github.com>
Ephemeral empty-response/prefill recovery scaffolding (the synthetic
assistant "(empty)" turn, the user nudge, the terminal "(empty)"
sentinel, and the thinking-only prefill placeholder) exists only to
drive the next API retry; the in-memory loop pops it before appending
the real response. The append-only flush did not mirror that, so a
mid-turn persist could commit scaffolding to the SQLite session store
(and JSON log), and a resumed session would replay synthetic
"(empty)"/nudge turns as genuine context — re-poisoning the empty-retry
boundary forever.
Filter ephemeral scaffolding at both durable-write sites
(_flush_messages_to_session_db + _save_session_log), by flag not
position, so buried scaffolding (an answered nudge leaves the synthetic
pair mid-list) is skipped too. Covers all three flags including
_thinking_prefill.
Adapted onto current main's identity-tracking flush.
Cherry-picked from #41281 by petrichor-op.
Whole-bug-class follow-up to the tui_gateway fix: the same -1
last_prompt_tokens sentinel (parked by conversation_compression after a
compression) leaked into other status readers, producing a raw -1 or a
NEGATIVE usage_percent on the transitional turn:
- agent/context_engine.py get_status() (the ABC default every external
context engine inherits) — highest blast radius
- gateway/slash_commands.py /usage context line
- cli.py session usage printout
All clamped to >=0, mirroring cli.py _get_status_bar_snapshot and the
tui_gateway fix. Adds an ABC get_status sentinel-clamp regression test.
The salvaged fix guards with `if ctx_max and last_prompt`, but last_prompt
comes from `last_prompt_tokens or 0` — the post-compression -1 sentinel
(conversation_compression) is truthy, so it leaked context_used=-1 on the
transitional turn. Clamp <0 to 0 so it reads as unknown (no gauge), matching
the CLI status-bar path (cli.py _get_status_bar_snapshot).
Follow-up on the salvaged #50518 (r266-tech).
_get_usage substituted the cumulative lifetime session_total_tokens into
the current-window context_used when an external context engine did not
report last_prompt_tokens, producing impossible status-bar readings
(e.g. 1.9m/120k clamped to 100%). Populate context_used/percent only
from a real current occupancy; leave the gauge unset otherwise. The
built-in compressor always reports last_prompt_tokens, so it's unaffected.
Fixes#50421.
Migrating the scheduler-reload seam from a single dotenv.load_dotenv patch to
two patches (load_hermes_dotenv + reset_secret_source_cache) lengthened the
positional list _make_run_job_patches returns, so the 4 callers that applied
patches[0..4] silently dropped the resolve_runtime_provider patch (now at [5]).
Under CI's hermetic env (all API keys blanked) auth then failed and AIAgent was
never constructed → 'NoneType has no attribute kwargs'. Callers now apply
patches[0..5]. Passed locally (keys present) but failed on CI shard 5/8.
Two live cron bugs, both surfaced by @banditburai in #35616 (whose larger
watchdog/supervisor work is already superseded by the CronScheduler provider
refactor on main):
- #32896: `cron list` crashed on a present-but-null `deliver` field —
`job.get("deliver", ["local"])` returns None for an explicit null, which
then hit `", ".join(None)`. Coalesce with `or ["local"]` (same pitfall
the sibling `repeat` line already guards against).
- #33465: cron jobs 401'd on Bitwarden/BSM-backed secrets. The per-run env
reload used a bare `load_dotenv(override=True)`, which re-applied only the
.env placeholder — startup had already recorded this HERMES_HOME in
env_loader._APPLIED_HOMES, so the external-secret re-pull no-oped. Route the
reload through load_hermes_dotenv() and call reset_secret_source_cache()
first to force the re-pull (Bitwarden's 300s value-cache keeps it off the
network; override honours secrets.bitwarden.override_existing, mirroring
startup).
Tests: null-deliver regression guard in test_cron.py; reset-before-reload
ordering guard in test_scheduler.py. Migrated 31 scheduler-reload test seams
from patching dotenv.load_dotenv to the new load_hermes_dotenv /
reset_secret_source_cache seam.
Salvaged from PR #35130 (the safe subset of jnibarger01's security pass):
- threat_patterns.py: replace unbounded (?:\w+\s+)* filler with bounded
{0,8} + cap scan input at MAX_SCAN_CHARS (64KiB), and bound the .*
runs in the exfil/config-mod patterns. Kills catastrophic backtracking
on adversarial near-misses.
- hermes_state.py: cap FTS5 query length (MAX_FTS5_QUERY_CHARS) and
extract quoted phrases with a linear scan instead of a regex so
pathological quote runs can't induce backtracking.
- acp_adapter/edit_approval.py + agent/tool_dispatch_helpers.py: recognize
'*** Move File: src -> dst' V4A headers so patch-mode edits are
permissioned/traversal-checked (previously only Update/Add/Delete), and
surface a proposal for mode=patch V4A calls (previously replace-only).
Tests: +ReDoS-bound + FTS5-cap + Move-File-target + V4A-approval cases.
The MCP input-schema normalizer in _normalize_mcp_input_schema promotes the
legacy JSON Schema 'definitions' meta-keyword to '$defs' (draft 2019-09+)
so local '$ref' resolution works downstream. The previous walk renamed
*any* key named 'definitions' anywhere in the tree, including inside
'properties' dicts. That turned user-facing parameter names into '$defs',
producing property keys that contain '$', which Anthropic and OpenAI
both reject with HTTP 400 (pattern '^[a-zA-Z0-9_.-]{1,64}$').
Real-world repro: an MCP server that exposes a CI/pipelines tool whose
'definitions' parameter is an array of pipeline-definition IDs. Such a tool
is enough on its own to break every conversation, because the full tools
array is sent on every request.
Fix: when descending into a 'properties' or 'patternProperties' mapping,
iterate property-name -> schema pairs directly, leaving the property names
verbatim. Ordinary JSON Schema semantics resume inside each property's
schema, so a legitimately nested 'definitions' meta-keyword inside a
property's schema is still promoted.
Adds two regression tests:
- test_definitions_as_property_name_is_preserved (the property-name case)
- test_definitions_property_and_meta_keyword_coexist (both forms in one
schema; the property name stays, the meta-keyword promotes)
The gateway/API server rebuilds the in-memory TodoStore by replaying
caller-supplied conversation_history. _hydrate_todo_store previously
accepted any role:tool message containing a "todos" array, so a forged
bare tool result could seed arbitrary todo state and re-inflate context
every turn (GHSA-5g4g-6jrg-mw3g).
Restrict hydration to tool results paired with an earlier assistant
todo tool call (matching tool_call_id, function name == todo, no
user/system boundary between). Reuse the existing _get_tool_call_id/
name_static helpers so dict- and object-shaped tool calls both work.
Add a generous MAX_TODO_RESULT_CHARS payload guard to drop absurd
forged results before parsing; item/content caps already exist on main.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
Follow-up for salvaged PR #35840: current main removed the
use_llm_processing kwarg (LLM summarization dropped) and moved the input
SSRF gate to async_is_safe_url. Adjust the new firecrawl-final-url test
to match.
Two related bugs caused subagent delegation to silently return empty summaries
with 0 tokens when the user configured delegation.provider=bedrock alongside
delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com.
Root cause #1 — misrouting in _resolve_delegation_credentials():
The configured_base_url branch unconditionally forced provider='custom' and
api_mode='chat_completions', only specializing for chatgpt.com, anthropic,
and kimi hosts. Bedrock (and other native-SDK providers) fell through as
'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at
Bedrock's native API. Bedrock rejected the payload and returned nothing,
which looked like an empty LLM response to the child agent.
Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip
the base_url short-circuit and fall through to resolve_runtime_provider(),
which knows how to construct the proper SDK client. base_url can still be
forwarded through that path for regional overrides.
Root cause #2 — '(empty)' sentinel accepted as success:
After N retries of empty LLM responses, run_agent.py emits the literal
string '(empty)' as final_response. _run_single_child then hit
`elif summary:` — '(empty)' is truthy, so status became 'completed' and
the parent surfaced a blank result with no error. Users saw api_calls=4,
tokens=0, duration~0.4s, status=completed.
Fix: treat final_response.strip() == '(empty)' as a failure so the parent
surfaces it instead of silently accepting zero-content 'success'.
Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock
(provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by
new tests in tests/tools/test_delegate.py.
The outbound chat resolver in BlueBubblesAdapter._resolve_chat_guid()
matched on participant addresses after the exact chatIdentifier check,
which let an outbound DM reply leak into a group thread when the same
contact existed in both a 1:1 DM and a group chat: if the group chat
was returned earlier by /api/v1/chat/query and the DM's
chatIdentifier differed from the bare address, the participant match
on the group fired first and returned the group GUID. That GUID was
then cached under the bare address, so every subsequent reply went to
the wrong chat.
Restrict resolution to:
1. raw GUID passthrough
2. exact chatIdentifier / identifier match
When no exact match exists the resolver now returns None and the
caller already handles that path safely: send() creates a fresh DM via
_create_chat_for_handle for address-shaped targets, and
_send_attachment fails with a clear "chat not found" error rather than
guessing into a group.
Adds regression tests under TestBlueBubblesGuidResolution covering:
- exact chatIdentifier match still resolves to the DM
- participant-only presence does not resolve to the group
- the DM is chosen even when the group is returned first
- unresolved targets are not cached (no stale-None and no stale-group)
Fixes#24157.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`hermes debug share` printed a privacy notice and then uploaded the
report to a public paste service in the same breath — the user never got
to say yes or no. Add a consent gate: an interactive [y/N] prompt, a
--yes/-y flag to skip it, and a hard refusal (exit 1) in non-interactive
contexts (no TTY on stdin) so debug data can't be exposed silently in
scripts/CI.
- New _confirm_upload() helper gates the actual upload after the notice.
- Applied to BOTH upload paths: the public paste.rs path and the --nous
Nous-S3 path (the latter is a sibling site the original PR missed).
- The /debug slash command passes yes=True (typing /debug is itself the
consent action, and input() would hang inside prompt_toolkit).
- Rewrote the privacy notice for accuracy: secrets (API keys/tokens/
passwords) ARE force-redacted before upload; PII (display name,
platform user ID, verbatim message content, filesystem paths) is NOT,
and that URL is public.
Fixes#22016.
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
When the last user message sits exactly at head_end (the first compressible
index), _ensure_last_user_message_in_tail's final max(last_user_idx,
head_end + 1) clamp returns head_end + 1, pushing the user into the compressed
region without its assistant reply. The summariser then records it as a
pending ask, and the next session re-executes the already-completed task
(lights off twice, file deleted twice, message re-sent).
Fix: apply Causal Coupling — a compaction boundary must never split a
(user -> assistant [-> tool results]) turn-pair. Add _find_turn_pair_end and,
when the clamp would orphan the user, push the cut forward to pair_end so the
completed pair is summarised together and marked done.
8 new tests in TestTurnPairPreservation; 133 compressor tests pass.
Follow-up to the salvaged #22070. The cron-deny tirith ImportError branch
was unconditionally fail-open; now it honours security.tirith_fail_open:
false by blocking (a cron session has no user to approve), mirroring the
main flow's fail-closed synthesis (#20733).
Adds regression tests: tirith-only content threat blocked in cron-deny,
plus fail-closed/fail-open ImportError behavior.
The CLI routes user input typed while the agent is running into
``_interrupt_queue`` (separate from ``_pending_input``) so the explicit
interrupt path can opt to deliver them as a single combined message.
That path only drains the queue when ``busy_input_mode == "interrupt"``
AND a ``pending_message`` was acknowledged.
If the agent's turn finishes naturally (no interrupt fires), any
messages typed during the turn stay stuck in ``_interrupt_queue``
forever. Subsequent ``Enter`` presses route input to the same blocked
queue and the CLI appears to hang. Original report: lunarnexus in
The fix restores the post-turn drain that was originally part of
drain off as "worth its own review" and never re-landed it; the user-
visible regression is that any non-interrupt-mode user typing during
a turn is silently dropped.
Implementation: extract the drain to a small helper
``_drain_interrupt_queue_to_pending_input`` matching the existing
``_maybe_continue_goal_after_turn`` style. ``process_loop``'s
``finally`` block calls it once per turn after the status-line refresh
and before goal continuation (so re-queued user input preempts an
auto-continuation prompt). The helper swallows ``Exception`` so it
can never break the main loop.
Addresses #20271.
test_no_dedup_seed_when_thread_creation_fails asserted the agent still ran
inline when auto-thread creation failed — the pre-#20243 silent-fallback
behavior. Flip that to assert_not_awaited() to match the new fail-closed
contract; the test's actual contract (phantom thread id must not leak into
the dedup cache on failure) is unchanged. Give the fake channel a send mock
so the failure-notice path runs cleanly.
When discord.auto_thread is enabled and a top-level server-channel message
should be routed to a new thread, a transient thread-create failure (e.g.
Cannot connect to host discord.com:443) returned None and _handle_message
fell through to an inline parent-channel reply — dumping a new task into a
shared channel and breaking thread-first workflows.
- _auto_create_thread retries the primary + seed-message paths once after a
750ms backoff for transient connect errors.
- _handle_message treats None as a hard failure: posts a short visible notice
in the parent channel and returns without invoking the agent. The notify
send is wrapped so a secondary connect error can't raise.
Fixes#20243
Text-only Matrix messages sent via the send_message engine (hermes send,
cron deliver: matrix) arrived unencrypted (red padlock) in E2EE rooms.
Media sends already routed through the mautrix adapter and encrypted fine,
but text-only sends took the raw-HTTP standalone_sender_fn path, which
never encrypts.
Route ALL Matrix sends through _send_matrix_via_adapter so text is
encrypted too. The adapter reuses the live gateway's E2EE session when
available (#46310) and falls back to an encryption-aware ephemeral adapter
for standalone/cron contexts. The registry standalone_sender_fn stays
registered for the contract; it is simply no longer reached for Matrix.
Salvaged from PR #20259 onto current main (the original patched the
pre-#41112 _send_matrix branch, which had since moved to the plugin's
standalone path).
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Group gating (_should_process_message) read the raw message_thread_id,
while event routing (_build_message_event) normalized it. A plain
non-forum group reply's message_thread_id is a reply-UI anchor, not a
topic, so an anchor id matching an ignored_threads entry wrongly
dropped the message, and the anchor was treated as a routable topic
under allowed_topics.
Extract _effective_message_thread_id and route both gating and
event-building through it, so gating and session routing agree on one
normalized value: real topic/forum messages keep their thread id, reply
anchors are dropped, and forum General-topic messages normalize to the
General-topic id.
When .restart_last_processed.json goes missing, a redelivered /restart from
Telegram polling can no longer be caught by the update_id comparison, so it
re-restarts the gateway forever (issue #18528, reported by @dontcallmejames
who hit it in production — gateway restarting every ~2min, zero messages
processed).
Fallback: on marker-missing, suppress the /restart only when we can confirm
we just came out of a restart cycle (_booted_from_restart, captured at startup
from .restart_notify.json before it is unlinked) AND the process is still
within a 60s post-boot window. Consumed one-shot. This closes the loop without
swallowing a genuine first /restart on a fresh boot — the flaw in the original
bare-uptime approach.
Credit to @dontcallmejames for the diagnosis and original patch.
@janrenz's PR #35862 added prompt_caching.enabled=false at init only. But
_anthropic_prompt_cache_policy re-derives _use_prompt_caching on every /model
switch (agent_runtime_helpers) and fallback-model swap (chat_completion_helpers),
which re-enabled markers and re-broke the strict proxy the toggle was meant to fix.
Move the kill switch into anthropic_prompt_cache_policy so it returns (False, False)
on every path. Drop the now-redundant init-time override (kept @janrenz's isinstance
hardening on the cache_ttl read). Add policy-level tests + docs for the toggle.
Follow-up to salvaged PR #35862.