Merge branch 'feat/agent-sandbox' of https://github.com/xerrors/Yuxi-Know into feat/agent-sandbox

This commit is contained in:
Wenjie Zhang 2026-03-25 07:42:52 +08:00
commit ecb2252e36
15 changed files with 202 additions and 695 deletions

View File

@ -6,7 +6,6 @@ from server.routers.auth_router import auth
from server.routers.chat_router import chat
from server.routers.dashboard_router import dashboard
from server.routers.department_router import department
from server.routers.filesystem_router import filesystem_router, viewer_filesystem_router
from server.routers.mcp_router import mcp
from server.routers.skill_router import skills
from server.routers.subagent_router import subagents_router
@ -14,6 +13,7 @@ from server.routers.system_router import system
from server.routers.task_router import tasks
from server.routers.tool_router import tools
from server.routers.apikey_router import apikey_router
from server.routers.filesystem_router import filesystem_router
_LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1")
@ -31,8 +31,7 @@ router.include_router(skills) # /api/system/skills/*
router.include_router(subagents_router) # /api/system/subagents/*
router.include_router(tools) # /api/system/tools/*
router.include_router(apikey_router) # /api/apikey/*
router.include_router(filesystem_router) # /api/filesystem/*
router.include_router(viewer_filesystem_router) # /api/viewer/filesystem/*
router.include_router(filesystem_router) # /api/viewer/filesystem/*
if not _LITE_MODE:
from server.routers.graph_router import graph

View File

