Skip to content

feat(cron/slack): flat in-channel continuable cron delivery surface - #260

Merged
hashbender merged 1 commit into
mainfrom
mirror/pr-56254
Jul 1, 2026
Merged

feat(cron/slack): flat in-channel continuable cron delivery surface#260
hashbender merged 1 commit into
mainfrom
mirror/pr-56254

Conversation

@hashbender

Copy link
Copy Markdown
Owner

Summary

Adds a per-platform cron_continuable_surface config key so a continuable cron job can deliver flat into a Slack channel — no dedicated hidden thread — and still be replied-to and continued in context. Salvage of NousResearch#56096 by @benbarclay, rebased onto current main.

Today continuable cron (cron.mirror_delivery / per-job attach_to_session) is thread-preferred: on any thread-capable platform it unconditionally mints a hidden handoff thread. This adds a "continuable but flat" mode.

How it works

platforms.<p>.extra.cron_continuable_surface takes "thread" (default, byte-identical to today) or "in_channel". In in_channel mode the scheduler skips the thread-open branch (leaves thread_id=None) so the delivery posts flat, then _seed_cron_channel_session creates the flat shared-channel session (slack, chat_id, None) and mirrors the brief into it — the same bucket reply_in_thread: false routes inbound channel replies to. A plain channel reply resolves to that session with the brief in context.

The scheduler reads the key generically from platform config; the in_channel branch is gated on the base-adapter capability flag supports_inchannel_continuable (Slack = True). Any platform without an implementation fails safe to thread with a debug log — no Slack-only special-case in core.

Changes

  • gateway/platforms/base.pysupports_inchannel_continuable capability flag (default False).
  • plugins/platforms/slack/adapter.py — flag True; _cron_continuable_surface() resolver; connect-time warning when in_channel is set without reply_in_thread: false.
  • gateway/config.py — shared-key bridge line.
  • cron/scheduler.py — resolve the surface generically, gate the in_channel branch on the capability flag, skip thread-open, and _seed_cron_channel_session to create + seed the flat session.
  • Docs: cron.md + slack.md (+ zh-Hans mirrors).

Salvage follow-up (ours)

Corrected stale "no new seed code (G6)" comments in the scheduler and the test class docstring — the earlier "let the existing mirror seed it" design was superseded during implementation (the mirror only appends to an existing session; the flat channel row is absent for a chat_postMessage delivery, so the seed must create it first).

Validation

Result
tests/cron/test_scheduler.py 205 pass
tests/gateway/test_slack_cron_continuable_surface.py 10 pass
tests/gateway/test_slack_mention.py 65 pass
Total 280 pass, 0 fail

Manual offline E2E scripts under tests/manual/ (no test_ funcs, __main__-guarded → not collected by CI). Base 0 commits behind main; diff shows only the PR's 13 files.

Infographic

Flat in-channel cron infographic


Salvaged from NousResearch#56096. Original contributor @benbarclay's commit authorship preserved via rebase-merge.

Nous Research


Mirror-of: NousResearch#56254
NousResearch#56254

@tenki-reviewer

tenki-reviewer Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Complete

Files Reviewed: 13
Findings: 4

By Severity:

  • 🟠 High: 1
  • 🟡 Medium: 3

PR #260 introduces continuable cron surfaces for Slack (in_channel, DM, continuable thread) but ships with a critical import-to-nonexistent-function bug that breaks Slack image-send SSRF redirect guarding, plus a documentation config-path error and a missing DM config warning.

Files Reviewed (13 files)
cron/scheduler.py
gateway/config.py
gateway/platforms/base.py
plugins/platforms/slack/adapter.py
tests/cron/test_scheduler.py
tests/gateway/test_slack_cron_continuable_surface.py
tests/gateway/test_slack_mention.py
tests/manual/cron_inchannel_dm_e2e.py
tests/manual/cron_inchannel_e2e.py
website/docs/user-guide/features/cron.md
website/docs/user-guide/messaging/slack.md
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md

@hashbender
hashbender merged commit 8054b03 into main Jul 1, 2026
3 checks passed

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: 🟠 High (68/100) — 1 high finding, 3 medium · 1389 LOC across 13 files


Assessment: request_changes

Critical Issue

  • redirect_target_from_response does not exist (finding-001): plugins/platforms/slack/adapter.py line 2163 imports a function from tools/url_safety that was never defined. This replaces previously-working inline SSRF redirect validation with guaranteed ImportError on every image-send response event hook — breaking the redirect guard AND image delivery simultaneously. The same broken import pre-exists in gateway/platforms/base.py but this PR introduces a new broken call site.

