Merge branch 'xerrors:main' into main

This commit is contained in:
zayn 2026-04-02 12:36:08 +08:00 committed by GitHub
commit 896e91abb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 253 additions and 164 deletions

View File

@ -94,6 +94,23 @@ def _extract_thread_id(runtime) -> str:
raise ValueError("thread_id is required in runtime configurable context") raise ValueError("thread_id is required in runtime configurable context")
def _extract_user_id(runtime) -> str:
config = getattr(runtime, "config", None)
if isinstance(config, dict):
configurable = config.get("configurable", {})
if isinstance(configurable, dict):
user_id = configurable.get("user_id")
if isinstance(user_id, str) and user_id.strip():
return user_id.strip()
context = getattr(runtime, "context", None)
user_id = getattr(context, "user_id", None)
if isinstance(user_id, str) and user_id.strip():
return user_id.strip()
raise ValueError("user_id is required in runtime configurable context")
def _get_visible_knowledge_bases_from_runtime(runtime) -> list[dict]: def _get_visible_knowledge_bases_from_runtime(runtime) -> list[dict]:
context = getattr(runtime, "context", None) context = getattr(runtime, "context", None)
selected = getattr(context, "_visible_knowledge_bases", None) selected = getattr(context, "_visible_knowledge_bases", None)
@ -105,9 +122,10 @@ def _get_visible_knowledge_bases_from_runtime(runtime) -> list[dict]:
def create_agent_composite_backend(runtime) -> CompositeBackend: def create_agent_composite_backend(runtime) -> CompositeBackend:
visible_skills = _get_visible_skills_from_runtime(runtime) visible_skills = _get_visible_skills_from_runtime(runtime)
thread_id = _extract_thread_id(runtime) thread_id = _extract_thread_id(runtime)
user_id = _extract_user_id(runtime)
visible_kbs = _get_visible_knowledge_bases_from_runtime(runtime) visible_kbs = _get_visible_knowledge_bases_from_runtime(runtime)
return CustomCompositeBackend( return CustomCompositeBackend(
default=ProvisionerSandboxBackend(thread_id=thread_id, visible_skills=visible_skills), default=ProvisionerSandboxBackend(thread_id=thread_id, user_id=user_id, visible_skills=visible_skills),
routes={ routes={
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills), "/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
"/home/gem/kbs/": KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs), "/home/gem/kbs/": KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs),

View File

@ -62,10 +62,13 @@ def _looks_like_binary(content: bytes) -> bool:
class ProvisionerSandboxBackend(BaseSandbox): class ProvisionerSandboxBackend(BaseSandbox):
def __init__(self, thread_id: str, *, visible_skills: list[str] | None = None): def __init__(self, thread_id: str, *, user_id: str, visible_skills: list[str] | None = None):
self._thread_id = str(thread_id or "").strip() self._thread_id = str(thread_id or "").strip()
if not self._thread_id: if not self._thread_id:
raise ValueError("thread_id is required for ProvisionerSandboxBackend") raise ValueError("thread_id is required for ProvisionerSandboxBackend")
self._user_id = str(user_id or "").strip()
if not self._user_id:
raise ValueError("user_id is required for ProvisionerSandboxBackend")
self._visible_skills = list(visible_skills or []) self._visible_skills = list(visible_skills or [])
self._provider = get_sandbox_provider() self._provider = get_sandbox_provider()
@ -91,7 +94,7 @@ class ProvisionerSandboxBackend(BaseSandbox):
def _get_client(self) -> Any: def _get_client(self) -> Any:
sync_thread_visible_skills(self._thread_id, self._visible_skills) sync_thread_visible_skills(self._thread_id, self._visible_skills)
connection = self._provider.get(self._thread_id, create_if_missing=True) connection = self._provider.get(self._thread_id, user_id=self._user_id, create_if_missing=True)
if connection is None: if connection is None:
raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}") raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}")

View File

@ -6,7 +6,7 @@ from pathlib import Path
from yuxi import config as conf from yuxi import config as conf
from yuxi.utils.paths import OUTPUTS_DIR_NAME, UPLOADS_DIR_NAME, VIRTUAL_PATH_PREFIX, WORKSPACE_DIR_NAME from yuxi.utils.paths import OUTPUTS_DIR_NAME, UPLOADS_DIR_NAME, VIRTUAL_PATH_PREFIX, WORKSPACE_DIR_NAME
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") _SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def get_virtual_path_prefix() -> str: def get_virtual_path_prefix() -> str:
@ -17,7 +17,7 @@ def _validate_thread_id(thread_id: str) -> str:
value = str(thread_id or "").strip() value = str(thread_id or "").strip()
if not value: if not value:
raise ValueError("thread_id is required") raise ValueError("thread_id is required")
if not _SAFE_THREAD_ID_RE.match(value): if not _SAFE_ID_RE.match(value):
raise ValueError("thread_id contains invalid characters") raise ValueError("thread_id contains invalid characters")
return value return value
@ -27,18 +27,28 @@ 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: def _validate_user_id(user_id: str) -> str:
"""Return the shared host-side directory used for thread workspace files.""" value = str(user_id or "").strip()
return Path(conf.save_dir) / "threads" / "shared" if not value:
raise ValueError("user_id is required")
if not _SAFE_ID_RE.match(value):
raise ValueError("user_id contains invalid characters")
return value
def _global_user_data_dir(user_id: str) -> Path:
"""Return the shared host-side directory used for one user's workspace files."""
safe_user_id = _validate_user_id(user_id)
return Path(conf.save_dir) / "threads" / "shared" / safe_user_id
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, user_id: str) -> Path:
_validate_thread_id(thread_id) _validate_thread_id(thread_id)
return _global_user_data_dir() / WORKSPACE_DIR_NAME return _global_user_data_dir(user_id) / WORKSPACE_DIR_NAME
def sandbox_uploads_dir(thread_id: str) -> Path: def sandbox_uploads_dir(thread_id: str) -> Path:
@ -49,14 +59,14 @@ def sandbox_outputs_dir(thread_id: str) -> Path:
return _thread_root_dir(thread_id) / OUTPUTS_DIR_NAME return _thread_root_dir(thread_id) / OUTPUTS_DIR_NAME
def ensure_thread_dirs(thread_id: str) -> None: def ensure_thread_dirs(thread_id: str, user_id: str) -> None:
_global_user_data_dir().mkdir(parents=True, exist_ok=True) _global_user_data_dir(user_id).mkdir(parents=True, exist_ok=True)
sandbox_workspace_dir(thread_id).mkdir(parents=True, exist_ok=True) sandbox_workspace_dir(thread_id, user_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]: def _resolve_user_data_base_dir(thread_id: str, user_id: str, relative_path: str) -> tuple[Path, Path]:
"""Map a virtual user-data path to the correct host-side base directory.""" """Map a virtual user-data path to the correct host-side base directory."""
parts = Path(relative_path).parts parts = Path(relative_path).parts
if not parts: if not parts:
@ -65,8 +75,8 @@ def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Pat
namespace = parts[0] namespace = parts[0]
if namespace == WORKSPACE_DIR_NAME: if namespace == WORKSPACE_DIR_NAME:
# Workspace is shared across threads, so it lives outside the per-thread root. # Workspace is shared across one user's threads, so it lives outside the per-thread root.
base_dir = sandbox_workspace_dir(thread_id) base_dir = sandbox_workspace_dir(thread_id, user_id)
target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir
return base_dir.resolve(), target_path.resolve() return base_dir.resolve(), target_path.resolve()
if namespace == UPLOADS_DIR_NAME: if namespace == UPLOADS_DIR_NAME:
@ -82,7 +92,7 @@ def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Pat
return base_dir.resolve(), (base_dir / relative_path).resolve() 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, *, user_id: 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()
@ -90,7 +100,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, target_path = _resolve_user_data_base_dir(thread_id, relative_path) base_dir, target_path = _resolve_user_data_base_dir(thread_id, user_id, relative_path)
try: try:
target_path.relative_to(base_dir) target_path.relative_to(base_dir)
@ -100,10 +110,10 @@ def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path:
return target_path return target_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, *, user_id: str) -> str:
target_path = Path(path).resolve() target_path = Path(path).resolve()
thread_root = sandbox_user_data_dir(thread_id).resolve() thread_root = sandbox_user_data_dir(thread_id).resolve()
global_workspace_root = sandbox_workspace_dir(thread_id).resolve() global_workspace_root = sandbox_workspace_dir(thread_id, user_id).resolve()
try: try:
relative_path = target_path.relative_to(global_workspace_root) relative_path = target_path.relative_to(global_workspace_root)

