Skip to content

feat(tasks): per-actor sandbox GitHub identity on multiplayer transitions#71941

Open
VojtechBartos wants to merge 10 commits into
masterfrom
vojtab/github-identity-transitions
Open

feat(tasks): per-actor sandbox GitHub identity on multiplayer transitions#71941
VojtechBartos wants to merge 10 commits into
masterfrom
vojtab/github-identity-transitions

Conversation

@VojtechBartos

@VojtechBartos VojtechBartos commented Jul 17, 2026

Copy link
Copy Markdown
Member

Problem

In multiplayer Slack threads, several people send follow-ups to one shared sandbox that is authenticated as whoever started it. Without per-actor identity, a follow-up from a different user runs with the starter's GitHub token, so a user with no repo access could push or open PRs as the starter.

sequenceDiagram
    actor A as User A (has access)
    actor B as User B (no access)
    participant S as Sandbox (shared)
    participant G as GitHub
    A->>S: starts task, clones repo
    Note over S: bound to A's token
    B->>S: follow-up: commit and open a PR
    Note over S: transition A to B
    S->>S: B has no access, so LOG OUT (strip remote + env file)
    B->>S: agent tries to push
    Note over S,G: no token to fall back on, push blocked (was: pushed as A)
Loading

Changes

  • Per-follow-up gate (_refresh_sandbox_github): resolve the sender; if they have repo access, rebind the sandbox to their token; if not, log out (strip the git remote + GitHub env file) so the prior actor's identity can't be reused. USER-authored runs only.
  • Runtime hardening: the token also survived in the agent-server's frozen process env and was re-applied by the owner-keyed refresh loop. Remove it from the launch env (delivered per command via the live /tmp/agent-github-env file instead), and skip refresh for runs bound to a different actor.
one running sandbox, the token lived in 3 places:
  git remote URL          cleared on logout   OK
  /tmp/agent-github-env   cleared on logout   OK
  agent-server env        frozen at launch    removed by this PR (+ refresh loop no longer re-adds it)

Companion PR (required together)

PostHog/code#3611 (agent-server): treats an emptied env file as an explicit logout (no fallback to process env) and runs gh calls as the current actor.

How did you test this code?

End-to-end on a dedicated GitHub org where both test users have app-mediated access to the same repo:

  • Follow-up from a user without access -> blocked; no fallback to the starter's token (verified in a live sandbox: emptied file + patched resolver yields no token).
  • Follow-up from a user with access -> rebinds to their token and pushes as them. Example PR opened by the follow-up actor: Add CONTRIBUTING.md VojtechBartosTesting/ph#1

Agent context

Model: Claude Opus 4.8.

@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch 2 times, most recently from f385546 to ceda299 Compare July 20, 2026 07:48
@VojtechBartos
VojtechBartos force-pushed the vojtab/mcp-session-identity-transitions branch 2 times, most recently from 8b6e210 to 92badb0 Compare July 20, 2026 08:45
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch 2 times, most recently from 06a7d5a to a14762d Compare July 20, 2026 09:16
@VojtechBartos
VojtechBartos force-pushed the vojtab/mcp-session-identity-transitions branch from 92badb0 to feec41d Compare July 20, 2026 09:16
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from a14762d to 7394f7f Compare July 20, 2026 09:21
@VojtechBartos
VojtechBartos force-pushed the vojtab/mcp-session-identity-transitions branch 2 times, most recently from 54a3240 to 02268db Compare July 20, 2026 10:12
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch 2 times, most recently from c2fbfe4 to a397d6f Compare July 20, 2026 10:30
@VojtechBartos
VojtechBartos force-pushed the vojtab/mcp-session-identity-transitions branch from 454d497 to 0294158 Compare July 20, 2026 10:40
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from a397d6f to a5c899a Compare July 20, 2026 10:40
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from a5c899a to 0675986 Compare July 20, 2026 11:48
Base automatically changed from vojtab/mcp-session-identity-transitions to master July 20, 2026 12:14
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch 2 times, most recently from e1ae71b to 6dcb4e9 Compare July 20, 2026 12:42
@VojtechBartos VojtechBartos changed the title feat(tasks): rebind or log out sandbox GitHub identity on actor transitions feat(tasks): per-actor sandbox GitHub identity on multiplayer transitions Jul 21, 2026
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from c9b507c to 4a7fbb2 Compare July 21, 2026 10:02
@VojtechBartos
VojtechBartos requested a review from a team July 21, 2026 12:13
@VojtechBartos
VojtechBartos marked this pull request as ready for review July 21, 2026 12:13
continue
rows.append((str(run.id), sandbox_id, run.task.repository))
return rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Originally flagged line 204; mapped to the nearest line in the diff (194).

🟠 Recheck the actor binding immediately before propagation

The marker is checked while building the propagation list, but it is not checked again before the owner token is written. The credential-refresh activity runs concurrently with follow-up delivery: it can select a sandbox while it is still bound to the owner, then a different Slack actor is logged out/rebound and marked, and finally this stale iteration writes the owner's token after that transition. A user without repository access can time a follow-up with the owner's token rotation and have their prompt execute using the owner's GitHub identity.

Prompt To Fix With AI
Make the transition and owner-token propagation mutually exclusive per sandbox (for example, use a sandbox-scoped Redis lock shared by _refresh_sandbox_github and _propagate_user_token). While holding it, re-read the github-identity marker immediately before writing; if it no longer identifies task.created_by_id, do not apply the owner token. Treat lock/read failures as no propagation.

Severity: high | Confidence: 91% | React with 👍 if useful or 👎 if not


