Skip to content

Fix Slack inbound thread session routing#72498

Merged
bek91 merged 8 commits intomainfrom
fix/slack-inbound
Apr 27, 2026
Merged

Fix Slack inbound thread session routing#72498
bek91 merged 8 commits intomainfrom
fix/slack-inbound

Conversation

@bek91
Copy link
Copy Markdown
Contributor

@bek91 bek91 commented Apr 27, 2026

Summary

  • Normalize actionable Slack thread roots and follow-up replies onto the same thread parent session key.
  • Seed thread-level routing for top-level Slack room app mentions, explicit bot mentions, and configured regex mentions when Slack replies will be threaded, while preserving channel-level routing for ordinary non-thread channel messages and group DMs.
  • Keep pending channel history on the seeded root turn; only actual thread replies switch to thread-scoped history.
  • Preserve first and batched reply metadata for seeded top-level roots: the session key is thread-scoped, but MessageThreadId stays unset so single-use reply modes are not accidentally upgraded to all-replies behavior.
  • Add explicit routing- and prepare-level coverage for the second incident where child replies in Slack thread 1777244748.777299 must not route by their own message timestamps.

Root Cause

Duplicate subagent spawns were downstream of two parent sessions being created for one logical Slack thread. The inbound root mention had no reply-shaped thread context, so it used the channel parent session agent:main:slack:channel:<channel>. The later URL-only follow-up carried thread_ts, so it used agent:main:slack:channel:<channel>:thread:<thread_ts>. This is session-key normalization: once the same logical Slack thread maps to one parent session, subagent spawn accounting stays attached to the same controller session.

A related second incident showed actual follow-up replies in Slack thread 1777244748.777299 persisting as separate parent sessions keyed by child message timestamps (1777245202.803289 and 1777245402.577379). The current routing fix prefers Slack thread_ts for actual thread replies; regressions pin that invariant with the real channel/thread/child timestamp and the bare-ping text <@B1> ?. Slack prepare routing receives native Slack thread_ts; the reply_to_id/topic_id names are derived runtime metadata, not inbound fields consumed by this routing layer.

Notes

  • Configured Slack mention regexes now seed after the effective route is known, so runtime-bound agents use their own mention patterns for the decision.
  • Runtime bindings that actually rewrite the route already pin the root and later thread replies to the same target session, so they are left on the bound parent session instead of adding a thread suffix.
  • Plugin-owned runtime bindings can exist without rewriting the route; those still use seeded thread routing when the effective message is a regex mention.
  • The patch intentionally does not set MessageThreadId on seeded top-level roots. Downstream Slack reply planning treats MessageThreadId as an explicit existing thread target, which would break first and batched by replying to every send. ReplyToId remains available for those single-use modes.
  • Screenshots are not applicable; this is Slack inbound routing/session-key logic with no UI or visual changes.

Tests

Testing level: targeted local validation. Full repo baseline/CI remains pending while this PR is in draft.

$ pnpm test:extension slack extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts extensions/slack/src/monitor/message-handler/prepare.test.ts
[test-extension] Running 85 test files for slack
Test Files 85 passed (85)
Tests 811 passed (811)
Duration 18.28s
$ pnpm tsgo:extensions:test
node scripts/run-tsgo.mjs -p tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo
$ git diff --check
  • codex review --base origin/main found no discrete actionable regressions after the single-use reply-mode correction.

AI-assisted: implementation and validation prepared with OpenAI Codex.

@openclaw-barnacle openclaw-barnacle Bot added channel: slack Channel integration: slack size: M maintainer Maintainer-authored PR size: L and removed size: M labels Apr 27, 2026
@bek91 bek91 marked this pull request as ready for review April 27, 2026 03:07
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 27, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Slack mention seeding can create many new thread session keys (session store flooding / eviction DoS)
2 🔵 Low Unvalidated Slack thread_ts/ts used as conversationId and threadId for session routing
1. 🟡 Slack mention seeding can create many new thread session keys (session store flooding / eviction DoS)
Property Value
Severity Medium
CWE CWE-400
Location extensions/slack/src/monitor/message-handler/prepare-routing.ts:108-150

Description

Top-level Slack channel messages can be forced into new per-message thread sessions when seedTopLevelRoomThread is enabled.

  • Input: any user in a channel can send repeated messages that mention the bot (via app_mention, opts.wasMentioned, explicit <@​botUserId> mention, or regex mention logic in prepareSlackMessage).
  • Behavior change: for such top-level room messages (not actual Slack thread replies), routing uses message.ts (or incomingThreadTs) as a synthetic threadId.
  • Sink: resolveThreadSessionKeys() builds session keys by appending :thread:<threadId> to the base session key, creating a distinct persisted session entry per unique message.ts.