View File

@ -19,6 +19,7 @@ def sandbox_id_for_thread(thread_id: str) -> str:
@dataclass(slots=True) @dataclass(slots=True)
class SandboxConnection: class SandboxConnection:
thread_id: str thread_id: str
user_id: str
sandbox_id: str sandbox_id: str
sandbox_url: str sandbox_url: str
@ -48,9 +49,10 @@ class ProvisionerSandboxProvider:
self._thread_locks[thread_id] = lock self._thread_locks[thread_id] = lock
return lock return lock
def _record_to_connection(self, thread_id: str, record: SandboxRecord) -> SandboxConnection: def _record_to_connection(self, thread_id: str, user_id: str, record: SandboxRecord) -> SandboxConnection:
connection = SandboxConnection( connection = SandboxConnection(
thread_id=thread_id, thread_id=thread_id,
user_id=user_id,
sandbox_id=record.sandbox_id, sandbox_id=record.sandbox_id,
sandbox_url=record.sandbox_url, sandbox_url=record.sandbox_url,
) )
@ -73,7 +75,7 @@ class ProvisionerSandboxProvider:
self._last_touch_at[connection.thread_id] = time.time() self._last_touch_at[connection.thread_id] = time.time()
return is_alive return is_alive
def acquire(self, thread_id: str) -> str: def acquire(self, thread_id: str, *, user_id: str) -> str:
lock = self._thread_lock(thread_id) lock = self._thread_lock(thread_id)
with lock: with lock:
current = self._connections.get(thread_id) current = self._connections.get(thread_id)
@ -91,14 +93,14 @@ class ProvisionerSandboxProvider:
record = self._client.discover(sandbox_id) record = self._client.discover(sandbox_id)
if record is None: if record is None:
logger.info(f"Creating sandbox {sandbox_id} for thread {thread_id}") logger.info(f"Creating sandbox {sandbox_id} for thread {thread_id}")
record = self._client.create(sandbox_id, thread_id) record = self._client.create(sandbox_id, thread_id, user_id)
else: else:
logger.info(f"Reusing sandbox {sandbox_id} for thread {thread_id}") logger.info(f"Reusing sandbox {sandbox_id} for thread {thread_id}")
connection = self._record_to_connection(thread_id, record) connection = self._record_to_connection(thread_id, user_id, record)
return connection.sandbox_id return connection.sandbox_id
def get(self, thread_id: str, *, create_if_missing: bool = False) -> SandboxConnection | None: def get(self, thread_id: str, *, user_id: str, create_if_missing: bool = False) -> SandboxConnection | None:
lock = self._thread_lock(thread_id) lock = self._thread_lock(thread_id)
with lock: with lock:
current = self._connections.get(thread_id) current = self._connections.get(thread_id)
@ -111,19 +113,19 @@ class ProvisionerSandboxProvider:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning(f"Failed to touch sandbox {current.sandbox_id} for thread {thread_id}: {exc}") logger.warning(f"Failed to touch sandbox {current.sandbox_id} for thread {thread_id}: {exc}")
return current return current
if current.user_id == user_id:
current = self._connections.get(thread_id) return current
if current: self._connections.pop(thread_id, None)
return current self._last_touch_at.pop(thread_id, None)
sandbox_id = sandbox_id_for_thread(thread_id) sandbox_id = sandbox_id_for_thread(thread_id)
record = self._client.discover(sandbox_id) record = self._client.discover(sandbox_id)
if record is None: if record is None:
if not create_if_missing: if not create_if_missing:
return None return None
record = self._client.create(sandbox_id, thread_id) record = self._client.create(sandbox_id, thread_id, user_id)
return self._record_to_connection(thread_id, record) return self._record_to_connection(thread_id, user_id, record)
def shutdown(self) -> None: def shutdown(self) -> None:
with self._lock: with self._lock:

View File

@ -29,11 +29,11 @@ class ProvisionerClient:
response = self._request("GET", "/health") response = self._request("GET", "/health")
return response.status_code == 200 return response.status_code == 200
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: def create(self, sandbox_id: str, thread_id: str, user_id: str) -> SandboxRecord:
response = self._request( response = self._request(
"POST", "POST",
"/api/sandboxes", "/api/sandboxes",
json={"sandbox_id": sandbox_id, "thread_id": thread_id}, json={"sandbox_id": sandbox_id, "thread_id": thread_id, "user_id": user_id},
) )
if response.status_code >= 400: if response.status_code >= 400:
raise RuntimeError(f"failed to create sandbox {sandbox_id}: {response.status_code} {response.text}") raise RuntimeError(f"failed to create sandbox {sandbox_id}: {response.status_code} {response.text}")

View File