Medium Issues

  • Wrong dotenv mock in test (finding-002): tests/cron/test_scheduler.py line 1944 mocks dotenv.load_dotenv (old function) but production code now calls hermes_cli.env_loader.load_hermes_dotenv. The mock is silently ineffective.

  • Wrong config path in docs (finding-003): website/docs/user-guide/features/cron.md documents dm_top_level_threads_as_sessions under the top-level slack: block, but the key lives at platforms.slack.extra.dm_top_level_threads_as_sessions and is not bridged. Users following docs get silent default true.

  • Missing DM config warning (finding-004): _warn_if_inchannel_without_flat_reply warns about missing reply_in_thread: false for channels but omits the parallel DM requirement dm_top_level_threads_as_sessions: false. DM continuation silently breaks under default config.

Cross-file Architecture

The PR sprawls across 13 files touching scheduler delivery routing, Slack adapter surface negotiation, gateway config bridging, and documentation. The core feature (continuable cron surfaces) is well-structured, but the SSRF guard refactor and config-path documentation errors need fixing before merge.

Comment on lines 2163 to +2168
async def _ssrf_redirect_guard(response):
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
from tools.url_safety import redirect_target_from_response
redirect_url = redirect_target_from_response(response)
if redirect_url and not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 redirect_target_from_response imported but never defined -- breaks Slack send_image redirect guard (bug)

In plugins/platforms/slack/adapter.py line 2165, the PR replaces the inline redirect-guard logic with from tools.url_safety import redirect_target_from_response. The function does not exist in tools/url_safety.py or anywhere else. The old code correctly checked response.is_redirect and response.next_request before accessing response.next_request.url. The new code will raise ImportError every time the httpx response event hook fires during image download in Slack, crashing the image send and falling through to text-only fallback -- simultaneously disabling SSRF redirect validation. The same missing function is also imported in gateway/platforms/base.py line 549 (pre-existing bug, not introduced here), but this PR introduces a NEW broken call site that replaces previously-working code.

💡 Suggestion: Revert the slack adapter _ssrf_redirect_guard to the original inline redirect-checking code that works with httpx response objects directly: check response.is_redirect and response.next_request, then extract str(response.next_request.url). This restores the working SSRF redirect guard that was replaced by a call to a non-existent function.

Suggested change
async def _ssrf_redirect_guard(response):
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
from tools.url_safety import redirect_target_from_response
redirect_url = redirect_target_from_response(response)
if redirect_url and not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
async def _ssrf_redirect_guard(response):
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
📋 Prompt for AI Agents

In plugins/platforms/slack/adapter.py lines 2163-2168, revert the _ssrf_redirect_guard inner function to the original inline redirect-checking code. Replace the import of redirect_target_from_response (which does not exist) with direct httpx response attribute checks: check response.is_redirect and response.next_request, then extract str(response.next_request.url). This restores the SSRF redirect validation that was broken by the refactor.


with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test mocks wrong dotenv function (load_dotenv instead of load_hermes_dotenv) (bug)

In tests/cron/test_scheduler.py line 1944, the test test_fallback_chain_merges_providers_and_legacy_model patches dotenv.load_dotenv, but the production code in cron/scheduler.py lines 2447-2452 calls hermes_cli.env_loader.load_hermes_dotenv and hermes_cli.env_loader.reset_secret_source_cache. All other tests in the file use the new mocking pattern; this test was missed. The mock is a no-op, allowing the real functions to execute unmocked, which can leak side effects and affect global state between tests.

💡 Suggestion: Replace patch('dotenv.load_dotenv') with patch('hermes_cli.env_loader.load_hermes_dotenv') and add patch('hermes_cli.env_loader.reset_secret_source_cache') to match all other tests in the file.

📋 Prompt for AI Agents

In tests/cron/test_scheduler.py, test test_fallback_chain_merges_providers_and_legacy_model at line 1944: replace patch('dotenv.load_dotenv') with patch('hermes_cli.env_loader.load_hermes_dotenv') and add patch('hermes_cli.env_loader.reset_secret_source_cache') in the adjacent line, matching the pattern used by every other test in the file (60+ occurrences).

Comment on lines +377 to +387
the separate, pre-existing knob **`slack.dm_top_level_threads_as_sessions`**:

- **`false`** — all top-level DMs share one rolling DM session, so a continuable
cron brief and your reply land in the **same** session and the job continues in
context. This is what you want for continuable cron in a DM.
- **`true`** (default) — each top-level DM message is its own session, so a reply
to a delivered brief starts a *fresh* session that has no record of the brief.
Continuation does not work in this mode (for cron or any other flat delivery).

