8523402db00a9f477dd2ee28cfaf974aec3ed555
1696 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
78b98032c5 |
fix(telegram): key the deaf-ingress report on dispatcher progress, not update age
Follow-up to the cherry-picked #102383 commit. The check as written was neither sensitive nor specific: - It aged the newest received update, so a wedged PTB dispatcher was never reported while new updates kept arriving more often than every 300s (probe: 1 update/250s for an hour -> 0 reports). - `delivered` counted only MessageEvents reaching the gateway handler, while `received`/`dispatched` counted every Update; a single handled callback_query, reaction, unauthorized user or unmentioned group message produced a false ERROR after 300s of quiet. - `_record_updates_received` skipped the generation/teardown guard `_record_polling_progress` applies, and the counters never reset across polling generations, so a late response from a fenced poll or a reconnect inflated the backlog. Now `received` and `dispatched` count the same population (every fetched update reaches the group-99 catch-all) and the report fires when a backlog persists with no dispatch progress across two 90s heartbeats, once per stall, re-armed on progress, reset per generation, at WARNING (diagnostic only; #71240 owns recovery). The delivered counter and the `note_inbound_delivered` facade method are dropped; the once-per-adapter "no message handler" error on BasePlatformAdapter.handle_message stays. `_record_polling_progress` returns whether the round-trip was accepted so the received stamp reuses its gate. Tests trimmed to two invariants; every guard proven red by mutation. Refs #102260 |
||
|
|
db407dd078 |
fix(telegram): report a healthy-but-deaf ingress instead of nothing (#102260)
Every Telegram health probe measures the transport. A getUpdates round-trip that returns 200 proves bytes are moving and nothing else: the stall watchdog (#92991), the pending-update probe (#42909/#55769), the get_me() heartbeat (#66377) and the polling-progress instrumentation all stay green while updates arrive and then die downstream. The adapter then publishes "connected", logs nothing at all, and is indistinguishable from a bot nobody has messaged. That is #102260: three weeks of telegram.state "connected" plus "polling confirmed healthy: getUpdates progressing (generation 1)" with zero inbound reaching the agent, surviving every restart. Two of the issue's three hypotheses do not hold on this code — _record_polling_progress fires on every round-trip (not only at start_polling), and _send_path_degraded is cleared on the first confirmed round-trip — and the reporter's own observation that fresh messages are received but not processed places the failure downstream of the transport, in the one stretch with no instrumentation at all. Add the missing delivered side of the accounting: - received: updates Telegram handed the process, read from the getUpdates envelope the adapter already parses (an empty result proves the transport, not arrival, so only non-empty results count). - dispatched: updates PTB's dispatcher carried through the whole handler chain, stamped in the existing group-99 catch-all before its early returns. - delivered: inbound events that reached the gateway's message handler, stamped in BasePlatformAdapter.handle_message for every platform. _check_ingress_delivery_gap runs on the existing heartbeat and, when updates arrived but nothing was delivered for 300s, names the broken hop: received > dispatched means the dispatcher is not draining, dispatched > delivered means Hermes is dropping what arrives. Diagnostic only — a received update legitimately reaches no gateway turn, and reconnecting a healthy transport cannot repair a dropped update, so this never drives recovery. Also make the silent discard on the shared funnel speak: handle_message returned with no log when no message handler was installed, so a mis-wired adapter discarded 100% of inbound while connected and able to send. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pT5hFJBRfLj8KMqFhm3qz |
||
|
|
008caa88a2 |
fix(platforms): extra_or_secret keeps blank-string YAML values where the old readers did
dingtalk _extra_get, mattermost _extra_or_env and slack _extra_or_env_flag/_channel_set fell through to env only on None, so `allowed_channels: ""` / `free_response_channels: ""` meant "no whitelist" rather than "use the env CSV". The shared reader treated blank as unset and silently widened those to the env value. New `blank_is_unset=False` knob restores the old semantics at those seven call sites; the default (blank = unset) stays for the readers whose old body was `extra.get(k) or env`. |
||
|
|
c0d7b05faa |
fix(whatsapp_cloud): allowlist "*" admits DM intake again
The mixin routes DM intake through _is_dm_allowed, and the Cloud override was a bare
wa_id set lookup with no "*" handling, so allow_from={"*"} (documented for
WHATSAPP_ALLOWED_USERS, inherited by Cloud) went from admitting intake on main to
denying it. Cloud now overrides _entry_matches instead: bare-wa_id membership, then
the shared WhatsApp matcher ("*" + aliases) — one predicate for strict DM auth, DM
intake and groups. whatsapp_cloud joins the parity matrix; a new wildcard row covers
every wildcard host on all three paths (sabotage: revert -> [whatsapp_cloud] fails).
|
||
|
|
7370c82c2d |
fix(wecom): WECOM_ALLOW_ALL_USERS opens DMs again; mixin refuses hosts with no env prefix
WeComAdapter mixed in OwnAccessPolicyMixin without ALLOW_ALL_ENV_PREFIX, so _allow_all_env_names() read "_ALLOW_ALL_USERS" and every open-DM WeCom deployment (setup still writes WECOM_ALLOW_ALL_USERS) silently denied all DMs. - WeComAdapter.ALLOW_ALL_ENV_PREFIX = "WECOM" - OwnAccessPolicyMixin.__init_subclass__ raises TypeError on an empty prefix so the omission cannot ship again (every other host already sets one). - Parity test now runs a third matrix row that sets each host's own <PREFIX>_ALLOW_ALL_USERS; the GATEWAY-only row is why this slipped through. Sabotage: prefix removed -> [platform] row fails even with the guard reverted. |
||
|
|
de114b3af1 |
refactor(platforms): one scoped-secret reader and spec-driven enablement/YAML-bridge boilerplate across all adapters
Scoped secrets — `gateway.platforms._shared.get_scoped_secret` is the single implementation of the "scope authoritative, unscoped default-profile falls back to os.environ" read: - plugins/platforms/buzz/adapter.py::_get_scoped_secret (113 LOC, ~100 of which were one docstring paragraph pasted 16x) -> 3-line forwarder over the canonical with `external_fallback=True`. Its one genuine extra rung (one-shot profile-scope build so a Bitwarden-managed key is visible to the startup gate, #95216) moves into `_shared` as that keyword plus `_unscoped_profile_secrets`. - weixin::_wx_secret, matrix::_startup_env_secret, the inline try/except copies in slack (SLACK_APP_TOKEN) and telegram (TELEGRAM_WEBHOOK_SECRET/_URL) -> canonical. - The "extra-first, then scoped env" reader written 11x under 6 names (weixin._extra_or_env, bluebubbles/ntfy/photon/wecom `_setting`, dingtalk `_extra_get`, mattermost `_extra_or_env`, slack `_extra_or_env_flag/_channel_set`, feishu closures) -> `_shared.extra_or_secret`. - `authz_mixin._platform_gate_env` -> `_shared.platform_gate_env`; discord/telegram drop their `_scoped_gate_env` twins; run.py / run_config_loaders.py / slack import it directly. Boilerplate — three table-driven helpers in `_shared` replace the pasted docs template: - `seed_extra_from_env(spec, home_env=)` replaces 8 `_env_enablement` bodies (buzz, google_chat, irc, line, ntfy, photon, simplex, teams; raft is a one-liner and untouched). - `apply_yaml_bridge(cfg, spec)` replaces 7 `_apply_yaml_config` bodies (buzz, dingtalk, feishu, matrix, mattermost, slack, whatsapp); discord/telegram keep bespoke bridges (alias keys, nested `platforms.*.extra`, generic-key exclusions). buzz and mattermost previously bypassed `yaml_env_setter` with hand-rolled `os.environ` writes. - `env_is_connected(*vars)` replaces 5 identical `_is_connected` (discord, homeassistant, mattermost, slack, sms). - 8 identity `_build_adapter` wrappers deleted; `adapter_factory=<Class>`. Behavior change: - buzz `_apply_yaml_config` returned None, so under multiplex a secondary Buzz profile got neither env (correctly skipped) nor `extra` for relay_url/channels/allow_all_users/...; it now seeds `extra` like every other hook. It also wrote reply_in_thread/reply_to_mode to the process env even inside a secondary profile's scope (first-writer-wins leak, #80099 class); it no longer does. BUZZ_POLL_INTERVAL is bridged through the same table. - `home_channel.name` default when `<X>_HOME_CHANNEL_NAME` is unset is now the literal "Home" for all plugins (irc/ntfy/buzz used the chat id; simplex/teams/photon/google_chat already used "Home", as do the built-in platforms in gateway/config_env.py). - weixin's non-secret tunables (send_chunk_*, rate_limit_circuit_*) now read through the scoped reader instead of raw os.getenv — a secondary profile no longer inherits the default's values. - `extra_or_secret` treats a blank string in extra as unset (falls to env) and an explicit False as a real value, the strictest of the merged copies. - slack `reaction_trigger_target` bridges via str(); `reaction_triggers` comma-joins any list-ish value (was list/tuple/set only) — same env text for every real YAML shape. Docs: website/docs/developer-guide/adding-platform-adapters.md (the template the copies were pasted from) and gateway/platforms/ADDING_A_PLATFORM.md now show the helpers and the scoped reader; gateway/AGENTS.md points at the one implementation. Tests: tests/gateway/test_shared_platform_boilerplate.py — every plugin `_env_enablement` reads only through the scoped getter (parametrized over the 8 plugins, spy on the seam, raw `os.getenv`/`get_env_value` asserted untouched); buzz bridge seeds `extra` for a secondary profile and still bridges env for the default; one home-name rule; extra_or_secret contract; external_fallback rung. Existing tests repointed: tests/agent/test_secret_scope_tier1_migration.py, tests/plugins/platforms/buzz/test_buzz_unscoped_requirement_gate.py. |
||
|
|
9b1990583d |
refactor(gateway): adapters share helpers.cancel_task / MessageDeduplicator / bounded_put
Six adapters defined their own `_cancel_task` and nine more inlined the same
cancel + suppress(CancelledError) + await block; five kept a hand-rolled TTL-dict
`_is_duplicate` next to the existing `helpers.MessageDeduplicator`; three carried a
`_bounded_put`. Each copy fixed the same bugs on its own schedule (self-cancel deadlock,
done-task re-await, eviction under load).
- `helpers.cancel_task`: None/done no-op, never awaits the current task, swallows the
task's own exception at teardown. Replaces qqbot/signal/yuanbao/buzz/photon/simplex
definitions and the inline copies in weixin, discord, email, irc, line, mattermost,
whatsapp and telegram.
- `helpers.MessageDeduplicator` replaces `_is_duplicate` in qqbot, ntfy, photon,
wecom_callback and LINE's `_MessageDeduplicator`; every site keeps its own
max_size/TTL (qqbot and ntfy 1000/300s, photon 4000/48h, wecom_callback 2000/300s,
LINE 1000/no TTL).
- `helpers.bounded_put` replaces photon/wecom/whatsapp_cloud copies; a re-put now
refreshes the key to the newest slot at every site.
- telegram gmail-triage scripts resolve under `get_hermes_home()` instead of a hard
`~/.hermes`, so profiles with HERMES_HOME set find them.
Not changed: `get_chat_info` stays `@abstractmethod` because
tests/gateway/test_relay_capability_surface.py locks the abstract set to exactly
{connect, disconnect, send, get_chat_info} as a cross-repo contract, so the ~17 no-op
overrides remain.
Behavior change: whatsapp_cloud `_bounded_put` was a pure FIFO (no refresh on re-put);
it now refreshes like the other two sites. Task cancellation at the migrated sites
swallows a task's terminal exception where a few copies previously only suppressed
CancelledError (all are shutdown/disconnect paths).
|
||
|
|
3a179fe524 |
refactor(gateway): every Ogg/Opus voice transcode routes through base.transcode_to_ogg_opus
Matrix, WhatsApp Cloud and the TTS tool each ran their own ffmpeg argv for the same speech-tuned libopus encode; they predate the shared helper and never migrated, so the codec flags, timeout handling and error reporting drifted (matrix 48k/30s, whatsapp_cloud async subprocess with no timeout and no `-ac 1`, tts an in-place sidecar repair). `transcode_to_ogg_opus` gains `timeout=` and `output_path=` (sibling-file and in-place writes go through a `.tmp.ogg` sidecar so a failed encode never truncates the source). Deleted: matrix `_matrix_transcode_voice_to_ogg`, tts `_ffmpeg_transcode_to_opus`; whatsapp_cloud `_convert_to_opus` keeps only its warn-once ffmpeg install hint and calls the helper via `asyncio.to_thread`. Matrix and tts keep their 48k bitrate. Behavior change: whatsapp_cloud transcodes now use mono (`-ac 1`), `-compression_level 10` and a 60s timeout like every other voice bubble; a failed encode logs at WARNING for all three sites (matrix previously DEBUG). |
||
|
|
8185834bc9 |
refactor(gateway): one OwnAccessPolicyMixin replaces five copied DM/group intake predicates
Weixin, WeCom, QQBot, WhatsApp (common + cloud) and Yuanbao's AccessPolicy each carried their own `_open_dm_opted_in` / `_is_dm_allowed` / `_is_dm_intake_allowed` / `_is_group_allowed`, differing only in the platform prefix of the allow-all env var and in small drifts. The same "allow-all must be scoped / fail closed" fix has landed on this trio at least four times; a shared rule means it lands once. gateway/platforms/access_policy_mixin.py::OwnAccessPolicyMixin owns the predicates, reads every env name through the scoped `get_scoped_secret` and exposes two hooks: `_entry_matches` (platform allowlist matching) and `_live_dm_allow_from` (env-seeded lists re-read live). `ALLOW_ALL_ENV_PREFIX` is the only per-adapter datum. Retained overrides (behavior genuinely differs): - whatsapp_cloud `_allow_all_env_names` adds WHATSAPP_CLOUD_ALLOW_ALL_USERS; `_is_dm_allowed` keeps its bare-wa_id normalisation. - whatsapp_common `_entry_matches` -> phone/LID alias matching; `_live_dm_allow_from`. - wecom `_is_group_allowed(chat_id, sender_id)` adds the per-group sender allowlist on top of the shared chat-level rule; `_entry_matches` strips `wecom:user:` prefixes. - qqbot `_entry_matches` (case-insensitive, `*`). - yuanbao `AccessPolicy.is_group_allowed`: `open` groups still require the allow-all opt-in (no runner-side mention gate), so it wraps the shared rule. Behavior change: Weixin `_is_dm_intake_allowed` now denies a blank/whitespace principal (the other four already did; safe variant chosen). Weixin's inline group gate now goes through `_is_group_allowed` with identical verdicts. |
||
|
|
81d77c280a |
refactor(platforms): photon rides the base send-retry loop; slack/discord parse Retry-After with the shared parser
Photon forked BasePlatformAdapter._send_with_retry before three fixes landed there: the server's retry_after is honoured over exponential backoff, a long server penalty (> 60s) returns a typed failure instead of sleeping inline (#91969), and an exhausted rate-limited send no longer posts the failure notice inside the flood penalty. The fork got none of them. Photon's two genuine differences are now hooks on the base — `_send_retry_is_final(result)` (structured auth/target refusals are returned as-is, no retry, no plain-text resend) and `_send_plain_fallback(...)` (no Markdown banner, richlink() bypassed) — and the 42-line fork is deleted. Slack's `_retry_after_from_exc` and Discord's `_extract_discord_retry_after` parsed the header by hand and only understood the numeric form; both now call agent.retry_utils.parse_retry_after_seconds (numeric or HTTP-date, either header casing). Discord keeps its `retry_after` attribute path, the `X-RateLimit-Reset-After` fallback and the 1s floor. Behavior change: Photon retries now add up to 1s of jitter to the backoff and honour a server retry_after; Slack/Discord recognise an HTTP-date Retry-After they previously ignored. |
||
|
|
73eadd54f5 |
fix(platforms): standalone senders return redacted error envelopes
20 `plugins/platforms/*/adapter.py::_standalone_send` paths (the out-of-process cron /
send_message delivery) built `{"error": f"... {e}"}` by hand — 83 literals. The exception text
of an httpx/aiohttp failure can carry the Authorization header, a signed URL or a response body
with the token in it, and that string became the tool result the model reads. Only sms went
through the redacting `tools.send_message_senders._error`; discord kept a private regex that
only knew `Authorization: Bot`.
`gateway.platforms._shared.send_error(message)` wraps that helper (agent.redact +
URL-secret scrub) and every standalone literal now goes through it, including the three
envelopes that carry extra keys (discord warnings, photon error_class/retryable, whatsapp's
`(None, err)` tuple). The sms and discord local wrappers are deleted. Telegram already
delegated to the core sender and is untouched.
Behavior change (security): vendor exception text in standalone-send failures is redacted
before reaching the model.
|
||
|
|
ad305bead5 |
refactor(gateway): send_exec_approval is a base template method; 9 adapters only render buttons
Nine surfaces (feishu, teams, slack, telegram, whatsapp_cloud, qqbot, matrix, discord, relay) each re-derived the approval choice set — [Allow Once]; session + always unless smart-denied; [Deny] — and four of them (discord, slack, teams, whatsapp_cloud) never adopted base._format_exec_approval, so header/reason/smart-deny wording and truncation budgets drifted per adapter. Three separate commits had to touch 5–9 adapters for one semantic fix. BasePlatformAdapter.send_exec_approval now builds an ExecApprovalPrompt (shared text via _format_exec_approval, shared `(label, choice, style)` rows via _exec_approval_actions) and hands it to the `_send_exec_approval_prompt` hook. Each adapter keeps only its widget mapping (~10–20 LOC); platform wording stays via the existing `_EA_*` class attrs, and a new `_exec_approval_cmd_budget` hook lets Slack/Discord budget the command against their hard message caps (3000-char section / 2000-char message) instead of computing it inline. `_EA_REASON_BUDGET` covers Slack's 500 / Discord's 300 reason caps. The runner used to detect button support by `hasattr(type(adapter), "send_exec_approval")`; that is now true for every adapter, so `_renders_exec_approval_buttons` asks `supports_exec_approval_buttons()` (hook overridden?) and keeps the duck-typed check for non-BasePlatformAdapter classes. Visible text changes (button semantics unchanged everywhere): - Discord: the smart-deny line now follows the reason (was inside the header before the fence); the truncation marker is "..." not "\n... [truncated]". - Slack: smart-deny line follows the reason instead of the header. - Teams: unchanged (same 2000-char preview, same smart-deny block). - WhatsApp Cloud: identical text; body still capped at 1024. - QQBot/relay: unchanged. |
||
|
|
aac36fd96f |
refactor(gateway): text-batch flush lives on BasePlatformAdapter; shield + cancel-race fixes reach all 8 adapters
Eight adapters (discord, telegram, wecom, matrix, whatsapp, simplex, feishu, weixin) each kept a copy of the delayed text-batch flush that base._enqueue_text_event schedules. Two correctness fixes had landed in single copies only: Discord's asyncio.shield around the dispatch (#12444 — a late chunk cancelling the flush task aborted the in-flight agent turn) and WeCom/Weixin's synchronous task-identity check before the pop (a superseded task waking late popped the event and the successor found nothing). The other adapters carried both bugs latent. The base now owns `_flush_text_batch` with both fixes, plus the `_pending_text_batches` / `_pending_text_batch_tasks` dicts and `_SPLIT_THRESHOLD` / delay attrs (defaults; adapters set their own). Platform policy goes through three small hooks instead of a copied body: `_text_batch_delay_for(pending)` (Telegram's fast/short tiers, WeCom's attachment-only wait), `_pop_text_batch(key)` (Feishu's side count table) and `_dispatch_text_batch(event)` (Feishu's per-chat lock). Telegram keeps its `_flush_buffered` body because its contract differs on purpose — a cancel after the pop must hold-and-re-raise so teardown can stop a flush — and gains the identity check there. Matrix's `_split_threshold` is renamed to the shared `_SPLIT_THRESHOLD`; SimpleX exposes its single delay through the shared attr names. `helpers.TextBatchAggregator` (zero users) sits inside the revert-scheduled PLUGIN-COMPAT block and is left for that revert. |
||
|
|
991b23ad8d |
refactor(sessions): one session-id minter; QQ update-prompt key from build_session_key
Nine f-string sites minted `YYYYMMDD_HHMMSS_<hex>` independently with the hex width already drifted (6 on CLI/TUI/agent/import, 8 in the gateway store, 12 in portability imports). hermes_cli/session_lost_and_found.py classifies schema-less salvage rows by that shape, so a site drifting the prefix would silently change recovery. hermes_state_ids.new_session_id(now, hex_len=) is now the only writer and owns SESSION_ID_PATTERN; stdlib-only so agent/, cli.py and gateway/ can import it without the SessionDB graph. Widths are kept per site on purpose: the Desktop's session-id candidate regex is pinned to 6 hex chars for interactive ids; the gateway store and portability importer keep 8/12 (more rows per second). Not a bug, so not "fixed". gateway/platforms/qqbot/adapter.py hard-coded `agent:main:qqbot:<scene>:<chat>` for the update-prompt authz key, ignoring the profile namespace build_session_key applies; a secondary bot in a multiplexed gateway got `agent:<profile>:...` keys and its clicks were rejected. The key now comes from the one builder via BasePlatformAdapter._source_session_key. Behavior change: QQ update-prompt clicks are authorized under the profile-namespaced key (byte-identical `agent:main:` for the default profile). |
||
|
|
164c5ea142 |
fix(agent): LLM traffic honours CIDR and wildcard NO_PROXY entries like the platform adapters do
Three answers to "is this host in NO_PROXY": process_bootstrap used the stdlib proxy_bypass_environment (no CIDR, no `*.`), gateway/platforms/base.py had a full matcher (should_bypass_proxy) and a second suffix-only one (is_host_excluded_by_no_proxy, used by Slack). Live-verified: with NO_PROXY=10.0.0.0/8 Telegram bypassed the proxy while the LLM call to a 10.x endpoint went through it. The full matcher moves to the leaf module agent/proxy_bypass.py (stdlib only, importable at early boot); both base.py functions are one-line forwarders and process_bootstrap._get_proxy_for_base_url uses it (passing host:port so port-qualified entries match). The six-key proxy env scan is also shared. |
||
|
|
576accd92b |
refactor(sqlite): one open_db/transaction layer for every small store; plugin DBs use the WAL fallback
Twelve modules each carried their own sqlite3.connect + PRAGMA + `with conn:` stack. The #69567 fd-leak fix (a `with conn:` commits but never closes, so each call leaked a connection and its WAL/SHM fds until GC) was pasted as code plus docstring into six of them and hosted_room_policy_checkpoint never received it; plugins/plugin_storage.plugin_db was the only production caller issuing a raw `PRAGMA journal_mode=WAL`, bypassing the network-FS fallback, the WAL-reset-bug gate and the never-live-downgrade invariant that hermes_state_wal.apply_wal_with_fallback carries. hermes_cli/sqlite_util.py (already home to add_column_if_missing/write_txn, imported by cron, gateway and hermes_cli alike) gains `open_db(path, *, db_label, busy_timeout_ms, wal, foreign_keys, synchronous_full, row_factory, check_same_thread, wal_lock_retries, initialize)` and `transaction(conn, immediate=)`; cron/ledger.py is deleted and hosted_rooms_common's open_sqlite/connect/transaction become 1-3 line forwarders. Migrated: agent/verification_evidence, cron/{executions,incidents,notepad, delivery_queue}, gateway/{delivery_ledger,hosted_room_policy_checkpoint, hosted_rooms_common (-> hosted_rooms, hosted_room_driver)}, hermes_cli/ projects_db, tools/async_delegation, plugins/plugin_storage. Behavior changes (each module keeps its effective PRAGMA set otherwise): - hosted_room_policy_checkpoint: connection now closed after every use and on init failure (was leaked per call), busy_timeout PRAGMA set explicitly. - projects_db: gains busy_timeout=5000 (was the sqlite3 default 5 s connect timeout with no PRAGMA); explicit and observable. - delivery_ledger / async_delegation: busy_timeout PRAGMA now mirrors the 10 s connect timeout they already had. - plugin_storage.plugin_db: WAL through apply_wal_with_fallback (DELETE on network filesystems / WAL-reset-vulnerable builds instead of raw WAL); busy_timeout=5000. - cron/incidents._redact_error: redact_sensitive_text(force=True) — the error text is persisted to disk. - delivery_ledger's private duplicate-column guard and the unguarded `ALTER TABLE ADD COLUMN` sites (shared_metrics, api_server_run_idempotency, holographic store, kanban model_override) go through add_column_if_missing. - hermes_state.py::_scrub_surrogates: dead byte-copy of hermes_state_messages._scrub_surrogates (0 callers) deleted. |
||
|
|
6a66a5d481 |
fix(desktop,dashboard): served profile's api_server/webhook read connected with their /p/<profile>/ URL; shared-gateway restart asks first
Under gateway.multiplex_profiles a secondary's api_server and webhook are never built as
adapters (run_adapters skips SHARED_LISTENER_MIRROR_PLATFORMS: the default's listener answers
/p/<profile>/...). The multiplexer record therefore has no `<profile>:api_server` entry,
profile_platforms_from_multiplexer() returned {} for them and both /api/messaging/platforms
and /api/status?profile= fell through to `pending_restart`: the Desktop Messaging card and
Command Center said "Restart needed" forever for a platform that was answering.
- gateway.status.shared_listener_mirror_platforms projects the default's LIVE api_server /
webhook entry onto every served secondary with `ingress_url` = `<listener>/p/<profile>/v1`
(`.../webhooks/<route>`); a dead default listener is not mirrored. The api_server / webhook
adapters stamp the listener they actually bound (`listener_base`) on connect so the URL is
the real one, not a config guess. `hermes status` lists those URLs beside the other
shared-ingress platforms.
- /api/status?profile= reports `gateway_shared_with` (every profile the multiplexer carries)
when the served rung answered; null for a standalone gateway.
- Desktop: the messaging card shows the URL line; "Restart gateway" from a served profile
(statusbar menu, Cmd+K, messaging/webhooks banners, Command Center) confirms "Restart the
shared gateway? All bots on this device reconnect: default, alpha, beta" (Restart all /
Cancel) and toasts "Shared gateway restarted (3 bots)". Standalone keeps the silent path.
- Dashboard: same confirm + toast on the System page and the sidebar restart; the 409 from
start/stop on a served profile renders as an inline notice instead of a raw error toast.
|
||
|
|
ac54e5e715 |
fix(api): forked sessions carry _branched_from so they stay listable
Review finding: with the child now created before the parent is ended, child.started_at < parent.ended_at, so _BRANCH_CHILD_SQL's timestamp fallback no longer classifies the fork as a branch child and the default GET /api/sessions dropped it. Persist the explicit marker the CLI /branch path already writes; test covers listing + the failed-fork parent survival. |
||
|
|
d0093a11f0 |
fix(sessions): /branch and API fork end the parent only after the child exists
Both paths ended the source session as "branched" before create_session ran, so a failed create left the user on a session already marked ended with no branch behind it. Create the child first; the parent is ended only once the branch is real. Salvage of #11048 (targeted the pre-split cli.py handler; ported to hermes_cli/cli_commands_mixin.py and the api_server fork sibling); authored by @vominh1919. Refs #11030 |
||
|
|
70d0f556d7 |
fix(branding): use the Caduceus ☤ (U+2624), not the Rod of Asclepius ⚕ (U+2625)
Every inline glyph — CLI banner/status bar/response labels/goodbye, setup and doctor boxes, gateway update prompts, WhatsApp reply prefix, TUI theme, locale strings and the docs — used ⚕, the staff of Asclepius (medicine). Hermes carries the Caduceus ☤. The ASCII-art logo was already correct. Mechanical swap across 60 files (no logic change); both glyphs are East-Asian-width Neutral so no layout shifts. Skins that set their own `response_label` / `goodbye` are unaffected. Direction from PR #7064 (@bixycler), the earliest of #7064 / #9611 / #15574, redone against current main. Fixes #9565 |
||
|
|
21bc1d3775 |
fix(api-server): trim RequestKey fallback comment, add import-isolation test
The salvaged comment restated the symptom at length; keep only the WHY. Drop the `# pragma: no cover` markers (the repo does not gate on coverage). One invariant test: with aiohttp.web_request lacking RequestKey, `web` stays bound to the aiohttp module and RequestKey is None (red on origin/main). |
||
|
|
943e04ba2b |
fix(gateway): keep aiohttp web import alive when RequestKey is unavailable
On aiohttp < 3.14 the RequestKey import fails and the shared except clause also resets the already-imported web module to None, so every admission reply (non-streaming POST /v1/runs) raised AttributeError: 'NoneType' object has no attribute 'json_response' and surfaced as HTTP 500. Import the two names in separate try/except blocks; RequestKey already has None-guards at its use sites. |
||
|
|
d76856cc69 |
fix(migrate): shared-ingress adapters declare serves_profile_prefix so migration reports them as notices
#108952 taught sms/line/teams/bluebubbles/whatsapp_cloud/msgraph_webhook/feishu/wecom-callback to serve a secondary at /p/<profile>/ on the default listener; #108928's preflight derives its port-binder blocker from the adapter class's serves_profile_prefix flag, which those adapters never set. Merged together, migrate would have blocked every profile the ingress work just unblocked. Declare the flag on each shared-ingress adapter and run plugin discovery before consulting the registry (plugin adapters are absent from a bare CLI process otherwise). |
||
|
|
6fc76ce751 |
feat(platforms): bind through shared_ingress.bind_listener in every inbound-port adapter
sms, line, teams, bluebubbles, msgraph_webhook, whatsapp_cloud, wecom_callback and feishu (webhook mode) build their aiohttp app exactly as before and hand it to bind_listener(): standalone and default-profile behaviour is unchanged (same host, port, reuse_address, access_log), while a multiplex secondary publishes the app for /p/<profile>/ forwarding instead of binding. BlueBubbles registers the /p/<profile>/ URL with its server; LINE builds media URLs off the shared listener's prefix when no LINE_PUBLIC_URL is set; WeCom skips its own port-in-use probe in shared mode. |
||
|
|
c0d8d6b5d5 |
feat(multiplex): serve secondary profiles' inbound-port platforms on the default's shared listener
Under gateway.multiplex_profiles a secondary profile with Twilio / LINE / Teams / BlueBubbles / Microsoft Graph / WhatsApp Cloud / WeCom-callback / Feishu-webhook credentials was refused WHOLE at config load (SecondaryPortBindingConfigError): every one of its platforms was skipped because these adapters bind their own port and the default profile owns the one listener. The refusal is gone. gateway/platforms/shared_ingress.py gives port-binding adapters a shared-listener mode: the runner stamps `_shared_listener_profile` on a secondary's port-binder, `bind_listener()` publishes the adapter's fully wired aiohttp app instead of starting a TCPSite, and the default listener (api_server, or the webhook adapter when there is no api_server) forwards `/p/<profile>/<tail>` to the served profile's adapter whose router matches `/<tail>`, under that profile's runtime scope. The request is therefore verified by the NAMED profile's adapter with its own secret and replies leave through that adapter; the un-prefixed path keeps serving the default byte for byte; an unknown profile or a profile without an adapter for the path is a 404, never another profile's bot. api_server and webhook stay MIRRORS (the default's own adapter answers /p/<profile>/ for them) and are the only port-binders a secondary must not enable; `SHARED_LISTENER_MIRROR_PLATFORMS` is that set in gateway/config.py. The adapter records its public callback URL (`ingress_url`) in gateway_state.json under `<profile>:<platform>` and logs it once at connect, so the operator knows what to paste into the vendor console. |
||
|
|
bcdb49ac7b |
feat(gateway): adapters declare serves_profile_prefix for /p/<profile>/ ingress
The multiplexer skips a secondary profile that enables a port-binding platform, unless the default listener already answers that platform under /p/<profile>/. Which adapters do is now a class attribute on the adapter (api_server and webhook today) instead of knowledge scattered in prose, so the migration preflight can tell "URL changes" from "profile would be skipped" and stays correct as new HTTP-inbound adapters gain the prefix. |
||
|
|
208bd0b65a |
fix(multiplex): per-profile plugin caches and the Yuanbao active adapter
Ramp Router efforts cache + warm/disk flags, xAI and OpenRouter image catalogs, Hindsight append-capability verdict, memory-provider skill registry, OpenViking atexit provider, Honcho loopback flow status, Langfuse client (os.environ-only credentials) and disk-cleanup's protected cron paths held one profile's credential- or home-derived value process-wide; YuanbaoAdapter._active_instance was last-connected-wins across profiles. Keyed by home key / credential fingerprint under an override, credentials read through the secret scope, warm threads run under copy_context(); unscoped module slots stay for the single-profile path and the existing monkeypatch tests. |
||
|
|
9848e22ed6 |
feat(multiplex)!: drop gateway.multiplex_profile_allowlist — serve every profile
The multiplexing default gateway now serves default + every live named profile under profiles/. profiles_to_serve(multiplex=True) is a pure directory read (tombstoned profiles skipped, never mkdir); every reader — gateway served set, /p/<profile>/ prefixes for api_server + webhook, the named-profile standalone guard, the Desktop cron ticker (its #108428 standdown for a profile owned by a running gateway is unchanged) — drops the allowlist parameter. Config v43 migration deletes the key from user config.yaml; DEFAULT_CONFIG, GatewayConfig and the top-level yaml bridge no longer carry it. BREAKING: anyone who set an allowlist now has their excluded profiles served. Archive or delete a profile you do not want served (Teknium approved). |
||
|
|
055e307b48 |
refactor(api_server): trim the idempotency-scope salvage to shape gates
Keep the two invariants (cross-profile isolation, same-namespace dedup) and a short docstring; the composite key already covers route and principal rotation. |
||
|
|
3d59486a86 |
fix(gateway): fold route + authenticated principal into idempotency key
Review on this PR (tracked issue #84256) found the profile-only fix left two more dimensions of the shared OpenAI-compat Idempotency-Key cache unscoped: 1. No endpoint/route discriminator. _run_idempotent() is shared by both POST /v1/chat/completions and POST /v1/responses, but _IdempotencyCache ._store keeps the fingerprint only as a value inside the key's slot — not part of the key itself. A client reusing one Idempotency-Key across both routes would have the second route's settled response silently overwrite the first route's storage slot, so a legitimate retry of the first request would recompute instead of replaying its own cached result. Each call site now passes a stable logical route discriminator ("chat_completions" / "responses") folded directly into the cache key, rather than relying on request.path (which would wrongly split /v1/... and its /p/<profile>/v1/... alias into different namespaces). 2. No authenticated principal. The sibling durable /v1/runs admission API's _run_idempotency_scope() already composes sha256(profile \0 expected-api-key-or-sentinel) for its non-room case. _run_idempotent() now delegates directly to self._run_idempotency_scope(request) for its principal/profile scope instead of re-deriving the profile alone, so both endpoints share one definition of "who is this request for" and a caller whose API_SERVER_KEY changes mid-cache-lifetime can no longer replay a previous principal's cached response. Adds regression tests proving: route isolation (including the exact chat -> responses -> chat retry scenario from the review), concurrent cross-profile inflight isolation, principal/key-rotation isolation, and that same-route/same-profile/same-principal dedupe is unchanged. Mutation-verified with three independent negative controls: reverting the whole diff (TypeError on the now-required route kwarg), keeping route as a parameter but dropping it from the key (only the two route-isolation tests fail), and keeping the shared-scope call but reverting it to a bare profile lookup (only the two principal-isolation tests fail). |
||
|
|
2b7eb97fbf |
fix(gateway): scope the OpenAI-compat Idempotency-Key cache by profile
_run_idempotent() (chat completions / responses) keyed the shared _idem_cache purely on the client-supplied Idempotency-Key header plus a body fingerprint, with no profile identity anywhere in the key. Under gateway.multiplex_profiles, both the native routes and their /p/<profile>/ mirrors share the same process-global cache, so two different profiles' clients sending the same Idempotency-Key with a matching fingerprint (a realistic case for automation/SDKs that derive the key deterministically from request content) silently share a cached response instead of each profile running its own turn. The sibling /v1/runs admission API already scopes its own idempotency namespace this way (_run_idempotency_scope() folds in _api_request_profile.get() or "default"); this mirrors that exact expression into _run_idempotent()'s cache key. |
||
|
|
199c66f70f |
fix(gateway): adapter settings resolve per profile under multiplex, not from the default's env
Under gateway.multiplex_profiles a served secondary profile's adapter is built and connected inside _profile_runtime_scope while os.environ still holds the DEFAULT profile's .env. Credentials and allowlists were already read through the profile scope (get_scoped_secret / _platform_gate_env), but the non-credential SETTINGS the adapters read with bare os.getenv were not, so a served profile silently ran with the default profile's values: webhook listener host/port/URL (SMS, Teams, LINE, Feishu, BlueBubbles), Signal's connect URL/account gate, mention gating and reactions (Slack, Matrix, Signal, Feishu, BlueBubbles, Discord), Matrix thread/session/E2EE policy and message-length limits, Discord backfill/command-sync/attachment caps, Buzz reply mode and env enablement seed, A2A agent name/port/description/toolsets, and the api_server model alias. Every such read now goes through the existing scoped reader (get_scoped_secret, or the adapter's own scope-aware helper): under a secondary's scope the profile's own .env is authoritative and a miss yields the default -- never another profile's value; the default profile and single-profile gateways keep reading os.environ exactly as before. Buzz and A2A previously short-circuited to "extra only / built-in default" under a scope, which also dropped the profile's OWN .env; they now read the scope so a served profile matches its standalone gateway. The parity harness (temp HERMES_HOME, default + 2 secondaries with distinct values for every env var each adapter reads, real load_gateway_config + adapter factory in both topologies) went from 70 raw process-env bypass sites across 14 adapters to only the HERMES_<PLATFORM>_* perf knobs and the api_server listener vars, which are process- global by design (agent.secret_scope._GLOBAL_ENV_*). |
||
|
|
5725efaf09 |
fix(gateway): scope resolve_proxy_url()'s platform_env_var read by multiplex profile
resolve_proxy_url() read platform_env_var (TELEGRAM_PROXY, DISCORD_PROXY, MATTERMOST_PROXY, MATRIX_PROXY) via raw os.environ.get() — a shared chokepoint used by 7+ adapters. Under a secondary multiplex profile, os.environ holds the default profile's YAML-to-env bridge output, so a secondary profile with its own (different or absent) proxy config would silently borrow the default profile's proxy, or vice versa — and proxy URLs can embed credentials (http://user:pass@host). Fix: read platform_env_var through gateway.platforms._shared's get_scoped_secret(), which is scope-aware (profile's own scope first, falling back to os.environ only when unscoped/default-profile) and already used by 10+ other adapters for the same class of setting. The generic HTTPS_PROXY/HTTP_PROXY/ALL_PROXY fallback stays a raw env read — those are OS/system-level network settings, not a per-profile Hermes concept. This closes a gap PR #100448 explicitly deferred ("proxy_url is deliberately NOT scoped here ... left for a dedicated fix to that shared helper"). |
||
|
|
c8b56d43da |
fix(whatsapp): quoting the bot's own image/voice/document attaches the file
Salvage of #77660 covers quotes of INBOUND media via the bridge's download cache. The reported case is the other half: a user quotes an image the bot sent (a cron-delivered chart) and asks "what is this?" — the bridge cache knows only inbound messages, and Meta's Cloud webhook ``context`` carries only the quoted wamid, so the agent received a text-only turn and could not see the image it had itself delivered. Widen ``gateway/rich_sent_store`` (already the (chat_id, message_id) → text index Telegram and the Cloud adapter use for quoted text) with ``record_media`` / ``lookup_media``: both WhatsApp adapters index the local path + MIME of every media send at send time and every inbound media receive, and on a quoted reply fold the resolved ``(path, mime)`` into the event's own ``media_urls``/``media_types`` so the existing vision/audio pipeline handles it like a direct attachment. ``lookup_media`` drops entries whose file no longer exists. Baileys: the outbound index is the fallback when the bridge cache misses; the cache-dir guard stays on bridge-supplied paths only (our own sends are paths we chose). Cloud: link sends (public URL, no local bytes) are not indexed. One invariant test per adapter, red on origin/main. |
||
|
|
819517fbac |
fix(gateway): routed profiles get their own max_turns, fallback chain, hooks, aux auth and media policy
One multiplexed gateway process serves every profile, but several per-turn reads still went through state frozen from the LAUNCH profile: - `_current_max_iterations` re-bridged `agent.max_turns`/`sessions.*` from the module constant `_hermes_home` into one process-wide HERMES_MAX_ITERATIONS, so every secondary ran with the default profile's turn budget. A routed turn (HERMES_HOME override) now resolves `agent.max_turns` from its own config. - `_refresh_fallback_model` read `_hermes_home/config.yaml` into one runner-wide slot, so secondaries fell back through the default's provider/model with their own keys. It now reads the active gateway home and keeps a last-known-good chain per home. - `_load_prefill_messages` resolved relative paths against the launch home. - `agent/auxiliary_client._AUTH_JSON_PATH` was an import-time constant, so a secondary's compression/title/vision calls authenticated to Nous with the default profile's token when it had no pool entry. Resolved per call via `hermes_cli.auth._auth_file_path()` (patched constant still wins in tests). - `gateway/hooks.HOOKS_DIR` was frozen at import and one `HookRegistry` was loaded outside any profile scope, so secondaries' `hooks/` never ran and the default profile's handlers received every profile's messages, responses and user ids. `HOOKS_DIR` now resolves per call (salvaged from #56508) and the runner holds one registry per served home, picked from the active scope at emit time and front-loaded under each secondary's startup scope. - Shell-hook subprocesses inherited the launch `os.environ` (default HERMES_HOME and the default profile's secrets). They now get the routed HERMES_HOME via `build_subprocess_env`, scrubbed under multiplexing, and the stdin payload carries `profile` so one script can tell which profile fired it. - Media-delivery policy (`gateway.strict`, `media_delivery_allow_dirs`, `trust_recent_files*`) was bridged once into env at startup and read from env per delivery; under a HERMES_HOME override the validator now reads the routed profile's config. Single-profile runs keep the env-bridge contract. Audit: /tmp/mux_audit F3, F4, F6 (auth.json half), F7, F12 (media). Live repro (temp HERMES_HOME A with profiles/B): before, B saw max_iterations 7, fallback A/fallback, TOKEN_A, A's hooks, strict=A; after, all B's values. |
||
|
|
388b881b33 |
fix(gateway,tools): per-profile Yuanbao home, env_passthrough allowlist and Slack ignored-channel guard under multiplex
- gateway/platforms/yuanbao.py::AutoSetHomeMiddleware: the first authorized DM to a SECONDARY Yuanbao bot wrote YUANBAO_HOME_CHANNEL into os.environ, making that tenant's chat the default profile's cron/notification home. The write now only happens unscoped; reads go through the scoped reader + config. - tools/env_passthrough.py::_config_passthrough: one module slot froze the first profile's terminal.env_passthrough for every profile's sandbox children; keyed by hermes_home_key(). - gateway/run.py::_slack_ignored_channels_from_gateway_config: the runner-level fail-safe only had the DEFAULT profile's GatewayConfig, so a secondary Slack bot's traffic was judged by the default's ignored list. It now takes the source's routed adapter (whose extra is the secondary's own config) and reads the env fallback through the scoped gate reader. |
||
|
|
545e74d0ea |
fix(gateway): a secondary profile's config.yaml no longer poisons the process env under multiplex
Under gateway.multiplex_profiles every secondary profile's config loads inside _profile_runtime_scope, yet every apply_yaml_config_fn hook (feishu, matrix, whatsapp, slack, dingtalk, discord non-gate keys, telegram non-gate keys) and gateway/config_loader.py::bridge_core_env_settings still wrote os.environ there. First-writer-wins: the first secondary with a require_mention / allowlist / allow_bots / reactions block made that policy the DEFAULT profile's (live: TELEGRAM_REQUIRE_MENTION written from a secondary load), and secondaries read the default's env for the same keys. - gateway/platforms/_shared.py::yaml_env_setter: the one env-write shape for YAML->env bridges — env wins, skipped under an active secondary scope. - Every hook now seeds its values into the profile's PlatformConfig.extra and uses yaml_env_setter; bridge_core_env_settings seeds telegram/signal require_mention into extra and skips the env write under scope. - Readers that bypassed extra/scope now consult extra first (matrix flags + session_scope, slack reactions, telegram reactions/mention_patterns/_extra_bool, discord reactions/auto_thread/history_backfill/approval_mentions/allow_mentions, feishu allow_bots, dingtalk mention_patterns, signal require_mention). Salvages the shape of PR #100604 (whatsapp, earliest report #80099), #100435 (discord) and #100448 (telegram) by @nftpoetrist on top of current main. Fixes #80099 Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com> |
||
|
|
27768a9fa5 |
fix(gateway): profile_routes no longer hijack DMs to a dedicated secondary bot (#104933)
Telegram DM chat_id == user_id for every bot, so a `chat_id` route meant to pin a user's DM with the SHARED bot to profile `ops` also captured that user's DM with team_b's dedicated bot: the turn, session key, state.db and secrets were ops's while the reply left via team_b's transport. `ProfileRoute` gains an optional `bot_profile` discriminator (None = the default profile's bot) and `matches()` requires it to equal the receiving adapter's owner profile. `build_source` passes `_owner_profile` and stamps it as the fallback profile for secondary adapters; `_profile_name_for_source` derives it from the transport ref for sources built elsewhere. Secondary→secondary routes remain possible by naming the bot explicitly. Salvages the discriminator idea from #105040 and #105049 in a smaller shape (no bot-username sniffing across adapter internals; the owning profile is already declared at `set_owner_profile`). Co-authored-by: 0xAlyDev <agentai891@gmail.com> Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com> |
||
|
|
65579d8a46 |
fix(gateway): bind HERMES_SESSION_PROFILE per /p/<profile>/ api request
_bind_api_server_session never passed profile= to set_session_vars, so every API-server turn bound HERMES_SESSION_PROFILE="" and the terminal tool collapsed ALL api_server sessions (default AND org profiles) onto the shared 'default' container key. In a multiplex gateway the first caller after restart therefore won the cache slot and org-profile turns could reuse the default profile's sandbox container (SSH key and secrets exposure). Pass the request profile through so org turns key their own profile-scoped container. (cherry picked from commit b8b01b3d8cdafab292dda4388592044393222886) |
||
|
|
adc4fad919 |
fix(qqbot): authorize approval buttons for named-profile session keys
`_parse_gateway_session_key()` required the literal `main` in the
namespace slot of `agent:<ns>:<platform>:<chat_type>:<chat_id>`, but
`_session_key_namespace()` (gateway/session.py) puts the multiplex
profile name there for named profiles. Every approval button click in a
named-profile QQ session therefore failed authorization ("Rejected
unauthorized approval click") and the pending approval timed out
fail-closed.
The namespace slot takes no part in any authorization decision, so the
parser now accepts any non-empty namespace; the platform and operator
checks are unchanged and pinned by new negative tests.
Fixes #98292
(cherry picked from commit 8e0f02ee770bcf931dd0adc8464f0edc185d2546)
|
||
|
|
0dcadf6f41 |
revert: remove Collective Wisdom V1 (#94266)
Reverts the in-tree org skill-marketplace: hermes_wisdom package, three model tools, CLI/gateway/desktop/dashboard/Telegram/Slack surfaces. Later non-Wisdom work on shared files (guest onboarding i18n, dashboard startup schema, Slack adapter, tui_gateway) is kept; Wisdom-only call sites and config were stripped from those files. |
||
|
|
8226c2f6a2 |
Merge pull request #107708 from dagardner-nv/feat/propagate-request-metadata-to-relay
feat(relay): pass API request metadata to Relay tracking |
||
|
|
cbd03e6e4c |
fix(gateway): secondary-profile adapters no longer inherit the default's allow-all / allowlists
Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's .env. Several
adapter-owned authorization gates still read GATEWAY_ALLOW_ALL_USERS, GATEWAY_ALLOWED_USERS
or their platform allowlist/allow-all raw from os.environ, so the default profile opting
into open access opened every secondary email/QQ/WhatsApp/Matrix/Teams/Slack/LINE/DingTalk
bot to any sender (email additionally skipped From: authentication), the default's Matrix
allowlist decided who may approve tool calls on a secondary bot, and a secondary that
opted in only in its own .env was silently deny-all.
Every such read now goes through the adapter's existing module-local scoped reader
(gateway.platforms._shared.get_scoped_secret / matrix _startup_env_secret): profile
scope first, scoped miss = default, never os.environ; the unscoped default-profile and
single-profile paths keep the environ read, where it IS the profile's own value.
Sites: email _allow_all_senders/_allowlist_in_effect; qqbot _open_dm_opted_in;
whatsapp_common _open_dm_opted_in/_live_dm_allow_from; teams _card_action_denied;
matrix _is_authorized_user, MATRIX_ALLOWED_USERS, MATRIX_IGNORE_USER_PATTERNS,
_extra_csv_set (allowed/free-response rooms); slack _slack_allow_bots/_slack_api_human_users;
line _truthy_env/allowlist (allow-all, user/group/room allowlists); dingtalk _extra_get
(allowed_users/chats, free-response chats, require_mention).
Live repro (temp HERMES_HOME, multiplex on, default env GATEWAY_ALLOW_ALL_USERS=true,
secondary scope without opt-in): EmailAdapter._allow_all_senders() True -> False,
QQAdapter._open_dm_opted_in() True -> False, Matrix _is_authorized_user('@stranger')
True -> False, Teams card action allowed -> denied.
Co-authored-by: Drexuxux <drexux0@gmail.com>
Co-authored-by: MoonsvnLyn <FirmamentalSpring@users.noreply.github.com>
Co-authored-by: svector-anu <anuoluwakolapo94@gmail.com>
Co-authored-by: babatorik <durgun.ismail@gmail.com>
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
|
||
|
|
a6ee31f55a |
feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266)
* feat(wisdom): add trusted publish and install foundation
* feat(wisdom): add private contribution loop
* feat(wisdom): add managed consumption workflows
* fix(wisdom): close cross-repository safety gaps
* fix(wisdom): align local package and lifecycle policy
* fix(wisdom): require explicit profile setup
* docs(wisdom): repin reconciled gateway head
* fix(wisdom): fence content downloads and approval receipts
* docs(wisdom): record generation-fenced downloads
* docs(wisdom): record unified delivery PR
* fix(ci): stop passing invalid classifier inputs
* docs(wisdom): remove internal requirements ledger
* feat(wisdom): localize dashboard and desktop copy
* feat(wisdom): complete local contribution and consumption UX
* style(wisdom): satisfy desktop lint
* chore(wisdom): refresh requirements pin
* test(dashboard): allow formatted profile copy
* test(wisdom): stabilize desktop interaction coverage
* fix(wisdom): surface dashboard action failures
* fix(wisdom): add repeatable Portal demo login
* feat(wisdom): add actionable skill notifications
* feat(wisdom): add notification install and update actions
* fix(wisdom): make Telegram skill alerts actionable
* fix(wisdom): always refresh demo Agent login
* feat(wisdom): embed Telegram notification actions
* fix(wisdom): preserve Telegram notifications after actions
* fix(wisdom): keep Telegram notification cards readable
* feat(wisdom): add Telegram candidate approval flow
* feat(wisdom): explain Telegram qualification reasons
* fix(wisdom): reconcile cross-surface candidate actions
* feat(telegram): add Collective Wisdom management command
* chore(wisdom): refresh Gateway contract pin
* chore(wisdom): advance Gateway contract pin
* feat(wisdom): align command UX across clients
* feat(slack): add Collective Wisdom management parity
* feat(wisdom): add security and professionalism reviews
* feat(wisdom): add first-time qualification guidance
* feat(wisdom): simplify qualification sharing choices
* feat(skills): add optional editorial metadata
* feat(wisdom): enrich legacy skill presentation
* fix(wisdom): harden review and update boundaries
* fix(wisdom): emit canonical review timestamps
* fix(wisdom): align with merged gateway and main
* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)
- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
7-day evidence builder that excludes bundled/hub/managed skills and
dismissed/handled/recently-suggested content hashes, strict pydantic
schemas for agent output with repair-or-reject, fixed copy templates
(Share / Teammate / Published / Update / Mute), idempotent retried
delivery ledger with stale-action resolution, weekly review job,
resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.
* wisdom: agent-led renderers and button action dispatcher
- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
packaging flow, Install/Update -> plan command. Never publishes/installs.
* wisdom: CLI verbs, agent_led config default, conversational catalog skill
- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
verbs, share/install flows and fixed notification templates.
* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons
- gateway housekeeping tick calls maybe_run_weekly_review with a home
channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
duration keyboard, send_wisdom_agent_recommendation rich card + fallback.
* fix(wisdom): integrate local mediation and harden model and setup boundaries
* fix(wisdom): honor authoritative recommendation policy and defer on failure
* fix(wisdom): synchronize opaque suppression and recheck delivery preferences
* feat(wisdom): route weekly selection through the session-owned assessment queue
* fix(wisdom): prepare and submit the reviewed generated share package
* feat(wisdom): separate native Share preparation from publication consent
* feat(wisdom): sync native mute choices through a leased preference outbox
* feat(wisdom): bind native mute controls to durable preference choices
* feat(wisdom): add scoped desktop and dashboard notification settings
* fix(wisdom): revalidate feed recommendations before assessment and delivery
* fix(wisdom): persist validated delivery receipts before completing notices
* feat(wisdom): add private notification claim and receipt client
* Persist Wisdom send reservations and recover delivery acknowledgements
* Route legacy Wisdom controls through current native review
* Add typed private Wisdom operation outcome client
* fix(wisdom): make agent-led advice usable in the local demo
* fix(wisdom): keep requested consent outside proactive limits
* fix(wisdom): distinguish unavailable assessments and preserve digest text
* fix(wisdom): assess ongoing usefulness beyond the current task
* fix(wisdom): restore immediate qualification sharing controls
* fix(wisdom): separate qualification review from installation advice
* fix(wisdom): collapse review checklists and simplify sharing copy
* fix(wisdom): show compact sharing progress and publication receipts
* fix(wisdom): require credential prefixes rather than matching skill names
* fix(wisdom): finish package checks before presenting sharing consent
* fix(wisdom): scan local skills before qualification cards
* fix(wisdom): update moderation results on existing sharing cards
* fix(wisdom): keep sharing review accessible from receipt cards
* fix(wisdom): align mediated review cards and collapsible checks
* fix(wisdom): clarify clean security summary wording
* fix(wisdom): normalize consent plans and add explicit recheck
* fix(wisdom): keep install and update receipts concise
* fix(wisdom): collapse assessments and deduplicate operation cards
* fix(wisdom): restore private Portal review from native cards
* fix(wisdom): sync Portal publication to original consent card
* fix(wisdom): show local skill version on sharing cards
* fix(wisdom): skip agent recommendations for self-published versions
* fix(wisdom): simplify candidate notices and local-edit recovery copy
* feat(wisdom): submit locally reviewed packages with one confirmation
* feat(wisdom): expose safe receipt and outcome sync recovery
* wisdom: onboarding notice says detect and share, names the user's own skill
Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark
Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.
* wisdom: one opener, no approval line, ask to share after the skill is shown
Product owner review of the candidate card.
- The Hermes written card now opens with the same sentence as the fixed card
("Your organisation has enabled Collective Wisdom, a feature designed to
automatically detect and share useful skills across all team members.")
instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
It is now the last line, after the skill name, description, why suggested
and the checks, and reads "Would you like to share it?" (matching the
agent led template wording).
Tests updated for the new order; proposalNotice removed from all desktop locales.
* wisdom: American spelling, organization
Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.
* wisdom: candidate card copy round 4 (owner review)
Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:
1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
card (Telegram rich card and plain fallback, legacy agent-led share
template).
3. The skill name and description are labelled: "Skill name: <name>" and
"What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
inappropriate content found)" with no per-check bullets and no "Pass";
a failed review reads "Needs a look before sharing at work (possible
inappropriate content)" and lists only the checks that flagged
something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
editorial_name, a simple one_line_description and a compelling
why_coworkers_benefit under 300 characters; "Be concise and
convincing." becomes "Be concise and compelling: the goal is that the
user wants to share it."
Tests updated for the new strings; review_text() gains direct coverage.
* wisdom: re-apply owner copy after rebase
- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice
* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors
Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.
* fix(wisdom): reconcile optional SDK tests and frontend lint
* fix(wisdom): default to agent-written notification summaries
* fix(wisdom): restore deferred install review and browse controls
* feat(wisdom): inspect installed setup with exact package provenance
* feat(wisdom): run native-approved installed setup steps with durable evidence
* fix(wisdom): recover interrupted setup with explicit native consent
* feat(wisdom): hand native installs into guided setup review
* fix(wisdom): continue requested setup with fixed notification copy
* fix(wisdom): preserve setup while waiting for a session model
* fix(wisdom): expose canonical setup review controls on desktop
* fix(wisdom): resume setup after recorded automatic updates
* fix(wisdom): make missing setup prerequisites recheckable
* chore(wisdom): align Agent with verified Gateway contract
* fix(wisdom): stop guessing team slugs in portal links
* fix(wisdom): retire pending advice on account sign-out
* fix(wisdom): cancel advice after terminal account revocation
* fix(wisdom): fence feed responses across account sign-out
* fix(wisdom): checkpoint signed-out feed before reactivation
* fix(wisdom): link proactive advice to scoped notification settings
* fix(wisdom): coalesce queued publication recommendations by version
* fix(wisdom): keep package review navigation local and deferable
* fix(wisdom): reflect installed state in discovery controls
* fix(wisdom): show exact checks before command confirmation
* chore(wisdom): pin bounded analytics privacy contract
* chore(wisdom): pin retired legacy notification contract
* feat(wisdom): review publisher usage with exact sharing copy
* fix(wisdom): align discovery and review check summaries
* fix(wisdom): show expired consent before confirmation
* fix(wisdom): require fresh review for legacy install controls
* fix(wisdom): preserve review expiry across check toggles
* fix(wisdom): retain update policy in native install reviews
* fix(wisdom): surface failed native card edits
* fix(wisdom): persist local command approval reviews
* fix(wisdom): use saved approvals for messaging commands
* test(wisdom): provide scan result in setup handoff fixture
* test(wisdom): exercise Telegram approvals with saved review state
* fix(wisdom): retain suppression policy for offline deferral
* fix(wisdom): reconsider candidates after deferred suppression expires
* fix(wisdom): bind review checks and report verified readiness separately
* fix(wisdom): persist accepted publication intent and recover exact outcomes
* fix(sync): pin UTF-8 tree ordering across writers
* chore(wisdom): pin organisation-scoped Gateway authorization
* fix(wisdom): restrict consent delivery to user-facing sessions
* chore(wisdom): refresh reviewed Gateway contract pin
* fix(wisdom): preserve kept tools in Blank Slate exclusions
* test(auth): reset anonymous fixture with a profile-scoped cache
* fix(wisdom): gate local surfaces and work on current profile entitlement
* fix(wisdom): invalidate quiet tool cache on entitlement changes
* test(wisdom): authorize local consent gateway fixtures
* fix(wisdom): keep entitlement decoding free of native crypto imports
* test(wisdom): provide local entitlement to demo CLI subprocess
* ci: leave upstream workflow unchanged in Wisdom PR
* fix(wisdom): ship package and contracts in Nix wheels
---------
Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
|
||
|
|
c3e01c753d |
fix(gateway): /p/<profile>/ webhook delivery and api_server callbacks stay on the routed profile
Under gateway.multiplex_profiles a /p/<profile>/ webhook route (or one with
`profile: <name>`) executed under that profile but delivered its reply as the
FIRST profile owning the target platform: `_find_adapter` took
`runner.adapters` then iterated `_profile_adapters` in dict order, and the
home-channel fallback read `runner.config` (the default profile's). The gh leg
for `github_comment` inherited the process environ, i.e. the default profile's
GH_TOKEN. Both directions leaked: a secondary route posted through the default
bot, and a default route borrowed a platform parked only on a secondary.
The api_server `/p/<profile>/api/platforms/<platform>/events` callback had the
same shape — `_get_platform_callback_adapter` read `runner.adapters` regardless
of `_api_request_profile`, so a secondary's Google Chat / Teams events were
verified and dispatched by the default adapter.
Now:
- webhook: `_delivery_info` / the deliver_only dict carry the resolved
profile; `_find_adapter(platform, profile)` resolves through the runner's
shared fail-closed `_authorization_adapter`; the delivery leg runs inside
`_profile_scope(profile)` and takes the home channel from that profile's
`load_gateway_config()`; `gh` gets GH_TOKEN/GITHUB_TOKEN from the profile
secret scope (default's values dropped from the child env when the profile
has none).
- api_server: the callback adapter resolves via
`_authorization_adapter(platform, _api_request_profile.get())`; a named
profile without the adapter is a 503, never the primary's adapter.
A profile without the target platform fails closed ("not connected" → 502 /
503) instead of a silent cross-profile send.
Fixes #65939
Fixes #84266
Co-authored-by: 604maestro <604maestro@protonmail.com>
Co-authored-by: mjshorty <mjshorty@users.noreply.github.com>
Co-authored-by: StellarisW <stellarisw@users.noreply.github.com>
|
||
|
|
3b044261b6 |
fix(gateway): media denylist covers every profile's credentials, not just the launch home
Under `gateway.multiplex_profiles` one process serves every `<root>/profiles/*`,
but `_media_delivery_denied_paths` expanded `_ROOT_CREDENTIAL_PATHS` only under
the import-time `_HERMES_HOME` / `_HERMES_ROOT`. A `MEDIA:<root>/profiles/<B>/.env`
(or auth.json, state.db, config.yaml, sessions/, mcp-tokens/) emitted in ANY
profile's turn — including B's own, whose HERMES_HOME override was never
consulted — passed validation and was natively uploaded to the chat. The ALLOW
side (`_profile_cache_roots`) already enumerated profiles at check time; the
DENY side did not.
`_credential_home_roots()` now yields the active `get_hermes_home()`, the shared
root and every `<root>/profiles/*` at check time (shared `_profile_dirs()` with
the allow side), and the denylist is built from that. Profile cache artifacts
and plain agent-written files under a profile stay deliverable.
Live repro (/tmp/mux_audit/fix-media-denylist/repro.py): before, all five of
profiles/B/{.env,auth.json,state.db,config.yaml,sessions/s1.json} validated as
deliverable while <root>/.env was blocked; after, all None, cache/images/gen.png
and report.pdf still deliverable.
No prior report. Write-side analogue: #107327 / #107335 (memo keying, different
mechanism — left as is).
|
||
|
|
6ee218602d |
feat(relay): pass API request metadata to Relay tracking
Signed-off-by: David Gardner <dagardner@nvidia.com> |
||
|
|
55b3ea0b11 |
fix(gateway): count each bot message once in the loop guard and consume the author variable
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it again, and the busy path asks a third time. Each call counted one loop-guard event, so a Telegram bot tripped the budget after a third of the configured messages. The verdict now only refuses a chat that is cooling down. The ingress gate counts an admitted bot message once. `parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag, and returns None for an author with neither id nor name. Names keep format characters and non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive number. Issue numbers move out of code comments. |
||
|
|
0ac631b6c0 |
feat(api): accept a turn author on session chat and runs; peer dm forwards it
POST /api/sessions/{id}/chat, /chat/stream and /v1/runs take an optional
"author" object. absent or null keeps today's behavior; a non-object is a 400
invalid_author. the value reaches run_conversation as turn_author and nothing
else: it is a claim by an API-key holder and grants no capability.
hermes peer dm and peer run add the author to the request body when the
message_agent runner launched them with HERMES_TURN_AUTHOR set, so a
cross-machine dm is attributed the same way a local one is.
|
||
|
|
d6f954f510 |
refactor(signal): fence realigned tables so the code-block pass owns every MONOSPACE range
The salvaged detector recorded a second MONOSPACE range from a hand-rolled position walker and then had to rebase earlier ranges by the padding delta (the contributor's follow-up commit). Two range bookkeepers over one text is where the bugs were: a pipe table inside a fenced code block was realigned AND double-styled, because the walker ran after the fences were stripped and could no longer tell code from table. Run table realignment on the raw markdown instead, outside fences only, and wrap each aligned table in a fence. The existing code-block pass then emits exactly one MONOSPACE range per table and the existing removal/adjust machinery shifts every later range — no second offset model, no rebase loop. The contributor's fixup commit becomes unnecessary and is not carried. Tests trimmed to two invariants: a CJK/emoji table aligns by display width with correct UTF-16 offsets for the range and the style after it, and a fenced pipe table stays code while a real table before it keeps a later code range pointed at its own text (also covers the lone-pipe negative). Refs #106181 |