From eb818bf2aa894bd5102974aa5b39018aca1beed4 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Tue, 21 Jul 2026 16:04:44 +0200 Subject: [PATCH] fix(tasks): let team members drive runs of slack-originated tasks Slack threads are multiplayer: any same-team user can steer a task via thread follow-ups, and the sandbox's credentials are minted for whoever spoke last. But run-mutating API actions were gated on task_control_q (creator-only), so a non-creator actor's sandbox 404'd on all callbacks: relay_message (the reply), append_log (the workflow heartbeat), and the status PATCH (completion signal). The run did the work invisibly, starved the workflow of heartbeats, timed out after 45 minutes, and the thread died silently. Mirror the Slack-side semantics in task_accessible_for_run_view: runs of slack-originated tasks are drivable and watchable by any team member. Task-level control (edit/delete) stays creator-only, and non-slack tasks are unaffected. --- products/tasks/backend/facade/api.py | 9 +++- products/tasks/backend/tests/test_api.py | 63 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 6c436d9e495b..21e15c649f50 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -1784,10 +1784,17 @@ def task_accessible_for_run_view( read actions, which the caller signals via ``bypass_visibility``. Run-mutating actions pass ``for_control`` to use the narrower ``task_control_q`` — public-channel visibility lets teammates watch a run, not drive it. + + Slack-originated tasks are the exception: those threads are multiplayer, so any same-team + user can already steer the run from Slack (follow-ups record them as the run's actor, with + sandbox credentials minted for them). The runs API mirrors that and lets team members drive + and watch Slack tasks' runs — otherwise a non-creator actor's sandbox 404s on every callback + (reply relay, log-append heartbeat, completion PATCH) and the thread dies silently. """ task_filter = Task.objects.filter(id=task_id, team_id=team_id) if not bypass_visibility: - task_filter = task_filter.filter(task_control_q(user_id) if for_control else task_visibility_q(user_id)) + scope_q = task_control_q(user_id) if for_control else task_visibility_q(user_id) + task_filter = task_filter.filter(scope_q | Q(origin_product=Task.OriginProduct.SLACK)) return task_filter.exists() diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index 90b7ae51e211..a6a27288f06f 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -9944,3 +9944,66 @@ def test_build_falls_back_to_stored_spec_when_builder_sandbox_gone(self, mock_wo self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["status"], "scanning") mock_workflow.assert_called_once() + + +class TestTaskRunSlackTaskTeamControl(BaseTaskAPITest): + """Slack-originated tasks are multiplayer: any same-team user may drive their runs. + + Guards the incident where a non-creator's thread follow-up resumed a run whose sandbox + then 404'd on every callback (status PATCH, log append, Slack relay), so the workflow + starved of heartbeats and the thread died silently. + """ + + def _create_run(self, *, origin_product: Task.OriginProduct) -> tuple[Task, TaskRun]: + creator = self.create_organization_user("thread-starter") + task = Task.objects.create( + team=self.team, + created_by=creator, + title="Thread task", + description="Test Description", + origin_product=origin_product, + ) + run = TaskRun.objects.create( + task=task, + team=self.team, + status=TaskRun.Status.IN_PROGRESS, + environment=TaskRun.Environment.CLOUD, + ) + return task, run + + @parameterized.expand( + [ + ("teammate_can_patch_slack_run", Task.OriginProduct.SLACK, "patch", status.HTTP_200_OK), + ("teammate_can_retrieve_slack_run", Task.OriginProduct.SLACK, "get", status.HTTP_200_OK), + ( + "teammate_cannot_patch_user_created_run", + Task.OriginProduct.USER_CREATED, + "patch", + status.HTTP_404_NOT_FOUND, + ), + ( + "teammate_cannot_retrieve_user_created_run", + Task.OriginProduct.USER_CREATED, + "get", + status.HTTP_404_NOT_FOUND, + ), + ] + ) + @patch("products.tasks.backend.models.TaskRun.publish_stream_state_event") + def test_non_creator_run_access_by_origin( + self, + _case_name: str, + origin_product: Task.OriginProduct, + method: str, + expected_status: int, + _mock_publish_stream_state_event: MagicMock, + ) -> None: + task, run = self._create_run(origin_product=origin_product) + + url = f"/api/projects/@current/tasks/{task.id}/runs/{run.id}/" + if method == "patch": + response = self.client.patch(url, {"output": {"marker": "from-teammate"}}, format="json") + else: + response = self.client.get(url) + + self.assertEqual(response.status_code, expected_status)