Commit Graph

99 Commits

Author SHA1 Message Date
teknium1
73ba639092 fix(imessage): strip markdown on every Photon plain send and keep link URLs on BlueBubbles
Follow-up on the two salvaged commits:
- One _sidecar_payload_text() helper decides the /send text for both the adapter
  path and _standalone_send (cron / send_message), which had the same raw-markdown
  leak on URL-bearing messages and never stripped with PHOTON_MARKDOWN=false.
- BlueBubbles (same iMessage surface) keeps [label](url) targets as bare URLs.
- Tests trimmed to two invariants.
2026-09-23 03:10:15 -07:00
Semir Kabir
ccfb68688e fix(photon): stop advertising fenced code-block support on iMessage
The gateway's tool-progress path emits terminal commands as fenced code
blocks on any adapter whose supports_code_blocks is True. The Photon
adapter set that flag from PHOTON_MARKDOWN, but the sidecar's /send
router (send-format.mjs) silently routes every URL-bearing message
through the plain-text builder — where fences survive as literal backtick
characters — and even the markdown path renders a fence as inline
monospace text, not a block. Net effect: raw fenced terminal commands
(and raw markdown around them) surfaced in iMessage bubbles no matter
what the user's prompt-level style rules said.

Fix the whole class: the adapter never claims code-block support, so the
gateway emits its compact one-line tool preview instead. Prose markdown
passthrough (bold/italic/headings) is unchanged, and no config change is
required — tool_progress can stay at its user's preferred mode.