if token:
try:
apply_github_credentials_to_sandbox(sandbox, repository, token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Require successful credential writes before accepting a rebind

apply_github_credentials_to_sandbox ignores the False results from both set_git_remote_token and update_sandbox_env_file, so it normally returns without raising even when the sandbox refused either write. This branch then records the new actor and delivers the prompt, while the prior actor's remote URL and/or live env token remain active. A follow-up actor with a less-privileged GitHub account can thus execute git or gh operations using the prior actor's credentials after a write failure.

Prompt To Fix With AI
Have apply_github_credentials_to_sandbox return success only if every applicable remote and env-file update succeeded (and treat exceptions as failure). In _refresh_sandbox_github, mark the actor and return True only on that confirmed result; otherwise clear both credential locations and fail the follow-up if the clear cannot be confirmed. Add tests for a nonzero remote update and a nonzero env-file write.

Severity: high | Confidence: 92% | React with 👍 if useful or 👎 if not

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

fixed

Comment thread products/tasks/backend/temporal/process_task/sandbox_credentials.py Outdated
@veria-ai

veria-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request adds per-actor GitHub identities to task sandboxes when multiplayer users transition control of a task.

Two issues have been addressed, but one significant identity-lifetime gap remains. Because actor binding expires before the sandbox does, a delayed GitHub command may run with the owner’s refreshed token rather than the active actor’s identity. The binding should persist for the full sandbox lifetime, or an unknown binding after a transition should block owner-token refreshes.

Open issues (1)

Fixed/addressed: 2 · PR risk: 7/10

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Playwright — 1 failed

🎭 Playwright report · View test results →

1 failed test:

  • Add a funnel insight, configure steps, and verify conversion rates (chromium)

These issues are not necessarily caused by your changes.
Annoyed by this section? Help fix flakies and failures and it will go green!

⚠️ Backend snapshots — 11 updated (11 modified, 0 added, 0 deleted)

Query snapshots: Backend query snapshots updated

Changes: 11 snapshots (11 modified, 0 added, 0 deleted)

What this means:

  • Query snapshots have been automatically updated to match current output
  • These changes reflect modifications to database queries or schema

Next steps:

  • Review the query changes to ensure they're intentional
  • If unexpected, investigate what caused the query to change

Review snapshot changes →

⚠️ Backend coverage — 12.0% of changed backend lines covered — 313 uncovered

🧪 Backend test coverage

Patch coverage — changed backend lines (products + core): ██░░░░░░░░░░░░░░░░░░ 12.0% (46 / 359)

File Patch Uncovered changed lines
products/tasks/backend/temporal/process_task/tests/test_sandbox_credentials.py 0.0% 207, 211, 213–217, 219–220, 222–225, 257, 293, 334, 345–351, 353–354, 356, 358–363, 365–366, 368–369, 371, 373–374, 376–379, 381–382, 384–385, 387, 389–393, 395–397, 399, 404, 406, 411–412, 414–422, 424, 427–428, 650, 655–659, 661–662, 665, 667–671, 674, 677, 686–689, 691, 693
products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py 0.0% 8, 320, 358, 361–366, 371–373, 375–379, 381, 384–385, 387–390, 392, 395–399, 401–405, 407, 410–414, 416–419, 421, 424–429, 431–433, 435, 441–446, 448–451, 453, 459–461, 463–467, 469, 472–476, 478–479, 481, 484–488, 490–491, 493, 498, 500–504, 506–509
products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py 9.2% 228–230, 461–464, 468–471, 475–476, 498–499, 501–504, 506–508, 510–511, 517–518, 520–523, 531, 542, 550, 555–558, 560–566, 569–572, 579–589
products/tasks/backend/temporal/process_task/sandbox_credentials.py 30.9% 125, 127–128, 136–138, 165–166, 182, 190–193, 195–199, 209, 212, 224–226, 229–232, 236–237, 262, 282–284, 292, 294, 297, 321, 324, 326–327, 394–396, 400, 463, 481, 489
products/tasks/backend/temporal/process_task/utils.py 41.7% 399, 403, 407, 416, 422, 434, 440
products/tasks/backend/logic/services/modal_sandbox.py 50.0% 456–458

🤖 Agents: add a test covering the lines above, or note why under "How did you test this code?". Machine-readable gap list: the patch-coverage artifact on this run (gh run download 30081252023 -n patch-coverage), or the coverage-data block at the end of this comment.

Per-product line coverage (touched products)
Product Coverage Lines
demo ███████████░░░░░░░░░ 56.2% 1,497 / 2,663
tasks ██████████████░░░░░░ 69.0% 30,115 / 43,643
signals ████████████████░░░░ 80.0% 22,090 / 27,597
cdp ████████████████░░░░ 81.0% 3,146 / 3,883
data_modeling █████████████████░░░ 82.7% 5,547 / 6,708
notebooks █████████████████░░░ 85.3% 7,275 / 8,531
agent_platform █████████████████░░░ 86.4% 3,807 / 4,405
actions █████████████████░░░ 86.6% 717 / 828
cohorts ██████████████████░░ 87.8% 4,488 / 5,114
product_tours ██████████████████░░ 87.9% 1,303 / 1,482
exports ██████████████████░░ 88.4% 6,951 / 7,863
data_warehouse ██████████████████░░ 88.9% 11,887 / 13,378
conversations ██████████████████░░ 89.3% 16,875 / 18,894
engineering_analytics ██████████████████░░ 89.4% 6,386 / 7,145
dashboards ██████████████████░░ 89.4% 5,983 / 6,693
error_tracking ██████████████████░░ 89.7% 10,168 / 11,336
alerts ██████████████████░░ 90.0% 4,056 / 4,508
early_access_features ██████████████████░░ 90.1% 1,031 / 1,144
mcp_analytics ██████████████████░░ 90.1% 2,763 / 3,065
streamlit_apps ██████████████████░░ 90.4% 2,501 / 2,767
slack_app ██████████████████░░ 90.7% 9,028 / 9,951
marketing_analytics ██████████████████░░ 91.0% 11,792 / 12,964
stamphog ██████████████████░░ 91.1% 4,056 / 4,450
product_analytics ███████████████████░ 92.6% 5,852 / 6,322
ai_observability ███████████████████░ 92.8% 15,041 / 16,214
surveys ███████████████████░ 93.0% 5,734 / 6,167
web_analytics ███████████████████░ 93.1% 14,426 / 15,492
posthog_ai ███████████████████░ 93.2% 1,326 / 1,422
approvals ███████████████████░ 93.3% 3,437 / 3,682
reminders ███████████████████░ 93.4% 468 / 501
workflows ███████████████████░ 93.6% 6,414 / 6,851
endpoints ███████████████████░ 94.1% 8,640 / 9,177
review_hog ███████████████████░ 94.7% 6,814 / 7,199
skills ███████████████████░ 94.7% 3,300 / 3,483
logs ███████████████████░ 95.4% 10,012 / 10,498
experiments ███████████████████░ 95.7% 25,158 / 26,282
annotations ███████████████████░ 96.2% 732 / 761
replay_vision ███████████████████░ 96.2% 15,260 / 15,864
revenue_analytics ███████████████████░ 96.3% 1,887 / 1,960
feature_flags ███████████████████░ 96.5% 17,112 / 17,736
user_interviews ███████████████████░ 96.5% 2,638 / 2,734
customer_analytics ███████████████████░ 97.1% 9,047 / 9,317
warehouse_sources ███████████████████░ 97.1% 330,460 / 340,224
data_catalog ███████████████████░ 97.4% 2,367 / 2,429
pulse ████████████████████ 98.4% 2,017 / 2,049

Report-only. Patch coverage = changed backend lines covered vs origin/master. Sorted lowest first.
Known gaps: lines covered only by Temporal tests show as uncovered; core line numbers may drift if master changed the same file.

@VojtechBartos
VojtechBartos requested a review from a team July 21, 2026 14:06
@posthog

posthog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 0 should fix, 5 consider.

Published 5 findings (view the review).

@posthog

posthog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

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.

ReviewHog Report

Changes

Issues: 5 issues

Files (4)
  • products/tasks/backend/constants.py
  • products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py
  • products/tasks/backend/temporal/process_task/sandbox_credentials.py
  • products/tasks/backend/temporal/process_task/utils.py

Comment on lines +463 to +467
try:
sandbox = Sandbox.get_by_id(sandbox_id)
return sandbox if sandbox.is_running() else None
except Exception:
return None

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.

Silent exception swallowing in _resolve_live_sandbox hides the root cause of fail-closed follow-up rejections

consider best_practice

Why we think it's a valid issue
  • Checked: _resolve_live_sandbox (send_followup_to_sandbox.py:449-467), its sole caller _refresh_sandbox_github (501-509), the fail-closed raise at 224-226, and the module's logging conventions (grepped all except/logger. sites).
  • Found: line 466-467 is a fully silent except Exception: return None — no log, no re-raise. The None it returns feeds _refresh_sandbox_github, which returns False (501-509) and makes the caller raise RuntimeError (224-226), rejecting the follow-up. The consequential error paths in this same module all preserve cause — error=str(e) at line 366, exc_info=True at lines 198 and 540 (and _propagate_user_token in sandbox_credentials.py:211-216 likewise) — so discarding the exception here is a real inconsistency, not the module norm. The benign silent swallows at lines 105/120 are non-decision helpers (heartbeat loop, attempt default), which is why they don't log.
  • Found: the collapsed causes have different remediation, and the code's own comment at 503-507 names both ('dead sandbox, or a transient control-plane lookup failure'); the swallow at 466 destroys the only evidence (exception type/message/stack) that distinguishes them.
  • Impact: a spike in fail-closed follow-up rejections is diagnosable only down to refresh_github_no_sandbox_handle_fail_closed (line 508) with no root cause, forcing on-call guesswork between a benign dead sandbox and an infra incident. This is a genuine, if low-severity, observability defect on a security-relevant fail-closed path — the failure is already loud (RuntimeError + info log), so this improves root-cause diagnosis rather than un-hiding a silent failure, which fits the reviewer's 'consider' tier. Fix is a one-line log consistent with the module's existing convention.
Issue description

_resolve_live_sandbox's except Exception: return None discards the underlying error without any logging, unlike almost every other error path in this module (e.g. refresh_github_apply_failed, refresh_mcp_token_mint_failed) which logs with context. Since this return value directly determines whether an actor-transition follow-up is rejected (_refresh_sandbox_github fails closed with a RuntimeError when the handle can't be resolved), an on-call engineer investigating a spike in rejected follow-ups has no way to distinguish "sandbox genuinely terminated" from "a transient control-plane/network error while resolving the handle" — the two have very different remediation paths.

