Skip to content

Commit fe5e07b

Browse files
committed
fix(tasks): rebind sandbox MCP session on actor transitions
The follow-up MCP refresh 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 rest of the window. One cache entry per sandbox (tasks:sandbox-mcp-session:{scope}) records which user the live session was last bound to and expires with the freshness window: same-actor repeats skip, while a speaker change, an expired or unknown entry, and a replacement sandbox (fresh scope) all refresh. The gate receives the delivery's already-resolved, payload- pinned actor, so it follows the message's sender; runs with no credential user skip the doomed mint instead of warning per message. Boot records the initial session binding under the sandbox id.
1 parent dd32bb2 commit fe5e07b

4 files changed

Lines changed: 221 additions & 222 deletions

File tree

products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@
2929
from products.tasks.backend.temporal.process_task.utils import (
3030
get_actor_distinct_id,
3131
get_imported_mcp_server_configs,
32+
get_sandbox_mcp_session_user,
3233
get_sandbox_ph_mcp_configs,
3334
get_task_run_credential_user,
3435
get_user_mcp_server_configs,
3536
is_slack_interaction_state,
36-
mark_mcp_token_issued,
37+
mark_sandbox_mcp_session,
3738
record_message_actor,
38-
should_refresh_mcp_token,
39+
sandbox_identity_scope,
3940
)
4041

4142
from ee.hogai.sandbox import STOP_REASON_END_TURN, TURN_COMPLETE_METHOD
@@ -158,10 +159,13 @@ def _deliver_followup(input: SendFollowupToSandboxInput) -> None:
158159
task_run, user_id=actor_user.id, distinct_id=get_actor_distinct_id(actor_user)
159160
)
160161

