Skip to content

feat(tasks): rebind sandbox GitHub identity on actor transitions#70668

Closed
VojtechBartos wants to merge 5 commits into
vojtab/mcp-session-identity-transitionsfrom
vojtab/sandbox-github-identity-transitions
Closed

feat(tasks): rebind sandbox GitHub identity on actor transitions#70668
VojtechBartos wants to merge 5 commits into
vojtab/mcp-session-identity-transitionsfrom
vojtab/sandbox-github-identity-transitions

Conversation

@VojtechBartos

Copy link
Copy Markdown
Member

Problem

Completes per-message identity for multiplayer Slack threads. #70454 makes the sandbox's MCP session follow the current speaker; this PR does the same for GitHub, and unifies both behind one gate so identity switching always happens in the same place.

Two GitHub-specific gaps today:

  1. No per-message switching. GitHub credentials (token, git author) are injected at boot and re-applied by the background credential-refresh loop on token-TTL cadence. When a different user takes over a thread, commits, pushes, and PRs keep running as the previous actor.
  2. The refresh loop reads a stale actor. refresh_sandbox_credentials receives the TaskProcessingContext captured at workflow start, whose state is a boot-time snapshot, so even its cadence refreshes resolve the boot-time actor forever. The actor-aware resolution added in feat(slack): add approval recovery flow #66212 never sees mid-run actor changes.

Changes

One reconciliation gate, ensure_sandbox_identity (new sandbox_identity.py module), called from the follow-up delivery activity right before the turn runs. It compares the run's current actor against the identity last pushed to this sandbox, per surface:

flowchart TD
    M[Follow-up message arrives] --> A{{Resolve actor from run state}}
    A --> MCP{MCP: transition or freshness window expired?}
    A --> GH{GitHub: transition?}
    MCP -- yes --> RM[Mint OAuth token, push refresh_session]
    GH -- yes --> RG[Re-inject GitHub token + git author, notify authorship]
    RM --> K[(Sandbox-scoped identity + freshness marks)]
    RG --> K

    classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff;
    classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000;
    classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;
    class A,RM,RG phBlue;
    class M phYellow;
    class K phGray;
