Commit Graph

1357 Commits

Author SHA1 Message Date
kshitijk4poor
eba3414727 refactor(discord): derive the resolved-ID count and accessor from the set
Quality-gate Lows (BS03 r1): resolved_count always equalled
len(self._username_resolved_ids) (each name is discarded from to_resolve on
its first match, so it adds exactly one ID), and resolved_allowlist_user_ids
filtered isdigit()/"*" out of a set that only ever holds str(member.id) and is
always initialised in __init__ and reset by the resolver. Drop the counter, the
filter, the getattr fallback (no test builds the adapter without the resolver
and then calls the accessor) and the stale "Numeric only" docstring line.
Behaviour unchanged (probe-BS03-1 output identical before and after).
2026-10-03 16:37:12 +05:30
kshitijk4poor
d13d92cc82 fix(discord): reset username-resolved IDs before the empty-allowlist guard
_resolve_allowed_usernames reset _username_resolved_ids only after the
early return for an empty allowlist / missing client, so a reconnect
after the allowlist was emptied kept the previous connect's resolved IDs
in the set the gateway authz union reads. Reset first so the set always
reflects the current connect.
2026-10-03 16:37:12 +05:30
EloquentBrush0x
462389ea5e fix(discord): a numeric allowlist entry removed after connect stops authorizing
resolved_allowlist_user_ids() returned every numeric ID in the adapter's
connect-time _allowed_user_ids, and the gateway authz layer unions that set
into the reloaded DISCORD_ALLOWED_USERS. So an ID removed from the allowlist
after connect (hermes pairing revoke from the CLI or dashboard, or a hand
edit) kept authorizing until the gateway restarted. #130920's in-place purge
only reaches adapters in the same process, and those commands run outside it.

Track the IDs _resolve_allowed_usernames actually resolved from username
entries and return only those: they are what the per-turn .env reload loses
(a0a63a1bc2); numeric entries are already read live from the env.

(cherry picked from commit 90451505545745dbb27ecad28f2c431ad5afd301)
2026-10-03 16:37:12 +05:30
EloquentBrush0x
675bad762a fix(discord): resolve allowlisted usernames by username only, not display name or nickname
_resolve_allowed_usernames resolved a DISCORD_ALLOWED_USERS name through member.name,
member.display_name or member.global_name, and the first match took the slot. Any member of a
shared server could set a nickname or display name to an allowlisted username, get the
resolved ID (DMs, tools, approval buttons) and lock the real user out.

Match the unique account username only, as #125034 did for SimpleX display names. An entry
that matches only a display name stays unresolved and gets a connect-time hint. Tests cover
a copied nickname and a copied display name seen before the real user, and the hint; the
Discord docs now say display names and nicknames are never matched.

(cherry picked from commit 252f0bccab39afe94f0f71254e47a8f785ec1fb0)
2026-10-03 16:37:12 +05:30
kshitijk4poor
0089fca37d fix(slack): gated slash commands get a bare ack, not "Running /x"
The channel gate ran inside _handle_slash_command, after the bolt listener had
already acked with an ephemeral "Running /x". In an ignored or non-allowed
channel the invoker was promised a run that never came, and an ignored channel
("never touched") still got a bot reply. Move both checks into
_slash_channel_gated() and consult it before the ack: gated -> bare ack().
_handle_slash_command keeps calling the same predicate for direct callers.
The listener becomes the _handle_hermes_command method so the existing
parametrized gate test drives the real ack path (BN11 gate r1 2c Low finding).
2026-10-03 16:36:48 +05:30
kshitijk4poor
46dd3161bb refactor(slack): classify the 1:1 DM once in _handle_slash_command
The allowed_channels guard recomputed str(channel_id).startswith("D") inline and
the next line computed it again as is_dm. Hoist is_dm above the gates and reuse it.
Same behaviour, one DM classification (BN11 gate r1 quality/reuse Low finding).
2026-10-03 16:36:48 +05:30
John Paul Soliva
b376591778 fix(slack): slash commands obey allowed_channels and ignored_channels, like messages
The message path drops a message from an ignored channel (_prefilter_inbound)
and, when allowed_channels is set, from any non-1:1 conversation outside it
(_channel_gate_allows). _handle_slash_command applied neither, so a slash
command ran in a channel the operator had excluded. Apply both gates by
channel id alone, before any lookup, so a failed conversations.info can never
drop a control command.