Suggested fix

Log the caught exception (e.g. logger.warning("resolve_live_sandbox_failed", sandbox_id=sandbox_id, exc_info=True)) before returning None, so fail-closed decisions triggered by this path are diagnosable from logs.

Prompt to fix with AI (copy-paste)
## Context
@products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py#L463-467

<issue_description>
`_resolve_live_sandbox`'s `except Exception: return None` discards the underlying error without any logging, unlike almost every other error path in this module (e.g. `refresh_github_apply_failed`, `refresh_mcp_token_mint_failed`) which logs with context. Since this return value directly determines whether an actor-transition follow-up is rejected (`_refresh_sandbox_github` fails closed with a `RuntimeError` when the handle can't be resolved), an on-call engineer investigating a spike in rejected follow-ups has no way to distinguish "sandbox genuinely terminated" from "a transient control-plane/network error while resolving the handle" — the two have very different remediation paths.
</issue_description>

<issue_validation>
- **Checked:** `_resolve_live_sandbox` (send_followup_to_sandbox.py:449-467), its sole caller `_refresh_sandbox_github` (501-509), the fail-closed raise at 224-226, and the module's logging conventions (grepped all `except`/`logger.` sites).
- **Found:** line 466-467 is a fully silent `except Exception: return None` — no log, no re-raise. The `None` it returns feeds `_refresh_sandbox_github`, which returns `False` (501-509) and makes the caller raise `RuntimeError` (224-226), rejecting the follow-up. The consequential error paths in this same module all preserve cause — `error=str(e)` at line 366, `exc_info=True` at lines 198 and 540 (and `_propagate_user_token` in sandbox_credentials.py:211-216 likewise) — so discarding the exception here is a real inconsistency, not the module norm. The benign silent swallows at lines 105/120 are non-decision helpers (heartbeat loop, attempt default), which is why they don't log.
- **Found:** the collapsed causes have different remediation, and the code's own comment at 503-507 names both ('dead sandbox, or a transient control-plane lookup failure'); the swallow at 466 destroys the only evidence (exception type/message/stack) that distinguishes them.
- **Impact:** a spike in fail-closed follow-up rejections is diagnosable only down to `refresh_github_no_sandbox_handle_fail_closed` (line 508) with no root cause, forcing on-call guesswork between a benign dead sandbox and an infra incident. This is a genuine, if low-severity, observability defect on a security-relevant fail-closed path — the failure is already loud (RuntimeError + info log), so this improves root-cause diagnosis rather than un-hiding a silent failure, which fits the reviewer's 'consider' tier. Fix is a one-line log consistent with the module's existing convention.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Log the caught exception (e.g. `logger.warning("resolve_live_sandbox_failed", sandbox_id=sandbox_id, exc_info=True)`) before returning `None`, so fail-closed decisions triggered by this path are diagnosable from logs.
</potential_solution>

Comment on lines +494 to +495
if get_sandbox_github_identity_user(scope) == actor_user.id:
return True # sandbox already reflects this actor — cheapest check first

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.

Cached github-identity marker prevents re-checking access for the same actor once logged out

consider bug

Why we think it's a valid issue
  • Checked: the fast path and both marking sites in _refresh_sandbox_github (send_followup_to_sandbox.py:494-495, 544, 552-554), the marker helper docstring and TTL (utils.py:361, 401-410), and the scheduled refresh's actor-transition guard (sandbox_credentials.py:315-323).
  • Found: mark_sandbox_github_identity(scope, actor_user.id) is written identically for rebind (544) and logout (553), and utils.py:401-410 confirms the conflation is intentional — so the 494-495 fast path (marker == actor_user.id → return True, no token re-check) short-circuits a logged-out actor exactly like a rebound one. The marker TTL is 3h (TOKEN_EXPIRATION_SECONDS / 2, utils.py:361), and the scheduled refresh skips any non-owner bound actor (315-323), so within that window nothing re-evaluates a logged-out non-owner's access.
  • Impact: a previously-denied actor who is added to the repo mid-thread keeps hitting the fast path and runs follow-ups with no git creds (git/gh silently fail) until the marker expires — up to ~3h — in the exact multiplayer scenario this PR targets. This is a real, concretely-triggered mishandled edge case, not speculation, which clears the bar for the lowest tier.
  • Impact (why only 'consider'): the direction is fail-closed (the actor is denied longer, never wrongly granted) and self-healing after ≤3h; the inverse, security-relevant direction (a de-authorized actor keeping access) is separately blocked by GitHub-side authz and by there being no retained token after a logout. So it is a minor UX-latency defect, correctly rated 'consider'.
Issue description

mark_sandbox_github_identity(scope, actor_user.id) is called with the same actor id whether they were rebound (have access) or logged out (denied access) — by design, per the docstring, so the 'same actor, no change' fast path at line 494 short-circuits both cases identically. This means once an actor is logged out for lacking repo access, every subsequent follow-up from that same actor within the marker's freshness window (MCP_TOKEN_REFRESH_INTERVAL_SECONDS, 3h) takes the 'already reflects this actor' fast path and never re-attempts get_sandbox_github_token — even if that actor's GitHub access changes (e.g. they get added to the repo) during the window. The scheduled GitHubSandboxCredential.refresh() loop doesn't fill this gap either: its new actor-transition guard (sandbox_credentials.py lines 310-323) compares the marker against task.created_by_id, so for a non-owner actor it keeps skipping for as long as the marker persists, regardless of the actor's current access.

Suggested fix

If this staleness window is acceptable, consider noting it explicitly in the docstring; otherwise shorten the TTL for a 'logged out' marking specifically (vs. a confirmed rebind), or have the scheduled refresh re-evaluate access for the current actor (not just skip based on task.created_by_id) so a newly-authorized actor doesn't have to wait out the full window.

