Skip to content

Commit 94530ef

Browse files
committed
fix(workspace): resolve workspace items from partner presentations (issue #1267)
When a user requests a workspace item (/files/workspace-items/...) that was generated in a Partner Chat session, resolve_published_item must search the partner's workspace presentations in addition to the user's direct bindings. Previously, the function only checked user bindings and raised 'The workspace is no longer registered for this user' for partner-generated items (404 in UI). Changes: - resolve_published_item now searches candidate_roots (user + all visible partners) - Gracefully handles partners without the requested workspace - Maintains security: searches only visible_partners() that the user can access - Backward compatible: user's direct bindings take precedence This completes the fix for issue #1267 Partner Chat Artifact Downloads. The outputs endpoint (commit 0d1696a) and workspace routers are already fixed; this addresses workspace-items endpoint for partner artifacts. Test: test_resolve_published_item_searches_partner_workspaces verifies fallback behavior with mocked partner workspaces. Fixes: #1267
1 parent 76e42b8 commit 94530ef

2 files changed

Lines changed: 127 additions & 23 deletions

File tree

deeptutor/services/workspace/service.py

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919

2020
from deeptutor.core.context import WorkspaceRuntimeContext
2121
from deeptutor.multi_user.context import get_current_user
22+
from deeptutor.multi_user.partner_access import visible_partners
23+
from deeptutor.multi_user.paths import get_path_service_for_scope
2224
from deeptutor.services.path_service import get_path_service
25+
from deeptutor.services.partners.scope import partner_scope
2326
from deeptutor.services.settings.interface_settings import atomic_update
2427
from deeptutor.utils.secret_files import ensure_private_directory
2528

@@ -772,31 +775,74 @@ def export_once(
772775
def resolve_published_item(
773776
self, workspace_id: str, workspace_item_id: str
774777
) -> tuple[Path, WorkspaceItem]:
778+
"""Resolve a published workspace item from the user's workspace or visible partner workspaces.
779+
780+
Tries the user's direct workspace bindings first, then searches visible
781+
partner workspace presentations if the item is not found (issue #1267).
782+
"""
775783
if not re.fullmatch(r"wsi_[0-9a-f]{32}", workspace_item_id):
776784
raise WorkspaceError("Invalid workspace item id.")
777-
binding = self.binding_by_id(workspace_id)
778-
root = self._presentation_root(binding)
779-
for directory in (root / "items", root / "blobs"):
780-
if directory.is_symlink():
781-
raise WorkspaceError(
782-
"The private workspace presentation path cannot contain symbolic links."
783-
)
784-
manifest_path = root / "items" / f"{workspace_item_id}.json"
785-
try:
786-
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
787-
item = WorkspaceItem(**payload)
788-
except (OSError, json.JSONDecodeError, TypeError) as exc:
789-
raise WorkspaceError("The presented workspace item is unavailable.") from exc
790-
if item.workspace_id != workspace_id or item.workspace_item_id != workspace_item_id:
791-
raise WorkspaceError("The workspace item manifest is invalid.")
792-
blob = (root / "blobs" / item.sha256).resolve()
785+
786+
candidate_roots: list[Path] = []
787+
788+
# Try user's direct workspace binding first
793789
try:
794-
blob.relative_to(root.resolve())
795-
except ValueError as exc:
796-
raise WorkspaceError("The workspace item path is invalid.") from exc
797-
if not blob.is_file():
798-
raise WorkspaceError("The presented workspace item is unavailable.")
799-
return blob, item
790+
binding = self.binding_by_id(workspace_id)
791+
candidate_roots.append(self._presentation_root(binding))
792+
except WorkspaceError:
793+
pass
794+
795+
# Search visible partner workspace presentations if user's binding misses
796+
if not candidate_roots:
797+
try:
798+
for partner in visible_partners():
799+
partner_id = str(partner.get("partner_id") or "").strip()
800+
if not partner_id:
801+
continue
802+
try:
803+
p_path_svc = get_path_service_for_scope(partner_scope(partner_id))
804+
p_ws_service = ContentWorkspaceService(
805+
path_service=p_path_svc,
806+
auth_scopes=self.auth_scopes,
807+
)
808+
p_binding = p_ws_service.binding_by_id(workspace_id)
809+
candidate_roots.append(p_ws_service._presentation_root(p_binding))
810+
except (WorkspaceError, Exception):
811+
# Partner doesn't have this workspace; try the next
812+
continue
813+
except Exception:
814+
pass
815+
816+
if not candidate_roots:
817+
raise WorkspaceError("The workspace is no longer registered for this user.")
818+
819+
# Search candidate roots for the workspace item
820+
for root in candidate_roots:
821+
for directory in (root / "items", root / "blobs"):
822+
if directory.is_symlink():
823+
raise WorkspaceError(
824+
"The private workspace presentation path cannot contain symbolic links."
825+
)
826+
manifest_path = root / "items" / f"{workspace_item_id}.json"
827+
if not manifest_path.is_file():
828+
continue
829+
try:
830+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
831+
item = WorkspaceItem(**payload)
832+
except (OSError, json.JSONDecodeError, TypeError) as exc:
833+
raise WorkspaceError("The presented workspace item is unavailable.") from exc
834+
if item.workspace_id != workspace_id or item.workspace_item_id != workspace_item_id:
835+
raise WorkspaceError("The workspace item manifest is invalid.")
836+
blob = (root / "blobs" / item.sha256).resolve()
837+
try:
838+
blob.relative_to(root.resolve())
839+
except ValueError as exc:
840+
raise WorkspaceError("The workspace item path is invalid.") from exc
841+
if not blob.is_file():
842+
raise WorkspaceError("The presented workspace item is unavailable.")
843+
return blob, item
844+
845+
raise WorkspaceError("The presented workspace item is unavailable.")
800846

801847

802848
_service = ContentWorkspaceService()

tests/services/workspace/test_content_workspace.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
2-
32
from pathlib import Path
3+
from unittest.mock import patch
4+
45

56
import pytest
67

@@ -151,6 +152,63 @@ def test_private_presentation_store_rejects_symlink_redirection(
151152
assert not any(outside.iterdir())
152153

153154

155+
def test_resolve_published_item_searches_partner_workspaces(
156+
workspace_service, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
157+
) -> None:
158+
"""Verify that resolve_published_item falls back to partner workspaces (issue #1267).
159+
160+
When a workspace item is not found in the user's direct bindings, the service
161+
should search visible partner workspace presentations before raising an error.
162+
"""
163+
164+
165+
user_service, paths = workspace_service
166+
167+
# Create item in user's workspace
168+
user_binding = user_service.current_binding(ensure_output=True)
169+
user_source = user_binding.root / "document.md"
170+
user_source.write_text("user document", encoding="utf-8")
171+
user_item = user_service.publish(user_binding, [{"path": "document.md", "title": "Doc"}])[0]
172+
173+
# Verify user can resolve their own item
174+
blob, loaded = user_service.resolve_published_item(
175+
user_item.workspace_id, user_item.workspace_item_id
176+
)
177+
assert blob.read_text(encoding="utf-8") == "user document"
178+
179+
# Create a separate partner workspace with an item
180+
partner_root = tmp_path / "partner_workspace"
181+
partner_root.mkdir()
182+
partner_paths = PathService(workspace_root=partner_root / "runtime")
183+
partner_paths.ensure_all_directories()
184+
185+
partner_service = ContentWorkspaceService(path_service=partner_paths)
186+
partner_binding = partner_service.current_binding(ensure_output=True)
187+
partner_source = partner_binding.root / "partner_doc.md"
188+
partner_source.write_text("partner document", encoding="utf-8")
189+
partner_item = partner_service.publish(partner_binding, [{"path": "partner_doc.md", "title": "Partner Doc"}])[0]
190+
191+
# Mock visible_partners to return the partner workspace
192+
mock_partner = {
193+
"partner_id": "test-partner-1",
194+
"name": "Test Partner",
195+
}
196+
197+
with patch("deeptutor.services.workspace.service.visible_partners") as mock_visible:
198+
with patch("deeptutor.services.workspace.service.get_path_service_for_scope") as mock_get_scope:
199+
# Setup mocks
200+
mock_visible.return_value = [mock_partner]
201+
mock_get_scope.return_value = partner_paths
202+
203+
# User should be able to resolve partner's item through fallback
204+
blob, loaded = user_service.resolve_published_item(
205+
partner_item.workspace_id, partner_item.workspace_item_id
206+
)
207+
assert blob.read_text(encoding="utf-8") == "partner document"
208+
assert loaded.title == "Partner Doc"
209+
assert loaded.workspace_item_id == partner_item.workspace_item_id
210+
211+
154212
def test_private_presentation_subdirectory_rejects_symlink_redirection(
155213
workspace_service, tmp_path: Path
156214
) -> None:

0 commit comments

Comments
 (0)