_handle_active_session_busy_message (the busy_session_handler most
platform adapters register) demotes busy_input_mode='interrupt' to
queue semantics for two reasons: active subagents (#30170) and, as of
this week, context compression in flight (#56391) — interrupting while
compression holds the state.db lock races a new turn against the
pre-rotation parent session, and if that new turn also grows past the
compression threshold it starts its own uncancellable compression on
the same stale parent, forking orphaned compression siblings.
_handle_message has its own, independent inline "PRIORITY" busy-path
(reached directly with a live running agent — see the `if _quick_key in
self._running_agents:` guard, exercised end-to-end by the existing
tests/gateway/test_running_agent_session_toggles.py harness). Its own
comment says it mirrors _handle_active_session_busy_message's subagent-
demotion rationale verbatim, and it does demote for active subagents,
but it never checked _session_has_compression_in_flight, so a plain-text
follow-up landing on this path while compression is mid-flight still
called running_agent.interrupt() unconditionally.
Fix: add the same _session_has_compression_in_flight(session_key) check
before the PRIORITY interrupt call, demoting to queue exactly like the
sibling path.
Tests: tests/gateway/test_priority_path_compression_demotion_56391.py
drives _handle_message end-to-end (reusing the test_running_agent_
session_toggles.py harness pattern) with a live running agent and a
mocked compression lock. Mutation-verified: reverting the fix makes the
demotion test fail (interrupt() gets called) against the pre-fix code;
a control test pins the unchanged default-interrupt behavior when no
compression lock is held.
Discord thread names share the same UTF-16 component budget as select
labels and buttons — route the sanitizers in gateway/run.py and the
adapter's rename_thread through utf16_len/_prefix_within_utf16_limit
instead of code-point slices. Adds rungmc357 to AUTHOR_MAP.
Sessions no longer auto-reset by default. SessionResetPolicy.mode now
defaults to "none" (was "both": 24h idle + daily 4am), matching the
setup wizard's existing no-reset default and community feedback that
surprise context loss hurts more than it helps.
- gateway/config.py: dataclass default + from_dict fallback -> "none";
installs whose config.yaml lacks a session_reset section stop
auto-resetting
- hermes_cli/setup.py: "Never auto-reset" is now the recommended/default
choice in hermes setup agent; stale comment updated
- docs (en + zh-Hans): default is no auto-reset, opt in via
session_reset in config.yaml
Users who explicitly configured idle/daily/both resets keep them.
Drop the Responses-API native compaction path and its opt-in umbrella
flag from the salvaged feature. On the Codex OAuth chat route Hermes
owns the message list and the summary compressor works (and stays
provider-portable — encrypted compaction items would lock the session
history to chatgpt.com and break /model switches and provider
fallback). On the app-server runtime (codex CLI/agent) the codex agent
owns the real thread context, so thread/compact/start is the only
mechanism that can actually shrink it (#36801) — that path is now the
default behavior for codex_app_server sessions, controlled by
compression.codex_app_server_auto (native|hermes|off), no umbrella
flag.
Removed: responses.compact() call path, codex_compaction_items replay/
persistence plumbing, codex_native_compaction + codex_responses_threshold
config keys, desktop settings fields, and their tests. Kept: everything
app-server (compact_thread(), compaction notifications, bookkeeping,
docs, tests) plus cache-busting keys for the surviving knobs.
Review finding: when the FTS write-corruption guard (#50502) prefers the
cached agent's live _session_messages over the reloaded transcript, that
history bypasses the replay-cleanup pass in _build_gateway_agent_history
— a stale dangerous confirmation could slip through unredacted on the
same-process salvage path. Re-apply the (idempotent) expiry stripper to
the selected live history.
When a high-risk side effect (e.g. host restart via shutdown.exe) runs,
the user's plain-text confirmation phrase is persisted in the conversation
transcript. If the host restart killed the gateway process before the
assistant's tool result was written, the transcript tail ends on the
assistant's text response - and the dangerous confirmation text remains
in the user role.
On the next inbound message - possibly a casual 'are you there?' from
the user minutes later - the LLM sees the stale confirmation and may
interpret the new turn as a fresh re-confirmation, re-executing the
destructive action. This is the failure mode reported in #59607.
Fix:
- Add strip_stale_dangerous_confirmations() in agent/replay_cleanup.py
that removes user messages whose content matches a known dangerous
confirmation pattern AND whose timestamp is older than 60 seconds.
- Add is_dangerous_confirmation() helper with the matched patterns
(i18n-aware: covers 確認強制重開機 from the original incident).
- Wire the stripper into _build_gateway_agent_history() right after the
existing 75ed07ace strippers, so the strip chain is:
strip_interrupted_tool_tails -> strip_dangling_tool_call_tail ->
strip_stale_dangerous_confirmations.
- Update _build_replay_entry() to preserve the timestamp on user
messages (it was previously dropped), since the new stripper needs it.
Complements 75ed07ace (which strips the assistant side of the broken
tail) by handling the user side: a stale plain-text confirmation that
the assistant has not yet responded to in a way the resume logic
recognises.
Failing-test-first discipline: the bug-detection test
test_stale_confirmation_text_is_stripped_on_resume fails on unfixed
code (proves the test catches the bug) and passes after the fix.
Five additional safety tests confirm no regression on:
- fresh confirmations (within expiry) are preserved
- non-confirmation text is preserved
- non-matching histories are untouched
- dangerous-pattern detection works in all cases (case, i18n, None)
- direct unit test of the strip helper
Refs: #59607
* fix(gateway): use process-level HERMES_HOME for identity files
Gateway identity files (PID, lock, runtime status, takeover/stop markers)
were written via get_hermes_home() which honours the _HERMES_HOME_OVERRIDE
contextvar used for per-session profile dispatch. When a profile-context
task happened to be active at write time, files landed in the wrong profile
directory.
Add _get_process_hermes_home() that skips the contextvar and uses only the
HERMES_HOME env var or platform default, and route all gateway identity file
paths through it.
Fixes#56986
* chore(release): map liuhao1024 author email for PR #56993 salvage
---------
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Ben <ben@nousresearch.com>
* fix(auth): resolve Anthropic OAuth file per-profile + close port-binding platform gaps
Two focused pieces salvaged from PR #57563:
1. _HERMES_OAUTH_FILE was computed at module import time — frozen before
HERMES_HOME/profile overrides, so multiplexed profile turns read and
wrote the DEFAULT profile's .anthropic_oauth.json (OAuth path hijack).
Replaced with a lazy _get_hermes_oauth_file(); all web_server.py call
sites updated.
2. _PORT_BINDING_PLATFORM_VALUES was missing whatsapp_cloud and line —
both bind aiohttp TCP listeners, so a secondary multiplex profile
enabling them would collide with the primary's listener instead of
failing fast at startup.
Original work by @austinlaw076. The rest of #57563 was redundant on
main (adapter routing sweep superseded by #56854's salvage; cron secret
scope landed in fdab380a1; nested-config fallback in from_dict).
* chore(release): map austinlaw076 author email for PR #57563 salvage
* test(hermes_cli): patch _get_hermes_oauth_file instead of removed _HERMES_OAUTH_FILE constant
---------
Co-authored-by: Austin <austin@openvm067.space>
Co-authored-by: Ben <ben@nousresearch.com>
* fix(gateway): per-profile pairing whitelist isolation for multiplex gateways
Pairing approvals are stored per profile (profiles/<name>/pairing/) and
authz routes pairing checks through the serving profile's store, so one
profile's approved users no longer authorize against every other
profile's whitelist in multiplex mode.
The global store remains for the hermes pairing CLI and single-profile
gateways; unregistered/unstamped sources fall back to it, preserving
existing behavior.
Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped:
the agent turn already runs inside _profile_runtime_scope on main, so
load_soul_md() resolves per-profile without changes.
Original work by @soddy022.
* ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR)
---------
Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com>
* fix(gateway): scope reset banners' session info to the serving profile
The auto-reset notice and the manual /reset //new banner both appended
_format_session_info() outside any profile scope, so a multiplexed
gateway advertised the base config's model/provider/context while the
session actually ran on the profile's.
Route both call sites through a new _reset_notice_session_info(source),
which enters _profile_runtime_scope for the source's profile when
gateway.multiplex_profiles is on (mirroring _run_agent's gating), so
_load_gateway_config()/_resolve_gateway_model() resolve the profile's
config.yaml via the existing context-local home override. Single-profile
gateways never enter the scope — behavior unchanged.
Both call sites invoke the helper via asyncio.to_thread: under the
scope, resolution can do blocking work (credential refresh,
context-length HTTP probes) that previously failed fast unscoped and
must not run on the event loop.
Fixes#59003
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(release): map irresi author email for PR #59048 salvage
---------
Co-authored-by: irresi <blueirobin02@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A /stop sets _interrupt_requested on the session's cached agent, but the
flag is only cleared by the turn finalizer. When the stopped run is hung
or still draining, the flag survives the forced lock release and the
session's NEXT user message is killed at the top of the tool loop
(conversation_loop.py interrupt check): the run completes with
interrupted=True, api_calls=0 and an empty response, which
_normalize_empty_agent_response passed through as pure silence — the
user's message was swallowed with no trace except a
'response ready: ... api_calls=0 response=0 chars' log line.
Two-layer fix:
- _interrupt_and_clear_session now evicts the cached agent whenever it
releases the running state. The next message rebuilds the agent from
session history (mirroring the /new and /model paths), while the old
agent object keeps its interrupt flag so a hung drain still dies when
it unblocks. This intentionally does NOT clear the flag in place:
turn_context deliberately preserves a pending interrupt across turn
start (it carries interrupt-message delivery), and clearing it could
revive a hung run the user just stopped.
- _normalize_empty_agent_response distinguishes a drain from a swallowed
turn: an interrupted run that did work (api_calls > 0) stays silent as
before (deliberate stop/steer; queued messages are delivered by the
recursive drain inside _run_agent), but an interrupted run with ZERO
api_calls never processed the user's message at all and now surfaces a
'send it again' notice instead of nothing.
Same silent-delivery class as a1f76ba7e (#29346), which covered the
extract-stripped case; regression tests added next to that coverage.
Fixes#44212
The CWE-22 traversal guard in SessionEntry.from_dict rejects any
interior '/' in session_key, but session_key is a logical routing
key (never used as a filesystem path) and Google Chat resource names
legitimately contain '/' (spaces/<id>, spaces/<id>/threads/<id>).
All Google Chat sessions were silently dropped on gateway start.
Split the validation: session_id keeps the strict _is_path_unsafe
guard (it's the value used as a filename); session_key now uses a
relaxed _is_session_key_unsafe helper that only blocks genuine
traversal vectors (parent-dir '..', leading '/', leading '\', leading
Windows drive-letter prefix) and allows interior '/'.
load_gateway_config() only surfaced the top-level `multiplex_profiles`
key into gw_data before calling GatewayConfig.from_dict(). A config.yaml
that pinned the flag under the nested `gateway:` section -- the form
written by `hermes config set gateway.multiplex_profiles true` -- was
silently ignored, so the gateway loaded with multiplex_profiles=False.
from_dict() already honors the nested fallback, but load_gateway_config()
builds gw_data from top-level keys first, so the nested value never
reached it.
Read gateway.multiplex_profiles into gw_data when the top-level key is
absent, mirroring the existing nested fallback for max_concurrent_sessions.
Adds a load_gateway_config() regression test that writes a config.yaml
with `gateway.multiplex_profiles: true` and asserts the loaded config has
multiplex_profiles=True (fails without the fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes#50051 by preserving nested gateway.multiplex_profiles and routing gateway config env reads through the active profile secret scope when present.
This keeps secondary profile adapter startup from inheriting default-profile platform tokens or port-binding enables while preserving legacy single-profile behavior outside a scope.
Constraint: latest upstream main f57ff7aef1 still reproduced both nested-config loss and cross-profile env leakage
Rejected: special-casing API_SERVER_* only | left other profile-scoped tokens vulnerable to the same leak
Confidence: high
Scope-risk: moderate
Directive: keep future gateway/config env reads on the scoped helper path unless a variable is explicitly process-global
Tested: pytest -q tests/gateway/test_multiplex_phase0.py tests/gateway/test_multiplex_credential_isolation.py tests/gateway/test_config.py -k 'multiplex or scope or getenv or api_server or relay'
Not-tested: full gateway startup across live platform adapters
Follow-up to the routing sweep: when a stamped secondary profile has no
_profile_adapters entry (adapter failed to connect / was refused), return
None instead of falling back to the default profile's adapter — the
fallback sends replies out the wrong bot, which is the exact leak class
this cluster fixes. Also restores main's deliberate fail-fast on
port-binding platforms in secondary profiles (the cherry-picked commit
had softened it to silent force-disable).
Co-authored-by: ManniBr <m888.braun@hotmail.com>
Replace 53 instances of self.adapters.get(source.platform) with
self._adapter_for_source(source) in gateway/run.py.
self.adapters is the default profile's adapter map. In multiplex mode,
secondary profiles (lars, kira, jonas, caro) have their adapters in
_profile_adapters[profile]. _adapter_for_source() (from authz_mixin.py)
correctly resolves through _profile_adapters when source.profile is set.
Without this fix, ALL response paths for secondary profiles — streaming,
sending, media delivery, voice, typing indicators, queue operations,
startup restore, and platform notices — route through the default
profile's bot token instead of the profile's own token.
Fixes: Multiplex profiles responding with wrong bot token on Telegram,
Discord, and all other platforms.
Follow-up to #9006/#58899. The gateway routing index (session_key ->
SessionEntry) now lives in a new gateway_routing table in state.db as the
primary store; sessions.json is demoted to an optional legacy mirror.
- hermes_state.py: schema v19 — gateway_routing table (scope + session_key
PK; scope = resolved sessions_dir so multiple stores sharing one state.db
never cross-contaminate) with save/replace/load/delete methods
- gateway/session.py: _save() writes the whole index atomically to the DB
(mirrors the old full-file JSON rewrite semantics) and only falls back to
JSON when the DB write fails; _ensure_loaded reads the DB first and folds
in legacy sessions.json entries for keys the DB lacks (pre-migration
import; DB entries win over stale JSON)
- gateway/config.py + hermes_cli/config.py: new write_sessions_json flag
(default true for compat/downgrade safety); gateway.write_sessions_json:
false stops producing the file entirely
- sessions.json _README updated to say it's a legacy mirror + how to
disable it
Rehydration is now lossless across restarts even with sessions.json deleted:
suspended/resume_pending/model_override/token state all round-trip through
the DB (the old sessions-table recovery only rebuilt the bare key mapping).
Follow-up to the salvaged #54944: before this, aiohttp's implicit 1 MiB
default client_max_size tripped BEFORE the intended 3 MB Meta cap could
apply on read() paths — the explicit value makes the documented limit
real while the bounded reader keeps chunked bodies from buffering past
3 MB (#58536/#58902/#59180 pattern).
* 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
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.
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).
Port from qwibitai/nanoclaw#2713: expose Hermes' existing Docker network isolation primitive through terminal config so operators can opt out of container egress.
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
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_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.
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.
Follow-up on the #58818 cron-drain fix. The housekeeping ticker uses the
same loop-scheduled-future pattern as cron — it refreshes the channel
directory via safe_schedule_threadsafe(build_channel_directory(...), loop)
and blocks on fut.result(timeout=30). The original fix swapped its
join(5) for _await_thread_exit(5), which is a strict improvement (the loop
stays alive so the future can run) but the 5s bound is shorter than the
30s future, so a refresh in flight at shutdown was still abandoned. Bound
the housekeeping drain at 35s (30s future + margin) via a dedicated
_HOUSEKEEPING_SHUTDOWN_DRAIN_TIMEOUT constant. Not user-facing (self-heals
next tick) but keeps the cooperative drain honest across both threads.
A cron delivery uses the live adapter by scheduling the send coroutine onto the
gateway event loop (safe_schedule_threadsafe) and blocking the ticker thread on
future.result(). On shutdown/restart the cleanup ran a synchronous
cron_thread.join(timeout=5), which blocks the event loop — so the pending
delivery coroutine could never execute, the join always timed out, and the
message was silently dropped (#58818). The default agent.restart_drain_timeout
is 0, so this fired on every restart with an in-flight delivery.
Replace the blocking joins with _await_thread_exit(), which polls is_alive()
via await asyncio.sleep so the loop keeps running and finishes the queued
delivery before teardown. The cron wait is bounded by the delivery future's own
60s ceiling (plus margin); housekeeping keeps a short bound. When no delivery is
in flight the ticker exits on stop_event immediately, so shutdown stays snappy.
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).