Merge branch 'main' into feat/builtin-skills-on-demand-install

This commit is contained in:
Wenjie Zhang 2026-03-28 19:36:41 +08:00
commit 8041c5ef4d
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" DEFAULT_VIRTUAL_PATH_PREFIX = "/home/gem/user-data"
VIRTUAL_PATH_PREFIX = DEFAULT_VIRTUAL_PATH_PREFIX 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_-]+$") _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" 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: def sandbox_user_data_dir(thread_id: str) -> Path:
return _thread_root_dir(thread_id) return _thread_root_dir(thread_id)
def sandbox_workspace_dir(thread_id: str) -> Path: 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: 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: 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: 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_workspace_dir(thread_id).mkdir(parents=True, exist_ok=True)
sandbox_uploads_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) 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: def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path:
clean_virtual_path = "/" + str(virtual_path or "").strip().lstrip("/") clean_virtual_path = "/" + str(virtual_path or "").strip().lstrip("/")
virtual_prefix = get_virtual_path_prefix() 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}") raise ValueError(f"path must start with {virtual_prefix}")
relative_path = clean_virtual_path[len(virtual_prefix) :].lstrip("/") relative_path = clean_virtual_path[len(virtual_prefix) :].lstrip("/")
base_dir = sandbox_user_data_dir(thread_id).resolve() base_dir, target_path = _resolve_user_data_base_dir(thread_id, relative_path)
target_path = (base_dir / relative_path).resolve()
try: try:
target_path.relative_to(base_dir) 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: 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() target_path = Path(path).resolve()
thread_root = sandbox_user_data_dir(thread_id).resolve()
global_workspace_root = sandbox_workspace_dir(thread_id).resolve()
try: try:
relative_path = target_path.relative_to(base_dir) relative_path = target_path.relative_to(global_workspace_root)
except ValueError as exc: except ValueError:
raise ValueError("file is outside thread user-data directory") from exc 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("/") prefix = get_virtual_path_prefix().rstrip("/")
if not str(relative_path): if not relative_path_str:
return prefix 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 __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -9,24 +8,23 @@ from yuxi import config as conf
from yuxi.agents.backends.sandbox import ( from yuxi.agents.backends.sandbox import (
ensure_thread_dirs, ensure_thread_dirs,
resolve_virtual_path, resolve_virtual_path,
sandbox_outputs_dir,
sandbox_uploads_dir,
sandbox_user_data_dir, sandbox_user_data_dir,
sandbox_workspace_dir,
virtual_path_for_thread_file, virtual_path_for_thread_file,
) )
from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.conversation_service import require_user_conversation from yuxi.services.conversation_service import require_user_conversation
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
def _get_virtual_root() -> str: 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") prefix = str(getattr(conf, "sandbox_virtual_path_prefix", "/home/gem/user-data") or "/home/gem/user-data")
return "/" + prefix.strip("/") 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( async def list_thread_files_view(
*, *,
thread_id: str, thread_id: str,
@ -51,8 +49,13 @@ async def list_thread_files_view(
raise HTTPException(status_code=400, detail="path must be a directory") raise HTTPException(status_code=400, detail="path must be a directory")
if recursive: 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) 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]] = [] entries: list[dict[str, Any]] = []
for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat() stat = child.stat()
@ -63,7 +66,7 @@ async def list_thread_files_view(
"name": child.name, "name": child.name,
"is_dir": child.is_dir(), "is_dir": child.is_dir(),
"size": stat.st_size if child.is_file() else 0, "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 "artifact_url": None
if child.is_dir() if child.is_dir()
else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}", 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} 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: 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]] = [] entries: list[dict[str, Any]] = []
def _scan_dir(base_actual_path: Path, base_virtual_path: str): 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, "name": child.name,
"is_dir": child.is_dir(), "is_dir": child.is_dir(),
"size": stat.st_size if child.is_file() else 0, "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 "artifact_url": None
if child.is_dir() if child.is_dir()
else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}", 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(): if not actual_path.is_file():
raise HTTPException(status_code=400, detail="artifact path is not a 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. resolved_path = actual_path.resolve()
thread_root = sandbox_user_data_dir(thread_id).resolve() allowed_roots = (
try: sandbox_workspace_dir(thread_id).resolve(),
actual_path.resolve().relative_to(thread_root) sandbox_uploads_dir(thread_id).resolve(),
except ValueError as exc: sandbox_outputs_dir(thread_id).resolve(),
raise HTTPException(status_code=403, detail="access denied") from exc )
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 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 asyncio
import io import io
import mimetypes import mimetypes
from datetime import UTC, datetime
from pathlib import PurePosixPath from pathlib import PurePosixPath
from urllib.parse import quote from urllib.parse import quote
@ -11,11 +10,20 @@ from fastapi import HTTPException
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.agents.backends import KBS_PATH, KnowledgeBaseReadonlyBackend, resolve_visible_knowledge_bases_for_context 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.backends.skills_backend import SelectedSkillsReadonlyBackend
from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills
from yuxi.services.filesystem_service import _resolve_filesystem_state from yuxi.services.filesystem_service import _resolve_filesystem_state
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
_PDF_EXTENSIONS = frozenset({".pdf"}) _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]: 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] = [] entries: list[dict] = []
for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat() stat = child.stat()
@ -229,7 +232,28 @@ def _list_local_entries(thread_id: str, actual_path) -> list[dict]:
"name": child.name, "name": child.name,
"is_dir": is_dir, "is_dir": is_dir,
"size": 0 if is_dir else stat.st_size, "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 return entries
@ -294,6 +318,10 @@ async def list_viewer_filesystem_tree(
try: try:
if _is_user_data_path(normalized_path): 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) actual_path = resolve_virtual_path(thread_id, normalized_path)
if not actual_path.exists(): if not actual_path.exists():
return {"entries": []} return {"entries": []}