Although the session store has maintenance (default max entries), an attacker can still flood unique seeded thread keys to:

  • churn/evict legitimate sessions (availability impact)
  • increase disk I/O and CPU for frequent store updates

Vulnerable code (thread id seeding from top-level message ts):

const seedCandidateThreadId = threadContext.incomingThreadTs ?? threadContext.messageTs;
const seededRoomThreadId =
  !isThreadReply &&
  isRoom &&
  seedTopLevelRoomThread &&
  replyToMode !== "off" &&
  seedCandidateThreadId
    ? seedCandidateThreadId
    : undefined;
...
const routedThreadId = canonicalThreadId ?? (isRoomish ? seededRoomThreadId : undefined);
...
: resolveThreadSessionKeys({
    baseSessionKey: route.sessionKey,
    threadId: routedThreadId,
  });

Recommendation

Mitigate session-store flooding from untrusted channel traffic:

  • Do not derive session/thread IDs from per-message timestamps for top-level channel messages unless there is a strong reason.
  • Only enable seeding for trusted users (e.g., allowlisted) or when a command is detected, not for any mention.
  • Add rate limiting / deduplication per channel+user for seeding events.
  • Consider reusing a stable key for top-level mentions (e.g., channel:<id>:mentions), or only seed when the bot actually posts a thread reply.

Example approach (gate seeding behind allowlist or command):

const shouldSeed =
  isRoom &&
  !isThreadReply &&
  replyToMode !== "off" &&
  isTrustedSender &&
  isActionableCommand;

const seededRoomThreadId = shouldSeed ? threadContext.messageTs : undefined;
2. 🔵 Unvalidated Slack thread_ts/ts used as conversationId and threadId for session routing
Property Value
Severity Low
CWE CWE-20
Location extensions/slack/src/monitor/message-handler/prepare-routing.ts:108-150

Description

resolveSlackRoutingContext uses Slack event fields (message.thread_ts / message.ts) directly to derive conversationId for runtime conversation bindings and as threadId for resolveThreadSessionKeys, without validating the value is a real Slack timestamp.

  • Input: threadContext.incomingThreadTs and threadContext.messageTs originate from the inbound Slack event (message.thread_ts, message.ts).
  • Propagation: seedCandidateThreadId = incomingThreadTs ?? messageTs and routedThreadId = canonicalThreadId ?? seededRoomThreadId.
  • Sinks:
    • resolveRuntimeConversationBindingRoute({ conversationId: routedThreadId })
    • resolveThreadSessionKeys({ threadId: routedThreadId })

Although Slack normally emits timestamps in ^\d+\.\d+$ form, this code path does not enforce it. If an attacker can inject/forge Slack events (misconfigured webhook verification, compromised upstream, or non-Slack callers), a malformed thread_ts/ts could:

  • cause conversation/session binding collisions by matching an existing binding key;
  • create unexpected session keys (potentially impacting persistence keys, adapter lookups, or log/event correlation);
  • increase risk when custom SessionBindingAdapter implementations assume conversationId is a Slack channel/thread identifier with a constrained format.

Vulnerable code:

const seedCandidateThreadId = threadContext.incomingThreadTs ?? threadContext.messageTs;
...
const routedThreadId = canonicalThreadId ?? (isRoomish ? seededRoomThreadId : undefined);
...
conversationId: routedThreadId,
...
threadId: routedThreadId,

Recommendation

Validate/normalize Slack timestamps before using them for routing/binding keys.

  • Reuse the existing helpers in extensions/slack/src/thread-ts.ts (normalizeSlackThreadTsCandidate) when deriving any thread/session identifiers.
  • If the value is invalid, treat it as undefined (no thread routing) and optionally log a warning.

Example fix:

import { normalizeSlackThreadTsCandidate } from "../../thread-ts.js";

const incomingThreadTs = normalizeSlackThreadTsCandidate(threadContext.incomingThreadTs);
const messageTs = normalizeSlackThreadTsCandidate(threadContext.messageTs);

const seedCandidateThreadId = incomingThreadTs ?? messageTs;
...
const routedThreadId = normalizeSlackThreadTsCandidate(
  canonicalThreadId ?? (isRoomish ? seededRoomThreadId : undefined),
);

This constrains conversationId/threadId to Slack’s expected ^\d+\.\d+$ format and reduces the risk of collisions or adapter/key misuse if events are ever spoofed.


