Files
chenxin 99a7789432 ✨ feat: Sandbox Runtime——isolated 插件子进程隔离执行(WP-06/07 收口)
- 迁移 17:plugin_execution(五态、退出码、时长、stdout 摘要、错误留痕)
- 沙箱执行器:独立子进程(代码经 stdin JSON 安全传输,不进命令行)、CPU rlimit + RLIMIT_AS(平台不支持时尽力而为并注明)、墙钟超时强杀、输出限额;runner 仅暴露 input/output 作用域
- 执行端点:isolated_process 形态、已安装且启用后可执行;**未启用沙箱一律 503 fail-closed**(HUNJING_SANDBOX_ENABLED 显式开启,审计记录拒绝原因)——AGENTS 第 6 节"无符合策略的 Sandbox 时拒绝执行"落地
- 每次执行落 plugin_execution + 审计(状态/时长);进程级隔离的边界如实注明(网络/文件系统最小化在 Linux 容器档交付)
- 测试 96 项全绿(+2):echo 真实执行回填、死循环 5s 超时强杀、异常 failed 含 stderr、无沙箱 503
2026-09-12 13:38:41 +08:00

171 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件域路由(WP-07)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import Plugin, PluginVersion, User
from app.plugins import service
router = APIRouter(prefix="/v1/plugins", tags=["plugins"])
def _plugin_out(p: Plugin) -> dict[str, Any]:
return {
"name": p.name, "kind": p.kind, "status": p.status,
"publisher_domain_id": p.publisher_domain_id,
}
def _version_out(v: PluginVersion) -> dict[str, Any]:
return {
"id": v.id, "plugin_name": v.plugin_name, "version": v.version,
"manifest": v.manifest, "content_hash": v.content_hash, "status": v.status,
}
def _installation_out(i: Any) -> dict[str, Any]:
return {
"id": i.id, "plugin_name": i.plugin_name, "version_id": i.version_id,
"enabled": i.enabled, "granted_permissions": i.granted_permissions,
}
@router.post("/versions", status_code=201)
async def publish(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
_, pv = await service.publish_version(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _version_out(pv)
@router.get("")
async def list_plugins(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
rows = list(
(
await db.scalars(
select(Plugin).where(Plugin.status == "published").limit(50)
)
).all()
)
return {"data": [_plugin_out(p) for p in rows]}
@router.post("/{name}/withdrawals")
async def withdraw(
name: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
plugin = await service.withdraw(
db, domain.id, name, getattr(request.state, "trace_id", None)
)
return _plugin_out(plugin)
@router.post("/{name}/installations", status_code=201)
async def install(
name: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
installation = await service.install(
db, domain.id, name, body.get("version", ""),
getattr(request.state, "trace_id", None),
)
return _installation_out(installation)
inst_router = APIRouter(prefix="/v1/plugin-installations", tags=["plugins"])
@inst_router.post("/{installation_id}/enablings", status_code=201)
async def enable(
installation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
installation = await service.enable(
db, domain.id, installation_id, body.get("granted_permissions", []),
getattr(request.state, "trace_id", None),
)
return _installation_out(installation)
@inst_router.post("/{installation_id}/upgrades", status_code=201)
async def upgrade(
installation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
installation = await service.upgrade(
db, domain.id, installation_id, body.get("version", ""),
body.get("granted_permissions"), getattr(request.state, "trace_id", None),
)
return _installation_out(installation)
invoke_router = APIRouter(prefix="/v1/plugins", tags=["plugins"])
@invoke_router.post("/{name}/invocations")
async def invoke(
name: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
return await service.invoke(
db, domain.id, name, body.get("input", {}), body.get("run_id"),
getattr(request.state, "trace_id", None),
)
@inst_router.post("/{installation_id}/executions", status_code=201)
async def execute_plugin(
installation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
"""沙箱执行 isolated 插件:未启用沙箱一律 503(fail-closed)。"""
domain = await _domain_of(db, user)
from app.core.config import get_settings
execution, result = await service.execute_isolated(
db, domain.id, installation_id, body.get("input", {}),
sandbox_enabled=get_settings().sandbox_enabled,
trace_id=getattr(request.state, "trace_id", None),
)
return {
"execution_id": execution.id, "status": result["status"],
"duration_ms": result.get("duration_ms"),
"output": result.get("output"), "error": result.get("error"),
}