feat: 调整沙盒 user-data 目录隔离策略,workspace 修改为用户级,优化文件系统逻辑及权限校验

This commit is contained in:
Wenjie Zhang 2026-03-28 19:36:06 +08:00
parent 4e740bab1b
commit 557ac824e5
7 changed files with 315 additions and 61 deletions

View File

@ -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}"

View File

@ -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

View File

@ -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": []}

View File

@ -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",
]

View File

@ -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}")

View File

@ -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)

View File

@ -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 在依赖缓存和安装阶段直接失败