Rebuilt from 2b7cde506c (#128767). Fixes #130358.
2026-10-03 16:36:48 +05:30
kshitijk4poor
05fdbf6317 refactor(matrix): import markdown once in the standalone sender, drop the None sentinel
The standalone payload builder imported markdown inside the per-chunk loop
under suppress(ImportError), which read as though the import could fail per
chunk. Try it once before the loop and branch on the result.

_standalone_send seeded `result = None` and returned it after the loop,
suggesting a possible None return. truncate_message always yields at least one
chunk, so the loop always runs; return the failing result directly and the
last result otherwise. No behaviour change.
2026-10-03 16:31:35 +05:30
Froraut
b015e59fb9 fix(matrix): address review — budget edit mentions and move plugin tests
An edit carries m.mentions twice: once inside m.new_content and once at the
top level. When a budget-sized edit names many distinct Matrix IDs, that
fixed part alone could pass the content budget. Shortening the fallback body
then had no room to work with, and the edit still went out at about 54 KB,
which an encrypted room would reject with M_TOO_LARGE.

When the edit is still over budget with an empty fallback, it now drops the
top-level mentions copy. That copy only drives the edit's own notifications,
and m.new_content keeps the full list.

The event-size tests move to tests/plugins/platforms/matrix/test_event_size.py,
mirroring the plugin source tree, and gain a mention-heavy text. The
tests/gateway/test_matrix_message_length.py file is back to main apart from the
env-override value that the new ceiling requires.

(cherry picked from commit 4b06bcb7dbffb732bef6e877cb66dea2fbff8c73)
2026-10-03 16:31:35 +05:30
Froraut
d584836ffa fix(matrix): keep every outbound event under the homeserver's byte cap
The homeserver rejects any event whose canonical JSON is over 65,536 bytes
(M_TOO_LARGE), but Matrix chunked outbound text on characters, 16,000 by
default with a 65,535 ceiling. Each message carries its text twice (body plus
formatted_body HTML), and an edit carries it four times (the "* " fallback
plus m.new_content).

On the default config this caused three failures:

- A formatted chunk of CJK text came to about 95 KB and was rejected, so
  _send_with_retry fell back to a plain 3,500-char truncation.
- A streamed edit near the limit came to about 80 KB even for English, so
  edits failed mid-stream.
- The standalone sender (cron without a running gateway) posted each
  16,000-char chunk as one event, and hit the same failure on non-Latin text.

The fix measures Matrix text in UTF-8 bytes, the unit of the cap:

- The adapter's message_len_fn now returns UTF-8 bytes, so send() and the
  stream consumer's split and progress-edit budgets count the same unit.
- A chunk gets a third of a 45,000-byte content budget, which leaves room for
  the event envelope and E2EE's 4/3 base64 inflation. That makes 15,000 the
  default and the ceiling for max_message_length, so English chunks stay
  about the same size.
- _build_text_message_content drops the HTML when escape-heavy text would
  push the content past the budget.
- An edit that would still be too large keeps its full m.new_content and
  shortens only the top-level fallback, which edit-aware clients never
  render.
- The standalone sender re-chunks on UTF-8 bytes and applies the same HTML
  guard.

(cherry picked from commit cab00c94175955a68ecd4d38c5748d63148ca6be)
2026-10-03 16:31:35 +05:30
kshitijk4poor
8f54cbdce5 refactor(email): compute the allowlist match once and drop a stale reason comment 2026-10-03 16:30:02 +05:30
kshitijk4poor
c4343445c4 fix(email): warn when a listed sender's mail fails authentication
r2 review: moving explicit failing verdicts to debug hid a listed contact whose
own domain breaks DKIM or whose forwarder breaks alignment. Those drops warn
again, with the verdict and no opt-out hint; forged stranger mail under open
access stays at debug. The two operator hints now live in one reason->hint
table behind a single warning call.
2026-10-03 16:30:02 +05:30
kshitijk4poor
a4dadad7cd fix(email): warn when a mis-pinned authserv_id drops an admitted sender
The previous commit moved every non-missing-header auth failure to debug,
which made a mis-pinned authserv_id (the only stamp comes from another
authserv-id) drop allowlisted mail silently at default log levels. That is
an operator misconfiguration, not routine forgery, so it warns again, with
a hint pointing at platforms.email.authserv_id instead of the opt-out.
2026-10-03 16:30:02 +05:30
kshitijk4poor
4c982e9ca4 fix(email): warn with the opt-out hint only when Authentication-Results is missing
Under open access every stranger is granted, so each forged or DMARC-failing
message logged a WARNING telling the operator to disable sender
authentication. Keep that warning (and hint) for the one verdict that points
at a server that never stamps the header; log every other unauthenticated
drop at debug, including an explicit failing verdict for an allowlisted
address. The reason string is lifted to _NO_AUTH_RESULTS_REASON so the
parser and the check share it.
2026-10-03 16:30:02 +05:30
dborosev
c2f595d6af fix: authenticate senders in public email mode 2026-10-03 16:30:02 +05:30
Brooklyn Nicholson
c82abc309a fix(telegram): widen fence anchors to keep mid-line and indented blocks
The anchored fence regex from the upstream commits (kept here via
cherry-pick) fixed #85381 but required the opening ``` to sit at a
line start with <=3 spaces of indent, silently downgrading two shapes
that main renders as <pre> into escaped literal prose:

- mid-line-opened fences ("Here is the code: ```python")
- fences indented by 4+ spaces (list/blockquote-nested code)

Widen the anchors while keeping the inline-triple fix:

- the opening line may now carry any leading whitespace or lead-in
  prose, as long as the opening ``` is the first triple-backtick run
  on the line and the rest of the line is backtick-free (so an inline
  pair like ```x``` can never end a line and open a fence);
- the closing fence may sit at any indent, not just 0-3 spaces.

The tempered prefix cannot skip past an earlier triple-backtick run,
so an inline pair followed later by a real fence no longer swallows
it (both main and the anchored regex mangled that mix in different
ways). The scar conformance vector reverts to main's committed value:
the mid-line-opened fence it encodes is protected again.
2026-10-02 22:25:39 -05:00
PRATHAMESH75
3480de0402 fix(telegram): match CRLF-terminated closing fences
The anchored fenced-code regex ended the closing fence with [ \t]*$, so a
close line terminated by CRLF (\r\n) — Windows-authored content — failed to
match: the \r sits between the fence and the $ position. That left the whole
block unprotected, escaping its backticks into literal ` runs and dropping
the code formatting. Allow an optional trailing \r before the line end.

Adds a CRLF-fence regression (fails on the old anchor).
2026-10-02 22:25:39 -05:00
PRATHAMESH75
5f7b1e2d65 fix(telegram): anchor fenced-code regex so inline triple backticks aren't mangled
The MarkdownV2 fenced-code protection regex
r'(```(?:[^\n]*\n)?[\s\S]*?```)' made the newline after the opening
fence optional, so it also matched *inline* triple-backtick spans on a
single line (e.g. "the syntax is ```x``` inline"). _protect_fenced
then treated the inline span as a pre block, escaping backticks into an
unbalanced entity; Telegram rejected the whole message with 'can't find
end of pre entity' and Hermes fell back to plain text, dropping all rich
formatting.

Anchor the pattern to line starts (0-3 spaces indent) so only standalone
fences match, and rewrite _protect_fenced to use explicit capture groups
(opening line / body / closing fence) instead of naive [:-3] slicing,
which also fixes a doubled closing fence when the close line had trailing
whitespace.

Fixes #85381
2026-10-02 22:25:39 -05:00
Tranquil-Flow
eaecc99c7e fix(discord): upsert recreated slash commands without a delete-first step (#104399)
A 429 between the recreated path's delete and upsert strands the command
deleted until a fully-clean later sync; on reconnect-heavy deployments
that never happens, so commands like /model silently vanish from the
slash picker. Discord's create endpoint overwrites same-name commands,
so the lone upsert recreates while keeping the command available.
2026-10-02 20:31:33 -05:00
teknium1
c8cde58273 fix(gateway): a split send that fails mid-way is never re-sent whole
SMS and both WeCom adapters now split long replies, and their chunk loops
returned a plain failure when a later chunk was rejected. The base send
path cannot tell that from a total failure, so _send_with_retry retried the
whole payload or sent the 3500-char plain-text fallback and reported
success: the recipient got the head twice and never got the tail.

The three loops now share gateway.platforms.helpers.send_chunks(), which
marks a failure after delivered chunks with the existing partial_overflow
contract (the one Telegram and Discord set and _is_partial_delivery reads).
The retry and plain-text fallback already refuse to re-send a partial
delivery, so the caller gets an honest failure and nothing is duplicated.
Resuming the undelivered tail is #124295's scope.
2026-10-01 17:18:56 -07:00
teknium1
a959a6ede1 fix(wecom): split long messages instead of silently truncating them
Both WeCom adapters dropped everything past the platform limit with no
error: the AI Bot adapter sliced markdown content at 4000 chars, and the
callback (self-built app) adapter sliced text at 2048 *characters* while
message/send keeps only the first 2048 *bytes* (so CJK replies lost
everything past about 680 characters). Cron output was additionally cut
to 4000 chars with a gateway-host file pointer.

AI Bot: send() queues one markdown message per MAX_MESSAGE_LENGTH chunk,
so every chunk draws its own token from the 30 msgs/min per-chat bucket
(errcode 846607), and each goes out on the cached reply req_id like any
other send in that chat (group chats cannot send proactively). Callback: chunk by UTF-8 bytes at 2048.
Both declare splits_long_messages so cron delivery hands over the full
payload. Each stops at the first failed chunk and returns its error.

Fixes #19689
Fixes #107443

Co-authored-by: luyao618 <364939526@qq.com>
Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: Andy <sz.private@gmail.com>
2026-10-01 17:18:56 -07:00
teknium1
33c7018054 fix(email): deliver oversized cron output as one whole email
The delivery router truncated cron output above 4000 chars and appended
a footer pointing at a file on the gateway host, which an email
recipient cannot open. One email carries the whole body, so declare
splits_long_messages and let the router hand over the full payload.

Fixes #61990

Co-authored-by: Petra <petra.kersten@agentmail.to>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-01 17:18:56 -07:00
teknium1
25077f6be8 fix(sms): split long replies under Twilio's 1600-char cap and deliver full cron output
send() called truncate_message() without a limit, so it chunked at the
4096 default; Twilio rejects any Body over 1600 characters (code 21617),
so every reply longer than 1600 chars failed outright. Oversized cron
output was first cut to 4000 chars by the delivery router (SMS did not
declare splits_long_messages) and then failed the same way.

Chunk at MAX_MESSAGE_LENGTH (1600) and declare splits_long_messages so
the router hands send() the full payload.

Fixes #62751

Co-authored-by: Philip D'Souza <9472774+PhilipAD@users.noreply.github.com>
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: euisuh <euisuh.jeong@gmail.com>
Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
2026-10-01 17:18:56 -07:00
kshitijk4poor
45871e100f refactor(matrix): let the auth callback default chat_type for invites/reactions
The hardcoded "group" duplicated the registered callback's own
chat_type default and implied a DM/group decision that was never made.
Passing chat_id only yields identical verdicts.
2026-10-02 02:59:59 +05:30
FroRaut
09c9ed2a61 fix(matrix): apply pairing revocations live
(cherry picked from commit 6c69d87fee43e5bcdcbc5f0e2d26fa9e1aa83ecc)
2026-10-02 02:59:59 +05:30
kshitijk4poor
4fb768fcda refactor(a2a): name the fail-closed dispatch predicate once
connect() and is_trusted_peer() each spelled out the same
"network-exposed token bind with no allow-list" condition; drift between
the two would let the startup warning and the actual refusal disagree.
Extract A2ASecurityContext.dispatch_fails_closed() and use it in both
places (truth table unchanged). Also hoist the is_loopback_host import to
module top (no cycle) and drop the restating docstring.
2026-10-02 00:45:30 +05:30
kshitijk4poor
66ed823b5c fix(a2a): keep no-token localhost peers trusted when an allow-list is set
The fail-closed change dropped the localhost_only() short-circuit from
is_trusted_peer. In no-token mode authenticate() always yields ip:<addr>,
so any install with A2A_TRUSTED_PEERS / a2a.trusted_peers configured
started 403-ing every local dispatch. Restore the short-circuit and pin it
in test_localhost_trusts_all.

Also:
- log the non-loopback/no-allow-list misconfiguration once in
  A2AAdapter.connect() instead of an ERROR on every refused request;
- is_loopback_bind reuses agent.proxy_bypass.is_loopback_host instead of a
  hand-rolled loopback check;
- fold the loopback backward-compat test into
  test_no_allowlist_trusts_authenticated (now explicit A2A_HOST=127.0.0.1)
  and drop implementation asserts on is_loopback_bind().
2026-10-02 00:45:30 +05:30
kshitijk4poor
0e2271b832 docs(a2a): document A2A_TRUSTED_PEERS requirement on non-loopback binds
Operators exposing the adapter with a token now get dispatch refused
without an allow-list (#126756); say so in the env table.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-02 00:45:30 +05:30
kshitijk4poor
9878bf9696 refactor(a2a): collapse duplicated loopback branch in is_trusted_peer
The loopback branch repeated the final `identity in self.trusted_peers`
membership check. Only the empty-allowlist case depends on the bind, so
branch on that alone: loopback stays open, non-loopback logs and fails
closed. Behaviour is unchanged (probe: 0 fails).

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Sahil Vishnalya <sahilvishnaliya250@gmail.com>
2026-10-02 00:45:30 +05:30
JerryLiu369
a0cad39f5d fix(a2a): fail closed on non-loopback bind with empty trusted-peers allowlist (#126756)
(cherry picked from commit 26a8dca575cd86a693e6f3be57caa449f9e8a032)
2026-10-02 00:45:30 +05:30
unsupportedpastels
31c5d57ec6 docs(whatsapp): the bridge runs in its own session, not the gateway's process group
The #127047 comment said a supervisor's stop signal reaches the bridge
through the shared process group. The bridge is spawned with
start_new_session=True; what reaches it is a container-wide broadcast
such as s6-overlay's stage-3 SIGTERM.
2026-09-28 21:06:08 -04:00
unsupportedpastels
99b24916ce fix(photon): don't read a signal-raced sidecar exit as a fatal crash
Same race as the WhatsApp bridge (#127047): a container/supervisor stop
signals the whole process tree, so the Photon sidecar (its own session)
can die before the gateway's stop flow reaches disconnect(), while
_inbound_running is still True. _supervise_sidecar() then reported
SIDECAR_CRASHED and queued a reconnect during shutdown.

Consult the runner's _stop_requested_by_signal, which the signal handler
sets before any stop work runs. A sidecar exit while the gateway keeps
running is unchanged: still a retryable fatal error.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-28 21:06:08 -04:00
liuhao1024
f5a3cd6bd8 fix(whatsapp): don't read a signal-raced bridge exit as a fatal crash
A supervisor's stop signal (docker stop, s6-supervise, systemd) reaches the
bridge child in the same process group before the gateway's stop flow reaches
WhatsAppAdapter.disconnect(), so _shutting_down is still False when the poll
loop observes the -15 exit. _check_managed_bridge_exit() then reports a
retryable fatal adapter error, the stranded-platform check exits the gateway
with failure, and the supervisor restarts it into a crash loop (#127047).

Also consult the runner's _stop_requested_by_signal, which the signal handler
flips before any stop work runs. A bridge SIGTERM/SIGINT while the gateway
keeps running is unchanged: still a retryable fatal error so the reconnect
watcher revives it.

(cherry picked from commit 252ae311f06e402f9a8ef1482c87aaefe8d9ac39)
2026-09-28 21:06:08 -04:00
Teknium
9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00
kshitijk4poor
43800d55f2 fix(child-env): the bot desktop and gmail-triage scripts start from the scrubbed env
Two more spawns inherited the gateway's full environment. The bot
desktop launcher copied os.environ minus the display variables; the
agent drives that desktop and its dock opens xfce4-terminal, so every
provider key and bot token was one click away. The Telegram gmail-triage
buttons ran HERMES_HOME/scripts/gmail-triage/*.sh with no env at all, a
path the agent can write. The desktop now builds from
served_profile_child_env() like the agent browser (keeping the user's
HOME); the triage scripts use build_subprocess_env like cron and
webhook-filter scripts.
2026-09-29 02:06:58 +05:30
kshitijk4poor
e14936b508 fix(child-env): openviking, raft and buzz keep the user's HOME
The scrubbed builder rewrites HOME to the profile home in containers and
profile home mode (the official image does this by default). These are
third-party programs whose own config and logins live under the user's
HOME: ~/.openviking, the raft CLI profile, ~/.config/buzz. The openviking
change copied the parent's HOME back, which is itself a profile home
when Hermes runs under one; use HERMES_REAL_HOME, which the builder
already computes, for all three.
2026-09-29 02:06:58 +05:30
kshitijk4poor
ee942a7bbf fix(terminal-env): adapter secrets are the declared ones, not any platform-named variable
The shape rule (a platform prefix plus _TOKEN/_SECRET/_PASSWORD/_KEY) also
matched variables Hermes never reads: the platform list holds plain words
(LOCAL, GATEWAY, WEBHOOK, SLACK), so a user's SLACK_USER_TOKEN,
LOCAL_LLM_API_KEY or GATEWAY_API_KEY vanished from the terminal and
terminal.env_passthrough could not bring them back. The prefix census it
leaned on also failed open (an unreadable plugins dir cached an empty set).

Adapter secrets now come from what adapters declare: password entries of
the messaging OPTIONAL_ENV_VARS (built-ins plus every platform plugin
manifest) and secret-named keys of the gateway env-override table, both
Tier 1 and refused by passthrough; plus the secret-named required_env of
adapters registered in the current profile scope, read per spawn without
loading deferred adapters, Tier 2 only because required_env is an
unchecked setup list. Secrets nothing declared get a manifest entry
(TELEGRAM_WEBHOOK_SECRET, PHOTON_SIDECAR_TOKEN, A2A_PUSH_SECRET,
TEAMS_GRAPH_ACCESS_TOKEN, TEAMS_INCOMING_WEBHOOK_URL) or join the
policy's read-in-code list (QQ_STT_API_KEY, the two MSGRAPH names).
2026-09-29 02:06:58 +05:30
John Paul Soliva
f9d403472f fix(buzz): run the buzz CLI from the scrubbed child env
_exec_buzz built the CLI env from os.environ.copy(), so every buzz call
carried Hermes' gateway tokens and provider keys. It now starts from
hermes_subprocess_env() and adds only the relay URL, key and auth tag.
2026-09-29 02:06:58 +05:30
dsad
26f42dd772 fix(platforms): strip secrets from the raft bridge subprocess env
The raft bridge was spawned with {**os.environ, RAFT_CHANNEL_TOKEN},
handing the raft CLI every Hermes credential. It now gets
hermes_subprocess_env() plus RAFT_PROFILE (the profile-resolved value,
not the launch env's) and the bridge token.

Ported from the raft half of #56245 onto current main.
2026-09-29 02:06:58 +05:30
Teknium
a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00
liuhao1024
079ff12f96 fix(buzz): scoped requirements gate resolves the profile's buzz section like the runtime loader (#125985, salvage #126060)
Inside a multiplexed secondary profile scope, `_profile_buzz_extra` read the buzz block only from
`gateway.platforms.buzz`, while the runtime loader (`gateway/config_loader.py::platform_section`,
the same seam that feeds this plugin's `apply_yaml_config_fn`) also resolves a top-level `buzz:`
block and the documented `platforms.buzz` shape. A fully configured secondary profile therefore
failed `check_requirements` closed on every boot ("Platform 'Buzz' requirements not met").

Resolve the section through `platform_section` so the gate sees exactly what the loader sees.
Row L1 (profile-scoped gate reads config), topology T2 (multiplexed secondary profile).

Salvaged from #126060 by @liuhao1024 (authorship kept); the test is trimmed to one A->B->A
invariant across two profile homes. Supersedes #126067 (same mechanism, later).

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-28 05:24:26 -07:00
teknium1
75b6d946cd fix(a2a): scope the client-tools A2A_PORT gate and prove A→B→A under multiplex (#122126, salvage #122128)
Follow-up trim to @JE4NVRG's salvaged commit (is_connected → env_is_connected("A2A_PORT")):

- plugins/platforms/a2a/tools.py::_a2a_tools_available read os.getenv("A2A_PORT") too — the
  same unscoped gate in the same plugin, so every secondary profile also paid for the a2a
  toolset whenever the launch profile set A2A_PORT. Now get_scoped_secret, same policy as
  the adapter's port read (scoped miss fails closed; default/T1 reads its own environ).
- Tests trimmed to two invariants: A→B→A over two temp profile homes under
  set_multiplex_active(True) with the launch value in os.environ (profile B with no
  A2A_PORT of its own is NOT connected and gets no tools; red on origin/main at index 1),
  and the standalone T1 case where os.environ IS the profile.

Row L1 (boot-time platform enablement probe), topologies T2/T3 (multiplexed host serving
secondary profiles). No other plugins/platforms/*/__init__.py is_connected/check_requirements
does an unscoped env read (buzz #125985 is handled separately).

Co-authored-by: JE4NVRG <jean.v1803@gmail.com>
2026-09-28 05:24:26 -07:00
JE4NVRG
68c205b5b9 fix(a2a): resolve the inbound enablement gate through the profile-scoped reader
is_connected() gated on a raw os.getenv("A2A_PORT"), which under multiplexing
holds the launch (default) profile's value. Every secondary profile therefore
instantiated the inbound A2A server, all of them fell back to the module
default port 9900, and all but one died with "could not bind ... Address
already in use" (fatal / bind_failed in gateway_state.json) on every boot.

Resolve the gate through gateway.platforms._shared.env_is_connected, the same
scope-aware idiom the sibling adapters use -- the adapter's own port read
already goes through _get_scoped_secret for exactly this reason.

Tests: tests/plugins/test_a2a_plugin.py pins the behavior. A profile with no
A2A_PORT in its own scope is not connected and never reads os.environ (a raw
os.getenv fails the test), a scoped port connects, and extra.enabled still
short-circuits.

Fixes #122126
2026-09-28 05:24:26 -07:00
kshitijk4poor
3f4533b9de fix(email): read spf/dkim verdicts from their own Authentication-Results clause
The r3 fold scoped only the dmarc verdict to its clause. SPF and DKIM
still came from a whole-string, last-match-wins regex scan, so the same
quoted/comment smuggle closed for dmarc still authenticated a spoofed
From (GHSA-rxqh-5572-8m77), e.g.
  spf=fail smtp.mailfrom="x spf=pass smtp.mailfrom=example.com "@evil.test
  spf=fail (spf=pass) smtp.mailfrom=a@example.com
  spf=fail smtp.mailfrom=a.spf=pass@example.com
  dkim=pass header.d=evil.test header.i="x header.d=example.com y"@evil.test

Every verdict now comes from the leading method=result token of its
own clause (from _ar_clauses, comments dropped), and its domains only
from that clause. Properties are read by a token scanner that consumes
quoted-strings and other key=value tokens whole, so quoted contents are
never read as properties while a quoted value still is
(header.from="example.com"). SPF fails closed on more than one spf
clause; DKIM accepts any single dkim=pass clause whose own header.d
aligns (multi-signature mail is normal), never mixing clauses. The
whole-string methods/props (methods["dmarc"] was dead) are gone.

Also: a stray ')' at depth 0 is now unbalanced (it split header.from
out of the dmarc clause); a From with more than 64 '(' takes the silent
empty-sender drop instead of a parseaddr RecursionError logged as an
error; the cap test asserts the cap directly instead of wall-clock
timing; the empty-From drop assertion moves next to the other
_extract_email_address rejects; and the untested >1-dmarc, unbalanced
and backslash-escape rules get reject strings.
2026-09-27 20:49:08 +05:30
kshitijk4poor
517a97cd31 fix(email): cap From length, quote/comment-aware dmarc clauses, keep commented names
Round-3 review of the #124322 salvage:

- stdlib parseaddr is pure Python and superlinear on hostile input: a
  100KB `From: <a@a@...` held the GIL ~1s per message (main ~0), and any
  remote sender reaches it before auth. Refuse values over _MAX_FROM_LEN
  (2048; RFC 5322 lines cap at 998) right after unfolding so they take the
  existing empty-sender drop. The timing assertion now times
  _extract_email_address itself instead of a private regex.

- The dmarc clause split ignored quoted-strings and stripped comments one
  level only, so an attacker-controlled SPF-passing envelope sender like
  smtp.mailfrom="x;dmarc=pass header.from=example.com x"@evil.test planted
  a fake dmarc=pass ahead of the real dmarc=fail and authenticated
  admin@example.com. Split clauses with a quote- and nested-comment-aware
  scanner (comments dropped, quoted values kept so header.from="x" is still
  read and unquoted), and fail closed on an unbalanced value or when more
  than one clause starts with dmarc=. Tests pin the smuggled clause,
  reason="a;b", the nested comment, a quoted aligned header.from, and that
  every header.from in the dmarc clause must align (all->any goes red).

- `Doe, John (CEO) <j@x>` was dropped because the fallback refused any
  paren. Strip (comments) from the display part first; `attacker@evil.test
  (c) <victim>` stays rejected by the '@' rule.

- Results without '@' (`John` -> `john`, `a\"b <v@x>` -> `a\`) were
  dispatched as sender ids; return "" so they take the drop path.
2026-09-27 20:49:08 +05:30
kshitijk4poor
b821f8637b fix(email): linear, mailbox-safe From fallback; one-clause dmarc verdict
The r1 malformed-From fallback regex ([^<>\s]+@[^<>\s]+) backtracked
catastrophically on `From: <a@a@a@...`: 32KB held the GIL ~23s, and any
remote sender reaches it before auth. Capture <([^<>\s]+)> instead and
check the '@' in Python (same accepted set, 100KB in ~3ms).

The fallback also mapped multi-mailbox / group / comment-prefixed From
values (`attacker@evil.test, <victim@x>`, `Grp: a@evil; <victim@x>`,
`attacker@evil.test (c) <victim@x>`) to the bracketed victim, which a
dmarc=pass without header.from then authenticated. Only fall back when the
display part has no ; : ( ) and no '@' unless it is exactly the bracketed
address; `Doe, John <j@x>` and `j@x <j@x>` still resolve. The rule now lives
only in the docstring (the old inline comment was wrong).

dmarc: strip (comments) before splitting clauses, and take the verdict and
header.from from the same first clause that starts with dmarc=, so
`dmarc=pass (p=none; sp=none) header.from=evil.test`, a later dmarc=pass
clause after dmarc=fail, and duplicate misaligned header.from are rejected,
while `arc=pass (dmarc=fail ...); dmarc=pass header.from=<ours>` passes.
Property clean-up is shared via _auth_props.

The empty-sender drop now runs right after address parsing and gets an
assertion (it was untested). Tests extend existing ones (no new tests).
2026-09-27 20:49:08 +05:30
kshitijk4poor
2d44f5512b fix(email): keep malformed-but-real From values, drop empty senders, scope dmarc header.from
Strict parseaddr returns '' for common RFC-invalid From values that the
old regex resolved: an unquoted address as display name
(john@example.com <john@example.com>), an unquoted comma (Doe, John
<j@example.com>), or a@x.test <b@y.test>. Allowlisted senders using such
clients were silently dropped, and under open access every one of them
shared an empty chat_id. Fall back to the bracketed address only for the
unambiguous shape (no quotes, exactly one <...> pair), so the quoted
display-name spoof still resolves to the attacker. _parse_fetched_message
now drops messages whose From yields no address instead of dispatching
an empty identity.

The dmarc header.from alignment check read header.from from props merged
across all clauses, so a later dkim clause's header.from could override
the dmarc clause's value. Read it from the dmarc clause only, and pin the
misaligned dmarc=pass rejection in the existing dmarc test (previously
no test covered it). Also drop a redundant _domain_of ternary, trim the
_extract_email_address docstring and a duplicate test assertion.
2026-09-27 20:49:08 +05:30
kshitijk4poor
54b0f5db25 fix(email): only trust dmarc=pass when header.from matches the parsed From
_verify_sender_authentication accepted any dmarc=pass verdict, even one
issued for a different domain than the From we parsed. That is what let
the quoted-display-name spoof ride on the attacker's own truthful DMARC
pass. When the trusted Authentication-Results names header.from, require
it to align with the From domain; otherwise fall through to the aligned
SPF/DKIM checks. Defense in depth for the #124322 parser fix.
2026-09-27 20:49:08 +05:30
KeelTrace
d0c588972e fix(email): parse the From mailbox with the stdlib, not a first angle-bracket match
_extract_email_address took the FIRST <...> pair, so
From: "Victim <victim@example.com>" <attacker@evil.test> resolved to the
victim. The attacker's own domain passes DMARC truthfully, so the
allowlist (EMAIL_ALLOWED_USERS), pairing and session identity were all
evaluated against an address the sender does not control.

Use email.utils.parseaddr, which keeps the quoted text as the display
name and returns the real addr-spec. RFC 5322 folding is unfolded first
so a folded quoted display name is not mistaken for the mailbox.

Salvages #124322.
2026-09-27 20:49:08 +05:30
kshitijk4poor
f9771a445d fix(simplex): warn about name allowlist entries once per process, as authz reads them
Both reconnect paths (gateway/run_adapters.py watcher and multiplex
secondary) build a FRESH SimplexAdapter before connect(is_reconnect=True),
so the per-instance _allowlist_warned flag never suppressed anything: a
daemon-down cold boot re-logged the warning on every backoff retry. Gating
on `not is_reconnect` would instead lose the warning when the first connect
fails. Dedup at module level keyed on (hermes_home_key(), frozenset(names)),
still checked before the connectivity probe. No shared warn-once helper
exists (plugin_compat.warn_once is compat-specific).

Read the value with platform_gate_env (the reader authz uses; differs from
get_scoped_secret when a scope is installed with multiplex off) and decode
JSON list literals with decode_json_list_literal like _coerce_allow_set, so
'["4","9"]' written by `hermes config set` no longer warns that valid IDs
are ignored.

The caplog test now builds two fresh adapters (first connect fails, second
succeeds) and asserts exactly one warning naming only 'alice'; it fails with
2 warnings against the pre-fold adapter.
2026-09-27 20:47:41 +05:30