diff --git a/tests/test_console.py b/tests/test_console.py index 336463df..40e569ce 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -4,7 +4,7 @@ import asyncio import json import queue from typing import Any -from unittest.mock import MagicMock +from unittest.mock import ANY, MagicMock import pytest @@ -1046,6 +1046,8 @@ class TestConsoleHTTPEndpoints: page=1, per_page=25, extra_rows=[], + # Per-request private-project tenancy closure — identity varies. + row_filter=ANY, ) def test_get_workstreams_per_page_capped(self, client, mock_collector): diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py index edadcf64..f082cb02 100644 --- a/tests/test_coordinator_endpoints.py +++ b/tests/test_coordinator_endpoints.py @@ -520,6 +520,7 @@ def test_active_list_row_shape_includes_unified_fields(storage): "kind", "parent_ws_id", "user_id", + "project_id", } assert row["name"] == "lifted-coord" assert row["kind"] == "coordinator" diff --git a/tests/test_server_authz.py b/tests/test_server_authz.py index 38749bb1..3533850a 100644 --- a/tests/test_server_authz.py +++ b/tests/test_server_authz.py @@ -646,6 +646,7 @@ class TestListWorkstreamsTrustedTeamVisibility: "kind", "parent_ws_id", "user_id", + "project_id", } assert row["kind"] == "interactive" assert row["user_id"] == "user-shape" diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 053d2378..f90259e7 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -333,7 +333,13 @@ class WorkstreamProjectVisibility: """Apply the class rules to one workstream row.""" if self._bypass: return True - pid = (project_id or "").strip() + # Only a real string can name a project — anything else (None, + # a test double, a corrupted row) means "no project link", not + # "private". Keeps the fail-closed branch for genuine lookup + # failures rather than type noise. + if not project_id or not isinstance(project_id, str): + return True + pid = project_id.strip() if not pid: return True if ws_owner and ws_owner == self._user_id: diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index 544d5701..38c8e49a 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -1543,12 +1543,14 @@ def make_retry_handler( # A retry is a fresh turn initiated by the authenticated caller — # rebind per-user MCP credential resolution to them before the # re-send dispatches (the per-kind ``dispatch_retry`` closure - # calls ``send()`` without identity kwargs). + # calls ``send()`` without identity kwargs). getattr-guarded so + # per-kind session stubs without the method keep working. from turnstone.core.web_helpers import auth_user_id acting_uid = auth_user_id(request) - if acting_uid: - session.bind_acting_user(acting_uid) + bind_acting = getattr(session, "bind_acting_user", None) + if acting_uid and callable(bind_acting): + bind_acting(acting_uid) retry_msg = session.retry() @@ -2655,7 +2657,8 @@ def make_list_handler(cfg: SessionEndpointConfig) -> Handler: titles = resolve_titles([ws.id for ws in wss]) rows: list[dict[str, Any]] = [] for ws in wss: - project_id = getattr(ws, "project_id", "") or "" + raw_pid = getattr(ws, "project_id", "") + project_id = raw_pid if isinstance(raw_pid, str) else "" # Private-project tenancy — drop rows the requester may # not see (same predicate as the saved list). if not visibility.ws_visible(project_id, ws_owner=ws.user_id or ""): @@ -3797,8 +3800,14 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler: kwargs["attachments"] = resolved_atts if send_id: kwargs["send_id"] = send_id - if acting_uid: - kwargs["acting_user_id"] = acting_uid + # Fresh turn: rebind per-user MCP credentials to the + # authenticated sender. Bound here (not via a send() + # kwarg) so per-kind session stubs with explicit send + # signatures keep working; getattr-guarded for the same + # reason. The queue path above never rebinds. + bind = getattr(session, "bind_acting_user", None) + if acting_uid and callable(bind): + bind(acting_uid) session.send(message, **kwargs) except GenerationCancelled: # Safety net — send() normally handles this internally. diff --git a/turnstone/core/web_helpers.py b/turnstone/core/web_helpers.py index f8b67fb2..58bb68b2 100644 --- a/turnstone/core/web_helpers.py +++ b/turnstone/core/web_helpers.py @@ -349,7 +349,8 @@ def resolve_workstream_owner( ws_mem = mgr.get(ws_id) if ws_mem is not None: owner = ws_mem.user_id or "" - project_id = getattr(ws_mem, "project_id", "") or "" + raw_pid = getattr(ws_mem, "project_id", "") + project_id = raw_pid if isinstance(raw_pid, str) else "" if owner is None: # Not in memory — storage resolves persisted-but-not-loaded rows.