feat: 调整沙盒 user-data 目录隔离策略,workspace 修改为用户级,优化文件系统逻辑及权限校验
This commit is contained in:
parent
4e740bab1b
commit
557ac824e5
@ -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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": []}
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 在依赖缓存和安装阶段直接失败
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user