mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
a16cdf3ab1
## 文件工具可读任意文件(含加密主密钥)
sandbox_session 从请求体直接拼进工作区路径,没有任何净化。传 ".." 就能把
根目录挪出工作区,之后 _resolve 的越界检查是在这个被挪走的根下做的,等于失效:
curl -X POST /api/tools/file_read/run -d '{"sandbox_session":"..","args":{"path":".secret_key"}}'
→ {"ok":true,"content":"8UygDbx9..."} # 加密所有 provider Key 的 Fernet 主密钥
根因是同一段净化逻辑被抄了五遍:四个沙箱后端各一份,文件工具那份漏抄了。
所以提到 config.sanitize_session 统一一份,五处共用;RunToolIn.sandbox_session
再加 pattern 做纵深防御——畸形 session 直接 422,而不是悄悄净化成 default。
## 并发代码节点互相覆盖入口脚本
同一 run 的节点共用一个沙箱会话,而入口脚本写死 main.py。README 主打的
"连线即并行"会让并排的代码节点在同一 superstep 并发执行,都写 main.py 就
互相覆盖——后写的赢,每个节点却都拿到 ok=True 和"自己的"返回值。
8 路并发同 session:7/8 执行了别人的代码,error 全为 None
改成每次执行生成唯一入口脚本(__entry_<uuid>.py),跑完即删。共享工作区语义
保留,跨节点传文件照常。四个后端同构改造,实测 8/16 路并发均 0 串味。
## 顺带修掉两个自己埋的坑
- conda 迁移把沙箱解释器隔离弄丢了:conda 环境 sys.prefix == sys.base_prefix,
于是"干净解释器"挑出来的就是项目环境自己,沙箱代码能 import cryptography
直接解密落库的 Key。改成实际探测候选解释器是否看得见敏感包,都不干净就
退回 -S 关掉 site-packages。README 那句承诺现在重新成立了。
- -I 在 3.11+ 隐含 -P,脚本目录不进 sys.path,代码节点的 files 参数(多文件)
在 seatbelt/bubblewrap 下一直是坏的,import helper 直接 ModuleNotFoundError。
换成 -s -E,隔离意图不变,同目录 import 恢复。
- microvm 的 files_written 取 FsEntry.name(不存在),塞进去的是对象 repr。
改取 path 的尾段。
沙箱回归 16 项通过(两档各 8 项)。
259 lines
8.8 KiB
Python
259 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.base import get_session
|
|
from app.db.models import CustomTool, McpServer
|
|
from app.tools.custom import run_custom_tool
|
|
from app.tools.mcp_manager import mcp_manager
|
|
from app.tools.registry import ToolContext, all_specs, call_tool
|
|
|
|
router = APIRouter(prefix="/api/tools", tags=["tools"])
|
|
|
|
|
|
@router.get("")
|
|
async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
|
|
"""把内置、自定义、MCP 三类工具拉平成一个列表,前端节点面板直接用。"""
|
|
out: list[dict[str, Any]] = []
|
|
for name, spec in sorted(all_specs().items()):
|
|
out.append(
|
|
{
|
|
"id": name,
|
|
"name": name,
|
|
"description": spec.description,
|
|
"category": spec.category,
|
|
"source": "builtin",
|
|
"dangerous": spec.dangerous,
|
|
"schema": spec.json_schema(),
|
|
}
|
|
)
|
|
|
|
rows = (
|
|
await session.execute(select(CustomTool).where(CustomTool.enabled.is_(True)))
|
|
).scalars()
|
|
for row in rows:
|
|
out.append(
|
|
{
|
|
"id": row.name,
|
|
"name": row.name,
|
|
"description": row.description,
|
|
"category": f"自定义 · {row.kind}",
|
|
"source": "custom",
|
|
"dangerous": True,
|
|
"schema": row.parameters or {},
|
|
}
|
|
)
|
|
|
|
try:
|
|
out.extend({**t, "source": "mcp", "dangerous": True} for t in await mcp_manager.list_tools())
|
|
except Exception: # noqa: BLE001 - MCP 连不上不该让整个工具列表挂掉
|
|
pass
|
|
return out
|
|
|
|
|
|
class RunToolIn(BaseModel):
|
|
args: dict[str, Any] = Field(default_factory=dict)
|
|
# 这个字段会被拼进工作目录路径。净化在 sanitize_session 里兜底,这里
|
|
# 再加一道模式校验做纵深防御——畸形的 session 直接 422 拒掉,
|
|
# 而不是悄悄被净化成 "default" 让调用方以为自己写对了。
|
|
sandbox_session: str = Field(default="playground", pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
|
|
|
|
|
@router.post("/{tool_name:path}/run")
|
|
async def run_tool(
|
|
tool_name: str, payload: RunToolIn, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
"""直接试跑一个工具。工具面板上的"试一下"用它,不用为了测工具去搭一张图。"""
|
|
ctx = ToolContext(run_id="playground", node_id="playground",
|
|
sandbox_session=payload.sandbox_session)
|
|
import time
|
|
|
|
started = time.perf_counter()
|
|
try:
|
|
result = await call_tool(tool_name, payload.args, ctx, session=session)
|
|
except KeyError as e:
|
|
raise HTTPException(404, str(e)) from e
|
|
except Exception as e: # noqa: BLE001
|
|
return {
|
|
"ok": False,
|
|
"error": f"{type(e).__name__}: {e}",
|
|
"duration_ms": int((time.perf_counter() - started) * 1000),
|
|
}
|
|
return {
|
|
"ok": True,
|
|
"result": result,
|
|
"duration_ms": int((time.perf_counter() - started) * 1000),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 自定义工具
|
|
# --------------------------------------------------------------------------
|
|
|
|
custom_router = APIRouter(prefix="/api/custom-tools", tags=["tools"])
|
|
|
|
|
|
class CustomToolIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
description: str = ""
|
|
kind: str = "http" # http | python
|
|
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
enabled: bool = True
|
|
|
|
|
|
class CustomToolOut(CustomToolIn):
|
|
id: str
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@custom_router.get("", response_model=list[CustomToolOut])
|
|
async def list_custom(session: AsyncSession = Depends(get_session)) -> list[CustomTool]:
|
|
return list((await session.execute(select(CustomTool))).scalars())
|
|
|
|
|
|
@custom_router.post("", response_model=CustomToolOut, status_code=201)
|
|
async def create_custom(
|
|
payload: CustomToolIn, session: AsyncSession = Depends(get_session)
|
|
) -> CustomTool:
|
|
if (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none():
|
|
raise HTTPException(409, "同名工具已存在")
|
|
if payload.name in all_specs():
|
|
raise HTTPException(409, "这个名字和内置工具冲突")
|
|
row = CustomTool(**payload.model_dump())
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
@custom_router.patch("/{tool_id}", response_model=CustomToolOut)
|
|
async def update_custom(
|
|
tool_id: str, payload: CustomToolIn, session: AsyncSession = Depends(get_session)
|
|
) -> CustomTool:
|
|
row = await session.get(CustomTool, tool_id)
|
|
if not row:
|
|
raise HTTPException(404, "工具不存在")
|
|
for key, value in payload.model_dump().items():
|
|
setattr(row, key, value)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
@custom_router.delete("/{tool_id}", status_code=204)
|
|
async def delete_custom(tool_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
|
row = await session.get(CustomTool, tool_id)
|
|
if not row:
|
|
raise HTTPException(404, "工具不存在")
|
|
await session.delete(row)
|
|
await session.commit()
|
|
|
|
|
|
@custom_router.post("/{tool_id}/test")
|
|
async def test_custom(
|
|
tool_id: str, payload: RunToolIn, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
row = await session.get(CustomTool, tool_id)
|
|
if not row:
|
|
raise HTTPException(404, "工具不存在")
|
|
ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground")
|
|
try:
|
|
result = await run_custom_tool(session, row.name, payload.args, ctx)
|
|
except Exception as e: # noqa: BLE001
|
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
return {"ok": True, "result": result}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# MCP
|
|
# --------------------------------------------------------------------------
|
|
|
|
mcp_router = APIRouter(prefix="/api/mcp", tags=["tools"])
|
|
|
|
|
|
class McpIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=100)
|
|
transport: str = "stdio" # stdio | http
|
|
command: str | None = None
|
|
args: list[str] = Field(default_factory=list)
|
|
env: dict[str, str] = Field(default_factory=dict)
|
|
url: str | None = None
|
|
enabled: bool = True
|
|
|
|
|
|
class McpOut(McpIn):
|
|
id: str
|
|
status: str
|
|
last_error: str | None
|
|
tools_cache: list[Any]
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@mcp_router.get("/servers", response_model=list[McpOut])
|
|
async def list_servers(session: AsyncSession = Depends(get_session)) -> list[McpServer]:
|
|
return list((await session.execute(select(McpServer))).scalars())
|
|
|
|
|
|
@mcp_router.post("/servers", response_model=McpOut, status_code=201)
|
|
async def create_server(
|
|
payload: McpIn, session: AsyncSession = Depends(get_session)
|
|
) -> McpServer:
|
|
if (await session.execute(select(McpServer).where(McpServer.name == payload.name))).scalar_one_or_none():
|
|
raise HTTPException(409, "同名 MCP server 已存在")
|
|
row = McpServer(**payload.model_dump())
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
@mcp_router.patch("/servers/{server_id}", response_model=McpOut)
|
|
async def update_server(
|
|
server_id: str, payload: McpIn, session: AsyncSession = Depends(get_session)
|
|
) -> McpServer:
|
|
row = await session.get(McpServer, server_id)
|
|
if not row:
|
|
raise HTTPException(404, "MCP server 不存在")
|
|
for key, value in payload.model_dump().items():
|
|
setattr(row, key, value)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
@mcp_router.delete("/servers/{server_id}", status_code=204)
|
|
async def delete_server(server_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
|
row = await session.get(McpServer, server_id)
|
|
if not row:
|
|
raise HTTPException(404, "MCP server 不存在")
|
|
await session.delete(row)
|
|
await session.commit()
|
|
|
|
|
|
@mcp_router.post("/servers/{server_id}/probe")
|
|
async def probe_server(
|
|
server_id: str, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
row = await session.get(McpServer, server_id)
|
|
if not row:
|
|
raise HTTPException(404, "MCP server 不存在")
|
|
result = await mcp_manager.probe(row)
|
|
row.status = "ok" if result.get("ok") else "error"
|
|
row.last_error = result.get("error")
|
|
row.tools_cache = [t["name"] for t in result.get("tools", [])]
|
|
await session.commit()
|
|
return result
|
|
|
|
|
|
@mcp_router.post("/refresh")
|
|
async def refresh_mcp() -> dict[str, Any]:
|
|
return {"servers": await mcp_manager.refresh()}
|