Analyzed PR: #72498 at commit 1a371ff

Last updated on: 2026-04-27T06:16:35Z

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

This PR fixes Slack inbound thread session routing so that a top-level room app mention and its URL-only thread follow-up share one parent session key, eliminating duplicate subagent spawns. It does this by computing a mention check early (before resolveSlackRoutingContext) and passing seedTopLevelRoomThread to seed a thread-scoped session key on actionable roots, while keeping ordinary non-mentioned channel messages on the per-channel session.

Confidence Score: 4/5

Safe to merge with awareness of the two P2 edge cases; no P0/P1 defects found on the primary fix path.

All P2s. The core routing fix is well-targeted and covered by the new tests. Two edge cases worth monitoring: the isRoomish guard inadvertently extends thread seeding to group DMs beyond the stated scope, and a divergence between wasMentionedForRouting and wasMentioned can occur when a runtime conversation binding changes the agentId mid-prepare. Neither affects the primary scenario described in the PR.

prepare-routing.ts (the isRoomish vs isRoom guard on seededRoomThreadId) and prepare.ts (the pre/post-routing mention regex divergence with runtime bindings).

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/slack/src/monitor/message-handler/prepare-routing.ts
Line: 108-115

Comment:
**`isRoomish` includes group DMs in the seed guard**

`seededRoomThreadId` is gated on `isRoomish`, which is `isRoom || isGroupDm`. The PR description targets "Slack room app mentions" and "top-level channel messages," but group DMs (`isGroupDm = true`) satisfy this guard too. A bot mention in a group DM with `replyToMode !== "off"` will now receive a thread-scoped session key (`agent:main:slack:group:<channel>:thread:<ts>`), whereas the old code kept group DMs on the channel session (since `canonicalThreadId` was always `undefined` for non-thread-reply room-ish messages without runtime binding). This is an undocumented behavior change for group DMs. Consider using `isRoom` instead of `isRoomish` if the intent is strictly channel/group-channel rooms.

