初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,475 @@
|
||||
"""沙箱客户端:封装对 Rust 沙箱服务的调用,含司命授权签发与一炁事件回写。
|
||||
|
||||
使用约束(见 backend/CLAUDE.md):禁止在调用处裸 ``async with httpx.AsyncClient()``,
|
||||
应复用注入的 ``AsyncClient``(长生命周期,由上层依赖注入管理)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NoReturn, TypeVar
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.auth import AuthorizationContext, sign_authorization
|
||||
from app.integrations.sandbox.events import (
|
||||
EventSink,
|
||||
LoggingEventSink,
|
||||
completed_event,
|
||||
failed_event,
|
||||
started_event,
|
||||
)
|
||||
from app.integrations.sandbox.exceptions import (
|
||||
SandboxBadRequest,
|
||||
SandboxError,
|
||||
SandboxExecFailed,
|
||||
SandboxNotFound,
|
||||
SandboxUnauthorized,
|
||||
SandboxUnavailable,
|
||||
)
|
||||
from app.integrations.sandbox.models import (
|
||||
CancelResponse,
|
||||
ExecRequest,
|
||||
ExecResponse,
|
||||
FileListRequest,
|
||||
FileListResponse,
|
||||
FileReadRequest,
|
||||
FileReadResponse,
|
||||
FileStatRequest,
|
||||
FileStatResponse,
|
||||
FileWriteRequest,
|
||||
FileWriteResponse,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T", bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxStreamEvent:
|
||||
"""沙箱流式事件:stdout/stderr 增量(base64 str,保真透传)或 done(结束汇总 dict)。"""
|
||||
|
||||
kind: str # "stdout" | "stderr" | "done"
|
||||
payload: str | dict[str, Any]
|
||||
|
||||
|
||||
class SandboxClient:
|
||||
"""沙箱服务异步客户端。
|
||||
|
||||
授权语义:调用方需先经司命审核(在本类之外完成),通过 ``AuthorizationContext`` 携带
|
||||
授权上下文;本类据此签发短期 HMAC 凭证(exec 含本次请求的 body_hash 绑定),放入
|
||||
``Authorization`` 头,由沙箱端校验。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
settings: SandboxSettings,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> None:
|
||||
if settings.sandbox_auth_enabled and not settings.sandbox_auth_secret:
|
||||
raise ValueError("auth_enabled=True 时 auth_secret 不能为空(须与沙箱端共享)")
|
||||
self._http = http_client
|
||||
self._settings = settings
|
||||
self._sink: EventSink = event_sink or LoggingEventSink()
|
||||
|
||||
async def health(self) -> bool:
|
||||
"""健康探测,失败返回 False 而不抛异常。"""
|
||||
try:
|
||||
resp = await self._http.get(f"{self._settings.rust_sandbox_url}/healthz", timeout=5.0)
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
return resp.status_code == 200
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
request: ExecRequest,
|
||||
auth_ctx: AuthorizationContext,
|
||||
*,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> ExecResponse:
|
||||
"""同步执行:收集全部输出后返回。授权失败、网络错误或沙箱错误分别抛出对应异常,
|
||||
并回写 tool.failed 事件。``event_sink`` 可按调用覆盖,便于编排层按 run 绑定上下文。"""
|
||||
sink = event_sink or self._sink
|
||||
trace_id = request.trace_id or auth_ctx.trace_id
|
||||
run_id = auth_ctx.run_id
|
||||
tool = auth_ctx.tool
|
||||
|
||||
# 请求体带上解析后的 trace_id,便于沙箱日志关联
|
||||
if request.trace_id is None and trace_id is not None:
|
||||
request.trace_id = trace_id
|
||||
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool=tool,
|
||||
argv=request.argv or [f"code:{request.language}", request.code],
|
||||
risk_level=auth_ctx.risk_level,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self._http.post(
|
||||
f"{self._settings.rust_sandbox_url}/v1/exec",
|
||||
json=request.model_dump(exclude_none=True),
|
||||
headers=self._exec_auth_headers(auth_ctx, request),
|
||||
timeout=self._settings.http_timeout(request.timeout_ms),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=tool,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
error_code="sandbox_unavailable",
|
||||
error_message=str(exc),
|
||||
)
|
||||
)
|
||||
raise SandboxUnavailable(f"沙箱不可达: {exc}") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
exc_type: type[SandboxError]
|
||||
if resp.status_code == 401:
|
||||
exc_type = SandboxUnauthorized
|
||||
elif resp.status_code == 400:
|
||||
exc_type = SandboxBadRequest
|
||||
else:
|
||||
exc_type = SandboxExecFailed
|
||||
await self._fail(tool, trace_id, run_id, resp, exc_type, sink)
|
||||
|
||||
result = ExecResponse.model_validate(resp.json())
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool=tool,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
exit_code=result.exit_code,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
stdout_len=len(result.stdout),
|
||||
stderr_len=len(result.stderr),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
async def exec_stream(
|
||||
self,
|
||||
request: ExecRequest,
|
||||
auth_ctx: AuthorizationContext,
|
||||
) -> AsyncIterator[SandboxStreamEvent]:
|
||||
"""流式执行:实时产出 stdout/stderr 增量(base64 str)与 done 汇总(dict)。
|
||||
|
||||
消费沙箱 ``POST /v1/exec/stream`` 的 SSE。不回写一炁事件——started/completed/failed
|
||||
由编排层 ``ToolRuntime.execute_stream`` 负责,本方法只透传执行级输出。
|
||||
"""
|
||||
if request.trace_id is None and auth_ctx.trace_id is not None:
|
||||
request.trace_id = auth_ctx.trace_id
|
||||
|
||||
try:
|
||||
async with self._http.stream(
|
||||
"POST",
|
||||
f"{self._settings.rust_sandbox_url}/v1/exec/stream",
|
||||
json=request.model_dump(exclude_none=True),
|
||||
headers=self._exec_auth_headers(auth_ctx, request),
|
||||
timeout=self._settings.http_timeout(request.timeout_ms),
|
||||
) as resp:
|
||||
if resp.status_code == 401:
|
||||
raise SandboxUnauthorized(await self._read_error(resp))
|
||||
if resp.status_code == 400:
|
||||
raise SandboxBadRequest(await self._read_error(resp))
|
||||
if resp.status_code >= 400:
|
||||
raise SandboxExecFailed(await self._read_error(resp))
|
||||
async for event_type, data in self._iter_sse(resp):
|
||||
if event_type in ("stdout", "stderr"):
|
||||
yield SandboxStreamEvent(event_type, data)
|
||||
elif event_type == "done":
|
||||
try:
|
||||
yield SandboxStreamEvent("done", json.loads(data))
|
||||
except ValueError:
|
||||
continue
|
||||
except httpx.HTTPError as exc:
|
||||
raise SandboxUnavailable(f"沙箱不可达: {exc}") from exc
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
trace_id: str,
|
||||
auth_ctx: AuthorizationContext,
|
||||
) -> bool:
|
||||
"""取消运行中的任务(按 trace_id)。命中返回 True;任务不存在或已完成抛 SandboxNotFound。
|
||||
|
||||
cancel 不回写一炁事件(由编排层按需记录),凭证不绑定 body_hash(危害有限)。
|
||||
"""
|
||||
try:
|
||||
resp = await self._http.post(
|
||||
f"{self._settings.rust_sandbox_url}/v1/cancel",
|
||||
json={"trace_id": trace_id},
|
||||
headers=self._auth_headers(auth_ctx),
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SandboxUnavailable(f"沙箱不可达: {exc}") from exc
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise SandboxNotFound(f"任务不存在或已完成: {trace_id}")
|
||||
if resp.status_code >= 400:
|
||||
code, message = self._extract_error(resp)
|
||||
if resp.status_code == 401:
|
||||
raise SandboxUnauthorized(message)
|
||||
raise SandboxExecFailed(message, code=code)
|
||||
return CancelResponse.model_validate(resp.json()).cancelled
|
||||
|
||||
# ---- 文件 IO(不经 shell,二进制安全;与 Rust fileio.rs 契约对齐) ----
|
||||
|
||||
async def file_read(
|
||||
self,
|
||||
request: FileReadRequest,
|
||||
auth_ctx: AuthorizationContext,
|
||||
*,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> FileReadResponse:
|
||||
return await self._file_op(
|
||||
"read", "/v1/file/read", request, FileReadResponse, auth_ctx, event_sink=event_sink
|
||||
)
|
||||
|
||||
async def file_write(
|
||||
self,
|
||||
request: FileWriteRequest,
|
||||
auth_ctx: AuthorizationContext,
|
||||
*,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> FileWriteResponse:
|
||||
return await self._file_op(
|
||||
"write", "/v1/file/write", request, FileWriteResponse, auth_ctx, event_sink=event_sink
|
||||
)
|
||||
|
||||
async def file_list(
|
||||
self,
|
||||
request: FileListRequest,
|
||||
auth_ctx: AuthorizationContext,
|
||||
*,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> FileListResponse:
|
||||
return await self._file_op(
|
||||
"list", "/v1/file/list", request, FileListResponse, auth_ctx, event_sink=event_sink
|
||||
)
|
||||
|
||||
async def file_stat(
|
||||
self,
|
||||
request: FileStatRequest,
|
||||
auth_ctx: AuthorizationContext,
|
||||
*,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> FileStatResponse:
|
||||
return await self._file_op(
|
||||
"stat", "/v1/file/stat", request, FileStatResponse, auth_ctx, event_sink=event_sink
|
||||
)
|
||||
|
||||
async def _file_op(
|
||||
self,
|
||||
op: str,
|
||||
endpoint: str,
|
||||
request: FileReadRequest | FileWriteRequest | FileListRequest | FileStatRequest,
|
||||
response_cls: type[_T],
|
||||
auth_ctx: AuthorizationContext,
|
||||
*,
|
||||
event_sink: EventSink | None = None,
|
||||
) -> _T:
|
||||
"""文件操作通用流程:签发 file_op 凭证 → POST → 解析响应,并回写一炁事件。
|
||||
|
||||
与 exec 共用错误映射(``_fail``)与事件构造;argv 记 ``[file:<op>, <path>]``
|
||||
供审计 input_summary。文件操作在主进程运行(非 exec 子进程),无超时参数,
|
||||
用默认 HTTP 超时(``http_timeout(None)``)。
|
||||
"""
|
||||
sink = event_sink or self._sink
|
||||
trace_id = auth_ctx.trace_id
|
||||
run_id = auth_ctx.run_id
|
||||
tool = auth_ctx.tool
|
||||
path = request.path
|
||||
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool=tool,
|
||||
argv=[f"file:{op}", path],
|
||||
risk_level=auth_ctx.risk_level,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
resp = await self._http.post(
|
||||
f"{self._settings.rust_sandbox_url}{endpoint}",
|
||||
json=request.model_dump(exclude_none=True),
|
||||
headers=self._file_auth_headers(auth_ctx, op, path),
|
||||
timeout=self._settings.http_timeout(None),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=tool,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
error_code="sandbox_unavailable",
|
||||
error_message=str(exc),
|
||||
)
|
||||
)
|
||||
raise SandboxUnavailable(f"沙箱不可达: {exc}") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
exc_type: type[SandboxError]
|
||||
if resp.status_code == 401:
|
||||
exc_type = SandboxUnauthorized
|
||||
elif resp.status_code == 400:
|
||||
exc_type = SandboxBadRequest
|
||||
elif resp.status_code == 404:
|
||||
exc_type = SandboxNotFound
|
||||
else:
|
||||
exc_type = SandboxExecFailed
|
||||
await self._fail(tool, trace_id, run_id, resp, exc_type, sink)
|
||||
|
||||
result = response_cls.model_validate(resp.json())
|
||||
# completed 事件:read 带 returned 字节数 + 截断标记,write 带 bytes_written,
|
||||
# list/stat 无字节数语义,以 0 占位(exit_code=0 表示操作成功)。
|
||||
stdout_len = getattr(result, "returned", None) or getattr(result, "bytes_written", 0)
|
||||
truncated = bool(getattr(result, "truncated", False))
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool=tool,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
exit_code=0,
|
||||
duration_ms=int((time.monotonic() - t0) * 1000),
|
||||
timeout=False,
|
||||
truncated=truncated,
|
||||
stdout_len=stdout_len,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def _file_auth_headers(
|
||||
self,
|
||||
auth_ctx: AuthorizationContext,
|
||||
op: str,
|
||||
path: str,
|
||||
) -> dict[str, str]:
|
||||
"""签发文件操作凭证:file_op 绑定 {op, path}(exec 路径 body_hash 为 None,不绑定)。"""
|
||||
if not self._settings.sandbox_auth_enabled:
|
||||
return {}
|
||||
token = sign_authorization(
|
||||
self._settings.sandbox_auth_secret,
|
||||
auth_ctx,
|
||||
argv=None,
|
||||
stdin=None,
|
||||
ttl_seconds=self._settings.sandbox_auth_ttl_seconds,
|
||||
file_op=(op, path),
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def _auth_headers(
|
||||
self,
|
||||
auth_ctx: AuthorizationContext,
|
||||
argv: list[str] | None = None,
|
||||
stdin: str | None = None,
|
||||
*,
|
||||
code: str | None = None,
|
||||
language: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""签发授权凭证。
|
||||
|
||||
- exec 命令模式:传 argv/stdin,按 compute_body_hash 绑定。
|
||||
- exec 代码模式:传 code/language/stdin,按 compute_body_hash_code 绑定。
|
||||
- cancel:都不传,不绑定 body_hash。
|
||||
"""
|
||||
if not self._settings.sandbox_auth_enabled:
|
||||
return {}
|
||||
token = sign_authorization(
|
||||
self._settings.sandbox_auth_secret,
|
||||
auth_ctx,
|
||||
argv=argv,
|
||||
stdin=stdin,
|
||||
code=code,
|
||||
language=language,
|
||||
ttl_seconds=self._settings.sandbox_auth_ttl_seconds,
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def _exec_auth_headers(
|
||||
self,
|
||||
auth_ctx: AuthorizationContext,
|
||||
request: ExecRequest,
|
||||
) -> dict[str, str]:
|
||||
"""按 code/argv 模式分流签发 exec 授权头。"""
|
||||
if request.code is not None:
|
||||
return self._auth_headers(
|
||||
auth_ctx,
|
||||
stdin=request.stdin,
|
||||
code=request.code,
|
||||
language=request.language,
|
||||
)
|
||||
return self._auth_headers(auth_ctx, request.argv, request.stdin)
|
||||
|
||||
@staticmethod
|
||||
async def _read_error(resp: httpx.Response) -> str:
|
||||
"""流式响应的错误体:先 aread 再复用 _extract_error。"""
|
||||
await resp.aread()
|
||||
_, message = SandboxClient._extract_error(resp)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def _iter_sse(resp: httpx.Response) -> AsyncIterator[tuple[str, str]]:
|
||||
"""极简 SSE 解析:按空行分界,聚合 ``event:`` 与 ``data:`` 行(忽略 id/retry/注释/心跳)。"""
|
||||
event_type = "message"
|
||||
data_lines: list[str] = []
|
||||
async for line in resp.aiter_lines():
|
||||
if line == "":
|
||||
if data_lines:
|
||||
yield event_type, "\n".join(data_lines)
|
||||
event_type = "message"
|
||||
data_lines = []
|
||||
elif line.startswith("event:"):
|
||||
event_type = line[len("event:") :].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:") :].lstrip())
|
||||
# 其余行(id:/retry:/注释/心跳)忽略
|
||||
if data_lines:
|
||||
yield event_type, "\n".join(data_lines)
|
||||
|
||||
async def _fail(
|
||||
self,
|
||||
tool: str,
|
||||
trace_id: str | None,
|
||||
run_id: str | None,
|
||||
resp: httpx.Response,
|
||||
exc_type: type[SandboxError],
|
||||
sink: EventSink,
|
||||
) -> NoReturn:
|
||||
code, message = self._extract_error(resp)
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=tool,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
error_code=code,
|
||||
error_message=message,
|
||||
)
|
||||
)
|
||||
raise exc_type(message)
|
||||
|
||||
@staticmethod
|
||||
def _extract_error(resp: httpx.Response) -> tuple[str, str]:
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return "exec_failed", resp.text
|
||||
err = body.get("error") if isinstance(body, dict) else None
|
||||
if isinstance(err, dict):
|
||||
return str(err.get("code", "exec_failed")), str(err.get("message", resp.text))
|
||||
return "exec_failed", resp.text
|
||||
Reference in new issue
Block a user