@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import uuid import uuid
from collections.abc import Callable, Iterable, Mapping from collections.abc import Callable, Iterable, Mapping
from functools import partial from functools import partial
@ -31,6 +32,8 @@ from langchain_core.messages.utils import (
from langgraph.graph.message import REMOVE_ALL_MESSAGES from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
from yuxi.utils.paths import OUTPUTS_DIR_NAME
TokenCounter = Callable[[Iterable[MessageLikeRepresentation]], int] TokenCounter = Callable[[Iterable[MessageLikeRepresentation]], int]
DEFAULT_SUMMARY_PROMPT = """<role> DEFAULT_SUMMARY_PROMPT = """<role>
@ -74,9 +77,7 @@ Messages to summarize:
</messages>""" </messages>"""
_DEFAULT_MESSAGES_TO_KEEP = 20 _DEFAULT_MESSAGES_TO_KEEP = 20
_DEFAULT_TRIM_TOKEN_LIMIT = 4000
_DEFAULT_FALLBACK_MESSAGE_COUNT = 15 _DEFAULT_FALLBACK_MESSAGE_COUNT = 15
_DEFAULT_OFFLOAD_THRESHOLD = 1000 # Token 数阈值,超过此值则卸载到文件系统
_OFFLOAD_DIR = "/summary_offload" # 虚拟文件系统路径 _OFFLOAD_DIR = "/summary_offload" # 虚拟文件系统路径
ContextFraction = tuple[Literal["fraction"], float] ContextFraction = tuple[Literal["fraction"], float]
@ -143,7 +144,7 @@ def _offload_tool_result(msg: ToolMessage, threshold: int, token_counter: TokenC
# 生成文件路径 (工具名称-xxx) # 生成文件路径 (工具名称-xxx)
message_id = msg.id or str(uuid.uuid4())[:8] message_id = msg.id or str(uuid.uuid4())[:8]
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in tool_name) safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in tool_name)
file_path = f"{_OFFLOAD_DIR}/{safe_name}-{message_id}" file_path = (Path(OUTPUTS_DIR_NAME) / f"{_OFFLOAD_DIR}/{safe_name}-{message_id}").as_posix()
# 构建文件头部信息 # 构建文件头部信息
header_lines = [ header_lines = [
@ -226,7 +227,7 @@ class SummaryOffloadMiddleware(AgentMiddleware):
keep: ContextSize = ("messages", _DEFAULT_MESSAGES_TO_KEEP), keep: ContextSize = ("messages", _DEFAULT_MESSAGES_TO_KEEP),
token_counter: TokenCounter = count_tokens_approximately, token_counter: TokenCounter = count_tokens_approximately,
summary_prompt: str = DEFAULT_SUMMARY_PROMPT, summary_prompt: str = DEFAULT_SUMMARY_PROMPT,
trim_tokens_to_summarize: int | None = _DEFAULT_TRIM_TOKEN_LIMIT, trim_tokens_to_summarize: int | None = 4000,
# 工具结果卸载参数 # 工具结果卸载参数
summary_offload_threshold: int = 1000, summary_offload_threshold: int = 1000,
max_retention_ratio: float = 0.6, max_retention_ratio: float = 0.6,
@ -240,18 +241,12 @@ class SummaryOffloadMiddleware(AgentMiddleware):
keep: 摘要后保留的消息数量/ token 策略 (作为 fallback) keep: 摘要后保留的消息数量/ token 策略 (作为 fallback)
token_counter: token 计数函数 token_counter: token 计数函数
summary_prompt: 生成摘要的提示词模板 summary_prompt: 生成摘要的提示词模板
trim_tokens_to_summarize: 准备摘要消息时的最大 token trim_tokens_to_summarize: Summary 无损保留的消息
summary_offload_threshold: Summary 卸载阈值token 默认 1000 summary_offload_threshold: Summary 工具调用结果超过此 token 数阈值则卸载到文件系统
max_retention_ratio: 触发 Summary 如果不超过此比例相对于 trigger则不删除消息默认 0.6 max_retention_ratio: 触发 Summary 如果不超过此比例相对于 trigger则不删除消息默认 0.6
""" """
super().__init__() super().__init__()
# Handle renamed argument for backward compatibility if needed,
# but since we are refactoring, we map deprecated 'result_offload_threshold'
# to 'summary_offload_threshold' if present in kwargs.
if "result_offload_threshold" in deprecated_kwargs:
summary_offload_threshold = deprecated_kwargs.pop("result_offload_threshold")
if isinstance(model, str): if isinstance(model, str):
model = init_chat_model(model) model = init_chat_model(model)

View File

@ -83,8 +83,11 @@ def _normalize_presented_artifact_path(filepath: str, runtime: ToolRuntime) -> s
thread_id = getattr(runtime_context, "thread_id", None) thread_id = getattr(runtime_context, "thread_id", None)
if not thread_id: if not thread_id:
raise ValueError("当前运行时缺少 thread_id") raise ValueError("当前运行时缺少 thread_id")
user_id = getattr(runtime_context, "user_id", None)
if not user_id:
raise ValueError("当前运行时缺少 user_id")
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, str(user_id))
outputs_dir = sandbox_outputs_dir(thread_id).resolve() outputs_dir = sandbox_outputs_dir(thread_id).resolve()
normalized_input = str(filepath or "").strip() normalized_input = str(filepath or "").strip()
if not normalized_input: if not normalized_input:
@ -93,7 +96,7 @@ def _normalize_presented_artifact_path(filepath: str, runtime: ToolRuntime) -> s
stripped = normalized_input.lstrip("/") stripped = normalized_input.lstrip("/")
virtual_prefix = VIRTUAL_PATH_PREFIX.lstrip("/") virtual_prefix = VIRTUAL_PATH_PREFIX.lstrip("/")
if stripped == virtual_prefix or stripped.startswith(f"{virtual_prefix}/"): if stripped == virtual_prefix or stripped.startswith(f"{virtual_prefix}/"):
actual_path = resolve_virtual_path(thread_id, normalized_input) actual_path = resolve_virtual_path(thread_id, normalized_input, user_id=str(user_id))
else: else:
actual_path = Path(normalized_input).expanduser().resolve() actual_path = Path(normalized_input).expanduser().resolve()

View File

@ -209,12 +209,13 @@ def serialize_attachment(record: dict) -> dict:
async def _materialize_attachment_files( async def _materialize_attachment_files(
*, *,
thread_id: str, thread_id: str,
user_id: str,
upload: UploadFile, upload: UploadFile,
file_name: str, file_name: str,
file_content: bytes, file_content: bytes,
) -> dict: ) -> dict:
"""将原始附件与可选 markdown 副本落盘到线程 user-data。""" """将原始附件与可选 markdown 副本落盘到线程 user-data。"""
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
upload_virtual_path = _make_upload_virtual_path(file_name) upload_virtual_path = _make_upload_virtual_path(file_name)
uploads_dir = sandbox_uploads_dir(thread_id) uploads_dir = sandbox_uploads_dir(thread_id)
@ -384,6 +385,7 @@ async def upload_thread_attachment_view(
raise HTTPException(status_code=400, detail=f"附件过大,当前仅支持 {max_size_mb} MB 以内的文件") raise HTTPException(status_code=400, detail=f"附件过大,当前仅支持 {max_size_mb} MB 以内的文件")
materialized = await _materialize_attachment_files( materialized = await _materialize_attachment_files(
thread_id=thread_id, thread_id=thread_id,
user_id=str(conversation.user_id),
upload=file, upload=file,
file_name=file_name, file_name=file_name,
file_content=file_content, file_content=file_content,

View File

@ -67,7 +67,7 @@ async def _resolve_filesystem_state(
runtime_context.user_id = str(user.id) runtime_context.user_id = str(user.id)
await resolve_visible_knowledge_bases_for_context(runtime_context) await resolve_visible_knowledge_bases_for_context(runtime_context)
sandbox_backend = ProvisionerSandboxBackend(thread_id=thread_id) sandbox_backend = ProvisionerSandboxBackend(thread_id=thread_id, user_id=str(user.id))
return conversation, runtime_context, sandbox_backend return conversation, runtime_context, sandbox_backend

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.repositories.subagent_repository import SubAgentRepository from yuxi.repositories.subagent_repository import SubAgentRepository
from yuxi.storage.postgres.manager import pg_manager from yuxi.storage.postgres.manager import pg_manager
from yuxi.utils import logger from yuxi.utils import logger
from yuxi.utils.paths import OUTPUTS_DIR_NAME
# SubAgent specs cache for get_subagent_specs # SubAgent specs cache for get_subagent_specs
_subagent_specs_cache: list[dict[str, Any]] | None = None _subagent_specs_cache: list[dict[str, Any]] | None = None
@ -34,7 +35,7 @@ _DEFAULT_SUBAGENTS = [
"你是一位专注的研究员。你的工作是根据用户的问题进行研究。" "你是一位专注的研究员。你的工作是根据用户的问题进行研究。"
"进行彻底的研究,然后用详细的答案回复用户的问题,只有你的最终答案会被传递给用户。" "进行彻底的研究,然后用详细的答案回复用户的问题,只有你的最终答案会被传递给用户。"
"除了你的最终信息,他们不会知道任何其他事情,所以你的最终报告应该就是你的最终信息!" "除了你的最终信息,他们不会知道任何其他事情,所以你的最终报告应该就是你的最终信息!"
"将调研结果保存到主题研究文件中 sub_research/xxx.md 中。" f"将调研结果保存到主题研究文件中 {OUTPUTS_DIR_NAME}/sub_research/xxx.md 中。"
), ),
"tools": ["tavily_search"], "tools": ["tavily_search"],
"is_builtin": True, "is_builtin": True,

View File

@ -35,12 +35,13 @@ async def list_thread_files_view(
recursive: bool = False, recursive: bool = False,
) -> dict: ) -> dict:
conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
await require_user_conversation(conv_repo, thread_id, str(current_user_id)) conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
user_id = str(conversation.user_id)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
virtual_path = path or _get_virtual_root() virtual_path = path or _get_virtual_root()
try: try:
actual_path = resolve_virtual_path(thread_id, virtual_path) actual_path = resolve_virtual_path(thread_id, virtual_path, user_id=user_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@ -51,16 +52,16 @@ async def list_thread_files_view(
if recursive: if recursive:
if virtual_path.rstrip("/") == _get_virtual_root(): if virtual_path.rstrip("/") == _get_virtual_root():
return _list_user_data_root_entries(thread_id, virtual_path, recursive=True) return _list_user_data_root_entries(thread_id, user_id, virtual_path, recursive=True)
return _list_files_recursive(thread_id, actual_path, virtual_path) return _list_files_recursive(thread_id, user_id, actual_path, virtual_path)
if virtual_path.rstrip("/") == _get_virtual_root(): if virtual_path.rstrip("/") == _get_virtual_root():
return _list_user_data_root_entries(thread_id, virtual_path) return _list_user_data_root_entries(thread_id, user_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()
child_virtual_path = virtual_path_for_thread_file(thread_id, child) child_virtual_path = virtual_path_for_thread_file(thread_id, child, user_id=user_id)
entries.append( entries.append(
{ {
"path": child_virtual_path, "path": child_virtual_path,
@ -77,13 +78,13 @@ 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: def _list_user_data_root_entries(thread_id: str, user_id: str, virtual_path: str, recursive: bool = False) -> dict:
"""List the thread root and inject the shared workspace entry if needed.""" """List the thread root and inject the user workspace entry if needed."""
entries: list[dict[str, Any]] = [] entries: list[dict[str, Any]] = []
thread_root = sandbox_user_data_dir(thread_id) 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())): for child in sorted(thread_root.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat() stat = child.stat()
child_virtual_path = virtual_path_for_thread_file(thread_id, child) child_virtual_path = virtual_path_for_thread_file(thread_id, child, user_id=user_id)
if child.is_dir() and not child_virtual_path.endswith("/"): if child.is_dir() and not child_virtual_path.endswith("/"):
child_virtual_path = f"{child_virtual_path}/" child_virtual_path = f"{child_virtual_path}/"
entries.append( entries.append(
@ -99,11 +100,11 @@ def _list_user_data_root_entries(thread_id: str, virtual_path: str, recursive: b
} }
) )
if recursive and child.is_dir(): if recursive and child.is_dir():
nested = _list_files_recursive(thread_id, child, child_virtual_path) nested = _list_files_recursive(thread_id, user_id, child, child_virtual_path)
entries.extend(nested["files"]) entries.extend(nested["files"])
workspace_dir = sandbox_workspace_dir(thread_id) workspace_dir = sandbox_workspace_dir(thread_id, user_id)
workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir) workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir, user_id=user_id)
if workspace_virtual_path.rstrip("/") not in {str(entry["path"]).rstrip("/") for entry in entries}: 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. # workspace lives outside the per-thread root, so expose it as a top-level entry.
stat = workspace_dir.stat() stat = workspace_dir.stat()
@ -120,12 +121,12 @@ def _list_user_data_root_entries(thread_id: str, virtual_path: str, recursive: b
} }
) )
if recursive: if recursive:
nested = _list_files_recursive(thread_id, workspace_dir, workspace_virtual_path) nested = _list_files_recursive(thread_id, user_id, workspace_dir, workspace_virtual_path)
entries.extend(nested["files"]) entries.extend(nested["files"])
return {"path": virtual_path, "files": entries} 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, user_id: str, actual_path: Path, virtual_path: str) -> dict:
"""Recursively scan a directory while preserving viewer virtual paths.""" """Recursively scan a directory while preserving viewer virtual paths."""
entries: list[dict[str, Any]] = [] entries: list[dict[str, Any]] = []
@ -133,7 +134,7 @@ def _list_files_recursive(thread_id: str, actual_path: Path, virtual_path: str)
try: try:
for child in sorted(base_actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): for child in sorted(base_actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat() stat = child.stat()
child_virtual_path = virtual_path_for_thread_file(thread_id, child) child_virtual_path = virtual_path_for_thread_file(thread_id, child, user_id=user_id)
entries.append( entries.append(
{ {
"path": child_virtual_path, "path": child_virtual_path,
@ -165,10 +166,11 @@ async def read_thread_file_content_view(
limit: int = 2000, limit: int = 2000,
) -> dict: ) -> dict:
conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
await require_user_conversation(conv_repo, thread_id, str(current_user_id)) conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
user_id = str(conversation.user_id)
try: try:
actual_path = resolve_virtual_path(thread_id, path) actual_path = resolve_virtual_path(thread_id, path, user_id=user_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@ -201,13 +203,14 @@ async def resolve_thread_artifact_view(
path: str, path: str,
) -> Path: ) -> Path:
conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
await require_user_conversation(conv_repo, thread_id, str(current_user_id)) conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
user_id = str(conversation.user_id)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
normalized = "/" + path.lstrip("/") normalized = "/" + path.lstrip("/")
try: try:
actual_path = resolve_virtual_path(thread_id, normalized) actual_path = resolve_virtual_path(thread_id, normalized, user_id=user_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@ -218,7 +221,7 @@ async def resolve_thread_artifact_view(
resolved_path = actual_path.resolve() resolved_path = actual_path.resolve()
allowed_roots = ( allowed_roots = (
sandbox_workspace_dir(thread_id).resolve(), sandbox_workspace_dir(thread_id, user_id).resolve(),
sandbox_uploads_dir(thread_id).resolve(), sandbox_uploads_dir(thread_id).resolve(),
sandbox_outputs_dir(thread_id).resolve(), sandbox_outputs_dir(thread_id).resolve(),
) )
@ -242,14 +245,17 @@ async def save_thread_artifact_to_workspace_view(
path=path, path=path,
) )
target_dir = sandbox_workspace_dir(thread_id) / "saved_artifacts" conv_repo = ConversationRepository(db)
conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
user_id = str(conversation.user_id)
target_dir = sandbox_workspace_dir(thread_id, user_id) / "saved_artifacts"
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
target_path = _next_available_artifact_path(target_dir, source_path.name) target_path = _next_available_artifact_path(target_dir, source_path.name)
with source_path.open("rb") as src, target_path.open("wb") as dst: with source_path.open("rb") as src, target_path.open("wb") as dst:
shutil.copyfileobj(src, dst) shutil.copyfileobj(src, dst)
saved_virtual_path = virtual_path_for_thread_file(thread_id, target_path) saved_virtual_path = virtual_path_for_thread_file(thread_id, target_path, user_id=user_id)
return { return {
"name": target_path.name, "name": target_path.name,
"source_path": "/" + path.lstrip("/"), "source_path": "/" + path.lstrip("/"),

View File

@ -226,13 +226,13 @@ def _sort_entries(entries: list[dict]) -> list[dict]:
) )
def _list_local_entries(thread_id: str, actual_path) -> list[dict]: def _list_local_entries(thread_id: str, user_id: str, actual_path) -> list[dict]:
"""List a local directory and remap children back into viewer virtual paths.""" """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()
is_dir = child.is_dir() is_dir = child.is_dir()
display_path = virtual_path_for_thread_file(thread_id, child) display_path = virtual_path_for_thread_file(thread_id, child, user_id=user_id)
if is_dir and not display_path.endswith("/"): if is_dir and not display_path.endswith("/"):
display_path = f"{display_path}/" display_path = f"{display_path}/"
entries.append( entries.append(
@ -247,12 +247,12 @@ def _list_local_entries(thread_id: str, actual_path) -> list[dict]:
return entries return entries
def _list_user_data_root_entries(thread_id: str) -> list[dict]: def _list_user_data_root_entries(thread_id: str, user_id: str) -> list[dict]:
"""Expose thread-root files while keeping the shared workspace entry visible.""" """Expose thread-root files while keeping the user workspace entry visible."""
entries = _list_local_entries(thread_id, sandbox_user_data_dir(thread_id)) entries = _list_local_entries(thread_id, user_id, sandbox_user_data_dir(thread_id))
visible_paths = {str(entry.get("path") or "").rstrip("/") for entry in entries} visible_paths = {str(entry.get("path") or "").rstrip("/") for entry in entries}
workspace_dir = sandbox_workspace_dir(thread_id) workspace_dir = sandbox_workspace_dir(thread_id, user_id)
workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir).rstrip("/") workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir, user_id=user_id).rstrip("/")
if workspace_virtual_path not in visible_paths: if workspace_virtual_path not in visible_paths:
# workspace is stored outside the per-thread root, so add it explicitly when needed. # workspace is stored outside the per-thread root, so add it explicitly when needed.
stat = workspace_dir.stat() stat = workspace_dir.stat()
@ -327,16 +327,17 @@ 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) user_id = str(current_user.id)
ensure_thread_dirs(thread_id, user_id)
if normalized_path == USER_DATA_PATH: if normalized_path == USER_DATA_PATH:
entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id) entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, user_id)
return {"entries": _sort_entries(entries)} return {"entries": _sort_entries(entries)}
actual_path = resolve_virtual_path(thread_id, normalized_path) actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=user_id)
if not actual_path.exists(): if not actual_path.exists():
return {"entries": []} return {"entries": []}
if not actual_path.is_dir(): if not actual_path.is_dir():
raise HTTPException(status_code=400, detail="当前路径不是目录") raise HTTPException(status_code=400, detail="当前路径不是目录")
entries = await asyncio.to_thread(_list_local_entries, thread_id, actual_path) entries = await asyncio.to_thread(_list_local_entries, thread_id, user_id, actual_path)
return {"entries": _sort_entries(entries)} return {"entries": _sort_entries(entries)}
if _is_skills_path(normalized_path): if _is_skills_path(normalized_path):
@ -378,7 +379,7 @@ async def read_viewer_file_content(
try: try:
if _is_user_data_path(normalized_path): if _is_user_data_path(normalized_path):
actual_path = resolve_virtual_path(thread_id, normalized_path) actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=str(current_user.id))
if not actual_path.exists(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if not actual_path.is_file(): if not actual_path.is_file():
@ -471,7 +472,7 @@ async def download_viewer_file(
try: try:
if _is_user_data_path(normalized_path): if _is_user_data_path(normalized_path):
actual_path = resolve_virtual_path(thread_id, normalized_path) actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=str(current_user.id))
if not actual_path.exists(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if not actual_path.is_file(): if not actual_path.is_file():
@ -545,7 +546,7 @@ async def delete_viewer_file(
raise HTTPException(status_code=400, detail="当前目录不允许删除") raise HTTPException(status_code=400, detail="当前目录不允许删除")
try: try:
actual_path = resolve_virtual_path(thread_id, normalized_path) actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=str(current_user.id))
if not actual_path.exists(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if actual_path.is_dir(): if actual_path.is_dir():

View File

@ -26,8 +26,7 @@ E2E_TIMEOUT = httpx.Timeout(300.0, connect=10.0)
def _require_e2e_credentials() -> tuple[str, str]: def _require_e2e_credentials() -> tuple[str, str]:
if not E2E_USERNAME or not E2E_PASSWORD: if not E2E_USERNAME or not E2E_PASSWORD:
pytest.skip( pytest.skip(
"E2E credentials are not configured via E2E_USERNAME / E2E_PASSWORD " "E2E credentials are not configured via E2E_USERNAME / E2E_PASSWORD or TEST_USERNAME / TEST_PASSWORD."
"or TEST_USERNAME / TEST_PASSWORD."
) )
return E2E_USERNAME, E2E_PASSWORD return E2E_USERNAME, E2E_PASSWORD
@ -58,6 +57,15 @@ async def e2e_headers(e2e_client: httpx.AsyncClient) -> dict[str, str]:
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def e2e_agent_context(e2e_client: httpx.AsyncClient, e2e_headers: dict[str, str]) -> dict[str, str | int]: async def e2e_agent_context(e2e_client: httpx.AsyncClient, e2e_headers: dict[str, str]) -> dict[str, str | int]:
me_response = await e2e_client.get("/api/auth/me", headers=e2e_headers)
if me_response.status_code != 200:
pytest.fail(
f"Failed to fetch current user for E2E tests (status={me_response.status_code}): {me_response.text}"
)
user_id = me_response.json().get("id")
if user_id is None:
pytest.fail("Current user payload missing id field for E2E tests.")
default_response = await e2e_client.get("/api/chat/default_agent", headers=e2e_headers) default_response = await e2e_client.get("/api/chat/default_agent", headers=e2e_headers)
default_agent_id = None default_agent_id = None
if default_response.status_code == 200: if default_response.status_code == 200:
@ -77,8 +85,7 @@ async def e2e_agent_context(e2e_client: httpx.AsyncClient, e2e_headers: dict[str
config_response = await e2e_client.get(f"/api/chat/agent/{agent_id}/configs", headers=e2e_headers) config_response = await e2e_client.get(f"/api/chat/agent/{agent_id}/configs", headers=e2e_headers)
if config_response.status_code != 200: if config_response.status_code != 200:
pytest.fail( pytest.fail(
"Failed to list agent configs for E2E tests " f"Failed to list agent configs for E2E tests (status={config_response.status_code}): {config_response.text}"
f"(status={config_response.status_code}): {config_response.text}"
) )
configs = config_response.json().get("configs") or [] configs = config_response.json().get("configs") or []
@ -89,4 +96,4 @@ async def e2e_agent_context(e2e_client: httpx.AsyncClient, e2e_headers: dict[str
if not config_id: if not config_id:
pytest.fail(f"Agent config payload missing id field for agent {agent_id}.") pytest.fail(f"Agent config payload missing id field for agent {agent_id}.")
return {"agent_id": agent_id, "agent_config_id": int(config_id)} return {"agent_id": agent_id, "agent_config_id": int(config_id), "user_id": int(user_id)}

View File

@ -103,14 +103,15 @@ async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local
e2e_agent_context: dict[str, str | int], e2e_agent_context: dict[str, str | int],
): ):
agent_id = str(e2e_agent_context["agent_id"]) agent_id = str(e2e_agent_context["agent_id"])
user_id = str(e2e_agent_context["user_id"])
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id) thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
other_thread_id = await _create_thread(e2e_client, e2e_headers, agent_id) other_thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
ensure_thread_dirs(other_thread_id) ensure_thread_dirs(other_thread_id, user_id)
(sandbox_user_data_dir(thread_id) / "root-note.txt").write_text("root-visible\n", encoding="utf-8") (sandbox_user_data_dir(thread_id) / "root-note.txt").write_text("root-visible\n", encoding="utf-8")
(sandbox_workspace_dir(thread_id) / "demo.py").write_text("print(42)\n", encoding="utf-8") (sandbox_workspace_dir(thread_id, user_id) / "demo.py").write_text("print(42)\n", encoding="utf-8")
uploads_dir = sandbox_uploads_dir(thread_id) / "attachments" uploads_dir = sandbox_uploads_dir(thread_id) / "attachments"
uploads_dir.mkdir(parents=True, exist_ok=True) uploads_dir.mkdir(parents=True, exist_ok=True)
@ -218,10 +219,11 @@ async def test_viewer_filesystem_e2e_deletes_workspace_directory_recursively(
e2e_agent_context: dict[str, str | int], e2e_agent_context: dict[str, str | int],
): ):
agent_id = str(e2e_agent_context["agent_id"]) agent_id = str(e2e_agent_context["agent_id"])
user_id = str(e2e_agent_context["user_id"])
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id) thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
target_dir = sandbox_workspace_dir(thread_id) / "delete-dir" target_dir = sandbox_workspace_dir(thread_id, user_id) / "delete-dir"
nested_dir = target_dir / "deep" nested_dir = target_dir / "deep"
nested_dir.mkdir(parents=True) nested_dir.mkdir(parents=True)
(nested_dir / "artifact.txt").write_text("delete me\n", encoding="utf-8") (nested_dir / "artifact.txt").write_text("delete me\n", encoding="utf-8")

View File

@ -92,10 +92,11 @@ async def test_setting_default_agent_requires_admin(test_client, admin_headers,
async def test_save_thread_artifact_to_workspace_copies_output_file(test_client, standard_user): async def test_save_thread_artifact_to_workspace_copies_output_file(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
filename = f"artifact-{uuid.uuid4().hex[:8]}.md" filename = f"artifact-{uuid.uuid4().hex[:8]}.md"
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename
source_path.write_text("# artifact\n", encoding="utf-8") source_path.write_text("# artifact\n", encoding="utf-8")
@ -111,7 +112,7 @@ async def test_save_thread_artifact_to_workspace_copies_output_file(test_client,
assert payload["source_path"] == f"/home/gem/user-data/outputs/{filename}" assert payload["source_path"] == f"/home/gem/user-data/outputs/{filename}"
assert payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}" assert payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}"
saved_path = sandbox_workspace_dir(thread_id) / "saved_artifacts" / filename saved_path = sandbox_workspace_dir(thread_id, user_id) / "saved_artifacts" / filename
assert saved_path.exists() assert saved_path.exists()
assert saved_path.read_text(encoding="utf-8") == "# artifact\n" assert saved_path.read_text(encoding="utf-8") == "# artifact\n"
@ -122,11 +123,12 @@ async def test_save_thread_artifact_to_workspace_copies_output_file(test_client,
async def test_save_thread_artifact_to_workspace_auto_renames_conflicts(test_client, standard_user): async def test_save_thread_artifact_to_workspace_auto_renames_conflicts(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
filename = f"artifact-{uuid.uuid4().hex[:8]}.txt" filename = f"artifact-{uuid.uuid4().hex[:8]}.txt"
renamed_filename = filename.replace(".txt", " (1).txt") renamed_filename = filename.replace(".txt", " (1).txt")
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename
source_path.write_text("first\n", encoding="utf-8") source_path.write_text("first\n", encoding="utf-8")
@ -150,14 +152,15 @@ async def test_save_thread_artifact_to_workspace_auto_renames_conflicts(test_cli
assert first_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}" assert first_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}"
assert second_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{renamed_filename}" assert second_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{renamed_filename}"
first_saved = sandbox_workspace_dir(thread_id) / "saved_artifacts" / filename first_saved = sandbox_workspace_dir(thread_id, user_id) / "saved_artifacts" / filename
second_saved = sandbox_workspace_dir(thread_id) / "saved_artifacts" / renamed_filename second_saved = sandbox_workspace_dir(thread_id, user_id) / "saved_artifacts" / renamed_filename
assert first_saved.read_text(encoding="utf-8") == "first\n" assert first_saved.read_text(encoding="utf-8") == "first\n"
assert second_saved.read_text(encoding="utf-8") == "second\n" assert second_saved.read_text(encoding="utf-8") == "second\n"
async def test_save_thread_artifact_to_workspace_rejects_invalid_paths(test_client, standard_user): async def test_save_thread_artifact_to_workspace_rejects_invalid_paths(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
invalid_response = await test_client.post( invalid_response = await test_client.post(
@ -167,8 +170,8 @@ async def test_save_thread_artifact_to_workspace_rejects_invalid_paths(test_clie
) )
assert invalid_response.status_code == 404, invalid_response.text assert invalid_response.status_code == 404, invalid_response.text
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
directory_path = sandbox_workspace_dir(thread_id) / "nested-dir" directory_path = sandbox_workspace_dir(thread_id, user_id) / "nested-dir"
directory_path.mkdir(parents=True, exist_ok=True) directory_path.mkdir(parents=True, exist_ok=True)
directory_response = await test_client.post( directory_response = await test_client.post(
f"/api/chat/thread/{thread_id}/artifacts/save", f"/api/chat/thread/{thread_id}/artifacts/save",

View File

@ -123,10 +123,11 @@ async def test_viewer_tree_root_does_not_require_sandbox_listing(test_client, st
async def test_viewer_tree_user_data_uses_local_thread_directory(test_client, standard_user, monkeypatch): async def test_viewer_tree_user_data_uses_local_thread_directory(test_client, standard_user, monkeypatch):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "viewer_tree_demo.txt" actual_path = sandbox_workspace_dir(thread_id, user_id) / "viewer_tree_demo.txt"
actual_path.write_text("viewer tree", encoding="utf-8") actual_path.write_text("viewer tree", encoding="utf-8")
class _FailingSandbox: class _FailingSandbox:
@ -161,9 +162,10 @@ async def test_viewer_tree_user_data_uses_local_thread_directory(test_client, st
async def test_viewer_tree_user_data_root_keeps_thread_root_files_visible(test_client, standard_user): async def test_viewer_tree_user_data_root_keeps_thread_root_files_visible(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
root_file = sandbox_user_data_dir(thread_id) / "root-note.txt" root_file = sandbox_user_data_dir(thread_id) / "root-note.txt"
root_file.write_text("visible at root", encoding="utf-8") root_file.write_text("visible at root", encoding="utf-8")
@ -179,13 +181,14 @@ async def test_viewer_tree_user_data_root_keeps_thread_root_files_visible(test_c
assert "/home/gem/user-data/workspace/" 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): async def test_workspace_is_shared_across_same_user_threads_but_uploads_remain_thread_local(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
other_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) ensure_thread_dirs(thread_id, user_id)
shared_path = sandbox_workspace_dir(thread_id) / "shared_across_threads.txt" shared_path = sandbox_workspace_dir(thread_id, user_id) / "shared_across_threads.txt"
shared_path.write_text("shared workspace", encoding="utf-8") 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") upload_path = await _upload_attachment_file(test_client, thread_id, headers, "thread-local.txt", "private upload\n")
@ -232,12 +235,13 @@ async def test_viewer_file_returns_raw_content_without_line_numbers(test_client,
async def test_viewer_file_returns_unsupported_for_binary_payload(test_client, standard_user): async def test_viewer_file_returns_unsupported_for_binary_payload(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "binary.bin" actual_path = sandbox_workspace_dir(thread_id, user_id) / "binary.bin"
actual_path.write_bytes(b"\x00\x01\x02\x03binary") actual_path.write_bytes(b"\x00\x01\x02\x03binary")
file_path = virtual_path_for_thread_file(thread_id, actual_path) file_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
response = await test_client.get( response = await test_client.get(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",
@ -254,14 +258,15 @@ async def test_viewer_file_returns_unsupported_for_binary_payload(test_client, s
async def test_viewer_file_returns_image_preview_metadata(test_client, standard_user): async def test_viewer_file_returns_image_preview_metadata(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "demo.png" actual_path = sandbox_workspace_dir(thread_id, user_id) / "demo.png"
actual_path.write_bytes( actual_path.write_bytes(
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
) )
file_path = virtual_path_for_thread_file(thread_id, actual_path) file_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
response = await test_client.get( response = await test_client.get(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",
@ -278,12 +283,13 @@ async def test_viewer_file_returns_image_preview_metadata(test_client, standard_
async def test_viewer_file_returns_pdf_preview_metadata(test_client, standard_user): async def test_viewer_file_returns_pdf_preview_metadata(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "demo.pdf" actual_path = sandbox_workspace_dir(thread_id, user_id) / "demo.pdf"
actual_path.write_bytes(b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF") actual_path.write_bytes(b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF")
file_path = virtual_path_for_thread_file(thread_id, actual_path) file_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
response = await test_client.get( response = await test_client.get(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",
@ -317,13 +323,14 @@ async def test_viewer_download_returns_attachment_response(test_client, standard
async def test_viewer_download_returns_full_file_for_large_user_data_content(test_client, standard_user): async def test_viewer_download_returns_full_file_for_large_user_data_content(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
large_content = "0123456789abcdef" * 4096 large_content = "0123456789abcdef" * 4096
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "large_download.txt" actual_path = sandbox_workspace_dir(thread_id, user_id) / "large_download.txt"
actual_path.write_text(large_content, encoding="utf-8") actual_path.write_text(large_content, encoding="utf-8")
file_path = virtual_path_for_thread_file(thread_id, actual_path) file_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
response = await test_client.get( response = await test_client.get(
"/api/viewer/filesystem/download", "/api/viewer/filesystem/download",
@ -336,12 +343,13 @@ async def test_viewer_download_returns_full_file_for_large_user_data_content(tes
async def test_viewer_delete_removes_user_data_file(test_client, standard_user): async def test_viewer_delete_removes_user_data_file(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "delete_me.txt" actual_path = sandbox_workspace_dir(thread_id, user_id) / "delete_me.txt"
actual_path.write_text("delete me", encoding="utf-8") actual_path.write_text("delete me", encoding="utf-8")
file_path = virtual_path_for_thread_file(thread_id, actual_path) file_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
delete_response = await test_client.delete( delete_response = await test_client.delete(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",
@ -364,12 +372,13 @@ async def test_viewer_delete_removes_user_data_file(test_client, standard_user):
async def test_viewer_delete_removes_empty_user_data_directory(test_client, standard_user): async def test_viewer_delete_removes_empty_user_data_directory(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "empty-folder" actual_path = sandbox_workspace_dir(thread_id, user_id) / "empty-folder"
actual_path.mkdir() actual_path.mkdir()
dir_path = virtual_path_for_thread_file(thread_id, actual_path) dir_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
delete_response = await test_client.delete( delete_response = await test_client.delete(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",
@ -392,15 +401,16 @@ async def test_viewer_delete_removes_empty_user_data_directory(test_client, stan
async def test_viewer_delete_recursively_removes_user_data_directory(test_client, standard_user): async def test_viewer_delete_recursively_removes_user_data_directory(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
actual_path = sandbox_workspace_dir(thread_id) / "nested-folder" actual_path = sandbox_workspace_dir(thread_id, user_id) / "nested-folder"
nested_dir = actual_path / "child" nested_dir = actual_path / "child"
nested_dir.mkdir(parents=True) nested_dir.mkdir(parents=True)
nested_file = nested_dir / "notes.txt" nested_file = nested_dir / "notes.txt"
nested_file.write_text("remove recursively", encoding="utf-8") nested_file.write_text("remove recursively", encoding="utf-8")
dir_path = virtual_path_for_thread_file(thread_id, actual_path) dir_path = virtual_path_for_thread_file(thread_id, actual_path, user_id=user_id)
delete_response = await test_client.delete( delete_response = await test_client.delete(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",
@ -447,9 +457,10 @@ async def test_viewer_delete_rejects_protected_user_data_root_directories(
test_client, standard_user, protected_path: str test_client, standard_user, protected_path: str
): ):
headers = standard_user["headers"] headers = standard_user["headers"]
user_id = str(standard_user["user"]["id"])
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, user_id)
response = await test_client.delete( response = await test_client.delete(
"/api/viewer/filesystem/file", "/api/viewer/filesystem/file",

View File

@ -14,13 +14,18 @@ from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware
def _runtime( def _runtime(
*, *,
thread_id: str | None = "thread-1", thread_id: str | None = "thread-1",
user_id: str | None = "user-1",
skills: list[str] | None = None, skills: list[str] | None = None,
visible_kbs: list[dict] | None = None, visible_kbs: list[dict] | None = None,
): ):
configurable = {"thread_id": thread_id} if thread_id else {} configurable = {"thread_id": thread_id, "user_id": user_id} if thread_id and user_id else {}
return SimpleNamespace( return SimpleNamespace(
config={"configurable": configurable}, config={"configurable": configurable},
context=SimpleNamespace(skills=skills or [], _visible_knowledge_bases=visible_kbs or []), context=SimpleNamespace(
skills=skills or [],
_visible_knowledge_bases=visible_kbs or [],
user_id=user_id,
),
) )
@ -50,12 +55,12 @@ def test_skills_middleware_extracts_slug_for_new_paths() -> None:
def test_resolve_virtual_path_rejects_outside_prefix(): def test_resolve_virtual_path_rejects_outside_prefix():
with pytest.raises(ValueError, match="path must start with"): with pytest.raises(ValueError, match="path must start with"):
resolve_virtual_path("thread-1", "/etc/passwd") resolve_virtual_path("thread-1", "/etc/passwd", user_id="user-1")
def test_resolve_virtual_path_rejects_path_traversal(): def test_resolve_virtual_path_rejects_path_traversal():
with pytest.raises(ValueError, match="path traversal"): with pytest.raises(ValueError, match="path traversal"):
resolve_virtual_path("thread-1", "/home/gem/user-data/../secrets") resolve_virtual_path("thread-1", "/home/gem/user-data/../secrets", user_id="user-1")
def test_sandbox_id_for_thread_is_stable(): def test_sandbox_id_for_thread_is_stable():
@ -69,7 +74,7 @@ def test_sandbox_id_for_thread_is_stable():
def test_provisioner_read_reports_binary_files(monkeypatch) -> None: def test_provisioner_read_reports_binary_files(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object()) monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1") backend = ProvisionerSandboxBackend(thread_id="thread-1", user_id="user-1")
monkeypatch.setattr(backend, "_read_binary", lambda path, offset=0, limit=None: b"\x89PNG\r\n\x1a\n") monkeypatch.setattr(backend, "_read_binary", lambda path, offset=0, limit=None: b"\x89PNG\r\n\x1a\n")
result = backend.read("/home/gem/user-data/image.png") result = backend.read("/home/gem/user-data/image.png")
@ -79,7 +84,7 @@ def test_provisioner_read_reports_binary_files(monkeypatch) -> None:
def test_provisioner_read_reports_invalid_path(monkeypatch) -> None: def test_provisioner_read_reports_invalid_path(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object()) monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1") backend = ProvisionerSandboxBackend(thread_id="thread-1", user_id="user-1")
def _raise_invalid_path(path, offset=0, limit=None): def _raise_invalid_path(path, offset=0, limit=None):
raise ValueError("path traversal is not allowed") raise ValueError("path traversal is not allowed")
@ -93,7 +98,7 @@ def test_provisioner_read_reports_invalid_path(monkeypatch) -> None:
def test_provisioner_download_files_distinguishes_invalid_path_from_read_failure(monkeypatch) -> None: def test_provisioner_download_files_distinguishes_invalid_path_from_read_failure(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object()) monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1") backend = ProvisionerSandboxBackend(thread_id="thread-1", user_id="user-1")
def _fake_read_binary(path, offset=0, limit=None): def _fake_read_binary(path, offset=0, limit=None):
if path == "/bad-path": if path == "/bad-path":
@ -110,7 +115,7 @@ def test_provisioner_download_files_distinguishes_invalid_path_from_read_failure
def test_provisioner_execute_returns_error_response_on_client_failure(monkeypatch) -> None: def test_provisioner_execute_returns_error_response_on_client_failure(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object()) monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1") backend = ProvisionerSandboxBackend(thread_id="thread-1", user_id="user-1")
class _FakeClient: class _FakeClient:
class shell: class shell:

View File

@ -9,8 +9,8 @@ from yuxi.agents.toolkits.buildin.tools import _normalize_presented_artifact_pat
from yuxi.services.chat_service import extract_agent_state from yuxi.services.chat_service import extract_agent_state
def _runtime_with_thread(thread_id: str): def _runtime_with_thread(thread_id: str, user_id: str = "user-1"):
context = type("RuntimeContext", (), {"thread_id": thread_id})() context = type("RuntimeContext", (), {"thread_id": thread_id, "user_id": user_id})()
return type("RuntimeStub", (), {"context": context})() return type("RuntimeStub", (), {"context": context})()
@ -26,7 +26,7 @@ def test_merge_artifacts_deduplicates_and_preserves_order():
def test_normalize_presented_artifact_path_accepts_host_path(): def test_normalize_presented_artifact_path_accepts_host_path():
thread_id = "artifacts-host-path" thread_id = "artifacts-host-path"
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, "user-1")
output_file = sandbox_outputs_dir(thread_id) / "report.md" output_file = sandbox_outputs_dir(thread_id) / "report.md"
output_file.write_text("# demo", encoding="utf-8") output_file.write_text("# demo", encoding="utf-8")
@ -37,7 +37,7 @@ def test_normalize_presented_artifact_path_accepts_host_path():
def test_normalize_presented_artifact_path_accepts_virtual_path(): def test_normalize_presented_artifact_path_accepts_virtual_path():
thread_id = "artifacts-virtual-path" thread_id = "artifacts-virtual-path"
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, "user-1")
output_file = sandbox_outputs_dir(thread_id) / "summary.txt" output_file = sandbox_outputs_dir(thread_id) / "summary.txt"
output_file.write_text("demo", encoding="utf-8") output_file.write_text("demo", encoding="utf-8")
@ -51,7 +51,7 @@ def test_normalize_presented_artifact_path_accepts_virtual_path():
def test_normalize_presented_artifact_path_rejects_non_outputs_path(): def test_normalize_presented_artifact_path_rejects_non_outputs_path():
thread_id = "artifacts-reject-path" thread_id = "artifacts-reject-path"
ensure_thread_dirs(thread_id) ensure_thread_dirs(thread_id, "user-1")
upload_file = sandbox_uploads_dir(thread_id) / "note.txt" upload_file = sandbox_uploads_dir(thread_id) / "note.txt"
upload_file.write_text("demo", encoding="utf-8") upload_file.write_text("demo", encoding="utf-8")

View File

@ -26,6 +26,7 @@ def canonical_backend_name(backend: str) -> str:
class CreateSandboxRequest(BaseModel): class CreateSandboxRequest(BaseModel):
sandbox_id: str sandbox_id: str
thread_id: str thread_id: str
user_id: str
class SandboxResponse(BaseModel): class SandboxResponse(BaseModel):
@ -69,8 +70,9 @@ class MemoryProvisionerBackend:
return template.format(sandbox_id=sandbox_id) return template.format(sandbox_id=sandbox_id)
return template return template
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: def create(self, sandbox_id: str, thread_id: str, user_id: str) -> SandboxRecord:
_ = thread_id # unused in memory backend _ = thread_id # unused in memory backend
_ = user_id # unused in memory backend
with self._lock: with self._lock:
existing = self._records.get(sandbox_id) existing = self._records.get(sandbox_id)
if existing is not None: if existing is not None:
@ -167,6 +169,17 @@ class LocalContainerProvisionerBackend:
raise ValueError("thread_id contains invalid path traversal sequence") raise ValueError("thread_id contains invalid path traversal sequence")
return candidate return candidate
@staticmethod
def _validate_user_id(user_id: str) -> str:
candidate = str(user_id or "").strip()
if not candidate:
raise ValueError("user_id is required")
if any(ch in candidate for ch in ("/", "\\", "\x00")):
raise ValueError("user_id must be a single safe path segment")
if ".." in candidate:
raise ValueError("user_id contains invalid path traversal sequence")
return candidate
@staticmethod @staticmethod
def _sanitize_id(value: str) -> str: def _sanitize_id(value: str) -> str:
sanitized = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value.strip().lower()) sanitized = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value.strip().lower())
@ -184,13 +197,13 @@ class LocalContainerProvisionerBackend:
raise ValueError("thread skills path resolved outside threads host root") from exc raise ValueError("thread skills path resolved outside threads host root") from exc
return thread_skills return thread_skills
def _shared_workspace_host_path(self) -> Path: def _shared_workspace_host_path(self, user_id: str) -> Path:
threads_root = Path(self._threads_host_path).resolve() threads_root = Path(self._threads_host_path).resolve()
workspace = (threads_root / "shared" / "workspace").resolve() workspace = (threads_root / "shared" / user_id / "workspace").resolve()
try: try:
workspace.relative_to(threads_root) workspace.relative_to(threads_root)
except ValueError as exc: except ValueError as exc:
raise ValueError("shared workspace path resolved outside threads host root") from exc raise ValueError("user workspace path resolved outside threads host root") from exc
return workspace return workspace
def _thread_uploads_host_path(self, thread_id: str) -> Path: def _thread_uploads_host_path(self, thread_id: str) -> Path:
@ -221,9 +234,9 @@ class LocalContainerProvisionerBackend:
return source == expected_source return source == expected_source
return False return False
def _has_expected_user_data_mounts(self, container, thread_id: str) -> bool: def _has_expected_user_data_mounts(self, container, thread_id: str, user_id: str) -> bool:
expected_mounts = { expected_mounts = {
"/home/gem/user-data/workspace": str(self._shared_workspace_host_path()), "/home/gem/user-data/workspace": str(self._shared_workspace_host_path(user_id)),
"/home/gem/user-data/uploads": str(self._thread_uploads_host_path(thread_id)), "/home/gem/user-data/uploads": str(self._thread_uploads_host_path(thread_id)),
"/home/gem/user-data/outputs": str(self._thread_outputs_host_path(thread_id)), "/home/gem/user-data/outputs": str(self._thread_outputs_host_path(thread_id)),
} }
@ -306,9 +319,10 @@ class LocalContainerProvisionerBackend:
except NotFound: except NotFound:
return None return None
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: def create(self, sandbox_id: str, thread_id: str, user_id: str) -> SandboxRecord:
with self._lock: with self._lock:
safe_thread_id = self._validate_thread_id(thread_id) safe_thread_id = self._validate_thread_id(thread_id)
safe_user_id = self._validate_user_id(user_id)
existing = self._get_container(sandbox_id) existing = self._get_container(sandbox_id)
if existing is not None: if existing is not None:
existing.reload() existing.reload()
@ -316,7 +330,7 @@ class LocalContainerProvisionerBackend:
logger.info("Recreating sandbox %s because skills mount is stale", sandbox_id) logger.info("Recreating sandbox %s because skills mount is stale", sandbox_id)
self.delete(sandbox_id) self.delete(sandbox_id)
existing = None existing = None
elif not self._has_expected_user_data_mounts(existing, safe_thread_id): elif not self._has_expected_user_data_mounts(existing, safe_thread_id, safe_user_id):
logger.info("Recreating sandbox %s because user-data mounts are stale", sandbox_id) logger.info("Recreating sandbox %s because user-data mounts are stale", sandbox_id)
self.delete(sandbox_id) self.delete(sandbox_id)
existing = None existing = None
@ -339,7 +353,7 @@ class LocalContainerProvisionerBackend:
logger.warning("Failed to delete stale sandbox %s before recreate: %s", sandbox_id, exc) logger.warning("Failed to delete stale sandbox %s before recreate: %s", sandbox_id, exc)
threads_root = Path(self._threads_host_path).resolve() threads_root = Path(self._threads_host_path).resolve()
shared_workspace = self._shared_workspace_host_path() shared_workspace = self._shared_workspace_host_path(safe_user_id)
shared_workspace.mkdir(parents=True, exist_ok=True) shared_workspace.mkdir(parents=True, exist_ok=True)
thread_uploads = self._thread_uploads_host_path(safe_thread_id) thread_uploads = self._thread_uploads_host_path(safe_thread_id)
thread_outputs = self._thread_outputs_host_path(safe_thread_id) thread_outputs = self._thread_outputs_host_path(safe_thread_id)
@ -356,6 +370,7 @@ class LocalContainerProvisionerBackend:
"app": "yuxi-sandbox", "app": "yuxi-sandbox",
"sandbox-id": sandbox_id, "sandbox-id": sandbox_id,
"thread-id": thread_id, "thread-id": thread_id,
"user-id": user_id,
"managed-by": "yuxi-sandbox-provisioner", "managed-by": "yuxi-sandbox-provisioner",
}, },
"volumes": { "volumes": {
@ -391,7 +406,11 @@ class LocalContainerProvisionerBackend:
thread_id = str((container.labels or {}).get("thread-id") or "").strip() thread_id = str((container.labels or {}).get("thread-id") or "").strip()
if not thread_id: if not thread_id:
return None return None
user_id = str((container.labels or {}).get("user-id") or "").strip()
if not user_id:
return None
safe_thread_id = self._validate_thread_id(thread_id) safe_thread_id = self._validate_thread_id(thread_id)
safe_user_id = self._validate_user_id(user_id)
if not self._is_expected_skills_mount(container, safe_thread_id): if not self._is_expected_skills_mount(container, safe_thread_id):
logger.info("Discarding stale sandbox %s with legacy skills mount", sandbox_id) logger.info("Discarding stale sandbox %s with legacy skills mount", sandbox_id)
try: try:
@ -399,7 +418,7 @@ class LocalContainerProvisionerBackend:
except Exception as exc: except Exception as exc:
logger.warning("Failed to delete stale sandbox %s during discover: %s", sandbox_id, exc) logger.warning("Failed to delete stale sandbox %s during discover: %s", sandbox_id, exc)
return None return None
if not self._has_expected_user_data_mounts(container, safe_thread_id): if not self._has_expected_user_data_mounts(container, safe_thread_id, safe_user_id):
logger.info("Discarding stale sandbox %s with legacy user-data mounts", sandbox_id) logger.info("Discarding stale sandbox %s with legacy user-data mounts", sandbox_id)
try: try:
self.delete(sandbox_id) self.delete(sandbox_id)
@ -470,13 +489,13 @@ class KubernetesProvisionerBackend:
def _service_name(sandbox_id: str) -> str: def _service_name(sandbox_id: str) -> str:
return f"sandbox-{sandbox_id}" return f"sandbox-{sandbox_id}"
def _build_pod_spec(self, sandbox_id: str, thread_id: str): def _build_pod_spec(self, sandbox_id: str, thread_id: str, user_id: str):
pod_name = self._pod_name(sandbox_id) pod_name = self._pod_name(sandbox_id)
return self._client.V1Pod( return self._client.V1Pod(
metadata=self._client.V1ObjectMeta( metadata=self._client.V1ObjectMeta(
name=pod_name, name=pod_name,
labels={"app": "yuxi-sandbox", "sandbox-id": sandbox_id}, labels={"app": "yuxi-sandbox", "sandbox-id": sandbox_id},
annotations={"thread-id": thread_id}, annotations={"thread-id": thread_id, "user-id": user_id},
), ),
spec=self._client.V1PodSpec( spec=self._client.V1PodSpec(
restart_policy="Never", restart_policy="Never",
@ -491,11 +510,11 @@ class KubernetesProvisionerBackend:
command=["sh", "-c"], command=["sh", "-c"],
args=[ args=[
"chmod 777 /home/gem " "chmod 777 /home/gem "
"&& mkdir -p /mnt/shared-data/threads/shared/workspace " f"&& mkdir -p /mnt/shared-data/threads/shared/{user_id}/workspace "
f"/mnt/shared-data/threads/{thread_id}/user-data/uploads " f"/mnt/shared-data/threads/{thread_id}/user-data/uploads "
f"/mnt/shared-data/threads/{thread_id}/user-data/outputs " f"/mnt/shared-data/threads/{thread_id}/user-data/outputs "
f"/mnt/shared-data/threads/{thread_id}/skills " f"/mnt/shared-data/threads/{thread_id}/skills "
f"&& chmod -R 777 /mnt/shared-data/threads/shared/workspace " f"&& chmod -R 777 /mnt/shared-data/threads/shared/{user_id}/workspace "
f"/mnt/shared-data/threads/{thread_id}/user-data ", f"/mnt/shared-data/threads/{thread_id}/user-data ",
], ],
volume_mounts=[ volume_mounts=[
@ -514,7 +533,7 @@ class KubernetesProvisionerBackend:
self._client.V1VolumeMount( self._client.V1VolumeMount(
name="shared-data", name="shared-data",
mount_path="/home/gem/user-data/workspace", mount_path="/home/gem/user-data/workspace",
sub_path="threads/shared/workspace", sub_path=f"threads/shared/{user_id}/workspace",
), ),
self._client.V1VolumeMount( self._client.V1VolumeMount(
name="shared-data", name="shared-data",
@ -572,7 +591,7 @@ class KubernetesProvisionerBackend:
), ),
) )
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: def create(self, sandbox_id: str, thread_id: str, user_id: str) -> SandboxRecord:
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
with self._lock: with self._lock:
@ -586,7 +605,7 @@ class KubernetesProvisionerBackend:
try: try:
self._core_api.create_namespaced_pod( self._core_api.create_namespaced_pod(
namespace=self._namespace, namespace=self._namespace,
body=self._build_pod_spec(sandbox_id, thread_id), body=self._build_pod_spec(sandbox_id, thread_id, user_id),
) )
except ApiException as exc: except ApiException as exc:
if exc.status != 409: if exc.status != 409:
@ -798,7 +817,7 @@ def health():
def create_sandbox(payload: CreateSandboxRequest): def create_sandbox(payload: CreateSandboxRequest):
try: try:
# Backend.create() already handles container reuse (discovers existing container first) # Backend.create() already handles container reuse (discovers existing container first)
record = backend_impl.create(payload.sandbox_id, payload.thread_id) record = backend_impl.create(payload.sandbox_id, payload.thread_id, payload.user_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001

View File

@ -35,6 +35,7 @@
<!-- 0.6.1 的内容请放在这里 --> <!-- 0.6.1 的内容请放在这里 -->
- 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。 - 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。
- 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。
历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。 历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。