Prompt to fix with AI (copy-paste)
## Context
@products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py#L494-495
@products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py#L552-554

<issue_description>
mark_sandbox_github_identity(scope, actor_user.id) is called with the same actor id whether they were rebound (have access) or logged out (denied access) — by design, per the docstring, so the 'same actor, no change' fast path at line 494 short-circuits both cases identically. This means once an actor is logged out for lacking repo access, every subsequent follow-up from that same actor within the marker's freshness window (MCP_TOKEN_REFRESH_INTERVAL_SECONDS, 3h) takes the 'already reflects this actor' fast path and never re-attempts get_sandbox_github_token — even if that actor's GitHub access changes (e.g. they get added to the repo) during the window. The scheduled GitHubSandboxCredential.refresh() loop doesn't fill this gap either: its new actor-transition guard (sandbox_credentials.py lines 310-323) compares the marker against task.created_by_id, so for a non-owner actor it keeps skipping for as long as the marker persists, regardless of the actor's current access.
</issue_description>

<issue_validation>
- **Checked:** the fast path and both marking sites in `_refresh_sandbox_github` (send_followup_to_sandbox.py:494-495, 544, 552-554), the marker helper docstring and TTL (utils.py:361, 401-410), and the scheduled refresh's actor-transition guard (sandbox_credentials.py:315-323).
- **Found:** `mark_sandbox_github_identity(scope, actor_user.id)` is written identically for rebind (544) and logout (553), and utils.py:401-410 confirms the conflation is intentional — so the 494-495 fast path (`marker == actor_user.id → return True`, no token re-check) short-circuits a logged-out actor exactly like a rebound one. The marker TTL is 3h (`TOKEN_EXPIRATION_SECONDS / 2`, utils.py:361), and the scheduled refresh skips any non-owner bound actor (315-323), so within that window nothing re-evaluates a logged-out non-owner's access.
- **Impact:** a previously-denied actor who is added to the repo mid-thread keeps hitting the fast path and runs follow-ups with no git creds (git/gh silently fail) until the marker expires — up to ~3h — in the exact multiplayer scenario this PR targets. This is a real, concretely-triggered mishandled edge case, not speculation, which clears the bar for the lowest tier.
- **Impact (why only 'consider'):** the direction is fail-closed (the actor is denied longer, never wrongly granted) and self-healing after ≤3h; the inverse, security-relevant direction (a de-authorized actor keeping access) is separately blocked by GitHub-side authz and by there being no retained token after a logout. So it is a minor UX-latency defect, correctly rated 'consider'.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
If this staleness window is acceptable, consider noting it explicitly in the docstring; otherwise shorten the TTL for a 'logged out' marking specifically (vs. a confirmed rebind), or have the scheduled refresh re-evaluate access for the current actor (not just skip based on task.created_by_id) so a newly-authorized actor doesn't have to wait out the full window.
</potential_solution>

Comment on lines +535 to +557
if token:
applied = False
try:
applied = apply_github_credentials_to_sandbox(sandbox, repository, token)
except Exception:
logger.warning("refresh_github_apply_failed", run_id=run_id, exc_info=True)
if applied:
# Record the new actor only on a fully-confirmed rebind. A partial write leaves one
# credential location on the prior actor's token, so fall through to logout instead.
mark_sandbox_github_identity(scope, actor_user.id)
logger.info("refresh_github_rebound", run_id=run_id, user_id=actor_user.id)
return True
logger.warning("refresh_github_apply_incomplete", run_id=run_id, user_id=actor_user.id)

# No usable rebind (no token, or the rebind write could not be confirmed): log the sandbox
# out. Fail closed only if even the clear can't be confirmed — the previous actor's
# credentials might still be live.
if clear_github_credentials_from_sandbox(sandbox, repository):
mark_sandbox_github_identity(scope, actor_user.id)
logger.info("refresh_github_logged_out", run_id=run_id, user_id=actor_user.id)
return True
logger.warning("refresh_github_logout_failed", run_id=run_id, user_id=actor_user.id)
return False

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.

Logout path can raise an unhandled sandbox exception, unlike the guarded rebind path right above it

consider best_practice

Why we think it's a valid issue
  • Checked: _refresh_sandbox_github rebind vs logout branches (send_followup_to_sandbox.py:535-557), the credential-clear chain (sandbox_credentials.py:57-77, 80-122, 143-149), sandbox.execute semantics (modal_sandbox.py:746-796), the caller's fail-closed raise (224-226), and the activity wrapper (84-114) plus post-delivery error handling (278-319).
  • Found: the asymmetry is real — execute() raises SandboxNotRunningError/SandboxTimeoutError/SandboxExecutionError (modal 751-756, 779-796), the rebind at 538 catches it (539-540) but the logout clear_github_credentials_from_sandbox at 552 is unguarded and reaches sandbox.execute/write_file (sandbox_credentials.py:67, 89, 115), so a stop/timeout race throws out of the function.
  • Impact / why not should_fix: both failure modes fail closed identically — _refresh_sandbox_github runs at line 224 before send_user_message (239), so neither delivers the follow-up; no security or correctness difference. The finding's rationale is overstated: the thrown errors are classified custom exceptions that are logged (logger.error modal 789) or Sentry-captured (780) and recorded by Temporal as typed activity failures — not 'raw, unclassified' or 'not surfaced'. The 'no _write_error_and_complete' claim is not a differentiator either, since the controlled RuntimeError path (224-226) also skips it (that helper only runs post-delivery at 307/317).
  • Impact (what actually remains): the only genuine delta is observability/consistency — the run-scoped refresh_github_logout_failed warning isn't emitted on the exception sub-case, and the error type differs from the sibling branch. Wrapping the logout call to mirror line 538-540 is a sensible small improvement on the fail-safe path, but real-but-minor.
  • Priority: lowering should_fix → consider: functional and fail-closed behavior is identical between the two paths and the sandbox exceptions are already classified and logged/captured, so this is an error-handling-consistency/observability nit, not a reliability defect that must be fixed.
Issue description

In _refresh_sandbox_github, the rebind call apply_github_credentials_to_sandbox(sandbox, repository, token) (line 538) is wrapped in try/except Exception, but the logout/clear call two branches down, clear_github_credentials_from_sandbox(sandbox, repository) (line 552), is called unguarded. Both functions ultimately call sandbox.execute(...), which (see products/tasks/backend/logic/services/modal_sandbox.py:746-796) can raise SandboxNotRunningError, SandboxTimeoutError, or SandboxExecutionError rather than just returning a non-zero exit code. A very plausible race — the sandbox stopping/crashing in the window between _resolve_live_sandbox's is_running() check and this call, or simply timing out — will throw one of these from clear_github_credentials_from_sandbox, propagating as a raw, unclassified exception straight out of _refresh_sandbox_github and _deliver_followup, bypassing the function's own controlled False return (and the RuntimeError("Could not rebind or clear...") the caller raises on that path). This is exactly the fail-safe/logout branch the whole feature depends on, so an ungraceful crash here is worse for operability than a controlled failure: no consistent log signal, no classified error surfaced to the workflow, and no _write_error_and_complete call before the activity dies.

Suggested fix

Wrap clear_github_credentials_from_sandbox(sandbox, repository) in the same try/except Exception pattern used for the rebind call above it, logging (e.g. refresh_github_logout_exception) and falling through to the existing return False fail-closed path on failure, so both credential-mutation calls in this function handle sandbox execution errors identically.