So for a continuable cron job delivered to a 1:1 DM, set
`slack.dm_top_level_threads_as_sessions: false`. `cron_continuable_surface` is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Documentation uses wrong config path for dm_top_level_threads_as_sessions (bug)

Both English (website/docs/user-guide/features/cron.md lines 377, 387) and Chinese (website/i18n/zh-Hans/.../cron.md lines 382, 390) documentation reference the config key as slack.dm_top_level_threads_as_sessions, implying it works under a top-level slack: YAML block like the other keys shown nearby (cron_continuable_surface, reply_in_thread, require_mention). However, dm_top_level_threads_as_sessions is NOT bridged in gateway/config.py -- it is only read from self.config.extra in the adapter. The correct path is platforms.slack.extra.dm_top_level_threads_as_sessions. Users following the documented pattern will silently get the default true behavior.

💡 Suggestion: Update both the English and Chinese documentation to use the canonical full path platforms.slack.extra.dm_top_level_threads_as_sessions, and add a note that this key is NOT available as a top-level slack: shortcut (unlike the other three keys).

📋 Prompt for AI Agents

In website/docs/user-guide/features/cron.md, change the two references to slack.dm_top_level_threads_as_sessions (lines 377 and 387) to platforms.slack.extra.dm_top_level_threads_as_sessions and add a clarifying note that this key lives under platforms.slack.extra, not the top-level slack: block. Apply the same change to website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md lines 382 and 390.

Comment on lines +1593 to +1628
def _warn_if_inchannel_without_flat_reply(self, team_name: str) -> None:
"""Warn when ``in_channel`` is set without the required ``reply_in_thread: false`` pairing.

The two knobs are orthogonal (D4/D5): ``cron_continuable_surface:
in_channel`` skips thread creation on delivery, and ``reply_in_thread:
false`` makes the bot answer inbound channel messages flat and key them
to the whole-channel session ``(slack, channel_id, None)``. For a
continuable in-channel cron to actually continue on a plain reply, BOTH
must hold: the seed lands in the shared-channel session, and the reply
must resolve to (and be answered in) that same flat session.

Enforcement is WARN, not hard-require (D5): the misconfiguration fails
SAFE — ``in_channel`` without ``reply_in_thread: false`` yields a
threaded continuation (≈ today's behaviour), never a dropped/orphaned
session — so a config-load rejection would be heavier than warranted
and would make the two knobs non-orthogonal. Mirrors the existing
connect-time warning pattern (``_warn_if_missing_group_dm_scopes``,
``_warn_if_not_bot_token``).
"""
try:
if self._cron_continuable_surface() != "in_channel":
return
# reply_in_thread defaults True (legacy: reply in a thread).
if self.config.extra.get("reply_in_thread", True):
logger.warning(
"[Slack] %s: cron_continuable_surface=in_channel is set "
"WITHOUT reply_in_thread=false. A continuable in-channel "
"cron job will deliver flat, but the bot will still reply "
"to your continuation in a thread — so it falls back to a "
"threaded continuation (\u2248 default behaviour), not the "
"flat channel session you asked for. Set "
"platforms.slack.extra.reply_in_thread: false to pair them.",
team_name,
)
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No warning when DM in_channel cron requires dm_top_level_threads_as_sessions=false (bug)

The _warn_if_inchannel_without_flat_reply method at plugins/platforms/slack/adapter.py line 1593 emits a connect-time warning when cron_continuable_surface=in_channel is set without reply_in_thread=false (channel-level config). However, it does not warn about the DM-level requirement: DM continuation with in_channel cron requires dm_top_level_threads_as_sessions=false. Under the default (true), a DM reply to a flat cron seed creates a per-message session that diverges from the flat seed session key, silently breaking the continuation. The e2e test at tests/manual/cron_inchannel_dm_e2e.py confirms this divergence.

💡 Suggestion: Extend _warn_if_inchannel_without_flat_reply to also check dm_top_level_threads_as_sessions when cron_continuable_surface=in_channel, emitting an additional warning when it defaults to True.

📋 Prompt for AI Agents

In plugins/platforms/slack/adapter.py, method _warn_if_inchannel_without_flat_reply (around line 1612-1627): after the existing reply_in_thread check, add a second check: examine self.config.extra.get('dm_top_level_threads_as_sessions', True) and if True, emit a logger.warning that DM continuations with cron_continuable_surface=in_channel require dm_top_level_threads_as_sessions: false. Direct users to set platforms.slack.extra.dm_top_level_threads_as_sessions: false in config.yaml.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant