Commit graph

1246 commits

Author SHA1 Message Date
cifangyiquan
089f09fa5f fix(api_server): mark rejected API_SERVER_KEY as non-retryable fatal error
When _api_key_passes_startup_guard() rejects the key (missing,
placeholder/too short, or fail-closed unverifiable strength), connect()
returned a bare False with no fatal-error info. gateway.run's reconnect
watcher treats that as transient and re-queues with backoff forever —
each retry re-instantiating the adapter and its ResponseStore sqlite
connection. Observed in production (#37011): ~501 leaked connections
(1002 fds) over ~2.5 days until EMFILE made the whole gateway
unresponsive.

Set a non-retryable fatal error (api_server_key_invalid) in connect()
when the guard rejects, covering all three rejection branches, so the
platform drops from the reconnect queue; recover with
`/platform resume api_server` after fixing the key. Same treatment as
the port-conflict guard (api_server_port_in_use, #65665 / bda8bd76a8).

Tests mirror the port-conflict precedent: each rejection path asserts
connect() is False, has_fatal_error True, fatal_error_retryable False,
and fatal_error_code api_server_key_invalid, plus a strong-key control.

Re-implementation of #38803 by @cifangyiquan against current main —
their patch targeted the old inline guard in connect() which was since
extracted to _api_key_passes_startup_guard() (and gained the
fail-closed branch in 683059feb5), so the original diff no longer
applies. Their production diagnosis and non-retryable direction
preserved.

Refs: #38803, #37011
2026-07-23 11:54:19 -07:00
skywind
3e2fe3fcab fix: close orphaned code fences on all send paths
Adds `ensure_closed_code_fences()` helper to detect text with an odd
count of triple-backtick markers (indicating an unclosed code block)
and append a closing fence.

Applies the fix to all four identified gap paths:
  G1: truncate_message early-break path for final chunk
  G2: _send_or_edit streaming edit path (most commonly hit)
  G3: overflow split first chunk (covered by G2's fix)
  G4: _send_fallback_final fallback send path

Closes: #TBD
2026-07-23 11:50:28 -07:00
Teknium
558dab0e3e feat(slack): opt-in reaction triggers, removed events, hooks, channel handoff
Build the full reaction pipeline on top of the #29916 base:

- Opt-in gate: slack.reaction_triggers (default OFF — reaction events
  stay acked-and-dropped so busy channels don't wake the agent on every
  emoji). 'true' routes reactions on the bot's OWN messages; an explicit
  emoji-name list routes those emojis from any message (handoff flows).
- reaction_removed events now route too, distinguished by the
  cross-platform text convention reaction:added:<emoji> /
  reaction:removed:<emoji> (matches the Feishu and Photon adapters, so
  agents and skills see one shape everywhere).
- Authorization: the reactor becomes the synthesized message's user, so
  the early _is_user_authorized gate and allowed_channels whitelist
  apply exactly as for typed messages. _hermes_force_process only skips
  the mention requirement (a reaction on the bot's own message is
  definitionally addressed to the bot), mirroring Feishu/Photon.
- Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a
  message item fires reaction:added / reaction:removed through the new
  BasePlatformAdapter.set_reaction_handler → GatewayRunner
  ._handle_reaction_event → HookRegistry.emit, independent of the
  routing opt-in. Documented in hooks.md.
- Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target
  routes the reaction turn to a configured channel (top-level via
  _hermes_no_thread_response + reply-anchor suppression in
  gateway/platforms/base.py) or C123:<ts> thread.
- Manifest: reaction_removed event subscription added alongside
  reaction_added/reactions:read.
- Docs: slack.md Reaction Triggers section; hooks.md event table rows.

Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added
handling — same plumbing class, superseded by this consolidated shape).

Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com>
Co-authored-by: Kev-fs <kevin@fleetsmarts.net>
Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
2026-07-23 11:50:08 -07:00
Eugeniusz Gilewski
42626da1ce fix(security): pin DNS resolutions for SSRF-safe fetches
Install connect-time DNS validation for Hermes-owned direct httpx clients so SSRF-sensitive fetch paths dial a vetted IP instead of re-resolving after preflight. This preserves Host/SNI semantics for direct HTTP(S) connections and keeps proxy routing as an explicit trusted egress boundary.

Wire the guarded clients into media cache downloads, vision downloads, Skills Hub direct/raw fetches, and platform attachment fetch paths that already perform SSRF preflight and redirect validation.

Fixes #8033

Co-authored-by: Tom Qiao <zqiao@microsoft.com>
2026-07-23 11:44:43 -07:00
Drexuxux
683059feb5 fix(api_server): fail closed when API_SERVER_KEY strength can't be verified
`_api_key_passes_startup_guard` refuses to start the API server on a weak
`API_SERVER_KEY`, and its own log says why:

    This endpoint dispatches terminal-capable agent work — a guessable key
    is remote code execution.

But the check is wrapped so that a failure to import it starts the server
anyway:

    try:
        from hermes_cli.auth import has_usable_secret
        if not has_usable_secret(self._api_key, min_length=16):
            ... return False
    except ImportError:
        pass
    return True

`hermes_cli.auth` imports httpx at module scope and pulls in a large slice of
the CLI, so an import failure is not hypothetical — a trimmed image, a partial
install, or a circular import during gateway startup all produce one. When it
happens the strength check silently disappears and only the presence check
above it remains, so a placeholder key passes.

Reproduced against the real guard with the import blocked:

    weak key, normal          : False
    weak key,   ImportError   : True    <-- starts on a 4-char key
    strong key, normal        : True

Fail closed instead: an unverifiable key does not get to expose the endpoint,
and the log names the actual problem so the operator can repair the install.
This is the posture tools/credential_files.py already takes — it refuses a
mount when its deny-list cannot be consulted rather than risking it. The catch
also widens from ImportError to Exception, so an AttributeError or an error
raised inside the check cannot reopen the same hole.

Both happy paths are untouched: a strong key still starts, a weak or missing
key is still refused with the existing messages.

Unrelated to #38803, which fixes the retry behaviour after this guard rejects
and assumes the guard ran.

tests/gateway/test_api_server.py: new TestApiKeyStartupGuardFailsClosed — a
weak key is refused when the check is unavailable, a strong key is refused too
(fail-closed), plus three controls pinning the unchanged normal paths. The two
fail-open tests fail on main; the three controls pass there. 222 passed in the
api_server suites; 1475 passed across every suite touching api_server, with
the same 8 pre-existing failures on clean main.
2026-07-23 07:34:26 -07:00
nu
ef8936d599 fix(slack): handle leading-space text commands 2026-07-22 20:55:51 -07:00
Teknium
28b6bd6570 fix(api): preserve compaction summaries at any history position during Responses auto-truncation
The gateway /compress path can force a user-leading layout that leaves
the compaction summary after a retained system head, so scanning only
the leading block misses it. Preserve marker-carrying messages wherever
they sit, filling the remaining budget with the most recent other
messages, and cover the non-leading position with a test.
2026-07-22 06:56:32 -07:00
luyifan
3807df7a06 fix(api-server): preserve compaction summaries on auto truncation
Fixes #55224
2026-07-22 06:56:32 -07:00
Teknium
1c21e96ed0 refactor(api): dedupe compressed-transcript persist sites, drop config opt-out
Rework on top of the salvaged #58133 commits:

- Remove the compression.persist_in_response_store config key — this is
  a bug fix (stored transcripts must reflect what the agent will actually
  replay), not behavior that should be opt-out-able.
- Drop the per-request load_config() imports the handler-level persist
  blocks added.
- Dedupe the two handler-level persist blocks: the compressed-transcript
  substitution already lives in _build_response_conversation_history
  (via result["_compressed"]), so the handlers only need to propagate
  the effective (possibly rotation-changed) session_id. The streaming
  path does this via a new session_id_snapshot arg on
  _persist_response_snapshot; the non-streaming path picks up
  result["session_id"] directly.
- Rotation propagation no longer gates on history-from-store: the first
  request in a chain can also rotate, and its stored session_id must be
  the child session or the next previous_response_id request resumes the
  pre-rotation session and re-compresses every turn.
2026-07-22 06:56:20 -07:00
尘漾
4166fdda6a fix(api_server): log warning when compressed response_store persist fails 2026-07-22 06:56:20 -07:00
尘漾
f66e773c44 fix(gateway): in-place compression not persisted in response_store
The persist logic only checked _result_sid != session_id (rotation),
missing in-place mode where session_id is unchanged but _compressed
flag is set. response_store history doubled every turn (11->26->55->110->225)
causing repeated re-compression.

Fix: detect compression via _did_compress or _rotated, and only update
_effective_session_id on actual rotation (not in-place).

Note: preflight loop break (turn_context.py) from original commit
eee64097a is excluded — it's an optimization, not a bug fix.

Cherry-picked from alidev eee64097a (api_server.py only)
2026-07-22 06:56:20 -07:00
尘漾
2ced02671e feat(api_server): persist compressed messages to prevent re-compression
- Detect when history is loaded from response_store (via previous_response_id)
- Add history_from_store parameter to distinguish history source
- When compression occurs, persist compressed messages instead of original
- Add persist_in_response_store config option (default True)
- Update session_id and response headers to reflect session rotation

Cherry-picked from alidev 2eb816f6b
2026-07-22 06:56:20 -07:00
liang
85f04d4d7e fix(gateway): Responses API stores bloated context after compression
Compression produces a compact transcript in result['messages'],
but _build_response_conversation_history detected a prefix mismatch
and concatenated the original conversation_history on front.

Detect compression via _last_compaction_in_place / session_id
rotation and signal through result['_compressed'] so the builder
uses the compressed transcript directly.
2026-07-22 06:56:20 -07:00
Andy
e89216e7f5 fix(secrets): harden encrypted Bitwarden cache 2026-07-22 04:40:07 -07:00
yungchentang
ff46376614 fix(gateway): preserve shared route transport adapter 2026-07-22 04:19:10 -07:00
Teknium
a2c2ec6332
fix(status): make gateway_updated_at a stable RFC3339-or-null contract (#68657)
The /api/status gateway_updated_at field and the gateway /health/detailed
updated_at field passed through whatever gateway_state.json contained,
untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy
gateways wrote unix epoch floats, and a corrupt or hand-edited state file
can inject numbers or arbitrary garbage — while the frontend types
(web/src/lib/api.ts) declare string | null.

Add normalize_updated_at() in gateway/status.py as the single funnel:
- str: accepted iff datetime.fromisoformat parses (trailing Z tolerated);
  naive timestamps coerced to UTC; canonical isoformat returned
- int/float: treated as unix epoch seconds -> UTC ISO string, with a
  plausibility guard (reject < 2000-01-01, > now+1day, non-finite)
- bool: rejected explicitly (int subclass, but never a timestamp)
- anything else: None

Apply it at both emit sites: the dashboard /api/status handler (covers
both the local read_runtime_status() branch and the remote
/health/detailed cross-container fallback branch) and the gateway API
server's /health/detailed response.

Contract tests: parametrized /api/status normalization (epoch float/int,
garbage string, None, bool, dict, absent key), remote-health numeric and
garbage bodies, dashboard shape test round-trip assertion, direct
normalize_updated_at units (range guards, Z suffix, naive coercion,
non-finite floats), and a write_runtime_status -> read_runtime_status
round-trip proving the writer side stays tz-aware parseable.
2026-07-21 13:12:41 -07:00
pierrenode
0d5982d910 fix(api-server): count "stopping" runs as active in readiness work counts
_readiness_work_counts()'s active_api_runs set is {"queued", "running",
"waiting_for_approval"} — it excludes "stopping", the status
_handle_stop_run() sets while a run is being interrupted. Since the stop
is fully cooperative (the run stays "stopping" — doing real
executor-thread work — until the agent actually notices the interrupt and
the task settles to "cancelled", an unbounded window, not a fixed
timeout), /health/detailed's background_queues.active_api_runs
undercounts real active work for that whole duration.

Fix: add "stopping" to the active-status set. background_queues.status
itself is hardcoded "ok" (gateway/readiness.py), so this doesn't change
overall readiness — it only corrects the count value external monitoring
tooling reads from this endpoint.
2026-07-21 12:56:31 -07:00
Kennedy Umege
eff293115d fix(gateway): report runtime source version in /health, not stale dist-info metadata
/health and /health/detailed resolve the version via importlib.metadata
first, falling back to hermes_cli.__version__. On editable/source
checkouts — including the standard git-based install that hermes-setup
performs — hermes_agent-*.dist-info can survive a source update
unchanged, so the health endpoints keep reporting the previous release
even though the running code (CLI, dashboard, release tags) is newer.
Stale metadata does not raise, so the source fallback never fires.

Flip the preference: use hermes_cli.__version__ (the runtime source of
truth shared by the CLI and dashboard) first, and fall back to
distribution metadata only when the source import fails. The
never-raise contract of the version probe is unchanged.

Observed live: CLI, dashboard, and pyproject all reported 0.18.2 while
/health returned 0.18.0 from a stale hermes_agent-0.18.0.dist-info left
behind by a source update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 12:56:31 -07:00
Jaret Bottoms
155fdd59f8 fix(gateway): take over live platform-lock token holders once
When --replace misses a cross-HERMES_HOME Telegram token holder, platform
connect used to retry forever. Terminate a verified gateway holder once
(with the takeover marker) and re-acquire the scoped lock (#65176).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 12:40:21 -07:00
Teknium
02d8cbadec fix(approval): honor allow_session across all button adapters
Widen the allow_session tier from Matrix to every adapter the gateway
notifies: Telegram, Discord, Slack, Feishu, and Teams gate their Session
button on it; WhatsApp Cloud and qqbot accept the kwarg (no session tier
in their button sets). Also thread allow_session through the plugin-
escalation gate, the execute_code guard payload, and the plain-text
fallback so every notify path carries the same capability flags.
2026-07-21 12:04:47 -07:00
Teknium
a31a31826c
fix(approval): raise gateway approval timeout to 300s, honest stale-tap UX, offer Always on mixed prompts (#68597)
Three related messaging-approval fixes:

1. approvals.timeout default 60 -> 300. PR #63501 collapsed the gateway
   wait onto the canonical approvals.timeout (previously
   gateway_timeout=300), silently shrinking messaging approval windows
   to 60s. Push-notification approvals routinely arrive later than a
   minute; taps landed after the wait had already failed closed.

2. Stale-tap honesty: adapters resolved the approval AFTER rendering
   '<checkmark> Approved by <user>' (Telegram/Discord/Slack), or ignored a zero
   resolve count (WhatsApp Cloud/Feishu). A tap on an expired prompt
   claimed approval while the command had already been denied. All
   button paths now resolve first and render 'Approval expired -
   command was not run' when nothing was waiting.

3. Mixed-warning prompts (dangerous pattern + tirith finding) now offer
   Always: the persistence layer already permanently allowlists the
   pattern key and downgrades the tirith key to session scope, but the
   UI hid Always whenever ANY tirith warning was present. Pure-tirith
   prompts still withhold Always (content findings are session-max by
   design), and Smart-DENY overrides remain once-only.
2026-07-21 05:41:41 -07:00
Gille
d7b36070ef
fix(checkpoints): honor gateway config and task cwd (#68195)
* fix(gateway): wire checkpoint config into agents

* fix(checkpoints): resolve gateway file paths by task cwd
2026-07-20 13:04:12 -07:00
Teknium
5854aad8b5
feat(gateway): durable delivery-obligation ledger for final responses (#67181)
A final response generated but not confirmed-delivered was the one
artifact the gateway could lose without a trace: crash or planned
restart between finalize and platform ACK dropped it silently, and the
resume path re-ran the whole turn at full cost (#58818 P1, #41696,
#63695's gateway half).

gateway/delivery_ledger.py records each outbound final response in
state.db (same conventions as the async-delegation ledger: WAL, owner
pid + process-start-time liveness, bounded retention):

  pending -> attempting -> delivered | failed
  startup sweep on dead-owner rows -> redeliver | abandoned

Contract (the lessons from the closed delivery-outbox attempt #61790):
- obligation recorded BEFORE the first send attempt; cleared only on
  SendResult.success (destination acceptance, #51184)
- ambiguity is labeled, never silently retried: rows that were mid-send
  when the process died redeliver with a visible '♻️ Recovered reply —
  may be a duplicate' prefix (honest at-least-once)
- stable ids from session_key + inbound message id + content, so
  distinct threads/topics can never collide
- poison rows bounded: 3 attempts / 24h freshness -> abandoned; claim
  atomically re-stamps ownership so racing sweeps can't double-claim
- redelivery clears resume_pending for the session so the resume path
  never re-runs a turn whose answer the ledger already holds
- best-effort everywhere: ledger failure can never block or delay a send
- slash-command/ephemeral/empty responses are not recorded; cron and
  proactive delivery stay on DeliveryRouter (separate subsystem)

Config: gateway.delivery_ledger (default on; no version bump needed).

Validation: 30 ledger+producer tests; 352 blast-radius gateway tests
green; cross-process E2E (record in process A, kill it mid-send, claim
+ marker + redeliver in a fresh process B against the same state.db).
2026-07-19 00:45:32 -07:00
Brooklyn Nicholson
d2cb318509 fix(api_server): reconcile SessionDB offload with per-profile DB routing
Rebased onto current main, where _ensure_session_db grew a per-profile
cache (get_hermes_home()-keyed) for /p/<profile>/ multiplex — after this
PR's base. The PR's async rewrite assumed the old single-self._session_db
model, so on current main `session_db=self._session_db` in _create_agent
would pass None in production (the real DB lives in the per-home cache).

Split the concern: keep a SYNC _ensure_session_db (per-profile, used by
_create_agent + the many sync-patching create_agent tests) and add an
async _ensure_session_db_async that captures the profile home on the loop
thread then offloads only the SQLite open via to_thread (single-flight).
Both share _open_and_cache_session_db. Request handlers use the async
variant; _create_agent reverts to the sync call. Updated the first-request
test's FakeDB to accept db_path to match main's SessionDB(db_path=...).

Co-authored-by: necoweb3 <sswdarius@gmail.com>
2026-07-18 23:14:57 -04:00
dsad
5529175084 fix: TOCTOU race in session create + offload SessionDB init
- Make create sequence (check + insert + title) atomic via single
  _execute_write call with BEGIN IMMEDIATE, closing the TOCTOU window
  where two concurrent same-ID creates could both return 201.

- Offload _ensure_session_db() to asyncio.to_thread with single-flight
  lock so first-request SQLite init doesn't block the event loop.

- Add concurrent same-ID create test (one 201, one 409) and
  first-request path test covering the initialization.
2026-07-18 23:07:29 -04:00
dsad
7ba944d054 fix(api_server): offload synchronous SessionDB calls off the event loop 2026-07-18 23:06:17 -04:00
kshitijk4poor
bca886c844 fix(gateway): invalidate pending STT cache when media merges into event
merge_pending_message_event extends the existing event's media_urls in
place when two media-bearing messages arrive in quick succession (photo
bursts, consecutive voice messages).  The gateway runner caches STT
transcripts on the event via _gateway_pending_stt_text; if the cached
event gains new media after the cache was populated, the stale transcript
was returned instead of transcribing the merged attachments.

Add _invalidate_pending_stt_cache() and call it from both media-merge
branches in merge_pending_message_event so the next transcription call
re-runs against the full merged media list.

Closes the merge-race edge case identified during review of PR #61519.
2026-07-19 07:27:29 +05:30
Teknium
fad6cbaed3 fix(discord): make reconnect recovery lifecycle-safe
Preserve monotonic final-delivery completion, isolate recovery config and storage per adapter/profile, coalesce reconnect scans, release cancelled claims, bypass split-message debounce for historical events, and move bounded ledger setup into a short-timeout state module.
2026-07-18 14:01:33 -07:00
Teknium
2278f2cb7e fix(discord): harden reconnect message recovery
Route recovered messages through the live Discord ingress policy, preserve dedup and completion invariants, bound and retain the recovery ledger, and expose the opt-in config with docs and backup coverage.
2026-07-18 14:01:33 -07:00
teknium1
d8fd45e9a8 fix(gateway): getattr-guard _status_text for bare-instance adapter tests
Gateway tests build adapters via object.__new__() without __init__ (the
documented bare-instance pattern), so the new _status_text dict must be
accessed through getattr guards in set_status_text, the _keep_typing
finally cleanup, and the Slack send_typing read — same treatment as
other post-hoc __init__ attributes. Fixes CI shard 2/8
(test_active_session_text_merge).
2026-07-18 12:28:59 -07:00
teknium1
d4396797c3 feat(gateway): live per-tool status line on Slack
Builds on the salvaged typing_status_text plumbing (PR #62007): instead
of a static 'is thinking...', Slack's assistant status line now updates
live as the agent works — 'is running pytest tests/…', 'is reading
docs/api.md…' — and reverts to the static text between tool calls.

Mechanics:
- agent/display.py: build_status_phrase() derives a <=49-char present-
  tense phrase from the existing _TOOL_VERBS table (+ 'is using <name>'
  for plugin/MCP tools; None for _thinking).
- base adapter: supports_status_text capability flag + set_status_text()
  per-chat store, cleared when the typing loop winds down.
- Slack adapter: send_typing() renders the live phrase when set, falling
  back to typing_status_text then 'is thinking...'.
- gateway/run.py: progress_callback stashes the phrase on tool.started
  and clears on tool.completed. Rendering rides the existing
  _keep_typing refresh cadence — zero additional Slack API calls, no
  rate-limit exposure. Works with tool_progress: off (Slack default);
  the callback is now armed whenever the adapter supports status text.
- display.live_status config (full|verb|off, default full): 'verb' hides
  argument previews for shared/customer-facing channels.

Also fixes a latent crash in the cherry-picked from_dict: malformed
non-dict 'extra' sections broke typing_status_text resolution (uses the
already-coerced extra dict).

Design notes: status text is a side-effect display channel only — never
enters the transcript, no prompt-cache impact. Lifecycle guarantees from
the stuck-status fix family are preserved (per-thread tracking,
clear-on-finish via existing stop_typing paths). Related: #45109
(closed; same direction via lifecycle states), #59010/#51363 (native
task cards — complementary, larger scope).
2026-07-18 12:28:59 -07:00
StellarisW
f57157a128 fix(gateway): recover Discord websocket and event-loop stalls
Replace REST-based Discord liveness probe with local WebSocket/heartbeat
state detection. REST success doesn't prove Gateway event delivery — a
half-closed WebSocket can leave Bot.start() alive while REST returns 200.
Now samples ready/open/ACK state and heartbeat latency; consecutive
unhealthy samples emit one retryable fatal code so GatewayRunner rebuilds
the adapter through the existing reconnect path.

Also fixes three lifecycle gaps in the recovery path:
1. asyncio.wait_for() can remain blocked if adapter cleanup swallows
   cancellation — now uses bounded asyncio.wait() with task detachment.
2. Multiplexed secondary-profile adapters had no profile-scoped reconnect
   owner — now uses one runner-owned reconnect slot per profile.
3. An in-flight turn could send its final text through the disconnected
   adapter after a replacement was registered — now resolves the live
   same-profile replacement for unsent final responses only (message IDs
   never migrate, edits/deletes stay on the old transport).

Adds an opt-in Linux/systemd event-loop watchdog (gateway.systemd_watchdog_seconds,
default 0) for the failure mode where the whole asyncio loop stops making
progress and no in-process liveness task can run. stdlib-only sd_notify,
Type=notify/WatchdogSec generation, READY/STOPPING lifecycle.

Co-authored-by: 王鑫 <wx.xw@bytedance.com>
2026-07-18 20:01:55 +05:30
Teknium
9fc0074bac fix(gateway): unify reset boundaries vs recovery — promote accidental ends, honor mode=none, adapter-aware resume guidance
Unifies the two gateway subsystems that were fighting each other: the
'never lose a session' recovery machinery (#54878 stale-route self-heal,
find_latest_gateway_session_for_peer reopening agent_close/ws_orphan_reap
rows) and the session reset/expiry machinery (expiry watcher, /new,
/resume, resume_pending freshness gate).

The unified contract:
- INTENTIONAL boundaries (expiry finalization, auto-reset, /new,
  /resume switch) are recorded durably via promote_to_session_reset(),
  which upgrades accidental recoverable end_reasons (agent_close,
  ws_orphan_reap) to the explicit boundary while preserving other
  explicit reasons (compression, etc.). Recovery then correctly refuses
  to resurrect them.
- ACCIDENTAL ends (crash, cleanup bug, mistaken reaper) stay
  recoverable — genuine crash recovery is untouched.

On top of the cherry-picked contributor commits:
- promote_to_session_reset widened to ws_orphan_reap + parameterized
  reason so auto-reset paths stay auditable (idle/daily/suspended/
  resume_pending_expired) (#61220, #61993, #63539)
- get_or_create_session auto-reset, reset_session (/new), and
  switch_session (/resume) all write through the promote path — the
  first-reason-wins end_session no-op could previously leave a reset
  session resurrectable behind a stale agent_close row (#61993)
- resume_pending freshness gate now honors session_reset.mode=none:
  explicit opt-out of automatic resets also opts out of the zombie
  gate (#61052)
- resume recovery note extracted to build_resume_recovery_note() and
  made adapter-aware via a new interactive_resume capability flag:
  webhook/api_server auto-resume turns now CONTINUE the interrupted
  task instead of emitting an unanswerable 'session restored'
  acknowledgement that abandoned the work (#57056)
- tests updated to call the real note builder instead of mirroring it

E2E-validated against a real SessionDB + SessionStore in a temp
HERMES_HOME: expiry->agent_close->no-resurrection, /new promote,
crash recovery preserved, mode=none opt-out, routing-table flag sync.
2026-07-17 04:51:39 -07:00
Teknium
bd37ff9138
feat(gateway): inline choice pickers for /reasoning and /fast (Telegram, Discord, Matrix) (#65799)
Bare /reasoning and /fast now render a native one-tap picker on
picker-capable platforms, with automatic fallback to the text status
card everywhere else — parity with the /model picker UX.

- gateway/slash_commands.py: generic send_choice_picker capability gate
  (detected on the adapter type, like send_model_picker); selection and
  typed arguments flow through one shared application path so they can
  never diverge; choices built from VALID_REASONING_EFFORTS so future
  levels appear automatically
- telegram: flat inline-keyboard picker (cp:<idx> callbacks), authorized
  users only (same gate as approval buttons)
- discord: ChoicePickerView select menu, auth mirrors ExecApprovalView,
  2-minute timeout with expiry edit
- matrix: reaction-based picker; reaction set extended to 12 slots to
  fit the full effort ladder + subcommands
- locales: picker_title + choice labels in all 16 languages
- docs: ADDING_A_PLATFORM.md capability table

Closes #61110.
2026-07-16 10:38:31 -07:00
Teknium
14f023cd00 fix(api_server): run platform event verifiers off-loop and fail closed
The platform callback verifier can do blocking network I/O (e.g. the
google-chat adapter fetches Google signing certs on a cache miss), which
would stall the event loop if called inline. Run sync verifiers via
asyncio.to_thread (await coroutine verifiers directly), and treat a
crashing verifier as a 401 rather than a 500 through the dispatch path —
a broken verifier must never admit an event.
2026-07-16 07:31:05 -07:00
aeyeopsdev
1305a690e0 feat(gateway): route platform HTTP event callbacks 2026-07-16 07:31:05 -07:00
Teknium
a6d9d1d2cf fix(security): widen non-ASCII compare_digest crash fix to all sibling sites
Same bug class as the salvaged #65305/#65307: hmac.compare_digest (and
secrets.compare_digest) raise TypeError when given a str containing
non-ASCII characters, and these call sites feed it raw request input.
Compare as UTF-8 bytes everywhere:

- gateway/platforms/msgraph_webhook.py: clientState from request body
- gateway/platforms/whatsapp_cloud.py: hub.verify_token query param +
  X-Hub-Signature-256 header (comment claimed 'works on str' — it
  doesn't for non-ASCII)
- plugins/platforms/feishu: verification token + x-lark-signature
- plugins/platforms/raft: bridge token header
- plugins/platforms/line: X-Line-Signature
- plugins/platforms/sms: X-Twilio-Signature
- tools/code_execution_tool.py: sandbox RPC token (both loops)

Regression tests for the two gateway-core sites (msgraph, whatsapp).
2026-07-16 07:22:24 -07:00
Drexuxux
1b69c47e97 fix(webhook): reject a non-ASCII signature header instead of crashing the endpoint
_validate_signature backs the public webhook receiver. It compared each
attacker-supplied signature/token header (GitHub X-Hub-Signature-256,
GitLab X-Gitlab-Token, generic X-Webhook-Signature / -V2, and the Svix v1
header) against a computed hex/base64 digest with hmac.compare_digest on
two str values. compare_digest raises TypeError on a str containing
non-ASCII characters, and the header is raw client input on an
unauthenticated endpoint — so any internet client could POST a single
non-ASCII byte in the signature header and raise out of the handler,
returning a 500 instead of a clean 401. Fail-closed, but an on-demand
crash of the request path.

Route all five comparisons through a small _hmac_str_equal() helper that
encodes both sides to UTF-8 bytes before the constant-time compare
(compare_digest has no ASCII restriction on bytes). Semantics are
unchanged for valid signatures; a hostile non-ASCII header now fails
closed with a rejection instead of raising.

Adds regression tests: non-ASCII GitHub/GitLab/generic/V2 signature
headers return False (no raise), and a non-ASCII configured secret still
matches its exact token value.

Also maps drexux0@gmail.com in scripts/release.py AUTHOR_MAP.
2026-07-16 07:22:24 -07:00
Drexuxux
efb6c21498 fix(api-server): reject a non-ASCII bearer token with 401 instead of crashing
_check_auth gates every OpenAI-compatible API server endpoint. It compared
the client's raw bearer token against the configured key with
hmac.compare_digest on two str values. compare_digest raises TypeError on
a str containing non-ASCII characters, and the token comes straight from
the Authorization header — so a request with a single non-ASCII byte in
the key (a stray unicode char, a smart quote, a pasted BOM) crashed the
handler with an unhandled TypeError. Every endpoint calls _check_auth
without a try/except, so the framework turned that into a 500 Internal
Server Error instead of the intended 401 Invalid API key.

Compare as bytes, matching web_server.py's dashboard-token check
(hmac.compare_digest(auth.encode(), expected.encode())). Encoding both
sides keeps the timing-safe comparison and its semantics identical for
valid keys while making a non-ASCII token fail closed with a clean 401.

Adds regression tests: a non-ASCII bearer token returns 401 (no raise),
and a non-ASCII configured key still authenticates against its exact
value.
2026-07-16 07:22:24 -07:00
Drexuxux
27b31bb7ff fix(relay): normalize a 0/negative max_message_length at the descriptor boundary
Follow-up to the truncate_message split-loop floor. Two review points:

- CapabilityDescriptor.from_json trusted the wire max_message_length
  verbatim, so a connector advertising 0 ('no limit') — or a buggy one
  sending 0/negative — produced a descriptor whose bound flowed straight
  into the adapter's MAX_MESSAGE_LENGTH and truncate_message. Normalize
  it to the documented 4096 default (mirrors from_platform_entry's
  'or 4096' and docs/relay-connector-contract.md), fixing the degenerate
  budget at its source rather than only surviving it downstream.

- Document the truncate_message length contract for a budget too small
  for one codepoint (max_length=1 with a 2-unit surrogate pair under
  utf16_len): the chunk intentionally exceeds max_length by that one
  indivisible codepoint, because emitting it whole preserves content
  where the alternatives are data loss or an infinite loop.

Tests: from_json normalizes 0 and negative bounds to 4096 and passes a
real positive bound through unchanged; the sub-codepoint budget emits
whole codepoints with no data loss (all emojis preserved) and a chunk
that necessarily exceeds the 1-unit budget.
2026-07-16 07:21:23 -07:00
Drexuxux
fbf5005a7e fix(gateway): stop truncate_message hanging on a pathologically small max_length
BasePlatformAdapter.truncate_message() splits an over-length reply into
chunks. When max_length is 0 or 1 (and the content is longer), the split
loop makes no progress and spins forever, appending empty chunks — an
unbounded hang that pins a CPU and grows the chunk list until OOM:

  - headroom = max_length - INDICATOR_RESERVE - ... goes negative, and the
    < 1 fallback (max_length // 2) is also 0;
  - so _cp_limit is 0, the region is empty, no split point is found, and
    split_at falls back to _cp_limit (0);
  - chunk_body is remaining[:0] = "", remaining never shrinks, loop repeats.

The same stall is reachable under utf16_len (Telegram) whenever the next
char is a surrogate-pair emoji wider than the whole budget, so _cp_limit
maps to 0 codepoints even for max_length >= 2.

A pathological max_length is not hypothetical: the relay capability
descriptor's max_message_length is taken verbatim from the connector
(gateway/relay/descriptor.py from_json) and assigned straight to the
adapter's MAX_MESSAGE_LENGTH (gateway/relay/adapter.py), and 0 is a
documented "no limit" value there.

Guarantee forward progress: floor headroom at 1, and floor the
final split_at at max(1, _cp_limit) so at least one codepoint is always
consumed per iteration. Normal splitting is unaffected (both floors only
bite when the budget is already degenerate).

Adds regression tests that run truncate_message on a worker thread and
fail if it doesn't return: max_length 0/1/2 terminate and preserve every
character, and the utf16 emoji case terminates too.
2026-07-16 07:21:23 -07:00
giggling-ginger
6ff65c4d20 fix(gateway): scope default-listener api_server requests under multiplex
Rebuilt from PR #61283 onto the /p/<profile>/ routing world (7aa21e336):
_profile_scope(None) now enters the DEFAULT profile's runtime scope when
multiplexing is active instead of returning nullcontext(). api_server is
a port-binding platform living on the default profile, so plain requests
(no /p/ prefix) are the primary path — with fail-closed get_secret they
crashed with UnscopedSecretError on the first credential read (#61276).

All three wrapped call sites (chat-completions executor, /v1/runs agent
construction and _run_sync) inherit the fix through the one seam.
Single-profile gateways keep the no-op. Regression tests ported from
the original PR to the _profile_scope seam.

Fixes #61276
2026-07-16 07:17:55 -07:00
Teknium
bda8bd76a8
fix(api_server): mark port conflict as non-retryable to stop infinite reconnect loop (#65665)
A bare False from connect() on EADDRINUSE made the gateway reconnect
watcher treat a port conflict as transient and retry forever at the
backoff cap — 1568+ retries over 5 days in a multi-profile production
setup, filling errors.log and leaking 2 ResponseStore fds per retry.
Set a non-retryable fatal error (api_server_port_in_use) in the bind
OSError branch so the platform drops from the reconnect queue;
recover via /platform resume api_server after changing the port.

Re-implementation of #52132 by @msalles1 against the direct-bind path
from #65621 (the pre-probe block their patch targeted no longer
exists). Their production diagnosis and test scenario preserved.
2026-07-16 06:29:09 -07:00
Jay Stothard
64746b4bd3 fix(gateway): validate multiplex adapter config by platform 2026-07-16 05:39:58 -07:00
liuhao1024
6160a80253 fix(gateway/platforms): migrate all Weixin fallbacks to get_secret() for consistent profile-scoped resolution
Per egilewski's security review, WEIXIN_BASE_URL and WEIXIN_CDN_BASE_URL
were still resolved from process-global environment variables, leaving
mixed-scope bypasses in multiplex mode.

Changed files:
- gateway/platforms/weixin.py: Added get_secret import, replaced os.getenv()
  with get_secret() for WEIXIN_ACCOUNT_ID, WEIXIN_TOKEN, WEIXIN_BASE_URL,
  WEIXIN_CDN_BASE_URL in WeixinAdapter.__init__() and send_weixin_direct()
- tools/send_message_tool.py: Added get_secret import, replaced os.getenv()
  with get_secret() for all WEIXIN_* fallbacks in _handle_send()

All runtime Weixin send paths now resolve both credentials and endpoint
configuration from the same profile-scoped source.
2026-07-16 05:39:58 -07:00
Agung Subastian
8fc989b416 fix(gateway): multiplex secret_scope for authz, Slack, webhooks
Secondary profiles under gateway multiplex keep tokens/allowlists in
profile secret_scope, not process os.environ. Auth and Slack were still
reading os.getenv, so Slack on a secondary profile failed allowlist and
socket mode. Webhook deliver also only looked at default adapters.

- Prefer get_secret for allowlists / allow-all flags (authz_mixin)
- Slack app token + allowlist via secret_scope with getenv fallback
- Wrap secondary profile message handlers in _profile_runtime_scope
  before auth runs
- Resolve home-channel env from secret_scope / PlatformConfig
- Webhook deliver falls back to _profile_adapters for target platform
- Template key event_type for webhook prompts
2026-07-16 05:39:58 -07:00
Teknium
164bca658e
fix(gateway): bind api_server directly instead of pre-probing 127.0.0.1 (#65621)
The single-family pre-probe (_port_is_available) raced the real bind and
reported a lingering TIME_WAIT socket as 'in use', failing gateway
restarts for up to ~60s (#10297). Port the webhook adapter's bind
mechanics (#63711/#65482): delete the probe, bind directly with clean
OSError handling and runner teardown, and scope reuse_address=False to
macOS only so Linux restarts rebind past TIME_WAIT instantly.

Credit to @lrawnsley (#10297) for identifying the TIME_WAIT restart
failure.
2026-07-16 05:39:43 -07:00
Teknium
2fd36b17c5 fix(gateway): deliver MEDIA: tags for every file type via validated egress
MEDIA: tags whose path had an unknown extension (.py, .log, .toml,
.weirdext, ...) fell between both extraction passes: the anchored
extension allowlist (MEDIA_TAG_CLEANUP_RE) did not match them, and the
extension-less pass explicitly skipped any path that HAD a suffix. The
file was never delivered even though the intended design (universal
ingress/egress) says any non-credential file should ship.

Widen _path_lacks_deliverable_extension() so the validated delivery
pass (MEDIA_EXTENSIONLESS_TAG_RE + validate_media_delivery_path)
covers every path the extension allowlist does not — unknown
extensions and extension-less files alike. Security posture is
unchanged: unknown-extension paths only deliver after full validation
(exists, symlinks resolved, credential/system denylist, strict-mode
allowlist+recency), and unvalidated tags stay visible in the text
instead of being silently dropped. Known extensions keep their
unconditional pre-existing behavior.

Because extract_media, _strip_media_tag_directives (non-streaming
dispatch), and strip_media_directives_for_display (streaming) all
share the same two regexes + predicate, all delivery paths pick up the
widened behavior with no per-site changes. Dispatch partition in
gateway/run.py already routes non-image/video extensions through
send_document.

Closes the gap reported in PR #36060; supersedes the allowlist-append
approach there (an extension allowlist can never enumerate every file
type a user asks the agent to produce).

Co-authored-by: Randimt <randimt@users.noreply.github.com>
2026-07-16 04:23:59 -07:00
HexLab98
7aa21e3362 fix(gateway): add api_server /p/<profile>/ multiplex routing
Docs and MultiplexConfigError already promise secondary profiles are
served through the shared listener's /p/<profile>/ prefix, but only the
webhook adapter registered those routes — api_server returned 404.
Mirror every HTTP route, validate the prefix, and scope agent runs /
session DB / model listing to the target profile.
2026-07-16 15:59:45 +05:30
Teknium
9420ad946a
fix(webhook): scope reuse_address=False to macOS only (#65482)
On Linux, SO_REUSEADDR only allows rebinding past TIME_WAIT (a second
live listener would need SO_REUSEPORT, which we never set), so
disabling it bought no protection there while making a quick gateway
restart fail to rebind for up to ~60s. Keep the BSD silent-split guard
on darwin, default semantics elsewhere.

E2E verified: dual-stack v4+v6 bind on one port, immediate rebind
after disconnect, and live-listener conflict still rejected.
2026-07-16 01:28:18 -07:00