Prompt to fix with AI (copy-paste)
## Context
@products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py#L535-557

<issue_description>
In `_refresh_sandbox_github`, the rebind call `apply_github_credentials_to_sandbox(sandbox, repository, token)` (line 538) is wrapped in `try/except Exception`, but the logout/clear call two branches down, `clear_github_credentials_from_sandbox(sandbox, repository)` (line 552), is called unguarded. Both functions ultimately call `sandbox.execute(...)`, which (see `products/tasks/backend/logic/services/modal_sandbox.py:746-796`) can raise `SandboxNotRunningError`, `SandboxTimeoutError`, or `SandboxExecutionError` rather than just returning a non-zero exit code. A very plausible race — the sandbox stopping/crashing in the window between `_resolve_live_sandbox`'s `is_running()` check and this call, or simply timing out — will throw one of these from `clear_github_credentials_from_sandbox`, propagating as a raw, unclassified exception straight out of `_refresh_sandbox_github` and `_deliver_followup`, bypassing the function's own controlled `False` return (and the `RuntimeError("Could not rebind or clear...")` the caller raises on that path). This is exactly the fail-safe/logout branch the whole feature depends on, so an ungraceful crash here is worse for operability than a controlled failure: no consistent log signal, no classified error surfaced to the workflow, and no `_write_error_and_complete` call before the activity dies.
</issue_description>

<issue_validation>
- **Checked:** `_refresh_sandbox_github` rebind vs logout branches (send_followup_to_sandbox.py:535-557), the credential-clear chain (sandbox_credentials.py:57-77, 80-122, 143-149), `sandbox.execute` semantics (modal_sandbox.py:746-796), the caller's fail-closed raise (224-226), and the activity wrapper (84-114) plus post-delivery error handling (278-319).
- **Found:** the asymmetry is real — `execute()` raises `SandboxNotRunningError`/`SandboxTimeoutError`/`SandboxExecutionError` (modal 751-756, 779-796), the rebind at 538 catches it (539-540) but the logout `clear_github_credentials_from_sandbox` at 552 is unguarded and reaches `sandbox.execute`/`write_file` (sandbox_credentials.py:67, 89, 115), so a stop/timeout race throws out of the function.
- **Impact / why not should_fix:** both failure modes fail closed identically — `_refresh_sandbox_github` runs at line 224 before `send_user_message` (239), so neither delivers the follow-up; no security or correctness difference. The finding's rationale is overstated: the thrown errors are classified custom exceptions that are logged (`logger.error` modal 789) or Sentry-captured (780) and recorded by Temporal as typed activity failures — not 'raw, unclassified' or 'not surfaced'. The 'no _write_error_and_complete' claim is not a differentiator either, since the controlled RuntimeError path (224-226) also skips it (that helper only runs post-delivery at 307/317).
- **Impact (what actually remains):** the only genuine delta is observability/consistency — the run-scoped `refresh_github_logout_failed` warning isn't emitted on the exception sub-case, and the error type differs from the sibling branch. Wrapping the logout call to mirror line 538-540 is a sensible small improvement on the fail-safe path, but real-but-minor.
- **Priority:** lowering should_fix → consider: functional and fail-closed behavior is identical between the two paths and the sandbox exceptions are already classified and logged/captured, so this is an error-handling-consistency/observability nit, not a reliability defect that must be fixed.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Wrap `clear_github_credentials_from_sandbox(sandbox, repository)` in the same `try/except Exception` pattern used for the rebind call above it, logging (e.g. `refresh_github_logout_exception`) and falling through to the existing `return False` fail-closed path on failure, so both credential-mutation calls in this function handle sandbox execution errors identically.
</potential_solution>

Comment on lines +489 to +499
if actor_user is None:
return True

run_id = str(task_run.id)
scope = sandbox_identity_scope(run_id, state)
if get_sandbox_github_identity_user(scope) == actor_user.id:
return True # sandbox already reflects this actor — cheapest check first

task = task_run.task
if get_pr_authorship_mode(task, state) != PrAuthorshipMode.USER:
return True

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.

GitHub identity fast-path never actually short-circuits on the common case, forcing the full sandbox round trip on every run's first follow-up and every ~3h afterward

consider performance

Why we think it's a valid issue
  • Checked: every mark_sandbox_github_identity / mark_sandbox_mcp_session call site (repo-wide grep), the GitHub fast path (send_followup_to_sandbox.py:489-499), the boot seeding in start_agent_server.py, the fall-through cost (_resolve_live_sandbox 449-467, get_sandbox_github_token, apply_github_credentials_to_sandbox → sandbox_credentials.py:57-77/80-122/125-134), and the TTL (utils.py:361).
  • Found: the sibling MCP gate seeds its marker at boot — start_agent_server.py:349-350, mark_sandbox_mcp_session(sandbox.id, params.actor_user_id), with the comment 'Record the boot identity so same-actor follow-ups ... skip the redundant refresh.' The GitHub marker is set only inside _refresh_sandbox_github (544, 553), never at provisioning. So get_sandbox_github_identity_user is None on message Reports that users will need #1 of every USER run, the fast path at 494-495 misses, and it runs the full path (control-plane get_by_id+is_running, token resolution, and ~3 in-sandbox execs for git-remote + env-file rewrite) to re-apply the token already bound at launch. With the 3h TTL it recurs every ~3h for a single-actor conversation. Premise fully confirmed, including the MCP-vs-GitHub asymmetry the finding implies.
  • Impact: a real, avoidable, common-case redundancy that defeats the fast path's stated intent, fixable in one line mirroring line 350 — so it clears the keep bar and is not overengineering (it targets the 100% common path, not an edge case).
  • Priority: lowering should_fix → consider: the redundant work is O(1) per follow-up (not N+1/unbounded/quadratic), on a low-frequency human-paced interactive path with per-run-isolated sandboxes and a cache-backed token lookup, and it is functionally correct (just redundant). The added latency is modest relative to the follow-up's own cost, making this a real-but-minor efficiency/consistency improvement rather than a scale-biting performance defect.
Issue description

