本次提交实现了AgentRun全生命周期终态处理能力,覆盖以下核心变更: 1. 新增4种AgentRun终态错误类型与HTTP状态映射,透传上游错误上下文 2. 新增AgentRun终态信息查询接口与DTO,支持非事件流场景下的状态感知 3. 优化沙箱路径转义规则,兼容服务账号UID格式 4. 新增出站管道SKIP逻辑,针对取消/中断场景避免无效重试 5. 补充完整单元测试覆盖所有新功能与优化点
205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from yuxi import config as conf
|
|
from yuxi.utils.logging_config import logger
|
|
from yuxi.utils.paths import (
|
|
OUTPUTS_DIR_NAME,
|
|
UPLOADS_DIR_NAME,
|
|
VIRTUAL_PATH_PREFIX,
|
|
WORKSPACE_AGENTS_DIR_NAME,
|
|
WORKSPACE_AGENTS_PROMPT_FILE_NAME,
|
|
WORKSPACE_DIR_NAME,
|
|
)
|
|
|
|
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
# 与 docker/sandbox_provisioner/app.py 中的 UNSAFE_PATH_SEGMENT_CHAR_RE 保持一致,
|
|
# 确保宿主机路径与容器挂载路径采用相同的转义规则。
|
|
_UNSAFE_PATH_SEGMENT_CHAR_RE = re.compile(r"[^A-Za-z0-9_-]")
|
|
|
|
|
|
def get_virtual_path_prefix() -> str:
|
|
return "/" + VIRTUAL_PATH_PREFIX.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_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 _sanitize_uid(uid: str) -> str:
|
|
"""将 uid 转义为文件系统安全的路径组件。
|
|
|
|
服务账号 uid 格式 ``svc:channel:{channel_type}:{account_id}`` 含冒号,
|
|
用作宿主机路径组件时需转义为 ``_``,与 sandbox_provisioner.app.
|
|
LocalContainerProvisionerBackend._validate_uid 保持一致的转义规则,
|
|
确保宿主机路径与容器挂载路径映射一致。
|
|
"""
|
|
candidate = str(uid or "").strip()
|
|
if not candidate:
|
|
raise ValueError("uid is required")
|
|
return _UNSAFE_PATH_SEGMENT_CHAR_RE.sub("_", candidate)
|
|
|
|
|
|
def _global_user_data_dir(uid: str) -> Path:
|
|
"""Return the shared host-side directory used for one user's workspace files."""
|
|
safe_uid = _sanitize_uid(uid)
|
|
return Path(conf.save_dir) / "threads" / "shared" / safe_uid
|
|
|
|
|
|
def sandbox_user_data_dir(thread_id: str) -> Path:
|
|
return _thread_root_dir(thread_id)
|
|
|
|
|
|
def sandbox_workspace_dir(thread_id: str, uid: str) -> Path:
|
|
validate_thread_id(thread_id)
|
|
return _global_user_data_dir(uid) / WORKSPACE_DIR_NAME
|
|
|
|
|
|
def sandbox_workspace_agents_prompt_file(thread_id: str, uid: str) -> Path:
|
|
return sandbox_workspace_dir(thread_id, uid) / WORKSPACE_AGENTS_DIR_NAME / WORKSPACE_AGENTS_PROMPT_FILE_NAME
|
|
|
|
|
|
def _threads_root_dir() -> Path:
|
|
return (Path(conf.save_dir) / "threads").resolve(strict=False)
|
|
|
|
|
|
def _resolve_threads_child_path(path: Path) -> Path:
|
|
root = _threads_root_dir()
|
|
resolved = path.resolve(strict=False)
|
|
if not resolved.is_relative_to(root):
|
|
raise ValueError("path resolved outside threads root")
|
|
return resolved
|
|
|
|
|
|
def _chmod_writable(path: Path, *, dir: bool = False) -> None:
|
|
safe_path = _resolve_threads_child_path(path)
|
|
mode = 0o777 if dir else 0o666
|
|
try:
|
|
safe_path.chmod(mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def ensure_workspace_default_files(workspace_dir: Path) -> None:
|
|
workspace_dir = _resolve_threads_child_path(workspace_dir)
|
|
agents_dir = workspace_dir / WORKSPACE_AGENTS_DIR_NAME
|
|
agents_file = agents_dir / WORKSPACE_AGENTS_PROMPT_FILE_NAME
|
|
|
|
try:
|
|
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
_chmod_writable(agents_dir, dir=True)
|
|
except FileExistsError:
|
|
logger.warning("工作区默认 Agents 目录创建失败:路径已被文件占用")
|
|
return
|
|
except OSError as exc:
|
|
logger.warning(f"工作区默认 Agents 目录初始化失败: {exc}")
|
|
return
|
|
|
|
try:
|
|
with agents_file.open("xb"):
|
|
pass
|
|
_chmod_writable(agents_file)
|
|
except FileExistsError:
|
|
if agents_file.is_dir():
|
|
logger.warning("工作区默认 AGENTS.md 创建失败:路径已被目录占用")
|
|
except OSError as exc:
|
|
logger.warning(f"工作区默认 Agents 文件初始化失败: {exc}")
|
|
|
|
|
|
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, uid: str) -> None:
|
|
_resolve_threads_child_path(_global_user_data_dir(uid)).mkdir(parents=True, exist_ok=True)
|
|
workspace_dir = _resolve_threads_child_path(sandbox_workspace_dir(thread_id, uid))
|
|
workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
ensure_workspace_default_files(workspace_dir)
|
|
_resolve_threads_child_path(sandbox_uploads_dir(thread_id)).mkdir(parents=True, exist_ok=True)
|
|
_resolve_threads_child_path(sandbox_outputs_dir(thread_id)).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _resolve_user_data_base_dir(thread_id: str, uid: 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 one user's threads, so it lives outside the per-thread root.
|
|
base_dir = sandbox_workspace_dir(thread_id, uid)
|
|
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, *, uid: 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, uid, 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, *, uid: str) -> str:
|
|
target_path = Path(path).resolve()
|
|
thread_root = sandbox_user_data_dir(thread_id).resolve()
|
|
global_workspace_root = sandbox_workspace_dir(thread_id, uid).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}"
|