Skip to content

feat(slack-app): bind sandbox MCP identity to each message's actor - #69618

Closed
VojtechBartos wants to merge 7 commits into
vojtab/slack-app-queue-workflowfrom
vojtab/slack-app-per-message-identity
Closed

feat(slack-app): bind sandbox MCP identity to each message's actor#69618
VojtechBartos wants to merge 7 commits into
vojtab/slack-app-queue-workflowfrom
vojtab/slack-app-per-message-identity

Conversation

@VojtechBartos

@VojtechBartos VojtechBartos commented Jul 9, 2026

Copy link
Copy Markdown
Member

Problem

Stacked on #68894. With the queue workflow, teammates take turns talking to one running agent — but the sandbox's PostHog MCP token stays bound to whoever created the task. A teammate's followup mints insights and dashboards as the original mentioner:

 Alice: @PostHog investigate this     agent acts as Alice  ✓
 Bob:   also check the funnel         agent acts as Alice  ✗  (should be Bob)
 Alice: now add an insight            agent acts as Alice  ✓  (by luck)

Changes

Every message the queue workflow processes rebinds the sandbox's MCP identity to that message's actor before the message is delivered:

 SlackAppMentionWorkflow                    tasks layer
 ───────────────────────                    ─────────────────────────────────────
 msg = queue.pop(0)                         rebind_sandbox_identity_for_user(run, actor)
 msg.per_message_identity = True  ────►       │
 (stamped by the workflow itself:             ▼
  no dispatcher can forget it)              actor == sandbox's current identity?
                                              ├─ yes, token fresh → no-op (2 cache reads)
 forward activity:                            ├─ yes, token stale → rotate (same user)
   JWT minted for actor                       └─ no → IDENTITY TRANSITION:
   rebind (best-effort, never                     mint OAuth token FOR ACTOR,
    blocks the message)                           push configs into the live session
   send message                                   (send_refresh_session), record mark

The transition check is what makes ping-pong threads correct: after Bob's turn, Alice's next message is detected as a swap back and always refreshes — "actor == creator" alone is no proof the sandbox is currently authed as the creator.

How the sandbox's current identity is tracked — two self-expiring cache entries, keyed by sandbox id:

 posthog_ai:sandbox-mcp-identity:{sandbox_id}            → user id   (7d TTL)
 posthog_ai:sandbox-mcp-token-issued:{sandbox_id}:{uid}  → True      (3h TTL)

 run r1 ────────────────────────────────────────────────────────────►
 sandbox sb-1                    ✂ retry /      sandbox sb-2
 [marks: mcp=Bob]                  restore      [marks: ∅]
                                                 ∅ ⇒ boot state = creator ✓
        └─ orphaned; nothing reads them          (nothing to clear, ever)

Keying by sandbox means a replacement sandbox (fresh provision, snapshot restore, mid-run workflow retry) starts unmarked by construction — it boots with creator credentials and the cache agrees, with zero clearing code. Absence always means "assume boot state", so an evicted entry degrades to one extra refresh, never to wrong behavior.

Everything is gated to the queue workflow: the legacy per-message workflow leaves per_message_identity False and keeps creator-bound credentials exactly as today. Rebinds are best-effort by contract — a failure logs and the message still goes through under the previous identity.

How did you test this code?

Automated only; the two-user Slack thread verification is planned as part of the queue-workflow dev rollout (the identity transitions are fully visible in worker logs: refresh_mcp_identity_transition / refresh_mcp_delivered).

  • test_send_followup_to_sandbox.py: identity transitions bypass the freshness rate limit; switching back to the creator refreshes (the ping-pong case); same-identity messages inside the window are no-ops; failed pushes don't record marks; no-config hosts don't re-mint per message.
  • test_followup_forwarding.py: same-user and cross-user followups rebind to the right actor; test_legacy_dispatch_keeps_creator_identity pins the flag-off path byte-identical to master (creator JWT, zero rebind calls).
  • Queue workflow tests (real Temporal server) assert every processed message arrives at activities with per_message_identity stamped True.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

N/A, internal credential mechanics behind the queue-workflow flag.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with Claude Code, starting from an unmerged June branch of Vojta's that re-authed MCP for cross-user followups; adapted so every message rebinds with the tasks layer detecting transitions (fixing ping-pong), and gated after review showed the shared activity would otherwise change legacy-workflow behavior. Three /simplify passes shaped the final form — the notable ones: identity marks re-keyed from run id to sandbox id (deleting all creation-path clearing code), cache-key composition moved inside the helpers after a hand-built key at one call site silently orphaned the boot-time rate-limit mark, and the per_message_identity stamp moved from the dispatch branch into the workflow loop so future dispatchers can't forget it. Split out of a combined MCP+GitHub PR; the GitHub/git-author half is stacked as #70024. Known boundaries, deliberate: identity lives in cache rather than TaskRun.state (eviction degrades safely to creator); web/PostHog-Code followups still rebind to the task creator — acceptable today because task control there is creator-only, and a named follow-up if web multiplayer opens up.

…s package

Move the per-message orchestration out of PostHogCodeSlackMentionWorkflow
into helpers/process_mention_message.py: process_mention_message plus the
MentionSignalState holder and a MentionSignalHandlersMixin carrying the
interactive signal handlers. The workflow class becomes a thin shell with
an unchanged defn name, signals, and command sequence, so in-flight
histories replay identically. helpers.py becomes the helpers package
__init__ so activity-side imports are unchanged. No behavior change.
… flag

