Skip to content

feat(plugins): add standalone_sender_fn for out-of-process cron delivery - #21805

Closed
GodsBoy wants to merge 1 commit into
NousResearch:mainfrom
GodsBoy:feat/plugin-standalone-sender-fn
Closed

feat(plugins): add standalone_sender_fn for out-of-process cron delivery#21805
GodsBoy wants to merge 1 commit into
NousResearch:mainfrom
GodsBoy:feat/plugin-standalone-sender-fn

Conversation

@GodsBoy

@GodsBoy GodsBoy commented May 8, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds an optional standalone_sender_fn field to PlatformEntry so plugin platforms (IRC, Teams, Google Chat) can register an out-of-process send path. Without this, deliver=<plugin> cron jobs fail with No live adapter for platform '<name>' when cron runs in a separate process from the gateway, even though cron_deliver_env_var (added in #21306) declared those platforms as eligible cron targets.

This is the missing third phase of plugin platform parity:

  1. 2e20f6ae2 (Apr 11) added in-process _send_via_adapter.
  2. af9336d57 (May 7) added cron_deliver_env_var so plugins become eligible cron targets.
  3. This PR: gives plugins a hook to actually deliver when cron runs out-of-process.

The hook is optional; existing plugins are unaffected.

Related Issue

Fixes #21804

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (the optional hook is also a new extension point for plugin authors)
  • 🔒 Security fix (input validation hardening on the new code paths, see Security section below)

Changes Made

  • gateway/platform_registry.py: new optional standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] field on PlatformEntry.
  • tools/send_message_tool.py: _send_via_adapter now falls through to the hook when _gateway_runner_ref() is None. Forwards thread_id, media_files, force_document kwargs. Validates the return shape. Re-raises asyncio.CancelledError instead of swallowing it. Restores the helpful Is the gateway running with this platform connected? suffix in the fall-through error and adds guidance on the new hook.
  • plugins/platforms/irc/adapter.py: stdlib-only _standalone_send that opens an ephemeral asyncio TCP/TLS connection with a -cron nick suffix (avoiding NICK collisions with the live gateway adapter), JOINs the channel before PRIVMSG so the default +n channel mode accepts the delivery, and QUITs cleanly. Also removes the dead _ensure_imports no-op.
  • plugins/platforms/teams/adapter.py: _standalone_send performs an OAuth client_credentials token grant against login.microsoftonline.com, then POSTs the activity to the Bot Framework /v3/conversations/<id>/activities endpoint. TEAMS_SERVICE_URL is validated against an allowlist of known Bot Framework hosts.
  • plugins/platforms/google_chat/adapter.py: _standalone_send resolves service-account credentials (inline JSON, file path, or ADC), refreshes the token under an asyncio.wait_for timeout, and POSTs to the Chat REST API. chat_id and thread_id are validated against strict resource-name regexes.
  • tests/tools/test_send_message_tool.py: 5 dispatch tests.
  • tests/gateway/test_irc_adapter.py: 6 IRC tests including JOIN ordering and CRLF-injection guards.
  • tests/gateway/test_teams.py: 5 Teams tests including SSRF allowlist and chat_id path-traversal guards.
  • tests/gateway/test_google_chat.py: 4 Google Chat tests including chat_id path-traversal guard.
  • website/docs/developer-guide/adding-platform-adapters.md: new section under Cron Delivery covering the hook signature, return contract, and exception handling.
  • gateway/platforms/ADDING_A_PLATFORM.md: hook listed in the optional-hooks summary.

How to Test

Reproducing the original bug (before this PR)

  1. Configure IRC: IRC_SERVER, IRC_NICKNAME, IRC_CHANNEL, IRC_HOME_CHANNEL in ~/.hermes/.env.
  2. Start the gateway in terminal A: hermes gateway.
  3. Start cron in terminal B (separate process): hermes cron run.
  4. Schedule a deliver=irc job: hermes cron add --deliver=irc --schedule="*/2 * * * *" --prompt="say hello".
  5. Observe the cron log: Job '...': delivery error: No live adapter for platform 'irc'....

Verifying the fix

  1. Pull this branch.
  2. Repeat steps 1-4 above.
  3. Cron log now shows: Job '...': delivered to irc:#<channel>. The message arrives in the configured channel.
  4. The gateway can be stopped and restarted; cron deliveries continue to succeed because the standalone path opens its own ephemeral connection per fire.

Automated tests

bash scripts/run_tests.sh tests/tools/test_send_message_tool.py tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py tests/gateway/test_google_chat.py reports 341 passed, 0 regressions, 7 pre-existing warnings.

Security

The new code paths receive operator-controlled config (env vars, pconfig.extra) and reach external services with bearer tokens. Defenses included:

Risk Defense
CRLF injection via IRC chat_id or message body _strip_irc_control_chars blanks \r/\n/\x00; chat_id rejected outright if it contains line terminators or whitespace
Teams SSRF / token exfiltration via tampered TEAMS_SERVICE_URL Allowlist (smba.trafficmanager.net, smba.infra.gov.teams.microsoft.us); HTTPS-only
URL path injection via Teams / GChat chat_id Strict regexes match the documented Bot Framework / Google Chat resource-name character sets
Hung Google STS endpoint stalling cron creds.refresh wrapped in asyncio.wait_for(timeout=10)
Hung writer.wait_closed after IRC QUIT Wrapped in asyncio.wait_for(timeout=5)
Bare except Exception swallowing CancelledError All three standalone senders re-raise CancelledError

Tests cover each guard.

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation (adding-platform-adapters.md, ADDING_A_PLATFORM.md)
  • I've updated cli-config.yaml.example if I added/changed config keys: N/A (no new config keys, only platform-plugin-internal env vars)
  • I've updated CONTRIBUTING.md or AGENTS.md: N/A (no architectural change to user-facing workflows)
  • I've considered cross-platform impact (Windows, macOS): the new code is pure Python asyncio + aiohttp; no POSIX-only calls.
  • I've updated tool descriptions/schemas if I changed tool behavior: the send_message tool's externally observable contract is unchanged when no plugin registers the hook; the only change is the cron error string now mentions the new option in addition to the existing "is the gateway running" guidance.

Notes for reviewers

  • Hook contract documented in gateway/platform_registry.py:128-148; the signature mirrors the internal _send_via_adapter shape so review friction is minimal.
  • IRC reference migration uses a -cron nick suffix to avoid NICK collisions with a live gateway adapter holding the configured nickname on the same network.
  • Teams and Google Chat reference migrations are intentionally one-shot: no token caching, no retry logic, no connection pooling. The cron scheduler already owns the retry surface; future optimization can layer caching on top without breaking the contract.
  • media_files and force_document kwargs are forwarded to the hook for signature parity, but the three reference implementations send text-only (with a docstring note). The live adapter still handles attachments via the SDK; cron jobs typically deliver text summaries.
  • The learnings-researcher step in pre-PR review noted that this work is the natural completion of the in-flight Phase 2 / Phase 3 plugin parity effort. The dispatch shape, registry field convention, and per-plugin migration shape all match the patterns Teknium established in 2e20f6ae2 and af9336d57.

Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in NousResearch#21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.

This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.

Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.

Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
  block CRLF command injection; bounded nick-collision retries; JOIN
  before PRIVMSG so channels with the default `+n` mode accept the
  delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
  Bot Framework hosts (`smba.trafficmanager.net`,
  `smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
  tenant_id constrained to the documented Bot Framework character set;
  per-request timeouts so a slow STS endpoint cannot starve the
  activity POST.
* Google Chat: chat_id and thread_id validated against strict
  resource-name regexes; service-account refresh wrapped in
  `asyncio.wait_for` so a hung token endpoint cannot stall the
  scheduler.

Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.

Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cron Cron scheduler and job management comp/plugins Plugin system and bundled plugins labels May 8, 2026
@GodsBoy

GodsBoy commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

CI Status Summary

All CI failures on this PR are unrelated to the changes. Posting this for triage transparency.

Failures and their root causes

Job Status Cause PR-related?
test fail (42 failures) 39 are the same pre-existing failures present on main (run 25550356574 from 10:22 UTC). 3 are flaky-on-PR-only: test_restart_drain (draining-state race), test_skill_provenance (origin tracking race), test_local_interrupt_cleanup (SIGTERM orphan-subprocess race, pre-existing per the assertion message). My diff doesn't touch any of these subsystems. No
docs-site-checks fail ascii-guard lint docs reports 8 errors at adding-platform-adapters.md:469-482 and 2 errors elsewhere. Lines 469-482 are the pre-existing markdown table for "Documentation" file checklist; the linter flags |-delimited table rows as ASCII-box rendering. My diff added a new section around lines 254-289 only. No
ruff + ty diff fail The lint result is 0 ruff issues, 18 new ty warnings (15 in pre-existing run_agent.py and unrelated test files; 3 false-positive-ish from ty's optional-dep handling in this PR). The job's own message: "Diagnostics are surfaced as warnings, this check never fails the build." The job error is HTTP 403 trying to post the lint summary as a PR comment, a first-time-contributor token-permissions issue. No
Scan PR for critical supply chain risks pass
check-attribution pass
e2e pass
nix (ubuntu-latest) pass
nix (macos-latest) pass

Local test verification

bash scripts/run_tests.sh tests/tools/test_send_message_tool.py tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py tests/gateway/test_google_chat.py reports 341 passed, 0 failures. The 20 new standalone-send tests added by this PR all pass on first run with the same hermetic test environment CI uses.

Happy to trim scope, split, or add additional tests if any of the above reads differently to a maintainer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: cron deliver= to plugin platforms (IRC, Teams, Google Chat) fails with 'No live adapter' when run out-of-process

2 participants