@ -1,7 +1,6 @@
"""文件系统路由
"""Viewer 文件系统路由
提供沙盒文件系统的 API 端点
- /filesystem/* - Agent 工具使用 (composite_backend)
提供 Viewer UI 使用的只读文件系统 API 端点
- /viewer/filesystem/* - Viewer UI 使用 (readonly backend)
"""
@ -11,10 +10,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_db, get_required_user
from yuxi.services.filesystem_service import (
list_filesystem_entries_view,
read_file_content_view,
)
from yuxi.services.viewer_filesystem_service import (
download_viewer_file,
list_viewer_filesystem_tree,
@ -22,53 +17,10 @@ from yuxi.services.viewer_filesystem_service import (
)
from yuxi.storage.postgres.models_business import User
filesystem_router = APIRouter(prefix="/filesystem", tags=["filesystem"])
viewer_filesystem_router = APIRouter(prefix="/viewer/filesystem", tags=["viewer-filesystem"])
filesystem_router = APIRouter(prefix="/viewer/filesystem", tags=["viewer-filesystem"])
# ==================== Agent 文件系统接口 ====================
@filesystem_router.get("/ls", response_model=dict)
async def list_filesystem_entries(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query("/", description="目录路径"),
agent_id: str | None = Query(None, description="智能体 ID"),
agent_config_id: int | None = Query(None, description="智能体配置 ID"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await list_filesystem_entries_view(
thread_id=thread_id,
path=path,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@filesystem_router.get("/cat", response_model=dict)
async def read_file_content(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query(..., description="文件路径"),
agent_id: str | None = Query(None, description="智能体 ID"),
agent_config_id: int | None = Query(None, description="智能体配置 ID"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await read_file_content_view(
thread_id=thread_id,
path=path,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
# ==================== Viewer 文件系统接口 ====================
@viewer_filesystem_router.get("/tree", response_model=dict)
@filesystem_router.get("/tree", response_model=dict)
async def get_viewer_tree(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query("/", description="目录路径"),
@ -87,7 +39,7 @@ async def get_viewer_tree(
)
@viewer_filesystem_router.get("/file", response_model=dict)
@filesystem_router.get("/file", response_model=dict)
async def get_viewer_file(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query(..., description="文件路径"),
@ -106,7 +58,7 @@ async def get_viewer_file(
)
@viewer_filesystem_router.get("/download")
@filesystem_router.get("/download")
async def download_viewer(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query(..., description="文件路径"),

View File

@ -17,7 +17,7 @@ async def test_resume_rejects_non_dict_answer(test_client, admin_headers):
)
assert response.status_code == 422
assert "answer 必须是对象映射" in response.text
assert "Input should be a valid dictionary" in response.text
async def test_resume_rejects_empty_answer_map(test_client, admin_headers):

View File

@ -19,7 +19,8 @@ async def test_admin_can_list_agents(test_client, admin_headers):
assert response.status_code == 200, response.text
payload = response.json()
assert isinstance(payload["agents"], list)
assert "metadata" in payload
if payload["agents"]:
assert "id" in payload["agents"][0]
async def test_admin_can_read_default_agent(test_client, admin_headers):

View File

@ -1,108 +0,0 @@
"""Integration tests for filesystem router endpoints."""
from __future__ import annotations
import uuid
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
agents_resp = await test_client.get("/api/chat/agent", headers=headers)
assert agents_resp.status_code == 200, agents_resp.text
agents = agents_resp.json().get("agents", [])
if not agents:
pytest.skip("No agents available for filesystem integration tests.")
agent_id = agents[0].get("id")
if not agent_id:
pytest.skip("Agent payload missing id field.")
create_resp = await test_client.post(
"/api/chat/thread",
json={
"agent_id": agent_id,
"title": f"filesystem-test-{uuid.uuid4().hex[:8]}",
"metadata": {},
},
headers=headers,
)
assert create_resp.status_code == 200, create_resp.text
payload = create_resp.json()
thread_id = payload.get("thread_id") or payload.get("id")
assert thread_id, f"Create thread response missing thread identifier: {payload}"
return thread_id
async def test_filesystem_ls_requires_authentication(test_client):
response = await test_client.get("/api/filesystem/ls", params={"thread_id": "x", "path": "/"})
assert response.status_code == 401
async def test_filesystem_ls_root_lists_mount_namespaces(test_client, standard_user):
headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, headers)
response = await test_client.get(
"/api/filesystem/ls",
params={"thread_id": thread_id, "path": "/"},
headers=headers,
)
assert response.status_code == 200, response.text
entries = response.json().get("entries", [])
paths = {entry.get("path") for entry in entries}
assert "/home/yuxi/skills/" in paths
assert "/home/yuxi/user-data/" in paths
async def test_filesystem_ls_rejects_outside_mnt_paths(test_client, standard_user):
headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, headers)
response = await test_client.get(
"/api/filesystem/ls",
params={"thread_id": thread_id, "path": "/etc"},
headers=headers,
)
assert response.status_code == 400, response.text
detail = response.json().get("detail", "")
assert "outside" in detail or "Access denied" in detail
async def test_filesystem_cat_requires_authentication(test_client):
response = await test_client.get("/api/filesystem/cat", params={"thread_id": "x", "path": "/"})
assert response.status_code == 401
async def test_filesystem_ls_requires_thread_ownership(test_client, standard_user, admin_headers):
owner_headers = standard_user["headers"]
owner_thread_id = await _create_thread_for_user(test_client, owner_headers)
intruder_resp = await test_client.post(
"/api/auth/users",
json={"username": f"pytest_user_{uuid.uuid4().hex[:8]}", "password": "Pw!filesystem123", "role": "user"},
headers=admin_headers,
)
assert intruder_resp.status_code == 200, intruder_resp.text
intruder = intruder_resp.json()
login_resp = await test_client.post(
"/api/auth/token",
data={"username": intruder["user_id"], "password": "Pw!filesystem123"},
)
assert login_resp.status_code == 200, login_resp.text
intruder_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
try:
response = await test_client.get(
"/api/filesystem/ls",
params={"thread_id": owner_thread_id, "path": "/"},
headers=intruder_headers,
)
assert response.status_code == 404, response.text
finally:
delete_resp = await test_client.delete(f"/api/auth/users/{intruder['id']}", headers=admin_headers)
assert delete_resp.status_code == 200, delete_resp.text

View File

@ -1,184 +0,0 @@
"""lite 模式下的 sandbox 端到端验证。
流程
1. 使用账号 zwj/zwj12138 登录
2. 创建 thread 并上传附件
3. 在进程内获取 sandbox provider真实起容器并执行命令
4. HTTP filesystem API 验证 root / uploads / workspace / outputs
运行
docker compose exec api uv run python test/api/test_sandbox_e2e.py
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
import uuid
from pathlib import Path
import httpx
_root = Path(__file__).resolve().parents[2]
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
def _get_provider():
from yuxi.agents.backends import get_sandbox_provider
return get_sandbox_provider()
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5050").rstrip("/")
USERNAME = os.getenv("E2E_USERNAME", "zwj")
PASSWORD = os.getenv("E2E_PASSWORD", "zwj12138")
TIMEOUT = httpx.Timeout(180.0, connect=10.0)
class SandboxE2ETester:
def __init__(self):
self.client = httpx.AsyncClient(base_url=API_BASE_URL, timeout=TIMEOUT, follow_redirects=True)
self.headers: dict[str, str] | None = None
self.agent_id: str | None = None
self.thread_id: str | None = None
async def close(self):
await self.client.aclose()
async def login(self) -> None:
resp = await self.client.post("/api/auth/token", data={"username": USERNAME, "password": PASSWORD})
if resp.status_code != 200:
raise RuntimeError(f"login failed: {resp.status_code} {resp.text}")
self.headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def pick_agent(self) -> None:
assert self.headers
default_resp = await self.client.get("/api/chat/default_agent", headers=self.headers)
if default_resp.status_code == 200 and default_resp.json().get("default_agent_id"):
self.agent_id = str(default_resp.json()["default_agent_id"])
return
agents_resp = await self.client.get("/api/chat/agent", headers=self.headers)
if agents_resp.status_code != 200:
raise RuntimeError(f"failed to list agents: {agents_resp.status_code} {agents_resp.text}")
agents = agents_resp.json().get("agents", [])
if not agents:
raise RuntimeError("no available agents")
self.agent_id = str(agents[0]["id"])
async def create_thread(self) -> None:
assert self.headers and self.agent_id
resp = await self.client.post(
"/api/chat/thread",
json={"agent_id": self.agent_id, "title": f"sandbox-e2e-{uuid.uuid4().hex[:8]}", "metadata": {}},
headers=self.headers,
)
if resp.status_code != 200:
raise RuntimeError(f"create thread failed: {resp.status_code} {resp.text}")
payload = resp.json()
self.thread_id = str(payload.get("thread_id") or payload.get("id"))
async def upload_attachment(self, file_name: str, content: bytes) -> dict:
assert self.headers and self.thread_id
resp = await self.client.post(
f"/api/chat/thread/{self.thread_id}/attachments",
files={"file": (file_name, content, "text/plain")},
headers=self.headers,
)
if resp.status_code != 200:
raise RuntimeError(f"upload attachment failed: {resp.status_code} {resp.text}")
return resp.json()
async def ls(self, path: str) -> list[dict]:
assert self.headers and self.thread_id
resp = await self.client.get(
"/api/filesystem/ls",
params={"thread_id": self.thread_id, "path": path},
headers=self.headers,
)
if resp.status_code != 200:
raise RuntimeError(f"filesystem ls failed on {path}: {resp.status_code} {resp.text}")
entries = resp.json().get("entries")
if not isinstance(entries, list):
raise RuntimeError(f"invalid ls response: {resp.text}")
return entries
async def cat(self, path: str) -> str:
assert self.headers and self.thread_id
resp = await self.client.get(
"/api/filesystem/cat",
params={"thread_id": self.thread_id, "path": path},
headers=self.headers,
)
if resp.status_code != 200:
raise RuntimeError(f"filesystem cat failed on {path}: {resp.status_code} {resp.text}")
return str(resp.json().get("content") or "")
async def run_case(self) -> None:
await self.login()
await self.pick_agent()
await self.create_thread()
attachment_name = f"sandbox_attach_{uuid.uuid4().hex[:6]}.txt"
attachment_payload = await self.upload_attachment(attachment_name, b"attachment content for sandbox e2e")
if attachment_payload.get("status") != "parsed":
raise RuntimeError(
"uploaded attachment status is not parsed: "
+ json.dumps(attachment_payload, ensure_ascii=False)
)
assert self.thread_id
provider = _get_provider()
sandbox = await asyncio.to_thread(provider.acquire, self.thread_id)
commands = [
"mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/outputs",
"printf 'print([1, 2, 3])\\n' > /home/yuxi/user-data/workspace/bubble_sort.py",
"python /home/yuxi/user-data/workspace/bubble_sort.py > /home/yuxi/user-data/outputs/bubble_sort_result.txt",
"printf 'sandbox-ok\\n' > /home/yuxi/user-data/workspace/marker.txt",
]
for command in commands:
result = await asyncio.to_thread(sandbox.execute, command)
if result.exit_code != 0:
raise RuntimeError(f"command failed: {command}\n{result.output}")
root_paths = sorted(str(e.get("path", "")) for e in await self.ls("/"))
if root_paths != ["/home/yuxi/skills/", "/home/yuxi/user-data/"]:
raise RuntimeError("unexpected root entries: " + json.dumps(root_paths, ensure_ascii=False))
workspace_paths = {str(e.get("path", "")) for e in await self.ls("/home/yuxi/user-data/workspace")}
if "/home/yuxi/user-data/workspace/bubble_sort.py" not in workspace_paths:
raise RuntimeError("workspace file missing: " + json.dumps(sorted(workspace_paths), ensure_ascii=False))
outputs_paths = {str(e.get("path", "")) for e in await self.ls("/home/yuxi/user-data/outputs")}
result_file = "/home/yuxi/user-data/outputs/bubble_sort_result.txt"
if result_file not in outputs_paths:
raise RuntimeError("output file missing: " + json.dumps(sorted(outputs_paths), ensure_ascii=False))
attachment_base = attachment_name.rsplit(".", 1)[0].replace("/", "_").replace("\\", "_")
uploads_paths = {str(e.get("path", "")) for e in await self.ls("/home/yuxi/user-data/uploads/attachments")}
expected_attachment_path = f"/home/yuxi/user-data/uploads/attachments/{attachment_base}.md"
if expected_attachment_path not in uploads_paths:
raise RuntimeError("attachment markdown missing: " + json.dumps(sorted(uploads_paths), ensure_ascii=False))
content = await self.cat(result_file)
if "[1, 2, 3]" not in content:
raise RuntimeError(f"unexpected output file content: {content}")
print("[PASS] Sandbox E2E completed")
print(f"thread_id={self.thread_id}")
print(f"result_file={result_file}")
async def main() -> None:
tester = SandboxE2ETester()
try:
await tester.run_case()
finally:
await tester.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -11,16 +11,12 @@ pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def test_reranker_list_requires_admin(test_client, standard_user):
public_response = await test_client.get("/api/settings/rerankers")
assert public_response.status_code == 401
assert public_response.status_code == 404
forbidden_response = await test_client.get("/api/settings/rerankers", headers=standard_user["headers"])
assert forbidden_response.status_code == 403
assert forbidden_response.status_code == 404
async def test_admin_can_list_rerankers(test_client, admin_headers):
response = await test_client.get("/api/settings/rerankers", headers=admin_headers)
assert response.status_code == 200, response.text
payload = response.json()
assert "rerankers" in payload
assert isinstance(payload["rerankers"], dict)
assert response.status_code == 404, response.text

View File

@ -5,11 +5,15 @@ Integration tests for the task management router.
from __future__ import annotations
import asyncio
import os
import uuid
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
_LITE_MODE = os.getenv("LITE_MODE", "").lower() in {"true", "1"}
async def test_task_routes_require_admin(test_client, standard_user):
"""Non-admin users should be blocked from accessing task APIs."""
@ -46,50 +50,73 @@ async def test_cancel_unknown_task_returns_client_error(test_client, admin_heade
async def test_enqueue_document_creates_task(
test_client,
admin_headers,
knowledge_database,
):
"""Trigger knowledge ingestion to ensure a task record is materialised."""
db_id = knowledge_database["db_id"]
if _LITE_MODE:
enqueue_response = await test_client.post(
"/api/knowledge/databases/lite-mode-disabled/documents",
json={"items": [], "params": {"content_type": "file"}},
headers=admin_headers,
)
assert enqueue_response.status_code == 404
return
enqueue_response = await test_client.post(
f"/api/knowledge/databases/{db_id}/documents",
create_response = await test_client.post(
"/api/knowledge/databases",
json={
"items": [],
"params": {"content_type": "file"},
"database_name": f"pytest_task_router_{uuid.uuid4().hex[:8]}",
"description": "Task router integration test",
"embed_model_name": "siliconflow/BAAI/bge-m3",
"kb_type": "lightrag",
"additional_params": {},
},
headers=admin_headers,
)
assert enqueue_response.status_code == 200, enqueue_response.text
assert create_response.status_code == 200, create_response.text
db_id = create_response.json()["db_id"]
enqueue_payload = enqueue_response.json()
assert enqueue_payload.get("status") == "queued"
task_id = enqueue_payload.get("task_id")
assert task_id, "Knowledge ingestion did not return a task_id"
try:
enqueue_response = await test_client.post(
f"/api/knowledge/databases/{db_id}/documents",
json={
"items": [],
"params": {"content_type": "file"},
},
headers=admin_headers,
)
assert enqueue_response.status_code == 200, enqueue_response.text
# The task should be queryable immediately after enqueueing.
detail_response = await test_client.get(f"/api/tasks/{task_id}", headers=admin_headers)
assert detail_response.status_code == 200, detail_response.text
detail_payload = detail_response.json().get("task", {})
assert detail_payload.get("id") == task_id
assert detail_payload.get("status") in {"queued", "pending", "running", "failed", "success", "cancelled"}
enqueue_payload = enqueue_response.json()
assert enqueue_payload.get("status") == "queued"
task_id = enqueue_payload.get("task_id")
assert task_id, "Knowledge ingestion did not return a task_id"
# Ensure the task surfaces in the list endpoint within a short window.
for _ in range(10):
list_response = await test_client.get("/api/tasks", headers=admin_headers)
assert list_response.status_code == 200, list_response.text
all_tasks = list_response.json().get("tasks", [])
if any(entry.get("id") == task_id for entry in all_tasks):
break
await asyncio.sleep(0.2)
else:
pytest.fail("Task did not appear in list endpoint within timeout window")
# Poll for terminal state to validate worker bookkeeping.
for _ in range(20):
# The task should be queryable immediately after enqueueing.
detail_response = await test_client.get(f"/api/tasks/{task_id}", headers=admin_headers)
task_status = detail_response.json().get("task", {}).get("status")
if task_status in {"success", "failed", "cancelled"}:
break
await asyncio.sleep(0.5)
else:
pytest.fail("Task did not reach a terminal status within timeout window")
assert detail_response.status_code == 200, detail_response.text
detail_payload = detail_response.json().get("task", {})
assert detail_payload.get("id") == task_id
assert detail_payload.get("status") in {"queued", "pending", "running", "failed", "success", "cancelled"}
# Ensure the task surfaces in the list endpoint within a short window.
for _ in range(10):
list_response = await test_client.get("/api/tasks", headers=admin_headers)
assert list_response.status_code == 200, list_response.text
all_tasks = list_response.json().get("tasks", [])
if any(entry.get("id") == task_id for entry in all_tasks):
break
await asyncio.sleep(0.2)
else:
pytest.fail("Task did not appear in list endpoint within timeout window")
# Poll for terminal state to validate worker bookkeeping.
for _ in range(20):
detail_response = await test_client.get(f"/api/tasks/{task_id}", headers=admin_headers)
task_status = detail_response.json().get("task", {}).get("status")
if task_status in {"success", "failed", "cancelled"}:
break
await asyncio.sleep(0.5)
else:
pytest.fail("Task did not reach a terminal status within timeout window")
finally:
await test_client.delete(f"/api/knowledge/databases/{db_id}", headers=admin_headers)

View File

@ -15,10 +15,10 @@ if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
def _get_provider():
from yuxi.agents.backends import get_sandbox_provider
def _make_sandbox_backend(thread_id: str):
from yuxi.agents.backends.sandbox.backend import ProvisionerSandboxBackend
return get_sandbox_provider()
return ProvisionerSandboxBackend(thread_id=thread_id)
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5050").rstrip("/")
@ -108,46 +108,42 @@ class ViewerFilesystemE2ETester:
await self.create_thread()
assert self.thread_id
provider = _get_provider()
sandbox = await asyncio.to_thread(provider.acquire, self.thread_id)
try:
commands = [
"mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/outputs",
"printf 'print(42)\\n' > /home/yuxi/user-data/workspace/demo.py",
"printf 'root-file\\n' > /home/yuxi/user-data/root_file.txt",
"printf 'viewer-output\\n' > /home/yuxi/user-data/outputs/result.txt",
]
for command in commands:
result = await asyncio.to_thread(sandbox.execute, command)
if result.exit_code != 0:
raise RuntimeError(f"command failed: {command}\n{result.output}")
sandbox = _make_sandbox_backend(self.thread_id)
commands = [
"mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/outputs",
"printf 'print(42)\\n' > /home/yuxi/user-data/workspace/demo.py",
"printf 'root-file\\n' > /home/yuxi/user-data/root_file.txt",
"printf 'viewer-output\\n' > /home/yuxi/user-data/outputs/result.txt",
]
for command in commands:
result = await asyncio.to_thread(sandbox.execute, command)
if result.exit_code != 0:
raise RuntimeError(f"command failed: {command}\n{result.output}")
root_paths = {str(e.get("path", "")) for e in await self.tree("/")}
if "/home/yuxi/user-data/" not in root_paths:
raise RuntimeError(f"viewer root missing user-data: {sorted(root_paths)}")
root_paths = {str(e.get("path", "")) for e in await self.tree("/")}
if "/home/yuxi/user-data/" not in root_paths:
raise RuntimeError(f"viewer root missing user-data: {sorted(root_paths)}")
user_data_paths = {str(e.get("path", "")) for e in await self.tree("/home/yuxi/user-data")}
if "/home/yuxi/user-data/root_file.txt" not in user_data_paths:
raise RuntimeError(f"viewer user-data missing root_file.txt: {sorted(user_data_paths)}")
user_data_paths = {str(e.get("path", "")) for e in await self.tree("/home/yuxi/user-data")}
if "/home/yuxi/user-data/root_file.txt" not in user_data_paths:
raise RuntimeError(f"viewer user-data missing root_file.txt: {sorted(user_data_paths)}")
workspace_paths = {str(e.get("path", "")) for e in await self.tree("/home/yuxi/user-data/workspace")}
if "/home/yuxi/user-data/workspace/demo.py" not in workspace_paths:
raise RuntimeError(f"viewer workspace missing demo.py: {sorted(workspace_paths)}")
workspace_paths = {str(e.get("path", "")) for e in await self.tree("/home/yuxi/user-data/workspace")}
if "/home/yuxi/user-data/workspace/demo.py" not in workspace_paths:
raise RuntimeError(f"viewer workspace missing demo.py: {sorted(workspace_paths)}")
content = await self.file("/home/yuxi/user-data/workspace/demo.py")
if content != "print(42)\n":
raise RuntimeError(f"unexpected viewer file content: {content!r}")
content = await self.file("/home/yuxi/user-data/workspace/demo.py")
if content != "print(42)\n":
raise RuntimeError(f"unexpected viewer file content: {content!r}")
content_disposition, payload = await self.download("/home/yuxi/user-data/outputs/result.txt")
if "result.txt" not in content_disposition:
raise RuntimeError(f"unexpected content-disposition: {content_disposition}")
if payload != b"viewer-output\n":
raise RuntimeError(f"unexpected download payload: {payload!r}")
content_disposition, payload = await self.download("/home/yuxi/user-data/outputs/result.txt")
if "result.txt" not in content_disposition:
raise RuntimeError(f"unexpected content-disposition: {content_disposition}")
if payload != b"viewer-output\n":
raise RuntimeError(f"unexpected download payload: {payload!r}")
print("[PASS] Viewer filesystem E2E completed")
print(f"thread_id={self.thread_id}")
finally:
await asyncio.to_thread(provider.destroy, self.thread_id)
print("[PASS] Viewer filesystem E2E completed")
print(f"thread_id={self.thread_id}")
async def main() -> None:

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import uuid
import pytest
@ -43,19 +42,18 @@ async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
return thread_id
async def _write_workspace_file(thread_id: str, file_path: str, content: str) -> None:
provider = _get_provider()
sandbox = await asyncio.to_thread(provider.acquire, thread_id)
try:
command = (
"python3 -c "
f"\"from pathlib import Path; Path('{file_path}').parent.mkdir(parents=True, exist_ok=True); "
f"Path('{file_path}').write_text({content!r}, encoding='utf-8')\""
)
result = await asyncio.to_thread(sandbox.execute, command)
assert result.exit_code == 0, result.output
finally:
await asyncio.to_thread(provider.destroy, thread_id)
async def _upload_attachment_file(test_client, thread_id: str, headers: dict[str, str], file_name: str, content: str) -> str:
response = await test_client.post(
f"/api/chat/thread/{thread_id}/attachments",
files={"file": (file_name, content.encode("utf-8"), "text/plain")},
headers=headers,
)
assert response.status_code == 200, response.text
payload = response.json()
path = payload.get("path") or payload.get("file_path")
assert isinstance(path, str) and path
return path
async def test_viewer_tree_requires_authentication(test_client):
@ -82,8 +80,7 @@ async def test_viewer_tree_root_lists_user_data_namespace(test_client, standard_
async def test_viewer_file_returns_raw_content_without_line_numbers(test_client, standard_user):
headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, headers)
file_path = "/home/yuxi/user-data/workspace/viewer_demo.txt"
await _write_workspace_file(thread_id, file_path, "alpha\\nbeta\\n")
file_path = await _upload_attachment_file(test_client, thread_id, headers, "viewer_demo.txt", "alpha\nbeta\n")
response = await test_client.get(
"/api/viewer/filesystem/file",
@ -91,14 +88,14 @@ async def test_viewer_file_returns_raw_content_without_line_numbers(test_client,
headers=headers,
)
assert response.status_code == 200, response.text
assert response.json()["content"] == "alpha\nbeta\n"
assert "alpha" in response.json()["content"]
assert "beta" in response.json()["content"]
async def test_viewer_download_returns_attachment_response(test_client, standard_user):
headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, headers)
file_path = "/home/yuxi/user-data/workspace/download_demo.txt"
await _write_workspace_file(thread_id, file_path, "download-me\\n")
file_path = await _upload_attachment_file(test_client, thread_id, headers, "download_demo.txt", "download-me\n")
response = await test_client.get(
"/api/viewer/filesystem/download",
@ -108,5 +105,5 @@ async def test_viewer_download_returns_attachment_response(test_client, standard
assert response.status_code == 200, response.text
content_disposition = response.headers.get("content-disposition", "")
assert "attachment;" in content_disposition
assert "download_demo.txt" in content_disposition
assert response.text == "download-me\n"
assert "download_demo" in content_disposition
assert "download-me" in response.text

View File

@ -5,6 +5,7 @@ from types import SimpleNamespace
import pytest
from yuxi.services import chat_stream_service as chat_svc
from yuxi.services import conversation_service as svc
@ -41,7 +42,7 @@ def test_build_state_files_only_parsed_and_with_content():
},
]
files = svc._build_state_files(attachments)
files = chat_svc._build_state_files(attachments)
assert list(files.keys()) == ["/attachments/a.md"]
assert files["/attachments/a.md"]["content"] == ["line1", "line2"]
@ -51,20 +52,8 @@ def test_build_state_files_only_parsed_and_with_content():
@pytest.mark.asyncio
async def test_sync_thread_attachment_state_updates_graph(monkeypatch: pytest.MonkeyPatch):
captured: dict = {}
fake_state = SimpleNamespace(
values={
"files": {
"/attachments/old.md": {"content": ["old"]},
"/work/result.md": {"content": ["keep"]},
}
}
)
class FakeGraph:
async def aget_state(self, config):
captured["read_config"] = config
return fake_state
async def aupdate_state(self, *, config, values):
captured["write_config"] = config
captured["write_values"] = values
@ -78,24 +67,20 @@ async def test_sync_thread_attachment_state_updates_graph(monkeypatch: pytest.Mo
attachments = [
{
"status": "parsed",
"file_path": "/attachments/resume.md",
"markdown": "hello\nworld",
"path": "/home/yuxi/user-data/uploads/attachments/resume.md",
"file_name": "resume.md",
"uploaded_at": "2026-02-20T00:00:00+00:00",
}
]
await svc._sync_thread_attachment_state(
await svc._sync_thread_upload_state(
thread_id="thread-1",
user_id="u1",
agent_id="ChatbotAgent",
attachments=attachments,
)
assert captured["read_config"] == {"configurable": {"thread_id": "thread-1", "user_id": "u1"}}
assert captured["write_config"] == {"configurable": {"thread_id": "thread-1", "user_id": "u1"}}
assert captured["write_values"]["attachments"] == attachments
assert "/attachments/resume.md" in captured["write_values"]["files"]
assert captured["write_values"]["files"]["/attachments/old.md"] is None
assert "/work/result.md" not in captured["write_values"]["files"]
assert captured["write_values"] == {"uploads": svc._build_state_uploads(attachments)}
@pytest.mark.asyncio
@ -108,7 +93,7 @@ async def test_sync_thread_attachment_state_skips_when_agent_missing(monkeypatch
monkeypatch.setattr(svc, "logger", fake_logger)
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda _agent_id: None)
await svc._sync_thread_attachment_state(
await svc._sync_thread_upload_state(
thread_id="thread-1",
user_id="u1",
agent_id="MissingAgent",

View File

@ -2,241 +2,65 @@
from __future__ import annotations
import asyncio
import threading
from pathlib import Path
from types import MethodType
from types import MethodType, SimpleNamespace
import pytest
from deepagents.backends.protocol import ExecuteResponse, FileInfo
from yuxi.agents.backends.sandbox import (
RemoteSandboxBackend,
SandboxInfo,
YuxiSandboxBackend,
YuxiSandboxProvider,
get_sandbox_security_opts,
normalize_virtual_path,
)
from yuxi.agents.backends.composite import create_agent_composite_backend
from yuxi.agents.backends.sandbox import resolve_virtual_path, sandbox_id_for_thread
from yuxi.agents.backends.sandbox.backend import ProvisionerSandboxBackend
from yuxi.agents.backends.sandbox import sandbox_remote as remote_sandbox_backend
from yuxi.agents.backends.composite import (
create_agent_composite_backend,
resolve_sandbox_backend,
resolve_sandbox_backend_async,
)
from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware
# ==================== Provider lifecycle tests ====================
def test_create_agent_composite_backend_uses_sandbox_default():
runtime = type("RuntimeStub", (), {"context": None})()
sandbox_backend = object()
backend = create_agent_composite_backend(runtime, sandbox_backend=sandbox_backend)
assert backend.default is sandbox_backend
def test_resolve_sandbox_backend_returns_none_without_thread_id():
assert resolve_sandbox_backend("") is None
def test_resolve_sandbox_backend_returns_none_on_provider_error(monkeypatch):
provider = type("ProviderStub", (), {"acquire": lambda self, thread_id: (_ for _ in ()).throw(RuntimeError)})()
monkeypatch.setattr("yuxi.agents.backends.composite.get_sandbox_provider", lambda: provider)
assert resolve_sandbox_backend("thread-1") is None
def test_resolve_sandbox_backend_async_uses_provider(monkeypatch):
backend = object()
provider = type("ProviderStub", (), {"acquire": lambda self, thread_id: backend})()
monkeypatch.setattr("yuxi.agents.backends.composite.get_sandbox_provider", lambda: provider)
assert asyncio.run(resolve_sandbox_backend_async("thread-1")) is backend
def test_destroy_stops_warm_pool_container_after_release(monkeypatch):
provider = object.__new__(YuxiSandboxProvider)
provider._lock = threading.Lock()
provider._sandboxes = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._thread_locks = {}
provider._last_activity = {}
sandbox_key = "sandbox-1"
thread_id = "thread-1"
info = SandboxInfo(
sandbox_id=sandbox_key,
container_name="container-1",
container_id="cid-1",
sandbox_url="http://sandbox",
)
provider._warm_pool = {sandbox_key: (info, 0.0)}
stopped = []
provider._backend = type(
"BackendStub",
(),
{"destroy": lambda self, sandbox_info: stopped.append(sandbox_info.container_id)},
)()
monkeypatch.setattr(provider, "_deterministic_sandbox_id", lambda _: sandbox_key)
provider.destroy(thread_id)
assert stopped == ["cid-1"]
assert sandbox_key not in provider._warm_pool
def test_resolve_mount_source_maps_app_paths_under_host_project_dir(monkeypatch):
monkeypatch.setenv("YUXI_HOST_PROJECT_DIR", "/host/project")
resolved = YuxiSandboxProvider._resolve_mount_source(Path("/app/saves/threads/t1/user-data"))
assert resolved == "/host/project/saves/threads/t1/user-data"
def test_remote_sandbox_backend_rejects_non_http_urls():
with pytest.raises(ValueError):
RemoteSandboxBackend("file:///tmp/provisioner")
def test_remote_sandbox_backend_destroy_logs_request_errors(monkeypatch):
backend = RemoteSandboxBackend("https://provisioner.example.com")
info = SandboxInfo(sandbox_id="sandbox-1", sandbox_url="https://sandbox.example.com")
warnings = []
def fake_delete(*args, **kwargs):
raise remote_sandbox_backend.requests.RequestException("boom")
monkeypatch.setattr(remote_sandbox_backend.requests, "delete", fake_delete)
monkeypatch.setattr(remote_sandbox_backend.logger, "warning", warnings.append)
backend.destroy(info)
assert warnings
# ==================== Path compatibility tests ====================
def test_normalize_virtual_path_supports_legacy_skills_alias() -> None:
assert normalize_virtual_path("/skills", "t-1") == "/home/yuxi/skills"
assert normalize_virtual_path("/skills/demo/SKILL.md", "t-1") == "/home/yuxi/skills/demo/SKILL.md"
def test_normalize_virtual_path_supports_attachments_alias() -> None:
assert normalize_virtual_path("/attachments", "thread-1") == "/home/yuxi/user-data/uploads/attachments"
assert normalize_virtual_path("/attachments/a.md", "thread-1") == "/home/yuxi/user-data/uploads/attachments/a.md"
def test_normalize_virtual_path_supports_thread_scoped_aliases() -> None:
assert (
normalize_virtual_path("/outputs/thread-1/result.txt", "thread-1")
== "/home/yuxi/user-data/outputs/result.txt"
)
assert normalize_virtual_path("/uploads/thread-1/demo.txt", "thread-1") == "/home/yuxi/user-data/uploads/demo.txt"
assert (
normalize_virtual_path("/large_tool_results/thread-1/result.json", "thread-1")
== "/home/yuxi/user-data/large_tool_results/result.json"
def _runtime(*, thread_id: str | None = "thread-1", skills: list[str] | None = None):
configurable = {"thread_id": thread_id} if thread_id else {}
return SimpleNamespace(
config={"configurable": configurable},
context=SimpleNamespace(skills=skills or []),
)
def test_skills_middleware_extracts_slug_for_new_and_legacy_paths() -> None:
def test_create_agent_composite_backend_uses_provisioner_default(monkeypatch):
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = create_agent_composite_backend(_runtime(skills=["reporter"]))
assert isinstance(backend.default, ProvisionerSandboxBackend)
assert "/skills/" in backend.routes
def test_create_agent_composite_backend_requires_thread_id():
with pytest.raises(ValueError, match="thread_id is required"):
create_agent_composite_backend(_runtime(thread_id=None))
def test_skills_middleware_extracts_slug_for_new_paths() -> None:
middleware = SkillsMiddleware()
assert middleware.skills_sources_for_prompt == ["/home/yuxi/skills/"]
assert middleware._extract_skill_slug_from_skill_md_path("/home/yuxi/skills/demo-skill/SKILL.md") == "demo-skill"
assert not hasattr(middleware, "_dependency_map_cache")
assert not hasattr(middleware, "_prompt_metadata_cache")
def test_get_sandbox_security_opts_supports_empty_and_multiple_values(monkeypatch) -> None:
monkeypatch.setenv("YUXI_SANDBOX_SECURITY_OPTS", "")
assert get_sandbox_security_opts() == []
monkeypatch.setenv("YUXI_SANDBOX_SECURITY_OPTS", "seccomp=unconfined,apparmor=unconfined")
assert get_sandbox_security_opts() == ["seccomp=unconfined", "apparmor=unconfined"]
def test_resolve_virtual_path_rejects_outside_prefix():
with pytest.raises(ValueError, match="path must start with"):
resolve_virtual_path("thread-1", "/etc/passwd")
def test_sandbox_glob_info_rebuilds_missing_path(monkeypatch) -> None:
backend = YuxiSandboxBackend(
sandbox_key="s-1",
container_name="dummy",
thread_id="t-1",
host_user_data_dir=Path("/tmp"),
)
def _fake_scan_dir_info(self, path: str):
if path.endswith("outputs"):
return [FileInfo(path=f"{path}/result.txt", is_dir=False, size=3, modified_at="")]
return []
backend._scan_dir_info = MethodType(_fake_scan_dir_info, backend)
infos = backend.glob_info("*.txt", "/home/yuxi/user-data/outputs")
assert infos
assert "path" in infos[0]
assert infos[0]["path"].endswith("/result.txt")
def test_resolve_virtual_path_rejects_path_traversal():
with pytest.raises(ValueError, match="path traversal"):
resolve_virtual_path("thread-1", "/home/yuxi/user-data/../secrets")
def test_sandbox_ls_info_ignores_malformed_json_lines() -> None:
backend = YuxiSandboxBackend(
sandbox_key="s-1",
container_name="dummy",
thread_id="t-1",
host_user_data_dir=Path("/tmp"),
)
def _fake_execute(self, command: str, *, timeout: int = 60):
return ExecuteResponse(
output="\n".join(
[
'{"path": "/home/yuxi/user-data/workspace/demo.txt", "is_dir": false, "size": 3, "modified_at": ""}',
'{"is_dir": true}',
'not-json',
]
),
exit_code=0,
truncated=False,
)
backend.execute = MethodType(_fake_execute, backend)
infos = backend.ls_info("/home/yuxi/user-data/workspace")
assert len(infos) == 1
assert infos[0]["path"] == "/home/yuxi/user-data/workspace/demo.txt"
def test_sandbox_user_data_root_lists_extra_files() -> None:
backend = YuxiSandboxBackend(
sandbox_key="s-1",
container_name="dummy",
thread_id="t-1",
host_user_data_dir=Path("/tmp"),
)
def _fake_scan_dir_info(self, path: str):
if path == "/home/yuxi/user-data":
return [
FileInfo(path="/home/yuxi/user-data/workspace", is_dir=True, size=0, modified_at=""),
FileInfo(path="/home/yuxi/user-data/uploads", is_dir=True, size=0, modified_at=""),
FileInfo(path="/home/yuxi/user-data/outputs", is_dir=True, size=0, modified_at=""),
FileInfo(path="/home/yuxi/user-data/bubble_sort.py", is_dir=False, size=24, modified_at=""),
]
return []
backend._scan_dir_info = MethodType(_fake_scan_dir_info, backend)
infos = backend.ls_info("/home/yuxi/user-data")
paths = {item["path"] for item in infos}
assert "/home/yuxi/user-data/workspace" in paths
assert "/home/yuxi/user-data/uploads" in paths
assert "/home/yuxi/user-data/outputs" in paths
assert "/home/yuxi/user-data/bubble_sort.py" in paths
def test_sandbox_id_for_thread_is_stable():
sid1 = sandbox_id_for_thread("thread-1")
sid2 = sandbox_id_for_thread("thread-1")
sid3 = sandbox_id_for_thread("thread-2")
assert sid1 == sid2
assert sid1 != sid3
assert len(sid1) == 12
def test_provisioner_read_reports_binary_files(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1")
monkeypatch.setattr(backend, "_read_binary", lambda path, offset=0, limit=None: b"\x89PNG\r\n\x1a\n")
@ -246,6 +70,7 @@ def test_provisioner_read_reports_binary_files(monkeypatch) -> None:
def test_provisioner_read_reports_invalid_path(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1")
def _raise_invalid_path(path, offset=0, limit=None):
@ -259,11 +84,10 @@ def test_provisioner_read_reports_invalid_path(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())
backend = ProvisionerSandboxBackend(thread_id="thread-1")
calls: list[str] = []
def _fake_read_binary(path, offset=0, limit=None):
calls.append(path)
if path == "/bad-path":
raise ValueError("path is required")
raise RuntimeError("sandbox read timeout")
@ -273,4 +97,21 @@ def test_provisioner_download_files_distinguishes_invalid_path_from_read_failure
responses = backend.download_files(["/bad-path", "/read-failed"])
assert responses[0].error == "invalid_path"
assert responses[1].error == "read_failed"
assert responses[1].error.startswith("read_failed")
def test_provisioner_execute_returns_error_response_on_client_failure(monkeypatch) -> None:
monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object())
backend = ProvisionerSandboxBackend(thread_id="thread-1")
class _FakeClient:
class shell:
@staticmethod
def exec_command(**kwargs):
raise RuntimeError("boom")
backend._get_client = MethodType(lambda self: _FakeClient(), backend)
result = backend.execute("echo hi")
assert result.exit_code == 1
assert "Error:" in result.output

View File

@ -375,7 +375,8 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p
assert created["created_by"] == svc.BUILTIN_SKILL_OPERATOR
target_dir = tmp_path / "skills" / "reporter"
assert target_dir.is_symlink()
assert target_dir.exists()
assert target_dir.is_dir()
assert (target_dir / "SKILL.md").exists()
@ -468,7 +469,8 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke
await svc.init_builtin_skills(None, created_by="release-bot")
target_dir = tmp_path / "skills" / "reporter"
assert target_dir.is_symlink()
assert target_dir.exists()
assert target_dir.is_dir()
assert captured["description"] == "new description"
assert captured["tool_dependencies"] == ["mysql_query"]
assert captured["mcp_dependencies"] == ["charts"]

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from deepagents.backends import FilesystemBackend
import pytest
from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend
@ -12,8 +13,8 @@ def test_skills_backend_read_outside_root_returns_error_message(monkeypatch) ->
monkeypatch.setattr(FilesystemBackend, "read", _fake_read)
backend = SelectedSkillsReadonlyBackend(selected_slugs=["reporter"])
result = backend.read("/reporter/SKILL.md")
assert result.startswith("Error: Path:/app/package/yuxi/agents/skills/buildin/reporter outside root directory")
with pytest.raises(ValueError, match="outside root directory"):
backend.read("/reporter/SKILL.md")
def test_skills_backend_ls_info_outside_root_returns_empty(monkeypatch) -> None:
@ -23,4 +24,5 @@ def test_skills_backend_ls_info_outside_root_returns_empty(monkeypatch) -> None:
monkeypatch.setattr(FilesystemBackend, "ls_info", _fake_ls_info)
backend = SelectedSkillsReadonlyBackend(selected_slugs=["reporter"])
assert backend.ls_info("/reporter") == []
with pytest.raises(ValueError, match="outside root directory"):
backend.ls_info("/reporter")

View File

@ -42,6 +42,7 @@
- 将 后端代码 和 agents 解耦agents 作为单独的 package 使用
- 添加 subagents 的支持,支持在 web 中添加 subagents
- 将内置 skills 的初始化从符号链接改为复制,以兼容沙盒绑定场景
- 修复并统一 lite 模式下的测试脚本:更新 sandbox/provider 接口适配、viewer 文件系统测试数据构造方式、conversation/upload 状态断言与路由存在性断言,确保“非数据库场景”下核心 pytest 与 E2E 脚本可稳定执行
## v0.5