Tests: new capability + E2E tests pin that the gateway cannot emit a
fence for Photon with real adapter + real display resolution; the old
supports_code_blocks-mirrors-env expectation (which pinned the buggy
behavior) now asserts the flag is always False.
2026-09-23 03:10:15 -07:00
Eddie Wang
449ac2fb8f fix(photon): strip markdown when the sidecar downgrades to the text builder
chooseSendFormat() routes markdown containing a raw http(s) URL through
spectrum-ts' text() builder, because the markdown builder's iMessage data
detection 500s on those messages (#73615). text() ships the payload
verbatim, and nothing strips the markers on the way down, so any reply
that mentions a link arrives in iMessage as literal markdown source:

    **Release 1.2.0** is out
    - **EUR 5** off this month
    https://example.com/releases/1.2.0

Every ** is visible in the bubble. Remove the URL from that same reply
and it renders correctly, which is what makes the URL the trigger rather
than the content.

spectrum-ts documents markdown() as degrading to readable plain text on
platforms without native support "instead of surfacing raw ** markers".
Selecting text() ourselves opts out of that guarantee, so the adapter has
to honour it instead.

Strip in _sidecar_send() when the payload is markdown and the sidecar will
downgrade it. The format key is deliberately preserved: the sidecar owns
the builder choice (test_rich_links.py pins that contract), and an older
sidecar without chooseSendFormat must keep rendering natively.

_send_plain_fallback() selects the text builder explicitly via
markdown=False and had the same leak, so it strips too.

Reuses the shared strip_markdown() helper rather than adding a second
implementation, so the PHOTON_MARKDOWN=false path and this path produce
identical output and both inherit any future fix to that helper.

Diagnosis previously reported in #85733, which was closed unmerged.

Stripping must not take the URL with it, though. The shared helper
collapsed [label](url) to label alone, which on this path is worse than
raw markdown: iMessage auto-links bare URLs and nothing else, so the
reply arrives with a description and no way to reach the link.

    Open the itinerary on Google Flights     <- URL gone entirely

So strip_markdown() takes keep_link_targets, which rewrites
[label](https://url) as "label\nurl" (own line, because these URLs are
often long) and leaves non-http targets such as mailto: or relative
paths label-only, since iMessage won't linkify those either. The default
is unchanged, so the SMS, IRC, Feishu and QQ callers keep dropping the
target as before.

plugins/platforms/line/adapter.py already carries a private
strip_markdown_preserving_urls() for exactly this reason ("LINE
auto-links bare URLs only"). This moves the behaviour behind the shared
helper instead, so Photon's three plain-text paths -- the downgrade,
_send_plain_fallback(), and PHOTON_MARKDOWN=false -- all agree.

gateway/platforms/bluebubbles.py is the same iMessage surface with the
same loss; left alone here to keep this change to one platform.
2026-09-23 03:10:15 -07:00
teknium1
3f58435428 fix(contributors): map forkbert noreply identity to PR #117479 author tylerbrevard; say why the probeUpstream wiring gate reads source 2026-09-20 18:23:28 -07:00
teknium1
8d8b7b9cff test(photon): pin that probeUpstream builds its synthetic id with the GUID helper 2026-09-20 18:23:28 -07:00
Forkbert
b113ab6de6 fix(photon): use GUID for liveness probe message id
(cherry picked from commit cdb6ccadb045e599db795823a100ae7b61d377ec)
2026-09-20 18:23:28 -07:00
teknium1
587bb10575 fix(platforms): Discord/WhatsApp/DingTalk gates honour allowlists stored as a JSON-list string
`hermes config set KEY '["-100","-200"]'` used to write the literal as one quoted
YAML string; the writer now emits a real list (acaac9a18, #88163) and the Telegram
gate decodes the legacy string shape (122ad719, #110213). The Discord, WhatsApp and
DingTalk gate parsers still comma-split that string into `{'["-100"', '"-200"]'}`,
so a config written before the writer fix silently locks every allowlisted chat,
channel or user out — with no warning.

Route every remaining comma-split gate through the shared
`gateway/platforms/_shared.py::decode_json_list_literal`:

- Discord `_gate_csv_set` (allowed/ignored/no-thread channels, allowed users/roles),
  `_discord_free_response_channels` and `_missed_message_backfill_channels` now share
  the one parser instead of three hand-rolled splits.
- WhatsApp `_coerce_allow_list` (allow_from, group_allow_from, free_response_chats).
- DingTalk `_csv_set` (allowed_users, allowed_chats, free_response_chats).

Plain CSV strings, YAML lists and malformed JSON keep their previous meaning.
2026-09-20 12:10:27 -07:00
teknium1
73774fae96 test(matrix): drop the docs-text change detector
The three tests asserted literal phrases in a module docstring and two
markdown files; they guard prose, not behaviour, and would break on any
rewording. The docs change itself is the deliverable.
2026-09-20 10:42:25 -07:00
chelsealong
e23a033e85 docs(matrix): surface the DM-classification bypass in the operator-facing docs
The prior commit only documented the <=2-member DM auto-classification
in the adapter.py module docstring — source an operator configuring
MATRIX_REQUIRE_MENTION etc. would never read. Issue #114733 explicitly
asks for the callout wherever MATRIX_ALLOWED_ROOMS,
MATRIX_FREE_RESPONSE_ROOMS, MATRIX_REQUIRE_MENTION, and MATRIX_AUTO_THREAD
are documented for operators, i.e. the env-var reference table and the
Matrix user guide. Add the same rule + bypassed vars + escape hatch to
both, matching the precedent set by 08aa67e473 for this kind of env-var
clarification, and extend the doc-content test to cover them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:42:25 -07:00
chelsealong
f45ed8e4ec docs(matrix): document the <=2-member DM auto-classification and its config bypass
_resolve_room_identity() classifies any room with <=2 joined members as a DM
regardless of m.direct or an explicit room name, so those rooms silently
bypass MATRIX_ALLOWED_ROOMS, MATRIX_FREE_RESPONSE_ROOMS, and
MATRIX_REQUIRE_MENTION, and use DM threading instead of
MATRIX_AUTO_THREAD/MATRIX_SESSION_SCOPE. This was previously only visible in
an inline code comment, not in the env-var docs an operator would read.

Fixes #114733
2026-09-20 10:42:25 -07:00
teknium1
3ffee76761 fix(gateway): race IPv6/IPv4 on every cold-start WebSocket dial
The bootstrap racer covers sync connects only. The gateway's WebSocket dials
(relay connector, Yuanbao, Buzz) go through ``websockets.connect`` →
``loop.create_connection``, whose ``happy_eyeballs_delay`` defaults to ``None``:
a serial walk that burns the full connect timeout on every blackholed AAAA
record before IPv4 answers — the same stall class #114265 reports, one layer up.

``websockets`` forwards unknown kwargs to ``loop.create_connection``, so each
call site passes ``happy_eyeballs_delay=0.25`` (the RFC 8305 delay anyio and
the sync racer already use). One invariant test per call site captures the
kwargs at a mocked ``websockets.connect``.
2026-09-18 09:56:28 -07:00
teknium1
ee9c0f1a32 fix(photon): evaluate the group mention gate before caching inline attachments
`_on_sidecar_message` ran `_normalize_content` (which base64-decodes and writes
inline attachment/voice bytes into the media cache via `_cache_inbound_attachment`)
before the `chat_type == "group" and self.require_mention` check, so an
unmentioned group attachment was persisted and then dropped. Gate on the
user-typed text extracted without touching attachment bytes (`_mention_gate_text`:
text / richlink / group text items), then normalise and strip the wake word only
for messages that pass.

Same class as the Teams fix in this PR (review follow-up). One invariant test:
unmentioned group attachment -> 0 cache writes, mentioned -> 1 and dispatched.
2026-09-18 09:55:55 -07:00
fangliquan
584fc99a53 test(photon): cover inbound NDJSON framing 2026-09-15 04:34:13 -07:00
fangliquan
992b517fdd fix(photon): preserve Unicode NDJSON separators 2026-09-15 04:34:13 -07:00
teknium1
178bf0436c fix(telegram): ignored_threads and mention_patterns read scoped env → own YAML like every sibling
Rebase reconciliation with main's JSON-allowlist decoding (#109423): the two
remaining readers that consulted config.extra before the env var now follow the
per-profile precedence rule (explicit scoped env → the profile's YAML → default),
and ignored_threads still decodes a JSON-string list after the read.

The Matrix blank-YAML test asserted YAML-over-env, the old precedence #108440's
review flagged; it now pins the contract: explicit env beats YAML, a blank env
value is unset (YAML applies), YAML beats the default, and an explicit empty
list is a real "no rooms" value.
2026-09-13 15:39:11 -07:00
teknium1
3ad65cc892 fix(matrix): blank YAML values fall through to env at every extra-first reader
545e74d0 (post-0.21.2) made the Matrix YAML bridge seed its values into
PlatformConfig.extra so secondary multiplex profiles read their own config. The
"csv" bridge kind seeds any non-None value, so `free_response_rooms: ''` now
reaches extra as '' — and the readers' `if raw is None` fallback no longer fires,
so MATRIX_FREE_RESPONSE_ROOMS is ignored and require_mention drops every
un-mentioned message. Before that commit the bridge only wrote env and the key
was absent from extra, so the env value applied.

Route the three identity-check readers (_extra_csv_set, _extra_truthy,
_resolve_max_message_length — the last a three-tier chain where '' also
short-circuited the plugin-registry default) through the shared
gateway.platforms._shared.extra_or_secret, whose default already treats a blank
string as unset (the idiom mattermost/dingtalk/slack readers use). Explicit
scalars, bools and lists (including []) stay authoritative.

Two invariant tests replace the salvaged suite (moved to
tests/plugins/platforms/matrix/ to mirror the source path): blank falls through
for all three readers; explicit values still beat env.

Fixes #109358
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
2026-09-13 14:42:33 -07:00
teknium1
122ad719b5 fix(telegram): runner-side allowlist gate decodes JSON list strings too
The adapter fix decoded `'["-100","-200"]'` before comma-splitting, but
the runner's central gate in gateway/authz_mixin.py::_coerce_allow_set
reads the same YAML-bridged env chain (TELEGRAM_GROUP_ALLOWED_CHATS,
TELEGRAM_ALLOWED_USERS via _auth_env) and still produced
{'["1"', '"2"]'}, so a group message admitted by the adapter could
still be rejected upstream.

Move the decoder to gateway/platforms/_shared.py, which both the adapter
and authz_mixin already import from (no plugin -> gateway cycle), and
route _coerce_allow_set through it. One invariant test on the runner
side, red before this change.
2026-09-13 14:41:01 -07:00
teknium1
a3b4d70ea2 test(telegram): two invariant tests for JSON-string allowlists, under the plugin's test mirror
Trim the salvaged suite to the two invariants the fix guarantees: every Telegram allowlist
key (the five `_extra_str_set` readers plus `ignored_threads`) decodes a JSON-encoded string
and the group gate then admits the listed chat; comma strings, native lists and malformed
JSON keep the legacy split. Moved from tests/gateway/ to tests/plugins/platforms/telegram/ to
mirror the source path.
2026-09-13 14:41:01 -07:00
teknium1
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.
2026-09-13 05:32:38 -07:00
teknium1
e73f94fa83 refactor(platforms): every plugin setup wizard uses the shared declines_reconfigure gate
Fourteen platform plugins hand-rolled the "already configured? Reconfigure? [y/N]"
gate at the top of interactive_setup (env check + info line + prompt_yes_no(..., False)),
with drifting wording ("X: already configured" vs "X is already configured." vs
"already enabled") and, for LINE and SimpleX, raw input() loops with their own
EOF/KeyboardInterrupt handling and no gate at all. Fixes to the gate (non-interactive
handling, wording, default) therefore reached only the core Telegram/BlueBubbles/webhook
wizards.

- hermes_cli/setup_platforms.py: `_declines_reconfigure` becomes the public
  `declines_reconfigure(label, question, *env_vars)` (any-of env check, so Matrix's
  token-or-password gate fits); `_save_prompted` becomes `save_prompted` alongside it.
  No alias kept; the three core callers are updated.
- buzz, dingtalk, discord, feishu, google_chat, irc, matrix, mattermost, raft, slack,
  teams, wecom: the hand-rolled gate is replaced by one `declines_reconfigure(...)` call;
  post-decline extras (Discord allowlist nudge, Slack manifest refresh, Raft "Keeping"
  line) stay local and unchanged.
- line, simplex: the raw input() loops move onto hermes_cli.cli_output.prompt (masked
  for secrets, "" on Ctrl-C/EOF) and gain the shared gate on their primary env var.

Behavior change: the gate's info line is now uniformly "<Label>: already configured"
(DingTalk/Feishu/WeCom lose the trailing period + inline ID; Buzz/IRC/Google Chat/Raft/
Teams no longer echo the current value in that line). Feishu and WeCom now gate on the
app/bot ID alone instead of ID AND secret. LINE and SimpleX gain a "Reconfigure?" [y/N]
prompt when already configured; their prompts now honour HERMES_NONINTERACTIVE and print
via the CLI helpers instead of bare print(). Prompt defaults (No) are unchanged everywhere.

Not touched: WhatsApp's gate keys on WHATSAPP_ENABLED being truthy (a "false" value must
not count as configured), which the shared any-set gate cannot express — left hand-rolled.

Test: tests/plugins/platforms/test_interactive_setup_reconfigure_gate.py parametrized over
the 14 wizards — with the primary env var set and the user declining, each wizard must have
called declines_reconfigure with that var and returned without prompting or saving.
Sabotage: reverting mattermost's gate fails that row.
2026-09-13 05:32:38 -07:00
teknium1
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).
2026-09-13 05:32:38 -07:00
teknium1
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.
2026-09-13 05:21:39 -07:00
kshitijk4poor
ab2f4602de refactor: MessageEvent to gateway/platforms/event.py; ElicitationHandler takes a call_context thunk
Breaks the two import cycles that forced Protocol stand-ins in the F821 sweep, so the two
sites now name the real types.

gateway/platforms/event.py (new leaf): MessageType, ProcessingOutcome, MessageEvent moved
out of base.py verbatim. Their only dependency is gateway.session.SessionSource; base.py
imported helpers.py at module level, so helpers could not name MessageEvent. Now
TextBatchAggregator is typed by the real MessageEvent. 249 importers repointed
(`from gateway.platforms.base import` -> `.event`, preserving each import's layout);
gateway.platforms.__init__ re-exports from .event. The three revert-scheduled PLUGIN-COMPAT
pointers that named these symbols (gateway.slash_commands → MessageType, dingtalk → MessageType,
photon → ProcessingOutcome) and their COMPAT_MANIFEST rows now target gateway.platforms.event.
Docs updated: ADDING_A_PLATFORM.md, adding-platform-adapters.md (en + zh-Hans).

tools/mcp_tool_sampling.py: ElicitationHandler no longer holds a back-reference to its
MCPServerTask (mcp_tool imports sampling, so the task type cannot be named there). It only
ever read owner._pending_call_context, so it takes `call_context: Callable[[], Context | None]`
and MCPServerTask passes `lambda: self._pending_call_context`. The consent call is one
`functools.partial`, run directly or inside the captured Context.

ty on the 11 touched production files vs origin/main: 0 new diagnostics, 14 resolved.
(The one `source: SessionSource = None` diagnostic moves with the class; typing it Optional
exposes ~60 unguarded call sites — separate follow-up.)

Tests: tests/gateway + tests/plugins + tests/tools + touched files, 18,235 passed; the 31
failures reproduce identically on origin/main (macOS /private/tmp, systemd socket,
long-path fixtures, live-service tests).
2026-09-07 22:47:33 +05:30
Teknium
b610e603db simplify(compat): plugins/platforms+web — drop 7 re-exports + 3 aliases, repoint 1 caller + 11 tests, re-remove credential_summary
dingtalk: drop DINGTALK_TYPE_MAPPING/EXT_MAP re-exports. google_chat: card_spec_to_cards_v2 test -> .cards.
matrix: drop module-level MAX_MESSAGE_LENGTH alias (no importers). teams: drop TeamsSummaryWriter re-export
(teams_pipeline/runtime + tests -> summary_writer). wecom: drop WeComStreamExpiredError/STREAM_EXPIRED_ERRCODE/
MAX_INTERMEDIATE_FRAMES re-exports (tests -> .streaming). parallel: drop _get_parallel_client/_get_async_parallel_client
aliases (tests -> _get_sync_client). email: drop stale 'alias' comment (_esecret_int is the only name).
photon: re-remove credential_summary() (shim-only, cb9b7c36f3); its no-leak test now drives print_credential_summary.
2026-09-03 13:04:17 -07:00
Teknium
cb9b7c36f3 review-fix(photon): restore credential_summary() (public on main) + test_credential_summary_no_secret_leak
ethernet8023: the no-leak test was deleted alongside the function it pinned; the
display-only credential status is live in print_credential_summary. credential_summary
restored byte-identical to BASE so the leak contract is CI-pinned again.
2026-09-03 09:36:07 -07:00
Teknium
8f1ef66b1f refactor(adapters/p2p_group): 12752->8794; buzz/photon/a2a/raft dedupe (sidecar paths unified in photon package, JSON-RPC helpers, Nostr event builders), dead a2a security getters + stream_message removed 2026-09-02 14:06:36 -07:00
nftpoetrist
c36def6aea fix(photon): scope project_id/node_bin/require_mention/reactions/sidecar config to the active profile under multiplexing
PhotonAdapter.__init__, check_requirements, validate_config,
_env_enablement, _markdown_enabled, _reactions_enabled, and
_standalone_send in adapter.py, plus load_project_credentials and
load_dashboard_project_id in auth.py, all read PHOTON_PROJECT_ID/
PHOTON_NODE_BIN/PHOTON_SIDECAR_PORT/PHOTON_SIDECAR_AUTOSTART/
PHOTON_PROBE_*/PHOTON_REQUIRE_MENTION/PHOTON_MENTION_PATTERNS/
PHOTON_REACTIONS/PHOTON_MARKDOWN/PHOTON_HOME_CHANNEL(_NAME)/
PHOTON_DASHBOARD_PROJECT_ID via raw os.getenv -- only
PHOTON_PROJECT_SECRET and PHOTON_SIDECAR_TOKEN were already scoped via
_get_scoped_secret.

Notably __init__'s project_id read was a stronger variant of the bug
(like the IRC fix in this series, item 11): the original
`os.getenv("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id`
ordering let a raw env read override even an explicitly configured
config.yaml extra -- a secondary profile that set its own project_id via
extra would still silently authenticate against the default profile's
Spectrum project, because the default profile's project id is always
bridged to os.environ under multiplex and env was checked first.

_reactions_enabled() and the require_mention/mention_patterns reads in
__init__ are exercised on every live inbound message / tapback, not just
at construction, so a secondary profile's reaction/mention-gating
behavior would be driven by the default profile's settings for the
adapter's entire runtime lifetime.

Switch every raw PHOTON_* read (except the two already scoped) to
_get_scoped_secret(), matching the module's existing helper (already
defined identically in both adapter.py and auth.py). Left
_dashboard_host()/_spectrum_host() and the interactive device-login flow
functions in auth.py untouched -- these are CLI-only management-plane
calls (`hermes photon login`/`setup`), not part of the gateway's
per-profile adapter construction/connection lifecycle, so they are not
reachable under a multiplexed secondary profile's scope; noted as a
"Scope note" in the PR body rather than silently expanding scope to
unreachable call sites.

Adds a new tests/plugins/platforms/photon/test_multiplex_profile_scope.py
(9 tests, two classes covering auth.py and adapter.py separately)
mirroring the fixture/assertion style established in
tests/gateway/test_line_plugin.py's TestMultiplexProfileScope, reusing
test_auth.py's tmp_hermes_home isolation pattern so tests don't depend on
the real ~/.hermes/auth.json fallback. Mutation-verified: stashed the
production fix and confirmed 7 of 9 new tests fail against pre-fix code
(the other 2 are non-differentiating regression guards -- unscoped-
default-profile-precedence, one per class -- which correctly pass either
way). Restored the fix; all 140 tests in tests/plugins/platforms/photon/,
the 10 photon-related parametrized tests in
test_adapter_startup_secret_scope.py, and the broader
test_multiplex_adapter_registry.py / test_adapter_connect_classification.py
suites (45 tests) pass.
2026-09-02 07:01:23 -07:00
liuhao1024
a684d154bc fix(buzz): let the requirement gate see externally managed secrets
check_requirements() runs at gateway startup before any per-profile
secret scope is installed, and the scope-less get_secret path reads
only os.environ -- so a Bitwarden-managed BUZZ_PRIVATE_KEY (only
BWS_ACCESS_TOKEN in .env) was invisible to the platform gate and Buzz
was silently skipped with a misleading install hint (#95216). When no
scope is active and the process env has no value, consult a cached
one-shot build of the profile secret mapping (build_profile_secret_scope
resolves external secret sources); an active scope still shadows this
rung entirely, so multiplexed cross-profile isolation is unchanged.
BUZZ_RELAY_URL reads in the gate now go through the same helper so an
externally managed relay passes too.
2026-08-31 07:28:30 -07:00
Teknium
d63f996a75 feat(photon): read-receipt toggle, receipt-type alias, docs
Follow-ups on top of #98964's cherry-pick:
- PHOTON_READ_RECEIPTS env toggle (default true) so users can keep
  messages at Delivered; declared in plugin.yaml optional_env
- adapter drops both 'read' and 'read_receipt' content types (alias
  coverage from #91759 by @mooserini) + regression test
- docs: photon.md feature note + environment-variables.md row
2026-08-30 18:37:50 -07:00
Zihan Huang
9744fc0c99 feat(photon): support iMessage read receipts 2026-08-30 18:37:50 -07:00
Teknium
c05f0bb81d test: importorskip discord.py in the slash-gate isolation test
CI's plugin-test slice runs without the discord optional extra; the raw
import failed with ModuleNotFoundError while every other test in the
file uses injected mock modules.
2026-08-01 10:51:42 -07:00
Teknium
81c0691e17 fix(gateway): per-profile Discord/Telegram allow-deny gates under multiplex_profiles
Under gateway.multiplex_profiles, Discord and Telegram authorization gates
(allowed/ignored channels, allowed users/roles, allow-all flags) were read
from process-global os.environ, populated first-writer-wins by the YAML->env
bridge in each adapter's _apply_yaml_config. The first profile to initialize
pinned its allow/deny lists — and its ALLOW_ALL flags — for every other
profile in the process (issue #72348, incl. the Telegram mirror reported in
the thread).

Fix (per-adapter-instance gate reads, whole class):

- gateway/authz_mixin.py: new _platform_gate_env — scope-authoritative gate
  read: under an installed profile secret scope with multiplex active, a
  missing key returns the default instead of falling through to os.environ
  (which may hold another profile's value). Single-profile behavior is
  byte-identical to os.getenv.
- Discord adapter:
  - connect() snapshots all gate env vars (_GATE_ENV_KEYS) inside the owning
    profile's runtime scope into a per-adapter dict; new accessors
    (_get_allowed_channels/_get_ignored_channels/_get_allowed_users/
    _get_allowed_roles/_get_no_thread_channels/_discord_allow_all_users/
    _gateway_allow_all_users/_get_allow_bots) resolve snapshot -> config.extra
    -> scope-aware env, replacing every raw os.getenv gate read: on_message
    channel gates, _is_allowed_user allow-all flags, slash authorization,
    fail-closed diagnostics, missed-message backfill, bot-message gating,
    and _component_check_auth (component buttons).
  - _apply_yaml_config always seeds gate values into PlatformConfig.extra
    (incl. new allowed_roles / allow_all_users keys) and SKIPS the
    process-global env writes when loading a profile-scoped config under
    multiplex; the legacy first-writer env bridge is preserved verbatim for
    single-profile deployments.
  - _resolve_allowed_usernames no longer unconditionally rewrites
    os.environ[DISCORD_ALLOWED_USERS] — under multiplex the resolved IDs stay
    adapter-local (snapshot refresh); single-profile keeps the env rewrite.
- Telegram adapter (mirror of the same class): intake prefilter and
  callback-auth fallbacks, _telegram_auth_env_configured, and the
  allowed/ignored chats-topics-threads getters now read via the scoped gate
  reader; _apply_yaml_config skips authorization env writes for
  profile-scoped loads and seeds free_response_chats/ignored_threads extras.

Regression tests (tests/plugins/platforms/test_discord_gate_isolation.py):
two adapter instances with different allow-lists enforce their OWN lists
order-independently across message, slash, and component gates; negative
allow-all case proves profile A's open-access flag cannot authorize profile
B; username-resolution env-clobber; YAML-bridge seeding/skip matrix; and the
Telegram scoped-reader matrix. Sabotage-verified: reverting either the
Discord snapshot accessors or the Telegram scoped reader fails 12/2 tests
respectively.

Credit: builds on the per-adapter accessor direction of PR #72427
(@JonthanaHanh) and the scope-aware-reader approach validated live on v0.19.0
by @yournetworkplug-ctrl for the Telegram mirror; scope corrections from
jackjin1997's and cal88's analysis in the issue thread (allow-all flags,
unguarded username-resolution env write, per-site channel reads).

Fixes #72348
2026-08-01 10:51:42 -07:00
Teknium
6b81590c55 test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00
Teknium
b62fc24dfa refactor(photon): resolve sidecar dir lazily, not at import time
resolve_sidecar_dir() probes the filesystem (touch/unlink) and can mirror
sidecar files to HERMES_HOME. Doing that as a module-import side effect
meant plugin discovery, `hermes --help`, and test collection all paid a
filesystem probe (and possibly a mirror copy) just for importing the
photon adapter or CLI.

Convert _SIDECAR_DIR/_NPM_ERROR_LOG in adapter.py and cli.py to lazy
cached accessors (_sidecar_dir()/_npm_error_log()); resolution now
happens on first actual use. Existing tests that monkeypatch the
_SIDECAR_DIR module global keep working — the accessors honor a
non-None value. Adds a regression test proving import performs no
resolution.
2026-07-28 22:41:32 -07:00
Shannon Sands
0dfd5546fc fix(photon): support immutable install trees for the sidecar (NS-606)
The Photon iMessage sidecar needs node_modules under
plugins/platforms/photon/sidecar/, but hosted/managed images keep the
whole install tree under an immutable /opt/hermes — every install and
self-heal path (setup CLI, stale-deps reinstall, cold install) died on
EROFS, and hosted users have no shell to work around it.

Three-layer fix, mirroring the WhatsApp bridge resolver pattern:

1. Bake the deps into the image. The Dockerfile now runs npm ci for the
   sidecar in the layer-cached dependency stage (deterministic installs
   from the committed lockfile; the postinstall spectrum-ts patch runs
   at build time). Hosted happy path needs no runtime install at all.

2. New sidecar_paths.resolve_sidecar_dir() decides where the sidecar
   runs from: PHOTON_SIDECAR_DIR override > writable source dir (dev
   installs, unchanged) > read-only dir with baked fresh deps (managed
   image) > mirror to $HERMES_HOME/photon/sidecar (writable data
   volume) when deps are missing or stale in a read-only tree. The
   mirror refreshes changed source files on image updates while
   keeping node_modules, so the existing lockfile-staleness self-heal
   works there.

3. connect() can now cold-install: _start_sidecar() runs the bounded
   npm ci bootstrap when node_modules is missing instead of raising
   immediately, and check_requirements() reports available when a
   self-install is possible (npm present + writable resolved dir) so
   the gateway actually creates the adapter on hosted instances. A
   failed bootstrap still raises the actionable error, which connect()
   surfaces as the retryable SIDECAR_FAILED fatal state on the
   dashboard.

Tests: resolver decision table (env override, in-place, mirror,
refresh, fail-open), cold-install lifecycle paths, and a Dockerfile
contract test guarding the baked-deps + no-chown invariants.

Fixes NS-606.
2026-07-28 22:41:32 -07:00
Teknium
5940026245 test(photon): drain detached fatal-notification task in zombie watchdog test
The lifecycle cluster made fatal notifications detached; the watchdog test
still asserted synchronously.
2026-07-28 22:22:42 -07:00
Teknium
f2364f1f81 test(photon): copy sidecar helper modules into the spectrum-patch fixture
index.mjs now imports sibling .mjs helpers (send-format, stream-staleness);
the fixture copied only index.mjs so the sidecar died on module resolution
before reaching the health endpoint.
2026-07-28 22:22:42 -07:00
Teknium
dcace573da fix(photon): rework zombie-stream watchdog for spectrum-ts 8 with strict probe semantics
Maintainer rework of #45580 (issue #54036) on top of the contributor's
cherry-pick, which targeted spectrum-ts 3.1.0 while main pins 8.0.0:

Sidecar (primary detection, new):
- stream-staleness.mjs: pure decision rules, executable under node.
  * classifyProbeRejection: only a not-found-shaped rejection of the
    synthetic-id read counts as a completed round-trip (ALIVE); any other
    rejection is INCONCLUSIVE — never alive. The original /probe treated
    ANY rejection as alive, which was too loose.
  * shouldProbe: probe only after 10+ min of stream silence (configurable
    via PHOTON_STREAM_SILENCE_PROBE_MS; <=0 disables) with a cooldown.
  * isZombieSuspect: zombie only on silence past threshold AND a
    probe-proven live channel. Silence alone NEVER degrades (shared lines
    can be quiet for hours); inconclusive probes NEVER degrade (network
    may be down — the iterator will throw and the re-subscribe loop
    recovers on its own).
- index.mjs: track last inbound-iterator yield (noteInboundYield), run a
  30s watchdog tick, and on a confirmed zombie feed markStreamDegraded ->
  the existing exit-75 restart path. /healthz gains a stream.staleness
  block (silentForMs, threshold, lastProbeOutcome, zombieSuspected).
  /probe reworked to strict semantics: 200 only on a proven round-trip,
  503 with outcome hung|inconclusive otherwise.

Adapter (second layer, reworked):
- _probe_once returns tri-state alive|hung|inconclusive; only a hung
  sidecar HTTP call counts toward the respawn counter — inconclusive
  resets nothing and triggers nothing.
- default probe_interval_seconds 60 -> 600 (conservative; avoid restart
  storms on quiet lines).
- _monitor_sidecar_health surfaces zombieSuspected from /healthz as a
  warning; the fatal UPSTREAM_STREAM_DEGRADED path is unchanged and fires
  when the sidecar escalates.

Tests: test_zombie_stream_watchdog.py executes the real node decision
module and drives the adapter against mocked /healthz responses;
test_presence_watchdog.py updated for the tri-state probe.

Also adds contributor mappings for nickkarhan (#53283) and vaibhavjnf
(#45580).
2026-07-28 22:22:42 -07:00
Teknium
87fe75fde4 test(photon): replace source-grep URL-routing tests with behavior tests
Follow-up to the URL markdown fix: extract the /send builder decision into
sidecar/send-format.mjs and rewrite test_url_send_path.py to execute the real
module under node (format+text in -> chosen builder out) instead of regex-
grepping index.mjs source, which is a banned test pattern in this repo.
2026-07-28 22:22:42 -07:00
vaibhavjnf
709dd3282f fix(photon): recover inbound after half-open ("zombie") gRPC stream
spectrum-ts's live-stream consumer (consumeLive in spectrum-ts 3.x) only
reconnects when its inbound async iterator throws or ends. A half-open
("zombie") gRPC socket — where the TCP connection stays ESTABLISHED but the
peer is gone (NAT idle-timeout, network blip, laptop sleep) — makes the
iterator hang forever: no error, no end. The SDK exposes no gRPC keepalive
knob (createClient takes only {address, tls, token}; grpc.keepalive_time_ms
defaults to -1 = pings off), so the inbound stream silently dies and stays
dead until the gateway is restarted. Symptom: the agent's iMessage line goes
"online but deaf" — Photon's cloud-side fallback answers users with "the agent
isn't online right now" and inbound never reaches the gateway.

Fix, entirely in the code we own (no SDK fork):

- Sidecar gains a POST /probe endpoint that drives a cheap unary read
  (space.getMessage on a synthetic id) over the SAME gRPC channel the inbound
  stream uses. A live channel round-trips in ms (server returns not-found,
  which is success for liveness); a zombie hangs. It sends nothing to any user
  and creates no chat (space.get is local in shared/dedicated mode; only the
  message read touches the wire).

- The adapter runs a presence watchdog: it probes on an interval, skips the
  probe when natural inbound traffic already proved liveness within the
  window, and after N consecutive failed probes respawns the sidecar — a fresh
  Spectrum() re-subscribes the stream and re-registers presence. Successful
  probes double as application-level keepalive, helping prevent the zombie
  from forming at all. Respawn is lock-guarded against double-spawn and the
  watchdog is torn down cleanly on disconnect.

Behavioural settings live in config.yaml (extra), bridged to env per the
.env-is-secrets-only convention:
  probe_interval_seconds (60), probe_timeout_seconds (10),
  probe_max_failures (3). A non-positive interval disables the watchdog.

Tests: tests/plugins/platforms/photon/test_presence_watchdog.py covers config
resolution, the disable switch, probe alive/dead(500)/timeout/no-client, the
core N-failures->one-respawn detection, success-resets-failures, stop-then-
start respawn ordering, and lock-guarding — all without spawning Node or
hitting the network.

Contributed by Vaibhav Sharma (X: @vabbyshabby).
2026-07-28 22:22:42 -07:00
Nick K
2f4462fcaa fix(photon): send markdown messages with URLs as text 2026-07-28 22:22:42 -07:00
huntsyea
cf550c0863 feat(photon): support rich link previews 2026-07-28 22:10:57 -07:00
vaibhavjnf
fe95194c59 feat(photon): render multiple-choice clarify as a native iMessage poll
The `clarify` tool's multiple-choice prompts flattened to a numbered text
list on Photon/iMessage, even though iMessage has a native poll bubble and
spectrum-ts already exposes it via the `poll()` content builder. Two gaps
caused the flattening:

  * Outbound: the sidecar only had `/send` (text); there was no way to send
    a poll, so the base adapter's numbered-text fallback was used.
  * Inbound: `normalizeContent()` handled only text/attachment/voice, so a
    poll vote (`poll_option`) was dropped on the floor ("[Photon content
    type not handled: poll_option]") and never resolved the clarify.

Fix, end to end:

  * Sidecar: import `poll` from spectrum-ts; add a `/send-poll` route
    (`space.send(poll(title, ...options))`); serialize inbound `poll_option`
    (the vote: chosen title + selected bool) and `poll` content in
    `normalizeContent()`.
  * Adapter: override `send_clarify` — for choices, send a native poll via
    `_sidecar_send_poll` and call `mark_awaiting_text` so the gateway's
    existing pending-clarify text-intercept resolves the answer; open-ended
    clarifies keep the plain-text path. Inbound `poll_option` selections are
    dispatched as a plain-text MessageEvent carrying the chosen option
    (deselections / empty votes are dropped). If the poll send fails (an
    older sidecar without `/send-poll`, or a send error) it falls back to the
    numbered-text clarify, so nothing regresses on a half-upgraded restart.

No new model tool, no new env var, no core change — the capability lives at
the platform edge. The poll vote reuses the existing clarify text-intercept
resolution path, so no new gateway resolution mechanism is introduced.

Tests: tests/plugins/platforms/photon/test_poll_clarify.py — inbound vote ->
choice text, deselection/empty-vote dropped, send_clarify sends a poll +
enables text-capture, open-ended stays text, and poll-failure falls back to
the text list. Full photon suite green.

Contributed by Vaibhav Sharma (X: @vabbyshabby).
2026-07-28 22:10:57 -07:00
Hermes Agent
06150f70af feat(photon): add native message effects 2026-07-28 22:10:57 -07:00
Hermes Agent
077c583c75 feat(photon): add native poll sending 2026-07-28 22:10:57 -07:00
Teknium
95d303138b test(photon): fix PlatformConfig kwargs in target_not_allowed standalone test 2026-07-28 21:45:52 -07:00
Teknium
e68f3fd825 fix(photon): harden structured sidecar error classes + target_not_allowed
Maintainer follow-up to the #51193 salvage:

- _send_with_retry: permanent classes (auth_or_config, target_not_allowed)
  now short-circuit BEFORE the unconditional plain-text fallback resend,
  including when a retry attempt surfaces one — no more double-sends of
  permanently-failing requests.
- sidecar classifySidecarError: new structured code target_not_allowed for
  Spectrum's 'Target not allowed for this project' AuthenticationError
  (shared/free-tier lines cannot initiate outbound sends to new targets).
  Classification applies to every handler sharing the catch-all
  serverError path (/send, /send-attachment, /react, /typing, ...).
- _standalone_send now parses the structured error body too (it reads
  sidecar responses independently of _sidecar_call) and returns
  error_class/retryable alongside the message.
- target_not_allowed maps to a canonical user-facing message in both
  paths; raw upstream error text never leaks through the structured code.

Closes the actionable halves of #50971, #51897, #52794.
2026-07-28 21:45:52 -07:00
SeoYeonKim
91c4c6f9d2 Preserve Photon sidecar retry semantics
Photon's Node sidecar intentionally hides raw handler exceptions, but the Python adapter still needs a safe failure class and retryability bit so delivery retries do not collapse into an opaque generic 500.

Constraint: Sidecar responses must not leak raw stack traces or private exception text

Rejected: Retry every internal sidecar error | masks permanent auth/config failures

Confidence: high

Scope-risk: narrow

Directive: Keep sidecar error text generic; extend safe error classes instead of exposing raw SDK failures

Tested: uv run --with pytest-timeout pytest tests/plugins/platforms/photon/test_overflow_recovery.py -q

Tested: uv run --with pytest-timeout pytest tests/plugins/platforms/photon -q

Tested: uv run ruff check plugins/platforms/photon/adapter.py tests/plugins/platforms/photon/test_overflow_recovery.py

Tested: python3 -m py_compile plugins/platforms/photon/adapter.py tests/plugins/platforms/photon/test_overflow_recovery.py

Tested: node --check plugins/platforms/photon/sidecar/index.mjs

Tested: git diff --check

Tested: python3 scripts/check-windows-footguns.py --diff origin/main

Not-tested: Live Photon/Spectrum delivery against a real iMessage account

Related: #50971
2026-07-28 21:45:52 -07:00
Frowtek
c0ff5e9169 fix(photon): run the Spectrum patch spawn off the gateway event loop
`PhotonAdapter._start_sidecar` is `async`, but it ran the Spectrum
mixed-attachment patch script with a bare `subprocess.run(...)`: it spawns
node and *waits* for it, with `timeout=10`. Executed inline that holds the
shared gateway event loop for the whole window, so no other platform's
messages, heartbeats, or sessions are serviced until it returns.

The same function already establishes this exact invariant twenty lines
above, where the stale-dependency reinstall hops to a worker thread:

    # Runs off the event loop so a cold install can't freeze every other
    # platform's traffic.
    if _sidecar_deps_stale():
        await asyncio.to_thread(_reinstall_sidecar_deps)

The patch spawn never got the same treatment. It is not startup-only
either — `_start_sidecar` is called from `connect()`, which takes
`is_reconnect`, so an ordinary Photon reconnect (network blip, sidecar
death) re-runs it and stalls a live gateway that is actively serving
Discord/Telegram/Slack traffic.

Dispatch it via `asyncio.to_thread` like its sibling. Same off-the-loop
class as the inbound-image decision (#66688) and the cron-fire verifier.

Adds a regression test asserting the spawn executes on a worker thread
rather than the loop thread.
2026-07-28 21:45:52 -07:00
Frowtek
a90a2b3c3c fix(photon): inspect port listeners off the gateway event loop
`_reap_stale_sidecar` is `async`, but it identified the processes holding
the sidecar port with two blocking helpers called inline:

* `_find_listener_pids` -> `subprocess.run(["lsof", ...], timeout=5.0)`
* `_pid_is_sidecar` -> `subprocess.run(["ps", ...], timeout=5.0)`, once
  per candidate pid

so the inspection can hold the shared gateway loop for 5 + 5·N seconds
while nothing else on it is serviced. It only runs once the /healthz probe
finds something already listening — the orphaned-sidecar recovery path —
and `_reap_stale_sidecar` is awaited from `_start_sidecar`, which runs on
every reconnect (`connect(is_reconnect=True)`). The stall therefore lands
on a live gateway that is still serving every other platform, right when a
crashed sidecar has already left an orphan behind.

Move the whole inspection to one `asyncio.to_thread` hop (one hop rather
than N+1 round trips). The reaping semantics are untouched: SIGTERM for
verified orphans, SIGKILL escalation, and both foreign-listener
RuntimeErrors behave exactly as before.

Same off-the-loop class as the inbound-image decision (#66688) and the
cron-fire verifier.

Adds a regression test asserting both the lsof lookup and the per-pid ps
check execute on a worker thread rather than the loop thread.
2026-07-28 21:45:52 -07:00