161-
# Push a fresh MCP config before the turn so the agent-server rebinds its
162-
# ACP session to a non-stale OAuth token. Non-fatal: if refresh fails we
163-
# still deliver the follow-up with the existing (possibly stale) creds.
164-
_refresh_sandbox_mcp(task_run, input.posthog_mcp_scopes, auth_token, actor_user=actor_user, state=state)
162+
# Rebind the sandbox's MCP session to this actor before the turn. On an
163+
# actor transition this must rebind or clear the prior session; if it can't,
164+
# fail closed rather than run the turn under the previous actor's creds.
165+
# Same-actor and first-bind refreshes stay best-effort.
166+
if not _refresh_sandbox_mcp(task_run, input.posthog_mcp_scopes, auth_token, actor_user=actor_user, state=state):
167+
error_msg = "Could not rebind sandbox MCP credentials for the follow-up actor"
168+
raise RuntimeError(f"send_followup failed: {error_msg}")
165169
artifacts = None
166170
artifact_ids = input.artifact_ids or []
167171
if artifact_ids:
@@ -256,28 +260,40 @@ def _refresh_sandbox_mcp(
256260
*,
257261
actor_user: Any,
258262
state: dict[str, Any] | None,
259-
) -> None:
260-
"""Mint a fresh OAuth token for the actor and push updated MCP configs to
261-
the sandbox.
262-
263-
Best-effort: retries once on failure, then logs and returns — a failed
264-
refresh must not block an otherwise-valid follow-up. Skipped when a token
265-
was already issued for this run within MCP_TOKEN_REFRESH_INTERVAL_SECONDS.
263+
) -> bool:
264+
"""Rebind the sandbox's MCP session to this message's actor.
265+
266+
Returns ``True`` when the session is safe to use (unchanged actor, first
267+
bind, or a successful rebind) and ``False`` only when an actor *transition*
268+
could neither rebind nor clear the previous actor's session — the caller
269+
then fails the follow-up closed. Same-actor rotation and first binds stay
270+
best-effort. Retries the refresh once before giving up.
266271
"""
267272
run_id = str(task_run.id)
268-
if not should_refresh_mcp_token(run_id):
269-
logger.info("refresh_mcp_skipped_within_interval", run_id=run_id)
270-
return
271273
if actor_user is None:
272274
# Without a credential user the mint is guaranteed to fail; skip
273275
# quietly rather than warn on every message.
274-
return
276+
return True
277+
278+
scope = sandbox_identity_scope(run_id, state)
279+
bound_user_id = get_sandbox_mcp_session_user(scope)
280+
if bound_user_id == actor_user.id:
281+
logger.info("refresh_mcp_skipped_within_interval", run_id=run_id, user_id=actor_user.id)
282+
return True
283+
is_transition = bound_user_id is not None
284+
if is_transition:
285+
logger.info(
286+
"refresh_mcp_identity_transition",
287+
run_id=run_id,
288+
previous_user_id=bound_user_id,
289+
user_id=actor_user.id,
290+
)
275291

276292
try:
277293
access_token = create_oauth_access_token_for_run(task_run.task, state, scopes=scopes)
278294
except Exception as e:
279295
logger.warning("refresh_mcp_token_mint_failed", run_id=run_id, error=str(e))
280-
return
296+
return not is_transition # first-bind: best-effort; transition: fail closed
281297

282298
mcp_configs = get_sandbox_ph_mcp_configs(
283299
token=access_token,
@@ -301,10 +317,16 @@ def _refresh_sandbox_mcp(
301317
if imported_mcp_configs:
302318
mcp_configs = mcp_configs + imported_mcp_configs
303319

304-
if not mcp_configs:
320+
if not mcp_configs and not is_transition:
321+
# First bind for this sandbox and the actor has no MCP configs: there is
322+
# no prior session to tear down, so just record the binding.
323+
mark_sandbox_mcp_session(scope, actor_user.id)
305324
logger.info("refresh_mcp_skipped_no_configs", run_id=run_id)
306-
return
325+
return True
307326

327+
# An actor transition where the new actor resolves no configs still has to
328+
# clear the previous actor's live session — an empty server list replaces it
329+
# wholesale. Binding stays gated on a successful send below.
308330
mcp_servers = [config.to_dict() for config in mcp_configs]
309331

310332
result = send_refresh_session(
@@ -314,9 +336,9 @@ def _refresh_sandbox_mcp(
314336
timeout=REFRESH_TIMEOUT_SECONDS,
315337
)
316338
if result.success:
317-
mark_mcp_token_issued(run_id)
339+
mark_sandbox_mcp_session(scope, actor_user.id)
318340
logger.info("refresh_mcp_delivered", run_id=run_id, attempts=1)
319-
return
341+
return True
320342

321343
logger.info(
322344
"refresh_mcp_retrying",
@@ -332,16 +354,17 @@ def _refresh_sandbox_mcp(
332354
timeout=REFRESH_TIMEOUT_SECONDS,
333355
)
334356
if retry.success:
335-
mark_mcp_token_issued(run_id)
357+
mark_sandbox_mcp_session(scope, actor_user.id)
336358
logger.info("refresh_mcp_delivered", run_id=run_id, attempts=2)
337-
return
359+
return True
338360

339361
logger.warning(
340362
"refresh_mcp_failed",
341363
run_id=run_id,
342364
error=retry.error,
343365
status_code=retry.status_code,
344366
)
367+
return not is_transition # transition that never rebound → fail closed
345368

346369

347370
def _get_stop_reason(result_data: dict[str, Any] | None) -> str:

products/tasks/backend/temporal/process_task/activities/start_agent_server.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
get_sandbox_ph_mcp_configs,
3131
get_task_run_credential_user,
3232
get_user_mcp_server_configs,
33-
mark_mcp_token_issued,
33+
mark_sandbox_mcp_session,
3434
)
3535

3636
from .get_task_processing_context import TaskProcessingContext
@@ -167,6 +167,9 @@ class StartAgentServerOutput:
167167
class _LaunchParams:
168168
mcp_configs: list[McpServerConfig]
169169
relayed_mcp_servers: list[str]
170+
# The user the boot-time credentials were minted for, recorded as the
171+
# sandbox's initial session identity.
172+
actor_user_id: int | None
170173
agentsh_domains: list[str] | None
171174
protected_base_branch: str | None
172175
event_ingest_token: str | None
@@ -298,6 +301,7 @@ def _prepare_launch(ctx: TaskProcessingContext, scopes: PosthogMcpScopes) -> _La
298301
return _LaunchParams(
299302
mcp_configs=mcp_configs,
300303
relayed_mcp_servers=relayed_names,
304+
actor_user_id=actor_user.id if actor_user else None,
301305
agentsh_domains=agentsh_domains,
302306
protected_base_branch=protected_base_branch,
303307
event_ingest_token=event_ingest_token,
@@ -340,10 +344,10 @@ def _invoke_start_agent_server(
340344
rtk_enabled=ctx.rtk_enabled,
341345
)
342346

343-
# Mark startup-time token issuance so follow-ups within the next
344-
# 30m window skip the redundant refresh.
345-
if params.mcp_configs:
346-
mark_mcp_token_issued(ctx.run_id)
347+
# Record the boot identity so same-actor follow-ups within the
348+
# freshness window skip the redundant refresh.
349+
if params.mcp_configs and params.actor_user_id is not None:
350+
mark_sandbox_mcp_session(sandbox.id, params.actor_user_id)
347351

348352
# Persist the effective rtk posture the agent launched with, so terminal
349353
# analytics can cohort runs by it (the state override alone misses the

0 commit comments

Comments
 (0)