2929from 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
4142from 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
347370def _get_stop_reason (result_data : dict [str , Any ] | None ) -> str :
0 commit comments