初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,63 @@
|
||||
"""安全沙箱集成:封装对 Rust 沙箱服务的调用(司命授权 + 一炁事件回写)。"""
|
||||
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.audit_sink import AuditEventSink
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.events import (
|
||||
CompositeEventSink,
|
||||
EventSink,
|
||||
LoggingEventSink,
|
||||
SandboxEvent,
|
||||
)
|
||||
from app.integrations.sandbox.exceptions import (
|
||||
SandboxBadRequest,
|
||||
SandboxError,
|
||||
SandboxExecFailed,
|
||||
SandboxNotFound,
|
||||
SandboxUnauthorized,
|
||||
SandboxUnavailable,
|
||||
)
|
||||
from app.integrations.sandbox.models import (
|
||||
CancelResponse,
|
||||
DirEntry,
|
||||
ExecRequest,
|
||||
ExecResponse,
|
||||
FileListRequest,
|
||||
FileListResponse,
|
||||
FileReadRequest,
|
||||
FileReadResponse,
|
||||
FileStatRequest,
|
||||
FileStatResponse,
|
||||
FileWriteRequest,
|
||||
FileWriteResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthorizationContext",
|
||||
"AuditEventSink",
|
||||
"CancelResponse",
|
||||
"CompositeEventSink",
|
||||
"DirEntry",
|
||||
"EventSink",
|
||||
"ExecRequest",
|
||||
"ExecResponse",
|
||||
"FileListRequest",
|
||||
"FileListResponse",
|
||||
"FileReadRequest",
|
||||
"FileReadResponse",
|
||||
"FileStatRequest",
|
||||
"FileStatResponse",
|
||||
"FileWriteRequest",
|
||||
"FileWriteResponse",
|
||||
"LoggingEventSink",
|
||||
"SandboxBadRequest",
|
||||
"SandboxClient",
|
||||
"SandboxError",
|
||||
"SandboxEvent",
|
||||
"SandboxExecFailed",
|
||||
"SandboxNotFound",
|
||||
"SandboxSettings",
|
||||
"SandboxUnauthorized",
|
||||
"SandboxUnavailable",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""审计事件 sink:把沙箱 tool.completed / tool.failed 事件写入 AuditEvent 表(起居注)。
|
||||
|
||||
与 EventStoreSink 并列——EventStoreSink 落一炁流程事件(events 表,run 内 sequence),
|
||||
AuditEventSink 落治理审计(audit_events 表,含 decision / risk_level / argv 脱敏摘要)。
|
||||
两者经 CompositeEventSink 同时注入 SandboxClient,互不感知。
|
||||
|
||||
审计只记终态(completed/failed);started 不记(审计关注决策结果,非中间态)。
|
||||
argv 经 record_audit_event 的 ``_summary`` → ``redact_text`` + 截断 1000 脱敏,作为
|
||||
input_summary(审计核心价值:谁执行了什么命令;脱敏打码密钥/token 等敏感模式后保留可读性)。
|
||||
|
||||
per-call/per-run 绑定 run_id + character_id(与 EventStoreSink 构造一致);risk_level +
|
||||
argv 由 started 事件 payload 携带,completed/failed 时消费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.integrations.sandbox.events import SandboxEvent
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
|
||||
class AuditEventSink:
|
||||
"""把沙箱事件转写为 AuditEvent(审计表),与 EventStoreSink 并列注入。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._run_id = run_id
|
||||
self._character_id = character_id
|
||||
# 缓存最近一次 started 的上下文(risk_level + argv),completed/failed 写审计时消费。
|
||||
# sink 为 per-call 实例(随一次 exec 生命周期),状态隔离安全。
|
||||
self._pending_risk: str | None = None
|
||||
self._pending_argv: list[str] = []
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None:
|
||||
etype = event.type
|
||||
payload = event.payload
|
||||
|
||||
if etype == "tool.started":
|
||||
# 缓存 risk_level + argv(started 携带),completed/failed 写审计时消费
|
||||
self._pending_risk = str(payload.get("risk_level") or "L1")
|
||||
self._pending_argv = list(payload.get("argv") or [])
|
||||
return
|
||||
|
||||
if etype not in ("tool.completed", "tool.failed"):
|
||||
return
|
||||
|
||||
tool = str(payload.get("tool") or "")
|
||||
risk_level = self._pending_risk or "L1"
|
||||
argv = self._pending_argv
|
||||
|
||||
if etype == "tool.completed":
|
||||
action = "tool.executed"
|
||||
decision = "allow"
|
||||
reason: str | None = None
|
||||
output_summary = self._completed_summary(payload)
|
||||
else: # tool.failed
|
||||
action = "tool.failed"
|
||||
decision = "deny"
|
||||
reason = str(payload.get("error_code") or "") or None
|
||||
output_summary = str(payload.get("error_message") or "") or None
|
||||
|
||||
# input_summary = argv 空格拼接(record_audit_event 内部 _summary 会 redact + 截断 1000)
|
||||
input_summary = " ".join(argv) if argv else None
|
||||
|
||||
async with self._session_factory() as session:
|
||||
await record_audit_event(
|
||||
session,
|
||||
action=action,
|
||||
pivot="tool_runtime",
|
||||
character_id=self._character_id,
|
||||
run_id=self._run_id,
|
||||
risk_level=risk_level,
|
||||
decision=decision,
|
||||
reason=reason,
|
||||
input_summary=input_summary,
|
||||
output_summary=output_summary,
|
||||
resource_type="tool",
|
||||
resource_id=tool or None,
|
||||
trace_id=event.trace_id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _completed_summary(payload: dict[str, Any]) -> str:
|
||||
parts = [f"exit={payload.get('exit_code')}", f"dur={payload.get('duration_ms')}ms"]
|
||||
if payload.get("timeout"):
|
||||
parts.append("timeout")
|
||||
if payload.get("truncated"):
|
||||
parts.append("truncated")
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""司命授权凭证:HMAC-SHA256 短期 token,与 Rust 沙箱端 ``auth.rs`` 共用算法与密钥。
|
||||
|
||||
凭证格式:``<payload_b64url>.<sig_b64url>``
|
||||
|
||||
- payload:JSON,字段顺序固定为
|
||||
``exp / trace_id / risk_level / tool / character_id / run_id / body_hash / rlimit_overrides / file_op_hash / isolation_mode``,
|
||||
必须与 Rust ``serde`` 字段顺序字节级一致(否则 HMAC 不匹配)。``isolation_mode`` 末尾追加,旧凭证向后兼容。
|
||||
- sig:``HMAC-SHA256(secret, payload_b64url)`` 的 base64url(无填充)。
|
||||
- body_hash:``compute_body_hash(argv, stdin)``,把凭证绑死到具体执行内容,防 TTL 内重放执行任意命令。
|
||||
|
||||
安全语义:客户端不判断风险,只把「经司命审核通过」的上下文连同请求内容摘要签成短期凭证;
|
||||
沙箱端验签 + body_hash 比对通过才执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationContext:
|
||||
"""一次授权的上下文:由司命审核通过后随请求下发。"""
|
||||
|
||||
trace_id: str
|
||||
risk_level: str # L0–L4,见契约字典 risk_level
|
||||
tool: str
|
||||
character_id: str | None = None
|
||||
run_id: str | None = None
|
||||
# 司命授予的资源配额上限(key: fsize/as/cpu/nproc/nofile → u64)。
|
||||
# 随凭证下发,sandbox 验签后信任并 clamp 到全局天花板;None 表示不绑定分级配额。
|
||||
# 属授权属性,不进 body_hash(body_hash 只绑 argv + stdin)。
|
||||
rlimit_overrides: dict[str, int] | None = None
|
||||
# 隔离模式授权(L0-L2 → process,L3-L4 → container)。随凭证下发,沙箱据此路由;
|
||||
# None 表示司命未指定。属授权属性,不进 body_hash,与 rlimit_overrides 同链路。
|
||||
isolation_mode: str | None = None
|
||||
|
||||
|
||||
def _b64url_no_pad(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_no_pad_decode(text: str) -> bytes:
|
||||
pad = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + pad)
|
||||
|
||||
|
||||
def compute_body_hash(argv: list[str], stdin: str | None) -> str:
|
||||
"""计算请求内容摘要,与 Rust 沙箱端 ``compute_body_hash`` 字节级一致。
|
||||
|
||||
口径:``SHA256(canonical JSON {"argv": [...], "stdin": null|str})``——
|
||||
JSON 紧凑(``separators=(",",":")``)、不转义非 ASCII(``ensure_ascii=False``)、
|
||||
字段顺序固定 ``argv, stdin``;返回小写十六进制(64 字符)。
|
||||
"""
|
||||
payload = {"argv": argv, "stdin": stdin}
|
||||
encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def compute_file_op_hash(op: str, path: str) -> str:
|
||||
"""计算文件操作摘要,与 Rust 沙箱端 ``compute_file_op_hash`` 字节级一致。
|
||||
|
||||
口径:``SHA256(canonical JSON {"op": str, "path": str})``——把凭证绑死到具体文件操作
|
||||
(read/write/list/stat),防 TTL 内重放执行任意文件读写。字段顺序固定 ``op, path``。
|
||||
"""
|
||||
payload = {"op": op, "path": path}
|
||||
encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def compute_body_hash_code(language: str, code: str, stdin: str | None) -> str:
|
||||
"""计算 code 模式请求内容摘要,与 Rust 沙箱端 ``compute_body_hash_code`` 字节级一致。
|
||||
|
||||
口径:``SHA256(canonical JSON {"language": str, "code": str, "stdin": null|str})``——
|
||||
字段顺序固定 ``language, code, stdin``、紧凑 JSON、不转义非 ASCII;返回小写十六进制。
|
||||
code 模式凭证 body_hash 用此函数(argv 模式仍用 ``compute_body_hash``),把凭证绑死到
|
||||
具体代码内容,防 TTL 内重放执行任意代码。
|
||||
"""
|
||||
payload = {"language": language, "code": code, "stdin": stdin}
|
||||
encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def sign_authorization(
|
||||
secret: str,
|
||||
ctx: AuthorizationContext,
|
||||
argv: list[str] | None = None,
|
||||
stdin: str | None = None,
|
||||
ttl_seconds: int = 30,
|
||||
*,
|
||||
code: str | None = None,
|
||||
language: str | None = None,
|
||||
file_op: tuple[str, str] | None = None,
|
||||
) -> str:
|
||||
"""签发授权凭证。
|
||||
|
||||
内容绑定(互斥,由调用方保证不同时传 argv 与 code):
|
||||
- exec 命令模式:传 ``argv``/``stdin``,按 ``compute_body_hash(argv, stdin)`` 绑定。
|
||||
- exec 代码模式:传 ``code``/``language``/``stdin``,按 ``compute_body_hash_code`` 绑定。
|
||||
- 低风险操作(cancel):都不传,不绑定 body_hash(file_op 单独走 file_op_hash)。
|
||||
|
||||
payload 字段顺序与 Rust ``AuthPayload`` 一致(isolation_mode 末尾追加,旧凭证向后兼容)。
|
||||
"""
|
||||
if not secret:
|
||||
raise ValueError("授权密钥不能为空")
|
||||
exp = int(time.time()) + ttl_seconds
|
||||
if code is not None:
|
||||
body_hash = compute_body_hash_code(language or "python", code, stdin)
|
||||
elif argv is not None:
|
||||
body_hash = compute_body_hash(argv, stdin)
|
||||
else:
|
||||
body_hash = None
|
||||
payload: dict[str, object] = {
|
||||
"exp": exp,
|
||||
"trace_id": ctx.trace_id,
|
||||
"risk_level": ctx.risk_level,
|
||||
"tool": ctx.tool,
|
||||
"character_id": ctx.character_id,
|
||||
"run_id": ctx.run_id,
|
||||
"body_hash": body_hash,
|
||||
# rlimit_overrides 内部按 key 排序,对齐 Rust serde BTreeMap 的字母序,
|
||||
# 保证多 key 时两端 payload JSON 字节一致 → HMAC 一致。
|
||||
"rlimit_overrides": (
|
||||
dict(sorted(ctx.rlimit_overrides.items())) if ctx.rlimit_overrides else None
|
||||
),
|
||||
# file_op_hash 绑定文件操作 {op, path}(exec 路径为 None);字段顺序对齐 Rust AuthPayload。
|
||||
"file_op_hash": compute_file_op_hash(file_op[0], file_op[1]) if file_op else None,
|
||||
# isolation_mode 末尾追加(字段顺序对齐 Rust AuthPayload;旧凭证无此字段仍可验签)。
|
||||
"isolation_mode": ctx.isolation_mode,
|
||||
}
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
payload_b64 = _b64url_no_pad(payload_json.encode("utf-8"))
|
||||
digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{payload_b64}.{_b64url_no_pad(digest)}"
|
||||
|
||||
|
||||
def verify_authorization(secret: str, token: str) -> dict[str, object]:
|
||||
"""校验授权凭证(对称实现,供本地测试与跨端一致性校验)。"""
|
||||
payload_b64, sep, sig_b64 = token.rpartition(".")
|
||||
if not sep:
|
||||
raise ValueError("授权凭证格式不合法")
|
||||
expected = _b64url_no_pad(
|
||||
hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
)
|
||||
if not hmac.compare_digest(expected, sig_b64):
|
||||
raise ValueError("授权凭证签名校验失败")
|
||||
payload = cast("dict[str, object]", json.loads(_b64url_no_pad_decode(payload_b64)))
|
||||
exp = payload.get("exp")
|
||||
if not isinstance(exp, int | float) or exp <= time.time():
|
||||
raise ValueError("授权凭证已过期")
|
||||
return payload
|
||||
@@ -0,0 +1,43 @@
|
||||
"""沙箱事件桥接:把 SandboxEvent 转写为一炁 Event 落入 EventStore。
|
||||
|
||||
实现 sandbox EventSink Protocol,使 SandboxClient 发出的 tool.started / tool.completed /
|
||||
tool.failed 事件自动进入一炁事件存储,无需改动客户端。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from app.integrations.sandbox.events import SandboxEvent
|
||||
from app.modules.runtime.events import Event, EventStore
|
||||
|
||||
|
||||
class EventStoreSink:
|
||||
"""将沙箱事件适配为一炁事件并写入 EventStore。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: EventStore,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._run_id = run_id
|
||||
self._character_id = character_id
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None:
|
||||
await self._store.append(
|
||||
Event(
|
||||
id=f"event_{uuid.uuid4().hex}",
|
||||
type=event.type,
|
||||
source=event.source,
|
||||
payload=dict(event.payload),
|
||||
visibility="public",
|
||||
sensitivity=event.sensitivity, # type: ignore[arg-type]
|
||||
run_id=self._run_id,
|
||||
character_id=self._character_id,
|
||||
trace_id=event.trace_id,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,127 @@
|
||||
"""沙箱调用的一炁事件构造与回写接口。
|
||||
|
||||
事件结构对齐 docs/architecture/data-and-security-architecture.md:``type / source / payload / trace_id /
|
||||
sensitivity / created_at``。当前一炁事件存储尚未实现,``EventSink`` 用 Protocol 抽象,
|
||||
默认实现写 loguru 日志;后续接入真实事件存储时实现 ``EventSink`` 注入即可,无需改动客户端。
|
||||
|
||||
payload 仅记录摘要(argv、输出长度等),不含密钥、Authorization、签名 URL 或完整输出,
|
||||
遵循事件 payload 脱敏约定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxEvent:
|
||||
"""沙箱领域事件(派生自一炁事件契约)。"""
|
||||
|
||||
type: str # tool.started / tool.completed / tool.failed
|
||||
source: str = "tool"
|
||||
trace_id: str | None = None
|
||||
sensitivity: str = "low" # none / low / medium / high
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
|
||||
class EventSink(Protocol):
|
||||
"""事件回写接口。"""
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None: ...
|
||||
|
||||
|
||||
class LoggingEventSink:
|
||||
"""默认事件回写:输出到 loguru(结构化字段,不含敏感原文)。"""
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None:
|
||||
logger.bind(
|
||||
event_type=event.type,
|
||||
source=event.source,
|
||||
trace_id=event.trace_id,
|
||||
sensitivity=event.sensitivity,
|
||||
payload=event.payload,
|
||||
).info("sandbox event: {}", event.type)
|
||||
|
||||
|
||||
class CompositeEventSink:
|
||||
"""扇出到多个 sink:一次 emit 顺序广播给所有子 sink。
|
||||
|
||||
供 ToolRuntime 同时落 EventStore(一炁流程事件)与 AuditEvent(治理审计),互不感知。
|
||||
任一子 sink 抛异常会中断后续 sink——审计写入失败应显式暴露(宁可事件漏写也不静默吞
|
||||
异常,便于及时发现 DB 问题)。
|
||||
"""
|
||||
|
||||
def __init__(self, sinks: list[EventSink]) -> None:
|
||||
self._sinks = sinks
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None:
|
||||
for sink in self._sinks:
|
||||
await sink.emit(event)
|
||||
|
||||
|
||||
def started_event(
|
||||
*,
|
||||
tool: str,
|
||||
argv: list[str],
|
||||
risk_level: str,
|
||||
trace_id: str | None,
|
||||
run_id: str | None,
|
||||
) -> SandboxEvent:
|
||||
return SandboxEvent(
|
||||
type="tool.started",
|
||||
trace_id=trace_id,
|
||||
payload={"tool": tool, "risk_level": risk_level, "run_id": run_id, "argv": argv},
|
||||
)
|
||||
|
||||
|
||||
def completed_event(
|
||||
*,
|
||||
tool: str,
|
||||
trace_id: str | None,
|
||||
run_id: str | None,
|
||||
exit_code: int | None,
|
||||
duration_ms: int,
|
||||
timeout: bool,
|
||||
truncated: bool,
|
||||
stdout_len: int,
|
||||
stderr_len: int,
|
||||
) -> SandboxEvent:
|
||||
return SandboxEvent(
|
||||
type="tool.completed",
|
||||
trace_id=trace_id,
|
||||
payload={
|
||||
"tool": tool,
|
||||
"run_id": run_id,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": duration_ms,
|
||||
"timeout": timeout,
|
||||
"truncated": truncated,
|
||||
"stdout_len": stdout_len,
|
||||
"stderr_len": stderr_len,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def failed_event(
|
||||
*,
|
||||
tool: str,
|
||||
trace_id: str | None,
|
||||
run_id: str | None,
|
||||
error_code: str,
|
||||
error_message: str,
|
||||
) -> SandboxEvent:
|
||||
return SandboxEvent(
|
||||
type="tool.failed",
|
||||
trace_id=trace_id,
|
||||
payload={
|
||||
"tool": tool,
|
||||
"run_id": run_id,
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""沙箱客户端异常。错误码遵循契约字典的 API 标准错误码口径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SandboxError(Exception):
|
||||
"""沙箱调用基础异常。"""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "sandbox_error") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class SandboxUnavailable(SandboxError):
|
||||
"""沙箱不可达(网络错误 / 连接超时)。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, code="sandbox_unavailable")
|
||||
|
||||
|
||||
class SandboxUnauthorized(SandboxError):
|
||||
"""授权凭证被拒(HTTP 401)。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, code="sandbox_unauthorized")
|
||||
|
||||
|
||||
class SandboxBadRequest(SandboxError):
|
||||
"""请求参数错误(HTTP 400)。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, code="bad_request")
|
||||
|
||||
|
||||
class SandboxExecFailed(SandboxError):
|
||||
"""沙箱内部执行错误(HTTP 5xx,非 401)。"""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "exec_failed") -> None:
|
||||
super().__init__(message, code=code)
|
||||
|
||||
|
||||
class SandboxNotFound(SandboxError):
|
||||
"""目标任务不存在或已完成(HTTP 404),例如取消一个未知 trace_id。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, code="not_found")
|
||||
@@ -0,0 +1,143 @@
|
||||
"""沙箱执行请求 / 响应模型,与 Rust 端 JSON 契约对齐。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ExecRequest(BaseModel):
|
||||
"""``POST /v1/exec`` 请求体。``argv``(命令式)与 ``code``+``language``(代码片段式)二选一。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
argv: list[str] | None = Field(None, min_length=1)
|
||||
code: str | None = None
|
||||
language: str | None = None # code 模式语言,默认 python
|
||||
stdin: str | None = None
|
||||
timeout_ms: int | None = None
|
||||
workdir: str | None = None
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
max_output_bytes: int | None = None
|
||||
trace_id: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_argv_code_mutex(self) -> ExecRequest:
|
||||
has_argv = self.argv is not None
|
||||
has_code = self.code is not None
|
||||
if has_argv == has_code:
|
||||
raise ValueError("必须且只能指定 argv 或 code 之一")
|
||||
if has_code and self.language is None:
|
||||
self.language = "python"
|
||||
return self
|
||||
|
||||
|
||||
class ExecResponse(BaseModel):
|
||||
"""``POST /v1/exec`` 响应体。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
ok: bool
|
||||
exit_code: int | None = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
duration_ms: int = 0
|
||||
timeout: bool = False
|
||||
cancelled: bool = False
|
||||
truncated: bool = False
|
||||
trace_id: str = ""
|
||||
isolation_mode: str = "process" # 实际生效的隔离模式(process/container)
|
||||
|
||||
|
||||
class CancelResponse(BaseModel):
|
||||
"""``POST /v1/cancel`` 响应体。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
cancelled: bool
|
||||
trace_id: str
|
||||
|
||||
|
||||
# ---- 文件 IO(不经 shell,二进制安全;与 Rust fileio.rs 契约对齐) ----
|
||||
|
||||
|
||||
class FileReadRequest(BaseModel):
|
||||
"""``POST /v1/file/read`` 请求。``path`` 为 base 内相对路径。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: str
|
||||
offset: int | None = None
|
||||
limit: int | None = None
|
||||
|
||||
|
||||
class FileReadResponse(BaseModel):
|
||||
"""``POST /v1/file/read`` 响应。``content_b64`` 为 base64url-no-pad(二进制安全)。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
content_b64: str
|
||||
size: int
|
||||
offset: int
|
||||
returned: int
|
||||
truncated: bool
|
||||
|
||||
|
||||
class FileWriteRequest(BaseModel):
|
||||
"""``POST /v1/file/write`` 请求。``content_b64`` 为 base64url-no-pad。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: str
|
||||
content_b64: str
|
||||
|
||||
|
||||
class FileWriteResponse(BaseModel):
|
||||
"""``POST /v1/file/write`` 响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
bytes_written: int
|
||||
|
||||
|
||||
class FileListRequest(BaseModel):
|
||||
"""``POST /v1/file/list`` 请求。``path`` 默认 base 根(``.``)。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: str = "."
|
||||
|
||||
|
||||
class DirEntry(BaseModel):
|
||||
"""目录条目。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
name: str
|
||||
kind: str # "file" | "dir" | "other"
|
||||
size: int
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
"""``POST /v1/file/list`` 响应(条目按名字排序)。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
entries: list[DirEntry]
|
||||
|
||||
|
||||
class FileStatRequest(BaseModel):
|
||||
"""``POST /v1/file/stat`` 请求。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: str
|
||||
|
||||
|
||||
class FileStatResponse(BaseModel):
|
||||
"""``POST /v1/file/stat`` 响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
kind: str
|
||||
size: int
|
||||
modified_unix: int
|
||||
Reference in new issue
Block a user