_refresh_sandbox_github's comment at line 495 calls the identity-marker check the "cheapest check first," intended to skip the expensive sandbox work when the actor hasn't changed. But mark_sandbox_github_identity is only ever called from inside this same function (verified via repo-wide grep — it's never seeded during initial task/sandbox provisioning), so get_sandbox_github_identity_user(scope) returns None for the very first follow-up of literally every USER-authored run, even when the sender is the run's own creator who already has valid credentials from launch. None == actor_user.id is false, so the fast path never triggers on message #1, and the code falls through to _resolve_live_sandbox (a control-plane Sandbox.get_by_id + is_running() call), get_sandbox_github_token (a token resolution/refresh that can itself hit GitHub's API), and apply_github_credentials_to_sandbox (a git-remote rewrite plus an env-file read+write inside the sandbox) — all to redundantly re-apply the same token that's already correctly bound. Because the marker also shares the generic MCP_TOKEN_REFRESH_INTERVAL_SECONDS TTL (3 hours, products/tasks/backend/temporal/process_task/utils.py:361), the same full cold path re-fires every ~3 hours for any long-lived, single-actor conversation, not only at genuine actor transitions.

Suggested fix

Seed the github-identity marker at sandbox provisioning time (wherever the initial GitHub credentials are first applied to a run's sandbox), so the fast path actually matches the intended "no transition happened" case from message #1 onward, instead of paying the full round trip on every run's first follow-up and periodically thereafter purely due to TTL expiry.

Prompt to fix with AI (copy-paste)
## Context
@products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py#L489-499

<issue_description>
`_refresh_sandbox_github`'s comment at line 495 calls the identity-marker check the "cheapest check first," intended to skip the expensive sandbox work when the actor hasn't changed. But `mark_sandbox_github_identity` is only ever called from inside this same function (verified via repo-wide grep — it's never seeded during initial task/sandbox provisioning), so `get_sandbox_github_identity_user(scope)` returns `None` for the very first follow-up of literally every USER-authored run, even when the sender is the run's own creator who already has valid credentials from launch. `None == actor_user.id` is false, so the fast path never triggers on message #1, and the code falls through to `_resolve_live_sandbox` (a control-plane `Sandbox.get_by_id` + `is_running()` call), `get_sandbox_github_token` (a token resolution/refresh that can itself hit GitHub's API), and `apply_github_credentials_to_sandbox` (a git-remote rewrite plus an env-file read+write inside the sandbox) — all to redundantly re-apply the same token that's already correctly bound. Because the marker also shares the generic `MCP_TOKEN_REFRESH_INTERVAL_SECONDS` TTL (3 hours, `products/tasks/backend/temporal/process_task/utils.py:361`), the same full cold path re-fires every ~3 hours for any long-lived, single-actor conversation, not only at genuine actor transitions.
</issue_description>

<issue_validation>
- **Checked:** every `mark_sandbox_github_identity` / `mark_sandbox_mcp_session` call site (repo-wide grep), the GitHub fast path (send_followup_to_sandbox.py:489-499), the boot seeding in start_agent_server.py, the fall-through cost (`_resolve_live_sandbox` 449-467, `get_sandbox_github_token`, `apply_github_credentials_to_sandbox` → sandbox_credentials.py:57-77/80-122/125-134), and the TTL (utils.py:361).
- **Found:** the sibling MCP gate seeds its marker at boot — `start_agent_server.py:349-350`, `mark_sandbox_mcp_session(sandbox.id, params.actor_user_id)`, with the comment 'Record the boot identity so same-actor follow-ups ... skip the redundant refresh.' The GitHub marker is set only inside `_refresh_sandbox_github` (544, 553), never at provisioning. So `get_sandbox_github_identity_user` is `None` on message #1 of every USER run, the fast path at 494-495 misses, and it runs the full path (control-plane `get_by_id`+`is_running`, token resolution, and ~3 in-sandbox execs for git-remote + env-file rewrite) to re-apply the token already bound at launch. With the 3h TTL it recurs every ~3h for a single-actor conversation. Premise fully confirmed, including the MCP-vs-GitHub asymmetry the finding implies.
- **Impact:** a real, avoidable, common-case redundancy that defeats the fast path's stated intent, fixable in one line mirroring line 350 — so it clears the keep bar and is not overengineering (it targets the 100% common path, not an edge case).
- **Priority:** lowering should_fix → consider: the redundant work is O(1) per follow-up (not N+1/unbounded/quadratic), on a low-frequency human-paced interactive path with per-run-isolated sandboxes and a cache-backed token lookup, and it is functionally correct (just redundant). The added latency is modest relative to the follow-up's own cost, making this a real-but-minor efficiency/consistency improvement rather than a scale-biting performance defect.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Seed the github-identity marker at sandbox provisioning time (wherever the initial GitHub credentials are first applied to a run's sandbox), so the fast path actually matches the intended "no transition happened" case from message #1 onward, instead of paying the full round trip on every run's first follow-up and periodically thereafter purely due to TTL expiry.
</potential_solution>

Comment on lines +513 to +533
try:
token = get_sandbox_github_token(
task.github_integration_id,
run_id=run_id,
state=state,
task=task,
actor_user=actor_user,
repository=repository,
)
except ReauthorizationRequired as e:
# New actor has no usable GitHub access to this repo (missing/invalid integration,
# no app installation, or no repo permission), so we log the sandbox out rather than
# run under the prior actor's creds. Reauthorization is surfaced elsewhere.
logger.info(
"refresh_github_actor_reauthorization_required",
run_id=run_id,
user_id=actor_user.id,
repository=repository,
reason=str(e),
)
token = None

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.

Token-mint failures other than ReauthorizationRequired are unhandled in the new follow-up GitHub gate

consider bug

Why we think it's a valid issue
  • Checked: the except clause in _refresh_sandbox_github (send_followup_to_sandbox.py:512-533), the full call chain of get_sandbox_github_token (utils.py:913-1022) and get_github_token (utils.py:669-681), the sibling handler GitHubSandboxCredential.refresh (sandbox_credentials.py:357-378), and every failure branch in _deliver_followup.
  • Found: the gate catches only ReauthorizationRequired (522). get_github_token raises Integration.DoesNotExist (utils.py:670) and CredentialUnavailableError (674), and is reached from get_sandbox_github_token in the USER path (984, 1010) and BOT path (1013), so both propagate uncaught here. The sibling refresh explicitly catches (Integration.DoesNotExist, UserIntegration.DoesNotExist) and ReauthorizationRequired (367-378), confirming these are expected, handled-elsewhere failure modes.
  • Found (impact correction): the finding's 'unlike every other failure branch, which calls _write_error_and_complete' is inaccurate — the adjacent MCP gate (218-219) and the GitHub gate's own controlled return FalseRuntimeError path (224-226) both raise without _write_error_and_complete. So the uncaught exception's SSE-stream behavior is identical to the gate's intentional fail-closed path, not a unique 'client hangs' regression.
  • Impact: the genuine defect is that these 'no usable token' exceptions bypass the gate's own graceful-degradation logic — ReauthorizationRequired is caught, logs out, and still delivers the follow-up (logged-out), whereas Integration.DoesNotExist/CredentialUnavailableError crash the activity. On a real-but-uncommon mid-run integration disconnect or GitHub-App suspension, the follow-up hard-fails (and retries) instead of degrading. Delivery is fail-closed either way (no wrong-actor push, no data loss), so it's a real reliability/consistency gap matching the 'missing handling for a failure mode that will occur' category, but bounded.
  • Priority: lowering should_fix → consider: the trigger is an uncommon mid-run integration/installation change, the consequence is a fail-closed hard-failed follow-up rather than a breach or data loss, and the reviewer's severity rests partly on the overstated 'unique SSE hang' impact that the gate's own controlled path shares.
Issue description

get_sandbox_github_token(...) is only guarded against ReauthorizationRequired here. But the same call chain can raise Integration.DoesNotExist (via get_github_token, utils.py:670, when the task's integration row was deleted mid-run) or CredentialUnavailableError (via installation_unavailable() in the same function) — both of which the periodic-refresh sibling GitHubSandboxCredential.refresh in sandbox_credentials.py (lines 367-378) explicitly catches and converts to a controlled CredentialUnavailableError. In this new per-follow-up gate, neither is caught, so they propagate as raw, unhandled exceptions out of _deliver_followup — unlike every other failure branch in this function (TaskRun.DoesNotExist, missing artifacts, Slack actor unavailable, retry-exhausted delivery), which explicitly calls _write_error_and_complete before raising. The SSE stream never receives a terminal event, so the client-side follow-up appears to hang instead of surfacing a clear, classified error.

Suggested fix

Catch (Integration.DoesNotExist, UserIntegration.DoesNotExist, CredentialUnavailableError) around the get_sandbox_github_token call in _refresh_sandbox_github, mirroring how GitHubSandboxCredential.refresh handles the same exceptions, and treat it the same way ReauthorizationRequired is treated here (log and fall through to logout) rather than letting it escape uncaught.

Prompt to fix with AI (copy-paste)
## Context
@products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py#L513-533

<issue_description>
`get_sandbox_github_token(...)` is only guarded against `ReauthorizationRequired` here. But the same call chain can raise `Integration.DoesNotExist` (via `get_github_token`, `utils.py:670`, when the task's integration row was deleted mid-run) or `CredentialUnavailableError` (via `installation_unavailable()` in the same function) — both of which the periodic-refresh sibling `GitHubSandboxCredential.refresh` in `sandbox_credentials.py` (lines 367-378) explicitly catches and converts to a controlled `CredentialUnavailableError`. In this new per-follow-up gate, neither is caught, so they propagate as raw, unhandled exceptions out of `_deliver_followup` — unlike every other failure branch in this function (`TaskRun.DoesNotExist`, missing artifacts, Slack actor unavailable, retry-exhausted delivery), which explicitly calls `_write_error_and_complete` before raising. The SSE stream never receives a terminal event, so the client-side follow-up appears to hang instead of surfacing a clear, classified error.
</issue_description>

<issue_validation>
- **Checked:** the except clause in `_refresh_sandbox_github` (send_followup_to_sandbox.py:512-533), the full call chain of `get_sandbox_github_token` (utils.py:913-1022) and `get_github_token` (utils.py:669-681), the sibling handler `GitHubSandboxCredential.refresh` (sandbox_credentials.py:357-378), and every failure branch in `_deliver_followup`.
- **Found:** the gate catches only `ReauthorizationRequired` (522). `get_github_token` raises `Integration.DoesNotExist` (utils.py:670) and `CredentialUnavailableError` (674), and is reached from `get_sandbox_github_token` in the USER path (984, 1010) and BOT path (1013), so both propagate uncaught here. The sibling refresh explicitly catches `(Integration.DoesNotExist, UserIntegration.DoesNotExist)` and `ReauthorizationRequired` (367-378), confirming these are expected, handled-elsewhere failure modes.
- **Found (impact correction):** the finding's 'unlike every other failure branch, which calls `_write_error_and_complete`' is inaccurate — the adjacent MCP gate (218-219) and the GitHub gate's own controlled `return False`→`RuntimeError` path (224-226) both raise without `_write_error_and_complete`. So the uncaught exception's SSE-stream behavior is identical to the gate's intentional fail-closed path, not a unique 'client hangs' regression.
- **Impact:** the genuine defect is that these 'no usable token' exceptions bypass the gate's own graceful-degradation logic — `ReauthorizationRequired` is caught, logs out, and still delivers the follow-up (logged-out), whereas `Integration.DoesNotExist`/`CredentialUnavailableError` crash the activity. On a real-but-uncommon mid-run integration disconnect or GitHub-App suspension, the follow-up hard-fails (and retries) instead of degrading. Delivery is fail-closed either way (no wrong-actor push, no data loss), so it's a real reliability/consistency gap matching the 'missing handling for a failure mode that will occur' category, but bounded.
- **Priority:** lowering should_fix → consider: the trigger is an uncommon mid-run integration/installation change, the consequence is a fail-closed hard-failed follow-up rather than a breach or data loss, and the reviewer's severity rests partly on the overstated 'unique SSE hang' impact that the gate's own controlled path shares.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Catch `(Integration.DoesNotExist, UserIntegration.DoesNotExist, CredentialUnavailableError)` around the `get_sandbox_github_token` call in `_refresh_sandbox_github`, mirroring how `GitHubSandboxCredential.refresh` handles the same exceptions, and treat it the same way `ReauthorizationRequired` is treated here (log and fall through to logout) rather than letting it escape uncaught.
</potential_solution>

@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from a556b58 to 7e5552b Compare July 22, 2026 12:59
Comment thread products/tasks/backend/temporal/process_task/sandbox_credentials.py Outdated
identity must treat a partial write as an unconfirmed rebind: leaving one location on the
previous actor's token would let a follow-up actor act as them.
"""
remote_applied = set_git_remote_token(sandbox, repository, github_token) if repository else True

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.

Transitions can race scheduled refresh or token propagation because the marker check and credential writes aren’t atomic, so the owner token could overwrite the new actor.

Can we serialize all per-sandbox credential writers around check, writes, and marker update? Just want to avoid regression upon token refresh mechanism, people get pissed (rightfully so) whenever they lose work in their cloud workflows :sad

# unsets then re-exports GH_TOKEN/GITHUB_TOKEN from the env file.
if [ -f /tmp/agentsh-bash-env.sh ]; then
# shellcheck source=/dev/null
. /tmp/agentsh-bash-env.sh

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Do not source a mutable sandbox script before gh

/tmp/agentsh-bash-env.sh is writable state in the shared, root-run sandbox (and the launch code explicitly treats a snapshot-persisted copy as untrusted). A first Slack actor can replace it with a payload that reads /tmp/agent-github-env; when a later actor is rebound and invokes gh, this new . executes that payload after the later actor's token has been written. This lets an earlier actor exfiltrate a later actor's GitHub credential despite the identity transition.

Prompt To Fix With AI
Do not execute `/tmp/agentsh-bash-env.sh` from the gh shim. Move the minimal credential-file parsing needed by gh into a root-owned, immutable script/image layer, or otherwise have a trusted supervisor pass the current token to gh without sourcing any sandbox-writable script. Ensure a prior actor cannot alter the code that reads a later actor's credential file.

Severity: high | Confidence: 91% | React with 👍 if useful or 👎 if not

@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from fe655fe to c9c19da Compare July 23, 2026 12:34

# Only the fast in-sandbox writes run under this lock (token resolution stays outside), so a short
# TTL comfortably covers them; the wait stays well under the refresh activity's 2 min timeout.
_CREDENTIAL_LOCK_TTL_SECONDS = 30

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.

This lock also protects normal cloud-run credential refreshes, but it can expire while the protected writes are still running because the remote update and chmod may each take 30 seconds.
CAn we renew the lock or increase its TTL to cover the complete worst-case operation so concurrent refreshes cannot interleave, it was a big issue back a month or so ago

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ahh interesting, yeah, we can, what TTL you are suggesting, like 5 minutes would be enough?

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.

Five minutes seems okay fort now, yup. Could we also document how it’s derived and add a test covering a writer that exceeds the old 30-second lease? Longer term, we would need some lock renewal but for now I think that;s okay

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yup, will do!

# Install the gh shim at runtime too (see agentsh.GH_GUARD_INSTALL_PATH): a resume from a
# pre-shim filesystem snapshot — or any window where the base image lags this backend —
# would otherwise leave gh with no token once the frozen launch-env token is unset.
self.write_file(GH_GUARD_INSTALL_PATH, read_gh_guard_script())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Do not source a sandbox-writable credential hook

The runtime-installed gh shim sources /tmp/agentsh-bash-env.sh before it invokes gh. That file is in the shared sandbox filesystem and is writable by the previous Slack actor's agent. An attacker can persist commands in it, then when a more-privileged user sends the next follow-up and their token is written to /tmp/agent-github-env, the shim executes those commands in the privileged command environment. This lets the prior actor use or exfiltrate the new actor's GitHub credential, defeating the per-actor rebind on resumed snapshots.

Prompt To Fix With AI
Do not execute any script from the shared sandbox filesystem as part of credential delivery. On an actor transition, use a freshly provisioned/isolated execution environment for the new actor, or make the credential loader and its token source inaccessible and immutable to prior-agent code. Reinstalling a known script alone is insufficient unless every hook and process state that can run before `gh` is also reset and isolated.

Severity: high | Confidence: 91% | React with 👍 if useful or 👎 if not

@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from ce9dc2b to 9471020 Compare July 23, 2026 14:26
…itions

Follow-ups are gated per-actor for MCP; do the same for GitHub. On an actor
transition, re-inject the new actor's GitHub token if they have usable access,
otherwise log the sandbox out (strip the token from the git remote and env) so
the previous actor's GitHub identity can't be used by a follow-up actor who
lacks it. Reauthorization for that actor is surfaced by the existing
credential-refresh path, unchanged. Fail closed only when the sandbox can be
neither rebound nor cleared. Only USER-authored runs carry per-actor identity;
BOT runs share one installation token.
Two runtime gaps let a follow-up actor keep the previous actor's GitHub identity
after a per-message logout:

- The agent-server was launched with GITHUB_TOKEN/GH_TOKEN in its process env,
  frozen for the process lifetime. Clearing the live /tmp/agent-env file could
  not revoke that copy, so in-process tools resurrected it. Add both vars to
  SANDBOX_AGENT_LAUNCH_UNSET_ENV_VARS; the token is still delivered per command
  via the file (re-sourced by BASH_ENV, seeded before the unset).
- The periodic user-token refresh loop re-applied the run owner's token to every
  live sandbox keyed on the owner's integration, overwriting a transition. Skip
  runs whose sandbox-github-identity marker is bound to a different actor.

Pairs with the agent-server fix (PostHog/code) that treats an emptied env file
as an explicit logout instead of falling back to the process env.
Record why the github identity gate logs the sandbox out (missing/invalid
integration, no app installation, or no repo permission) so the transition
outcome is debuggable.
Address review findings on the per-actor GitHub identity gate:
- The scheduled per-run refresh (GitHubSandboxCredential.refresh) resolved the
  actor from the startup context and re-applied the owner token without checking
  the sandbox-github-identity marker, restoring the original actor about one
  refresh interval after a transition. Skip the refresh when the sandbox is bound
  to a different actor, mirroring _propagate_user_token.
- apply_github_credentials_to_sandbox now reports whether both writes succeeded;
  the gate records the new actor only on a confirmed rebind and falls through to
  logout on a partial write.
- Fail the follow-up closed when the live sandbox handle cannot be resolved on a
  transition, rather than delivering under the prior actor s retained creds.
Address ReviewHog observability/robustness findings:
- Log the cause in _resolve_live_sandbox before returning None, so a fail-closed
  follow-up rejection is diagnosable (dead sandbox vs transient lookup error).
- Guard the logout clear call like the rebind above it: a sandbox exec that
  raises (stopped/timed out mid-call) now fails closed instead of escaping.
- Broaden the token-resolution except to the credential-unavailable family
  (CredentialUnavailableError, Integration/UserIntegration.DoesNotExist), not
  only ReauthorizationRequired, so a mid-run disconnect logs out cleanly rather
  than propagating uncontrolled.
Centralize the duplicated per-actor identity check (sibling propagation and
scheduled refresh both read the same marker) into _actor_rebound_away_from_owner
so the two copies cannot drift, and name it distinctly from the loop-owner
eligibility gate it sits beside.
Scheduled refresh, sibling propagation, and the per-message actor gate all
write a sandbox's GitHub credentials. Without mutual exclusion, a slow
owner-token write can land after a follow-up rebound the sandbox to a
different actor, resurrecting the owner's identity for that actor.

Serialize every writer on a per-sandbox redis lock and re-check the actor
binding inside it before writing, so the check, write, and marker update run
atomically.
The backend delivers the per-actor GitHub token through BASH_ENV, which only
non-interactive `bash -c` honors. The agent runs its tool commands in an
interactive shell, so `gh` there had no token and PR creation failed once the
frozen process-env token was removed.

Add a gh PATH shim (mirroring git-guard) installed first on PATH that sources
the same credential script on every call, so gh authenticates as the current
actor regardless of shell mode and honors logout (an emptied file exports
nothing). gh is the only env-dependent consumer: git uses the remote URL and
the signed-commit tool reads the file in-process.
The 30s lease could expire mid-write: each managed write is a chain of
in-sandbox execs bounded by a 30s timeout, and the per-message gate can run two
chains back-to-back (apply the new token, then log out), so a slow sandbox can
hold the lock ~2 min. Raise the TTL to 5 min so the lease cannot expire
mid-write and let a concurrent refresh acquire and interleave. Document the
derivation and add a test covering a writer past the old 30s lease.
The gh PATH shim is baked into new base images, but a resume from a pre-shim
filesystem snapshot — or any window where the base image lags this backend —
would lack it, leaving gh with no token once the frozen launch-env token is
unset. Write and chmod the shim from start_agent_server (reading its single
source of truth) so it's present on every launch regardless of image age.
@VojtechBartos
VojtechBartos force-pushed the vojtab/github-identity-transitions branch from 9471020 to 73b4353 Compare July 24, 2026 09:04


def _mark_sandbox_identity(kind: str, scope: str, user_id: int) -> None:
get_tasks_cache().set(_sandbox_identity_cache_key(kind, scope), user_id, timeout=MCP_TOKEN_REFRESH_INTERVAL_SECONDS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Preserve actor binding beyond the cache TTL

The sole record that this sandbox was transitioned to a non-owner expires after three hours. Once it expires, _actor_rebound_away_from_owner returns None; the scheduled refresh, which uses the startup owner's context, accepts that as safe and _apply_owner_token_locked writes the owner's token back. The next follow-up from an actor without repo access then takes the prior marker/credential path and can execute GitHub operations as the owner.

Prompt To Fix With AI
Store the current GitHub credential actor/binding durably for the sandbox lifetime (including an explicit logged-out state), or ensure owner-scoped scheduled refresh and propagation fail closed when the identity marker is absent after a possible actor transition. Do not treat an expired/evicted cache marker as owner-safe. Add a test that transitions to a different actor, expires/removes the marker, runs GitHubSandboxCredential.refresh(), and asserts the owner token is not applied.

Severity: high | Confidence: 94% | React with 👍 if useful or 👎 if not

absent entry reads as "must re-establish", which is always safe because
re-establishing re-applies or clears rather than trusting stale creds.
"""
_mark_sandbox_identity("github-identity", scope, user_id)

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.

High: Actor binding expires before the sandbox

This marker expires after three hours, is not renewed by the same-actor fast path, and the sandbox can live for six hours. An actor can keep the run active and leave a delayed GitHub command; after the marker expires, _apply_owner_token_locked interprets the missing value as owner-bound and the periodic refresh injects the owner's token. Persist the binding for the sandbox's lifetime, or make owner refreshes treat an unknown binding as unsafe once a transition has occurred.

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.

3 participants