View File

@ -127,6 +127,13 @@ def format_utc_datetime(value: dt.datetime | None) -> str | None:
return utc_isoformat(value) 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__ = [ __all__ = [
"UTC", "UTC",
"SHANGHAI_TZ", "SHANGHAI_TZ",
@ -141,4 +148,5 @@ __all__ = [
"coerce_any_to_utc_datetime", "coerce_any_to_utc_datetime",
"normalize_iterable_to_utc", "normalize_iterable_to_utc",
"format_utc_datetime", "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: if str(_root) not in sys.path:
sys.path.insert(0, str(_root)) sys.path.insert(0, str(_root))
from yuxi.agents.backends.sandbox import ( # noqa: E402
def _make_sandbox_backend(thread_id: str): ensure_thread_dirs,
from yuxi.agents.backends.sandbox.backend import ProvisionerSandboxBackend sandbox_outputs_dir,
sandbox_uploads_dir,
return ProvisionerSandboxBackend(thread_id=thread_id) sandbox_user_data_dir,
sandbox_workspace_dir,
)
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5050").rstrip("/") 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.headers: dict[str, str] | None = None
self.agent_id: str | None = None self.agent_id: str | None = None
self.thread_id: str | None = None self.thread_id: str | None = None
self.other_thread_id: str | None = None
async def close(self): async def close(self):
await self.client.aclose() await self.client.aclose()
@ -69,33 +72,48 @@ class ViewerFilesystemE2ETester:
payload = resp.json() payload = resp.json()
self.thread_id = str(payload.get("thread_id") or payload.get("id")) 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 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( resp = await self.client.get(
"/api/viewer/filesystem/tree", "/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, headers=self.headers,
) )
if resp.status_code != 200: if resp.status_code != 200:
raise RuntimeError(f"viewer tree failed on {path}: {resp.status_code} {resp.text}") raise RuntimeError(f"viewer tree failed on {path}: {resp.status_code} {resp.text}")
return list(resp.json().get("entries") or []) 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 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( resp = await self.client.get(
"/api/viewer/filesystem/file", "/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, headers=self.headers,
) )
if resp.status_code != 200: if resp.status_code != 200:
raise RuntimeError(f"viewer file failed on {path}: {resp.status_code} {resp.text}") raise RuntimeError(f"viewer file failed on {path}: {resp.status_code} {resp.text}")
return dict(resp.json()) 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 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( resp = await self.client.get(
"/api/viewer/filesystem/download", "/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, headers=self.headers,
) )
if resp.status_code != 200: if resp.status_code != 200:
@ -106,38 +124,66 @@ class ViewerFilesystemE2ETester:
await self.login() await self.login()
await self.pick_agent() await self.pick_agent()
await self.create_thread() await self.create_thread()
await self.create_other_thread()
assert self.thread_id assert self.thread_id and self.other_thread_id
sandbox = _make_sandbox_backend(self.thread_id) ensure_thread_dirs(self.thread_id)
commands = [ ensure_thread_dirs(self.other_thread_id)
"mkdir -p /home/gem/user-data/workspace /home/gem/user-data/outputs", (sandbox_user_data_dir(self.thread_id) / "root-note.txt").write_text("root-visible\n", encoding="utf-8")
"printf 'print(42)\\n' > /home/gem/user-data/workspace/demo.py", (sandbox_workspace_dir(self.thread_id) / "demo.py").write_text("print(42)\n", encoding="utf-8")
"printf 'root-file\\n' > /home/gem/user-data/root_file.txt", uploads_dir = sandbox_uploads_dir(self.thread_id) / "attachments"
"printf 'viewer-output\\n' > /home/gem/user-data/outputs/result.txt", uploads_dir.mkdir(parents=True, exist_ok=True)
] (uploads_dir / "thread1.txt").write_text("thread-one-upload\n", encoding="utf-8")
for command in commands: (sandbox_outputs_dir(self.thread_id) / "result.txt").write_text("viewer-output\n", encoding="utf-8")
result = await asyncio.to_thread(sandbox.execute, command)
if result.exit_code != 0:
raise RuntimeError(f"command failed: {command}\n{result.output}")
root_paths = {str(e.get("path", "")) for e in await self.tree("/")} root_paths = {str(e.get("path", "")) for e in await self.tree("/")}
if "/home/gem/user-data/" not in root_paths: if "/home/gem/user-data/" not in root_paths:
raise RuntimeError(f"viewer root missing user-data: {sorted(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")} 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: expected_root_paths = {
raise RuntimeError(f"viewer user-data missing root_file.txt: {sorted(user_data_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")} 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: if "/home/gem/user-data/workspace/demo.py" not in workspace_paths:
raise RuntimeError(f"viewer workspace missing demo.py: {sorted(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") file_payload = await self.file("/home/gem/user-data/workspace/demo.py")
if file_payload.get("content") != "print(42)\n": if file_payload.get("content") != "print(42)\n":
raise RuntimeError(f"unexpected viewer file content: {file_payload!r}") 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: 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}") 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") content_disposition, payload = await self.download("/home/gem/user-data/outputs/result.txt")
if "result.txt" not in content_disposition: if "result.txt" not in content_disposition:
raise RuntimeError(f"unexpected content-disposition: {content_disposition}") raise RuntimeError(f"unexpected content-disposition: {content_disposition}")

