将共享 workspace 的宿主机目录从 saves/user-data/workspace 调整为 saves/threads/shared/workspace。 同时同步更新本地 Docker 与 Kubernetes provisioner 的 user-data 挂载逻辑,确保 workspace 为共享目录,而 uploads 与 outputs 继续保持线程私有,避免多租户项目下路径语义错误。 补充更新沙盒架构文档与 roadmap 表述,使代码实现与文档说明保持一致。
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
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_-]+$")
|
|
|
|
|
|
def get_virtual_path_prefix() -> str:
|
|
configured = str(getattr(conf, "sandbox_virtual_path_prefix", "") or "").strip()
|
|
if not configured:
|
|
return DEFAULT_VIRTUAL_PATH_PREFIX
|
|
return "/" + configured.strip("/")
|
|
|
|
|
|
def _validate_thread_id(thread_id: str) -> str:
|
|
value = str(thread_id or "").strip()
|
|
if not value:
|
|
raise ValueError("thread_id is required")
|
|
if not _SAFE_THREAD_ID_RE.match(value):
|
|
raise ValueError("thread_id contains invalid characters")
|
|
return value
|
|
|
|
|
|
def _thread_root_dir(thread_id: str) -> Path:
|
|
safe_thread_id = _validate_thread_id(thread_id)
|
|
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) / "threads" / "shared"
|
|
|
|
|
|
def sandbox_user_data_dir(thread_id: str) -> Path:
|
|
return _thread_root_dir(thread_id)
|
|
|
|
|
|
def sandbox_workspace_dir(thread_id: str) -> Path:
|
|
_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_DIR_NAME
|
|
|
|
|
|
def sandbox_outputs_dir(thread_id: str) -> Path:
|
|
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()
|
|
|
|
if clean_virtual_path != virtual_prefix and not clean_virtual_path.startswith(f"{virtual_prefix}/"):
|
|
raise ValueError(f"path must start with {virtual_prefix}")
|
|
|
|
relative_path = clean_virtual_path[len(virtual_prefix) :].lstrip("/")
|
|
base_dir, target_path = _resolve_user_data_base_dir(thread_id, relative_path)
|
|
|
|
try:
|
|
target_path.relative_to(base_dir)
|
|
except ValueError as exc:
|
|
raise ValueError("path traversal detected") from exc
|
|
|
|
return target_path
|
|
|
|
|
|
def virtual_path_for_thread_file(thread_id: str, path: str | Path) -> str:
|
|
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(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 relative_path_str:
|
|
return prefix
|
|
return f"{prefix}/{relative_path_str}"
|