From 557ac824e52fe7816004bba64b7cfaee128a877e Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sat, 28 Mar 2026 19:36:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E6=B2=99=E7=9B=92=20?= =?UTF-8?q?user-data=20=E7=9B=AE=E5=BD=95=E9=9A=94=E7=A6=BB=E7=AD=96?= =?UTF-8?q?=E7=95=A5=EF=BC=8Cworkspace=20=E4=BF=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=BA=A7=EF=BC=8C=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=B3=BB=E7=BB=9F=E9=80=BB=E8=BE=91=E5=8F=8A=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/agents/backends/sandbox/paths.py | 69 ++++++++++--- .../yuxi/services/thread_files_service.py | 92 +++++++++++++++--- .../services/viewer_filesystem_service.py | 46 +++++++-- backend/package/yuxi/utils/datetime_utils.py | 8 ++ .../test/api/test_viewer_filesystem_e2e.py | 96 ++++++++++++++----- .../test/api/test_viewer_filesystem_router.py | 63 +++++++++++- docs/develop-guides/roadmap.md | 2 + 7 files changed, 315 insertions(+), 61 deletions(-) diff --git a/backend/package/yuxi/agents/backends/sandbox/paths.py b/backend/package/yuxi/agents/backends/sandbox/paths.py index 12f3cb01..795e0d4f 100644 --- a/backend/package/yuxi/agents/backends/sandbox/paths.py +++ b/backend/package/yuxi/agents/backends/sandbox/paths.py @@ -7,6 +7,9 @@ from yuxi import config as conf DEFAULT_VIRTUAL_PATH_PREFIX = "/home/gem/user-data" VIRTUAL_PATH_PREFIX = DEFAULT_VIRTUAL_PATH_PREFIX +_WORKSPACE_DIR_NAME = "workspace" +_UPLOADS_DIR_NAME = "uploads" +_OUTPUTS_DIR_NAME = "outputs" _SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") @@ -32,28 +35,61 @@ def _thread_root_dir(thread_id: str) -> Path: return Path(conf.save_dir) / "threads" / safe_thread_id / "user-data" +def _global_user_data_dir() -> Path: + """Return the shared host-side directory used for thread workspace files.""" + return Path(conf.save_dir) / "user-data" + + def sandbox_user_data_dir(thread_id: str) -> Path: return _thread_root_dir(thread_id) def sandbox_workspace_dir(thread_id: str) -> Path: - return _thread_root_dir(thread_id) / "workspace" + _validate_thread_id(thread_id) + return _global_user_data_dir() / _WORKSPACE_DIR_NAME def sandbox_uploads_dir(thread_id: str) -> Path: - return _thread_root_dir(thread_id) / "uploads" + return _thread_root_dir(thread_id) / _UPLOADS_DIR_NAME def sandbox_outputs_dir(thread_id: str) -> Path: - return _thread_root_dir(thread_id) / "outputs" + return _thread_root_dir(thread_id) / _OUTPUTS_DIR_NAME def ensure_thread_dirs(thread_id: str) -> None: + _global_user_data_dir().mkdir(parents=True, exist_ok=True) sandbox_workspace_dir(thread_id).mkdir(parents=True, exist_ok=True) sandbox_uploads_dir(thread_id).mkdir(parents=True, exist_ok=True) sandbox_outputs_dir(thread_id).mkdir(parents=True, exist_ok=True) +def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Path, Path]: + """Map a virtual user-data path to the correct host-side base directory.""" + parts = Path(relative_path).parts + if not parts: + base_dir = sandbox_user_data_dir(thread_id) + return base_dir.resolve(), base_dir.resolve() + + namespace = parts[0] + if namespace == _WORKSPACE_DIR_NAME: + # Workspace is shared across threads, so it lives outside the per-thread root. + base_dir = sandbox_workspace_dir(thread_id) + target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir + return base_dir.resolve(), target_path.resolve() + if namespace == _UPLOADS_DIR_NAME: + base_dir = sandbox_uploads_dir(thread_id) + target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir + return base_dir.resolve(), target_path.resolve() + if namespace == _OUTPUTS_DIR_NAME: + base_dir = sandbox_outputs_dir(thread_id) + target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir + return base_dir.resolve(), target_path.resolve() + + base_dir = sandbox_user_data_dir(thread_id) + return base_dir.resolve(), (base_dir / relative_path).resolve() + + def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path: clean_virtual_path = "/" + str(virtual_path or "").strip().lstrip("/") virtual_prefix = get_virtual_path_prefix() @@ -62,8 +98,7 @@ def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path: raise ValueError(f"path must start with {virtual_prefix}") relative_path = clean_virtual_path[len(virtual_prefix) :].lstrip("/") - base_dir = sandbox_user_data_dir(thread_id).resolve() - target_path = (base_dir / relative_path).resolve() + base_dir, target_path = _resolve_user_data_base_dir(thread_id, relative_path) try: target_path.relative_to(base_dir) @@ -74,15 +109,27 @@ def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path: def virtual_path_for_thread_file(thread_id: str, path: str | Path) -> str: - base_dir = sandbox_user_data_dir(thread_id).resolve() target_path = Path(path).resolve() + thread_root = sandbox_user_data_dir(thread_id).resolve() + global_workspace_root = sandbox_workspace_dir(thread_id).resolve() try: - relative_path = target_path.relative_to(base_dir) - except ValueError as exc: - raise ValueError("file is outside thread user-data directory") from exc + relative_path = target_path.relative_to(global_workspace_root) + except ValueError: + try: + relative_path = target_path.relative_to(thread_root) + except ValueError as exc: + raise ValueError("file is outside allowed user-data directories") from exc + relative_path_str = relative_path.as_posix() + else: + workspace_relative = relative_path.as_posix() + relative_path_str = ( + _WORKSPACE_DIR_NAME + if workspace_relative in {"", "."} + else f"{_WORKSPACE_DIR_NAME}/{workspace_relative}" + ) prefix = get_virtual_path_prefix().rstrip("/") - if not str(relative_path): + if not relative_path_str: return prefix - return f"{prefix}/{relative_path.as_posix()}" + return f"{prefix}/{relative_path_str}" diff --git a/backend/package/yuxi/services/thread_files_service.py b/backend/package/yuxi/services/thread_files_service.py index 4f38e3c6..bda68ba8 100644 --- a/backend/package/yuxi/services/thread_files_service.py +++ b/backend/package/yuxi/services/thread_files_service.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -9,24 +8,23 @@ from yuxi import config as conf from yuxi.agents.backends.sandbox import ( ensure_thread_dirs, resolve_virtual_path, + sandbox_outputs_dir, + sandbox_uploads_dir, sandbox_user_data_dir, + sandbox_workspace_dir, virtual_path_for_thread_file, ) from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.conversation_service import require_user_conversation +from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp def _get_virtual_root() -> str: + """Return the virtual root exposed by the thread-files API.""" prefix = str(getattr(conf, "sandbox_virtual_path_prefix", "/home/gem/user-data") or "/home/gem/user-data") return "/" + prefix.strip("/") -def _to_iso8601(timestamp: float | None) -> str | None: - if timestamp is None: - return None - return datetime.fromtimestamp(timestamp, tz=UTC).isoformat() - - async def list_thread_files_view( *, thread_id: str, @@ -51,8 +49,13 @@ async def list_thread_files_view( raise HTTPException(status_code=400, detail="path must be a directory") if recursive: + if virtual_path.rstrip("/") == _get_virtual_root(): + return _list_user_data_root_entries(thread_id, virtual_path, recursive=True) return _list_files_recursive(thread_id, actual_path, virtual_path) + if virtual_path.rstrip("/") == _get_virtual_root(): + return _list_user_data_root_entries(thread_id, virtual_path) + entries: list[dict[str, Any]] = [] for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): stat = child.stat() @@ -63,7 +66,7 @@ async def list_thread_files_view( "name": child.name, "is_dir": child.is_dir(), "size": stat.st_size if child.is_file() else 0, - "modified_at": _to_iso8601(stat.st_mtime), + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime), "artifact_url": None if child.is_dir() else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}", @@ -73,7 +76,56 @@ async def list_thread_files_view( return {"path": virtual_path, "files": entries} +def _list_user_data_root_entries(thread_id: str, virtual_path: str, recursive: bool = False) -> dict: + """List the thread root and inject the shared workspace entry if needed.""" + entries: list[dict[str, Any]] = [] + thread_root = sandbox_user_data_dir(thread_id) + for child in sorted(thread_root.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): + stat = child.stat() + child_virtual_path = virtual_path_for_thread_file(thread_id, child) + if child.is_dir() and not child_virtual_path.endswith("/"): + child_virtual_path = f"{child_virtual_path}/" + entries.append( + { + "path": child_virtual_path, + "name": child.name, + "is_dir": child.is_dir(), + "size": stat.st_size if child.is_file() else 0, + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime), + "artifact_url": None + if child.is_dir() + else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}", + } + ) + if recursive and child.is_dir(): + nested = _list_files_recursive(thread_id, child, child_virtual_path) + entries.extend(nested["files"]) + + workspace_dir = sandbox_workspace_dir(thread_id) + workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir) + if workspace_virtual_path.rstrip("/") not in {str(entry["path"]).rstrip("/") for entry in entries}: + # workspace lives outside the per-thread root, so expose it as a top-level entry. + stat = workspace_dir.stat() + if not workspace_virtual_path.endswith("/"): + workspace_virtual_path = f"{workspace_virtual_path}/" + entries.append( + { + "path": workspace_virtual_path, + "name": workspace_dir.name, + "is_dir": True, + "size": 0, + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime), + "artifact_url": None, + } + ) + if recursive: + nested = _list_files_recursive(thread_id, workspace_dir, workspace_virtual_path) + entries.extend(nested["files"]) + return {"path": virtual_path, "files": entries} + + def _list_files_recursive(thread_id: str, actual_path: Path, virtual_path: str) -> dict: + """Recursively scan a directory while preserving viewer virtual paths.""" entries: list[dict[str, Any]] = [] def _scan_dir(base_actual_path: Path, base_virtual_path: str): @@ -87,7 +139,7 @@ def _list_files_recursive(thread_id: str, actual_path: Path, virtual_path: str) "name": child.name, "is_dir": child.is_dir(), "size": stat.st_size if child.is_file() else 0, - "modified_at": _to_iso8601(stat.st_mtime), + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime), "artifact_url": None if child.is_dir() else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}", @@ -163,11 +215,21 @@ async def resolve_thread_artifact_view( if not actual_path.is_file(): raise HTTPException(status_code=400, detail="artifact path is not a file") - # Additional guard to ensure path remains under thread root even if helper changes. - thread_root = sandbox_user_data_dir(thread_id).resolve() - try: - actual_path.resolve().relative_to(thread_root) - except ValueError as exc: - raise HTTPException(status_code=403, detail="access denied") from exc + resolved_path = actual_path.resolve() + allowed_roots = ( + sandbox_workspace_dir(thread_id).resolve(), + sandbox_uploads_dir(thread_id).resolve(), + sandbox_outputs_dir(thread_id).resolve(), + ) + if not any(_is_path_within(resolved_path, root) for root in allowed_roots): + raise HTTPException(status_code=403, detail="access denied") return actual_path + + +def _is_path_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index 59aa86f7..2a6d466c 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import io import mimetypes -from datetime import UTC, datetime from pathlib import PurePosixPath from urllib.parse import quote @@ -11,11 +10,20 @@ from fastapi import HTTPException from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.backends import KBS_PATH, KnowledgeBaseReadonlyBackend, resolve_visible_knowledge_bases_for_context -from yuxi.agents.backends.sandbox import SKILLS_PATH, USER_DATA_PATH, resolve_virtual_path, virtual_path_for_thread_file +from yuxi.agents.backends.sandbox import ( + SKILLS_PATH, + USER_DATA_PATH, + ensure_thread_dirs, + resolve_virtual_path, + sandbox_user_data_dir, + sandbox_workspace_dir, + virtual_path_for_thread_file, +) from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills from yuxi.services.filesystem_service import _resolve_filesystem_state from yuxi.storage.postgres.models_business import User +from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) _PDF_EXTENSIONS = frozenset({".pdf"}) @@ -209,13 +217,8 @@ def _sort_entries(entries: list[dict]) -> list[dict]: ) -def _to_iso8601(timestamp: float | None) -> str: - if timestamp is None: - return "" - return datetime.fromtimestamp(timestamp, tz=UTC).isoformat() - - def _list_local_entries(thread_id: str, actual_path) -> list[dict]: + """List a local directory and remap children back into viewer virtual paths.""" entries: list[dict] = [] for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): stat = child.stat() @@ -229,7 +232,28 @@ def _list_local_entries(thread_id: str, actual_path) -> list[dict]: "name": child.name, "is_dir": is_dir, "size": 0 if is_dir else stat.st_size, - "modified_at": _to_iso8601(stat.st_mtime), + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "", + } + ) + return entries + + +def _list_user_data_root_entries(thread_id: str) -> list[dict]: + """Expose thread-root files while keeping the shared workspace entry visible.""" + entries = _list_local_entries(thread_id, sandbox_user_data_dir(thread_id)) + visible_paths = {str(entry.get("path") or "").rstrip("/") for entry in entries} + workspace_dir = sandbox_workspace_dir(thread_id) + workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir).rstrip("/") + if workspace_virtual_path not in visible_paths: + # workspace is stored outside the per-thread root, so add it explicitly when needed. + stat = workspace_dir.stat() + entries.append( + { + "path": f"{workspace_virtual_path}/", + "name": workspace_dir.name, + "is_dir": True, + "size": 0, + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "", } ) return entries @@ -294,6 +318,10 @@ async def list_viewer_filesystem_tree( try: if _is_user_data_path(normalized_path): + ensure_thread_dirs(thread_id) + if normalized_path == USER_DATA_PATH: + entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id) + return {"entries": _sort_entries(entries)} actual_path = resolve_virtual_path(thread_id, normalized_path) if not actual_path.exists(): return {"entries": []} diff --git a/backend/package/yuxi/utils/datetime_utils.py b/backend/package/yuxi/utils/datetime_utils.py index 08459610..7543a220 100644 --- a/backend/package/yuxi/utils/datetime_utils.py +++ b/backend/package/yuxi/utils/datetime_utils.py @@ -127,6 +127,13 @@ def format_utc_datetime(value: dt.datetime | None) -> str | None: return utc_isoformat(value) +def utc_isoformat_from_timestamp(timestamp: float | int | None) -> str | None: + """Format a Unix timestamp as an ISO 8601 UTC datetime string.""" + if timestamp is None: + return None + return dt.datetime.fromtimestamp(timestamp, tz=UTC).isoformat() + + __all__ = [ "UTC", "SHANGHAI_TZ", @@ -141,4 +148,5 @@ __all__ = [ "coerce_any_to_utc_datetime", "normalize_iterable_to_utc", "format_utc_datetime", + "utc_isoformat_from_timestamp", ] diff --git a/backend/test/api/test_viewer_filesystem_e2e.py b/backend/test/api/test_viewer_filesystem_e2e.py index 4d333391..f9ef8dd0 100644 --- a/backend/test/api/test_viewer_filesystem_e2e.py +++ b/backend/test/api/test_viewer_filesystem_e2e.py @@ -14,11 +14,13 @@ _root = Path(__file__).resolve().parents[2] if str(_root) not in sys.path: sys.path.insert(0, str(_root)) - -def _make_sandbox_backend(thread_id: str): - from yuxi.agents.backends.sandbox.backend import ProvisionerSandboxBackend - - return ProvisionerSandboxBackend(thread_id=thread_id) +from yuxi.agents.backends.sandbox import ( # noqa: E402 + ensure_thread_dirs, + sandbox_outputs_dir, + sandbox_uploads_dir, + sandbox_user_data_dir, + sandbox_workspace_dir, +) API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5050").rstrip("/") @@ -33,6 +35,7 @@ class ViewerFilesystemE2ETester: self.headers: dict[str, str] | None = None self.agent_id: str | None = None self.thread_id: str | None = None + self.other_thread_id: str | None = None async def close(self): await self.client.aclose() @@ -69,33 +72,48 @@ class ViewerFilesystemE2ETester: payload = resp.json() self.thread_id = str(payload.get("thread_id") or payload.get("id")) - async def tree(self, path: str) -> list[dict]: + async def create_other_thread(self) -> None: + assert self.headers and self.agent_id + resp = await self.client.post( + "/api/chat/thread", + json={"agent_id": self.agent_id, "title": f"viewer-fs-e2e-{uuid.uuid4().hex[:8]}", "metadata": {}}, + headers=self.headers, + ) + if resp.status_code != 200: + raise RuntimeError(f"create second thread failed: {resp.status_code} {resp.text}") + payload = resp.json() + self.other_thread_id = str(payload.get("thread_id") or payload.get("id")) + + async def tree(self, path: str, *, thread_id: str | None = None) -> list[dict]: assert self.headers and self.thread_id and self.agent_id + target_thread_id = thread_id or self.thread_id resp = await self.client.get( "/api/viewer/filesystem/tree", - params={"thread_id": self.thread_id, "path": path, "agent_id": self.agent_id}, + params={"thread_id": target_thread_id, "path": path, "agent_id": self.agent_id}, headers=self.headers, ) if resp.status_code != 200: raise RuntimeError(f"viewer tree failed on {path}: {resp.status_code} {resp.text}") return list(resp.json().get("entries") or []) - async def file(self, path: str) -> dict: + async def file(self, path: str, *, thread_id: str | None = None) -> dict: assert self.headers and self.thread_id and self.agent_id + target_thread_id = thread_id or self.thread_id resp = await self.client.get( "/api/viewer/filesystem/file", - params={"thread_id": self.thread_id, "path": path, "agent_id": self.agent_id}, + params={"thread_id": target_thread_id, "path": path, "agent_id": self.agent_id}, headers=self.headers, ) if resp.status_code != 200: raise RuntimeError(f"viewer file failed on {path}: {resp.status_code} {resp.text}") return dict(resp.json()) - async def download(self, path: str) -> tuple[str, bytes]: + async def download(self, path: str, *, thread_id: str | None = None) -> tuple[str, bytes]: assert self.headers and self.thread_id and self.agent_id + target_thread_id = thread_id or self.thread_id resp = await self.client.get( "/api/viewer/filesystem/download", - params={"thread_id": self.thread_id, "path": path, "agent_id": self.agent_id}, + params={"thread_id": target_thread_id, "path": path, "agent_id": self.agent_id}, headers=self.headers, ) if resp.status_code != 200: @@ -106,38 +124,66 @@ class ViewerFilesystemE2ETester: await self.login() await self.pick_agent() await self.create_thread() + await self.create_other_thread() - assert self.thread_id - sandbox = _make_sandbox_backend(self.thread_id) - commands = [ - "mkdir -p /home/gem/user-data/workspace /home/gem/user-data/outputs", - "printf 'print(42)\\n' > /home/gem/user-data/workspace/demo.py", - "printf 'root-file\\n' > /home/gem/user-data/root_file.txt", - "printf 'viewer-output\\n' > /home/gem/user-data/outputs/result.txt", - ] - for command in commands: - result = await asyncio.to_thread(sandbox.execute, command) - if result.exit_code != 0: - raise RuntimeError(f"command failed: {command}\n{result.output}") + assert self.thread_id and self.other_thread_id + ensure_thread_dirs(self.thread_id) + ensure_thread_dirs(self.other_thread_id) + (sandbox_user_data_dir(self.thread_id) / "root-note.txt").write_text("root-visible\n", encoding="utf-8") + (sandbox_workspace_dir(self.thread_id) / "demo.py").write_text("print(42)\n", encoding="utf-8") + uploads_dir = sandbox_uploads_dir(self.thread_id) / "attachments" + uploads_dir.mkdir(parents=True, exist_ok=True) + (uploads_dir / "thread1.txt").write_text("thread-one-upload\n", encoding="utf-8") + (sandbox_outputs_dir(self.thread_id) / "result.txt").write_text("viewer-output\n", encoding="utf-8") root_paths = {str(e.get("path", "")) for e in await self.tree("/")} if "/home/gem/user-data/" not in root_paths: raise RuntimeError(f"viewer root missing user-data: {sorted(root_paths)}") user_data_paths = {str(e.get("path", "")) for e in await self.tree("/home/gem/user-data")} - if "/home/gem/user-data/root_file.txt" not in user_data_paths: - raise RuntimeError(f"viewer user-data missing root_file.txt: {sorted(user_data_paths)}") + expected_root_paths = { + "/home/gem/user-data/workspace/", + "/home/gem/user-data/uploads/", + "/home/gem/user-data/outputs/", + "/home/gem/user-data/root-note.txt", + } + if not expected_root_paths.issubset(user_data_paths): + raise RuntimeError(f"viewer user-data root mismatch: {sorted(user_data_paths)}") workspace_paths = {str(e.get("path", "")) for e in await self.tree("/home/gem/user-data/workspace")} if "/home/gem/user-data/workspace/demo.py" not in workspace_paths: raise RuntimeError(f"viewer workspace missing demo.py: {sorted(workspace_paths)}") + other_workspace_paths = { + str(e.get("path", "")) + for e in await self.tree( + "/home/gem/user-data/workspace", + thread_id=self.other_thread_id, + ) + } + if "/home/gem/user-data/workspace/demo.py" not in other_workspace_paths: + raise RuntimeError(f"shared workspace missing in second thread: {sorted(other_workspace_paths)}") + + other_upload_paths = { + str(e.get("path", "")) + for e in await self.tree( + "/home/gem/user-data/uploads", + thread_id=self.other_thread_id, + ) + } + if "/home/gem/user-data/uploads/attachments/" in other_upload_paths: + raise RuntimeError(f"thread-local uploads leaked to second thread: {sorted(other_upload_paths)}") + file_payload = await self.file("/home/gem/user-data/workspace/demo.py") if file_payload.get("content") != "print(42)\n": raise RuntimeError(f"unexpected viewer file content: {file_payload!r}") if file_payload.get("preview_type") != "text" or file_payload.get("supported") is not True: raise RuntimeError(f"unexpected viewer file preview metadata: {file_payload!r}") + other_file_payload = await self.file("/home/gem/user-data/workspace/demo.py", thread_id=self.other_thread_id) + if other_file_payload.get("content") != "print(42)\n": + raise RuntimeError(f"unexpected shared workspace content: {other_file_payload!r}") + content_disposition, payload = await self.download("/home/gem/user-data/outputs/result.txt") if "result.txt" not in content_disposition: raise RuntimeError(f"unexpected content-disposition: {content_disposition}") diff --git a/backend/test/api/test_viewer_filesystem_router.py b/backend/test/api/test_viewer_filesystem_router.py index 95f072e3..888fbd3a 100644 --- a/backend/test/api/test_viewer_filesystem_router.py +++ b/backend/test/api/test_viewer_filesystem_router.py @@ -6,7 +6,12 @@ import importlib import uuid import pytest -from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_workspace_dir, virtual_path_for_thread_file +from yuxi.agents.backends.sandbox import ( + ensure_thread_dirs, + sandbox_user_data_dir, + sandbox_workspace_dir, + virtual_path_for_thread_file, +) pytestmark = [pytest.mark.asyncio, pytest.mark.integration] @@ -149,9 +154,65 @@ async def test_viewer_tree_user_data_uses_local_thread_directory(test_client, st entries = response.json().get("entries", []) paths = {entry.get("path") for entry in entries} assert "/home/gem/user-data/workspace/" in paths + assert "/home/gem/user-data/uploads/" in paths + assert "/home/gem/user-data/outputs/" in paths assert all(str(path).startswith("/home/gem/user-data") for path in paths) +async def test_viewer_tree_user_data_root_keeps_thread_root_files_visible(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + ensure_thread_dirs(thread_id) + root_file = sandbox_user_data_dir(thread_id) / "root-note.txt" + root_file.write_text("visible at root", encoding="utf-8") + + response = await test_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": thread_id, "path": "/home/gem/user-data"}, + headers=headers, + ) + assert response.status_code == 200, response.text + + paths = {entry.get("path") for entry in response.json().get("entries", [])} + assert "/home/gem/user-data/root-note.txt" in paths + assert "/home/gem/user-data/workspace/" in paths + + +async def test_workspace_is_shared_across_threads_but_uploads_remain_thread_local(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + other_thread_id = await _create_thread_for_user(test_client, headers) + + ensure_thread_dirs(thread_id) + shared_path = sandbox_workspace_dir(thread_id) / "shared_across_threads.txt" + shared_path.write_text("shared workspace", encoding="utf-8") + + upload_path = await _upload_attachment_file(test_client, thread_id, headers, "thread-local.txt", "private upload\n") + + workspace_response = await test_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": other_thread_id, "path": "/home/gem/user-data/workspace"}, + headers=headers, + ) + assert workspace_response.status_code == 200, workspace_response.text + workspace_paths = {entry.get("path") for entry in workspace_response.json().get("entries", [])} + assert "/home/gem/user-data/workspace/shared_across_threads.txt" in workspace_paths + + shared_file_response = await test_client.get( + f"/api/chat/thread/{other_thread_id}/artifacts/home/gem/user-data/workspace/shared_across_threads.txt", + headers=headers, + ) + assert shared_file_response.status_code == 200, shared_file_response.text + assert shared_file_response.text == "shared workspace" + + upload_response = await test_client.get( + f"/api/chat/thread/{other_thread_id}/artifacts/{upload_path.lstrip('/')}", + headers=headers, + ) + assert upload_response.status_code == 404, upload_response.text + + async def test_viewer_file_returns_raw_content_without_line_numbers(test_client, standard_user): headers = standard_user["headers"] thread_id = await _create_thread_for_user(test_client, headers) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 952fc2a4..82153dcb 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -49,10 +49,12 @@ ### 修复 +- 调整沙盒 user-data 目录隔离策略:`workspace` 改为全局共享目录 `saves/user-data/workspace`,`uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试 - 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试 - 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置 - 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown,并补充对应后端与 viewer 路由测试 - 优化 viewer 文件系统目录树加载:根目录与 `/home/gem/user-data` 改为直接读取本地线程挂载目录,不再为只读树视图触发 sandbox 冷启动,并补充对应后端测试 +- 修复 `/home/gem/user-data` 根目录文件不可见的问题:根目录现在会同时展示 thread 目录下的真实文件和 `workspace` 入口,不再只保留固定命名空间目录 - 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标 - 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败