```suggestion
  const seededRoomThreadId =
    !isThreadReply &&
    isRoom &&
    seedTopLevelRoomThread &&
    replyToMode !== "off" &&
    seedCandidateThreadId
      ? seedCandidateThreadId
      : undefined;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/slack/src/monitor/message-handler/prepare.ts
Line: 297-317

Comment:
**Routing seed decision and final mention check can diverge when runtime binding changes the agentId**

`wasMentionedForRouting` is computed against `initialRoute.agentId`'s regexes (`initialMentionRegexes`), but after `resolveSlackRoutingContext` the route may change via runtime conversation binding to a different agentId. `wasMentioned` (used for the final `mentionDecision`) is then computed against the bound agent's `mentionRegexes`.

If the bound agent's regexes match but the initial agent's don't (`wasMentionedForRouting = false`, `wasMentioned = true`), the message passes the mention gate but its session key is channel-scoped (not seeded), so a later thread follow-up still diverges. The inverse case (`wasMentionedForRouting = true`, `wasMentioned = false`) seeds the session but the message is then dropped by the mention check — a silent session-key orphan. This is an edge case confined to runtime-bound conversations with differing per-agent mention regexes, but it's worth documenting or guarding explicitly.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts
Line: 136

Comment:
**`"batched"` missing from `replyToMode` iteration in existing channel-session test**

`buildCtx` and `buildAccount` now accept `"batched"` as a valid `replyToMode` value, but the existing "keeps top-level channel messages on the per-channel session regardless of replyToMode" test still iterates only over `["all", "first", "off"]`. Adding `"batched"` ensures the invariant is also pinned for that mode.

```suggestion
    for (const mode of ["all", "first", "off", "batched"] as const) {
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "test(slack): cover bare thread ping prep..." | Re-trigger Greptile

Comment on lines +108 to +115
const seededRoomThreadId =
!isThreadReply &&
isRoomish &&
seedTopLevelRoomThread &&
replyToMode !== "off" &&
seedCandidateThreadId
? seedCandidateThreadId
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 isRoomish includes group DMs in the seed guard

seededRoomThreadId is gated on isRoomish, which is isRoom || isGroupDm. The PR description targets "Slack room app mentions" and "top-level channel messages," but group DMs (isGroupDm = true) satisfy this guard too. A bot mention in a group DM with replyToMode !== "off" will now receive a thread-scoped session key (agent:main:slack:group:<channel>:thread:<ts>), whereas the old code kept group DMs on the channel session (since canonicalThreadId was always undefined for non-thread-reply room-ish messages without runtime binding). This is an undocumented behavior change for group DMs. Consider using isRoom instead of isRoomish if the intent is strictly channel/group-channel rooms.

Suggested change
const seededRoomThreadId =
!isThreadReply &&
isRoomish &&
seedTopLevelRoomThread &&
replyToMode !== "off" &&
seedCandidateThreadId
? seedCandidateThreadId
: undefined;
const seededRoomThreadId =
!isThreadReply &&
isRoom &&
seedTopLevelRoomThread &&
replyToMode !== "off" &&
seedCandidateThreadId
? seedCandidateThreadId
: undefined;
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/slack/src/monitor/message-handler/prepare-routing.ts
Line: 108-115

Comment:
**`isRoomish` includes group DMs in the seed guard**

`seededRoomThreadId` is gated on `isRoomish`, which is `isRoom || isGroupDm`. The PR description targets "Slack room app mentions" and "top-level channel messages," but group DMs (`isGroupDm = true`) satisfy this guard too. A bot mention in a group DM with `replyToMode !== "off"` will now receive a thread-scoped session key (`agent:main:slack:group:<channel>:thread:<ts>`), whereas the old code kept group DMs on the channel session (since `canonicalThreadId` was always `undefined` for non-thread-reply room-ish messages without runtime binding). This is an undocumented behavior change for group DMs. Consider using `isRoom` instead of `isRoomish` if the intent is strictly channel/group-channel rooms.

```suggestion
  const seededRoomThreadId =
    !isThreadReply &&
    isRoom &&
    seedTopLevelRoomThread &&
    replyToMode !== "off" &&
    seedCandidateThreadId
      ? seedCandidateThreadId
      : undefined;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +297 to +317
@@ -290,6 +314,7 @@ export async function prepareSlackMessage(params: {
isGroupDm,
isRoom,
isRoomish,
seedTopLevelRoomThread: opts.source === "app_mention" || wasMentionedForRouting,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Routing seed decision and final mention check can diverge when runtime binding changes the agentId

wasMentionedForRouting is computed against initialRoute.agentId's regexes (initialMentionRegexes), but after resolveSlackRoutingContext the route may change via runtime conversation binding to a different agentId. wasMentioned (used for the final mentionDecision) is then computed against the bound agent's mentionRegexes.

If the bound agent's regexes match but the initial agent's don't (wasMentionedForRouting = false, wasMentioned = true), the message passes the mention gate but its session key is channel-scoped (not seeded), so a later thread follow-up still diverges. The inverse case (wasMentionedForRouting = true, wasMentioned = false) seeds the session but the message is then dropped by the mention check — a silent session-key orphan. This is an edge case confined to runtime-bound conversations with differing per-agent mention regexes, but it's worth documenting or guarding explicitly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/slack/src/monitor/message-handler/prepare.ts
Line: 297-317

Comment:
**Routing seed decision and final mention check can diverge when runtime binding changes the agentId**

`wasMentionedForRouting` is computed against `initialRoute.agentId`'s regexes (`initialMentionRegexes`), but after `resolveSlackRoutingContext` the route may change via runtime conversation binding to a different agentId. `wasMentioned` (used for the final `mentionDecision`) is then computed against the bound agent's `mentionRegexes`.

If the bound agent's regexes match but the initial agent's don't (`wasMentionedForRouting = false`, `wasMentioned = true`), the message passes the mention gate but its session key is channel-scoped (not seeded), so a later thread follow-up still diverges. The inverse case (`wasMentionedForRouting = true`, `wasMentioned = false`) seeds the session but the message is then dropped by the mention check — a silent session-key orphan. This is an edge case confined to runtime-bound conversations with differing per-agent mention regexes, but it's worth documenting or guarding explicitly.

How can I resolve this? If you propose a fix, please make it concise.

@bek91
Copy link
Copy Markdown
Contributor Author

bek91 commented Apr 27, 2026

WIP

@bek91 bek91 force-pushed the fix/slack-inbound branch from 941725a to faaecd3 Compare April 27, 2026 06:06
@openclaw-barnacle openclaw-barnacle Bot added the agents Agent runtime and tooling label Apr 27, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the agents Agent runtime and tooling label Apr 27, 2026
@bek91 bek91 merged commit aac83e0 into main Apr 27, 2026
66 checks passed
@bek91 bek91 deleted the fix/slack-inbound branch April 27, 2026 06:19
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
Normalize actionable Slack thread roots and follow-up replies onto the same thread parent session key.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
Normalize actionable Slack thread roots and follow-up replies onto the same thread parent session key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: slack Channel integration: slack maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant