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)