Loading
  • MCP surface: moved from send_followup_to_sandbox.py unchanged in behavior (transition bypasses the freshness window; same-actor repeats within the window skip).
  • GitHub surface (new): transition-only, since token TTL between messages stays owned by the credential-refresh loop. On a speaker change it reuses GitHubSandboxCredential.refresh with the live run state (actor-aware token via feat(slack): add approval recovery flow #66212's resolution), rewrites the git author env (get_git_identity_env_vars), and notifies the agent-server with refreshed_credentials=["github"] and the effective PR authorship. A failed rebind leaves the mark untouched, so the next message retries.
  • Actor fallback policy: a speaker without a usable GitHub connection falls through the existing resolution chain to the installation token, i.e. bot-authored work. Bot attribution beats wrong-person attribution.
  • Staleness fix: refresh_sandbox_credentials re-reads the run state live before building credentials, so cadence refreshes also follow the current actor.
  • The delivery activity input gains an optional context (the run's TaskProcessingContext, passed by both workflows); activities scheduled by pre-rollout histories deserialize without it and simply skip the GitHub surface.
  • Boot records the GitHub session identity alongside the MCP one, keyed by sandbox id so replacement sandboxes start unmarked.

Note

Stacked on #70454 (the diff base). The gate stays best-effort: a failed rebind never blocks follow-up delivery.

How did you test this code?

327 passed, 4 skipped across process_task + execute_sandbox suites locally. New coverage in test_sandbox_identity.py:

  • TestGithubIdentityTransitionGate (new): a speaker change re-injects credentials with the live run state, rewrites the git author, notifies with authorship, and records the identity; same actor doesn't touch the sandbox; missing context / no GitHub credentials / missing sandbox id skip safely; a failed rebind leaves the mark unset so the next message retries; an unrefreshable credential (caller-token run) marks without notifying.
  • MCP gate tests moved from test_send_followup_to_sandbox.py and retargeted to the shared gate, asserting identical behavior through ensure_sandbox_identity.

Not manually tested end to end; the log trail (refresh_github_identity_transition, refresh_github_delivered, refresh_github_identity_failed) mirrors the MCP one for rollout verification.

Automatic notifications

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

Docs update

Internal behavior only, no docs affected.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Code (Claude Fable 5). Replaces closed #70024, rebuilt on master's run-state actor design. The gate extraction (rather than a second standalone refresh function) was an explicit ask: MCP and GitHub identity switching happen in the same place with the same transition semantics.

The follow-up MCP refresh gate skipped whenever any token had been
issued for the run within the freshness window, so when a different
user spoke next (multiplayer Slack threads), the live session kept the
previous actor's OAuth token for up to the full window.

Key the freshness mark per (sandbox, user) and remember which identity
the session was last bound to: a transition bypasses the window and
rebinds immediately, while repeat messages from the same actor still
skip the redundant refresh. Scoping the marks to the sandbox id also
means a replacement sandbox (retry, snapshot restore) starts unmarked
and gets a fresh token instead of inheriting a stale run-keyed mark.
@VojtechBartos VojtechBartos self-assigned this Jul 14, 2026
@trunk-io

trunk-io Bot commented Jul 14, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

The field records whose credentials the sandbox booted with; the MCP
prefix undersells it as more surfaces (GitHub) adopt the same mark.
@VojtechBartos
VojtechBartos force-pushed the vojtab/sandbox-github-identity-transitions branch from 93c1690 to 0b36040 Compare July 14, 2026 12:00
The marks belong to the tasks product, matching the existing tasks:
key convention (e.g. the GitHub token rotation lock), not posthog_ai.
Unify the per-message identity reconciliation for MCP and GitHub in one
gate (ensure_sandbox_identity), called right before a follow-up turn is
delivered. The MCP surface keeps its transition-or-freshness semantics;
the GitHub surface rebinds on actor transitions only, re-injecting the
actor's GitHub token and git author into the live sandbox and notifying
the agent-server with the effective PR authorship. Token TTL between
messages stays owned by the credential-refresh loop.

Also fix the credential-refresh loop reading the workflow-start snapshot
of the run state: it now re-reads the state live, so cadence refreshes
mint for the run's current actor instead of the boot-time one.
Applies review findings across the identity-gate stack:

- Pass the caller's already-resolved actor into ensure_sandbox_identity
  instead of re-resolving it (2-3 redundant DB queries per follow-up).
- Fold the git-author write into GitHubSandboxCredential so every
  credential write (identity gate and refresh loop alike) applies token
  and author together, and share one agent-server notify helper between
  the loop and the gate.
- Add TaskProcessingContext.with_state as the single owner of the
  boot-snapshot-to-live-state rebase, and fetch the TaskRun once per
  refresh tick instead of twice.
- Collapse the duplicated send/retry success blocks into a loop, share
  the mark-pair helper (utils.mark_mcp_session) with the boot path, and
  name the creator-default assumption once (_last_bound_identity).
- Guard the GitHub gate on sandbox_id before logging a transition.
- Tests: shared mock helpers module, class-level patch stacks,
  cache.clear() fixture instead of a hand-enumerated key list.
@VojtechBartos
VojtechBartos force-pushed the vojtab/sandbox-github-identity-transitions branch from 0b36040 to bb2c977 Compare July 14, 2026 12:12
@VojtechBartos
VojtechBartos force-pushed the vojtab/mcp-session-identity-transitions branch 21 times, most recently from feec41d to 54a3240 Compare July 20, 2026 09:21
@VojtechBartos
VojtechBartos force-pushed the vojtab/mcp-session-identity-transitions branch from 54a3240 to 02268db Compare July 20, 2026 10:12
@VojtechBartos

Copy link
Copy Markdown
Member Author

Superseded by #71941, which does rebind or log out on failure (a superset of the rebind-only behavior here) and is built on the current, rebased PR3. Closing this stale branch.

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