One SlackAppMentionWorkflow per thread/DM serializes message processing:
the webhook signal-with-starts the conversation workflow, messages queue
as signals carrying their own routing-time-resolved user, and the loop
feeds them one at a time through the shared mention orchestration. The
workflow idles out after 30s with an empty queue; the next message
starts a fresh instance that picks the conversation back up via
SlackThreadTaskMapping. Gated by the slack-app-queue-workflow flag; off,
dispatch stays one workflow per message.
…sing

Mentions and DMs get an ⌛ reaction when they land in the
conversation queue and an 👀 swap when their turn starts. Untagged
thread followups stay reaction-free since most are dropped by the
chitchat classifier. The reaction activity takes a single pydantic
input model; reactions are best-effort and never stall the queue.
@VojtechBartos VojtechBartos self-assigned this Jul 9, 2026
@trunk-io

trunk-io Bot commented Jul 9, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

Adds rebind_sandbox_identity_for_user to the tasks facade: mint a
PostHog MCP OAuth token for the given user and push fresh MCP configs
into the run's live sandbox, so subsequent agent actions attribute to
that user. Best-effort by contract — failures are logged, never raised.

The sandbox's current identity is tracked per run in the tasks cache so
transitions always bypass the token-freshness rate limit (including
switching back to the task creator in ping-pong threads), and every
sandbox-creation path clears the marks — a fresh sandbox boots with
creator credentials, so a mark surviving a mid-run workflow retry or
dead-restore fallback would block the actor's next rebind.
Messages dispatched to the per-conversation queue workflow carry
per_message_identity: the sandbox connection JWT is minted for the
message's actor and the facade rebind runs before delivery, so insights,
dashboards, and other PostHog writes attribute to whoever actually
spoke. Rebind failures are best-effort and never drop the message.

The legacy per-message workflow leaves the field False and keeps every
credential bound to the task creator, exactly as before.
@VojtechBartos
VojtechBartos force-pushed the vojtab/slack-app-per-message-identity branch from 3530bd2 to ad4731e Compare July 10, 2026 13:02
@VojtechBartos VojtechBartos changed the title feat(slack-app): bind sandbox identity to each message's actor feat(slack-app): bind sandbox MCP identity to each message's actor Jul 10, 2026
The freshness and identity marks describe what a sandbox holds, so key
them by sandbox id: a replacement sandbox (fresh provision, snapshot
restore, mid-run workflow retry) starts unmarked by construction and
defaults back to the boot-time creator identity — deleting all the
creation-path clear calls and the clear helper outright.

Make the cache helpers own the composite key as (scope, user_id)
signatures; the boot-time mark in start_agent_server had silently kept
the old single-run shape after the rekey, orphaning the write and
losing the post-boot refresh suppression. Stamp per_message_identity
inside the queue workflow loop instead of at dispatch, so no future
dispatcher into the workflow can forget it. Mint the rebind token via
create_oauth_access_token with a user override rather than re-composing
its body, and record both marks on the no-configs bail so hosts with no
resolvable MCP URL stop re-minting on every cross-user message.
@github-actions

Copy link
Copy Markdown
Contributor

🤖 CI report

ℹ️ ClickHouse migration SQL — 1 migration(s)

ClickHouse migration SQL per cloud environment

  • unset
    • all
      DROP TABLE IF EXISTS property_values_mv
      CREATE MATERIALIZED VIEW IF NOT EXISTS property_values_mv
      TO property_values
      AS SELECT
          team_id,
          property_type,
          property_key,
          property_value,
          property_count,
          coalesce(_timestamp, now()) as last_seen
      FROM posthog_test.kafka_property_values
  • US, EU
    • aux
      DROP TABLE IF EXISTS property_values_mv
      CREATE MATERIALIZED VIEW IF NOT EXISTS property_values_mv
      TO property_values
      AS SELECT
          team_id,
          property_type,
          property_key,
          property_value,
          property_count,
          coalesce(_timestamp, now()) as last_seen
      FROM posthog_test.kafka_property_values
  • DEV
    • data
      DROP TABLE IF EXISTS property_values_mv
      CREATE MATERIALIZED VIEW IF NOT EXISTS property_values_mv
      TO property_values
      AS SELECT
          team_id,
          property_type,
          property_key,
          property_value,
          property_count,
          coalesce(_timestamp, now()) as last_seen
      FROM posthog_test.kafka_property_values

@VojtechBartos
VojtechBartos force-pushed the vojtab/slack-app-queue-workflow branch 2 times, most recently from f94df18 to 1819f04 Compare July 13, 2026 13:26
@VojtechBartos
VojtechBartos force-pushed the vojtab/slack-app-queue-workflow branch 5 times, most recently from 8959499 to 45bfaca Compare July 13, 2026 16:13
@VojtechBartos

Copy link
Copy Markdown
Member Author

Closing as superseded. #66212 landed the actor bookkeeping this PR carried (run-state actor, actor-aware token minting, fail-closed Slack policy), and #70454 carries the remaining piece: making the live sandbox MCP session follow actor transitions.

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