View File

@ -6,7 +6,12 @@ import importlib
import uuid import uuid
import pytest 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] 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", []) entries = response.json().get("entries", [])
paths = {entry.get("path") for entry in entries} paths = {entry.get("path") for entry in entries}
assert "/home/gem/user-data/workspace/" in paths 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) 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): async def test_viewer_file_returns_raw_content_without_line_numbers(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)

View File

@ -50,10 +50,12 @@
### 修复 ### 修复
- 调整沙盒 user-data 目录隔离策略:`workspace` 改为全局共享目录 `saves/user-data/workspace``uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试
- 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试 - 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试
- 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置 - 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试 - 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试
- 优化 viewer 文件系统目录树加载:根目录与 `/home/gem/user-data` 改为直接读取本地线程挂载目录,不再为只读树视图触发 sandbox 冷启动,并补充对应后端测试 - 优化 viewer 文件系统目录树加载:根目录与 `/home/gem/user-data` 改为直接读取本地线程挂载目录,不再为只读树视图触发 sandbox 冷启动,并补充对应后端测试
- 修复 `/home/gem/user-data` 根目录文件不可见的问题:根目录现在会同时展示 thread 目录下的真实文件和 `workspace` 入口,不再只保留固定命名空间目录
- 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标 - 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标
- 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败 - 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败