跳到主要内容

协议契约与沙箱生命周期状态机

30 秒导读: OpenSandbox 是给 AI agent 跑「不可信代码」的沙箱平台。它先不谈实现,而是用 四份 OpenAPI 规范 把系统切成几个界面清楚的面:控制面(建/删/暂停沙箱)、数据面(在沙箱里跑命令/文件)、出站面(改网络策略)、诊断面(捞日志)。这四份契约共同围着一个东西转——沙箱的八态生命周期状态机(Pending → Running → …→ Terminated,外加 Failed)。本章讲这层「地基」:契约怎么分、状态怎么流转、请求/响应长什么样、怎么鉴权。后面 02–06 章讲的每一个后端、每一条数据路径,都是在把这层契约翻译成真实容器行为。

本章是全组的地基章。你可以把它当成一张「坐标系」:后面每章讲某个子系统时,都会回来引用这里定义的状态名、模型名、鉴权头。本章只讲契约与数据模型,不讲某个后端(Docker/K8s)具体怎么把契约变成容器(那是 02 / 03),也不讲 execd 在沙箱里内部怎么执行(那是 04)。


1. 这是什么:先看「契约层」为什么存在(零基础也能懂)

1.1 一句话定义

契约层 = 一组 OpenAPI 规范(.yaml/.yml),它先用文字精确规定「谁能调什么接口、传什么、返回什么、沙箱有哪些状态」,然后各语言的服务端与 SDK 都照这份契约实现。 它是「说明书先行」:规范是唯一事实来源,代码是它的一种实现。

1.2 为什么要把契约单独拎出来讲

想象你在给 AI agent 造一个「一次性电脑」:agent 说「跑这段 Python」,平台就得凭空变出一个隔离容器、把代码丢进去、跑完再销毁。这里牵涉好几拨完全不同的调用者:

  • 编排方(agent 框架 / 用户后端):要「创建、暂停、删除」沙箱——这是管理沙箱本身。
  • 沙箱内的执行者(你的代码、代码解释器):要「在已经建好的沙箱里跑命令、读写文件」——这是使用沙箱内部。
  • 安全 / 运维:要「改这个沙箱能访问哪些外网」「出问题时捞点日志」。

这三拨人关心的东西、访问的网络位置、鉴权方式都不一样。如果塞进一份大而全的 API,谁都得懂全部,边界还容易糊。OpenSandbox 的选择是:按「面(plane)」切成四份独立规范,每份有自己的 base URL、自己的鉴权头、自己的读者。

1.3 用起来什么样(最小直觉)

编排方创建一个沙箱,本质上就是往控制面发一个 POST:

# 示意:创建一个 python:3.11 沙箱,300 秒后自动过期
curl -X POST http://localhost:8080/v1/sandboxes \
-H "OPEN-SANDBOX-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"image": { "uri": "python:3.11" },
"entrypoint": ["tail", "-f", "/dev/null"],
"timeout": 300,
"resourceLimits": { "cpu": "500m", "memory": "512Mi" }
}'

返回里带一个 idstatus.state: "Pending"——沙箱开始异步启动。你随后轮询 GET /sandboxes/{id},看它爬到 Running,就能开始用了。这整套「传什么、回什么、经过哪些状态」,就是契约层规定的东西。


2. 顶层全景:四份规范怎么分工

2.1 一张图:四个面、各自的网络位置

先给一句「怎么读这张图」:从上到下是「离编排方越来越远、离沙箱内部越来越近」。控制面在平台服务器上,数据面/出站面在沙箱内部,诊断面又回到平台服务器。

┌─────────────────────────────┐
编排方 / SDK ───▶ │ 控制面 Control Plane │ 建/删/暂停/恢复/快照/查端点
(你的后端) │ sandbox-lifecycle.yml │ base: http://localhost:8080/v1
│ 鉴权: OPEN-SANDBOX-API-KEY │
└───────────────┬─────────────┘
│ 平台据此变出容器(见 02/03 章)

┌─────────────────────────────────────────────┐
│ 一个具体的沙箱容器 │
│ │
直接连沙箱端点 ──▶ │ 数据面 execd-api.yaml :44772 │ 跑代码/命令/文件/PTY
(跑代码/文件) │ 鉴权: X-EXECD-ACCESS-TOKEN │ (见 04 章)
│ │
直接连沙箱端点 ──▶ │ 出站面 egress-api.yaml :18080 │ 运行时改网络策略
(改网络策略) │ 鉴权: OPENSANDBOX-EGRESS-AUTH(可选) │ (见 06 章)
└─────────────────────────────────────────────┘

编排方 / SDK ─────────────────────┘ 捞诊断日志/事件
(排障) 诊断面 diagnostic-api.yml base: http://localhost:8080/v1
鉴权: OPEN-SANDBOX-API-KEY ← 与控制面同一入口

关键分界(容易搞混,先点破):控制面与数据面在两个不同的网络位置。 控制面是平台对外的统一入口(/v1),你拿 API Key 调它来「管理」沙箱;数据面 execd 跑在每个沙箱容器内部(端口 44772),你不是直接连它,而是先问控制面「这个沙箱的某端口对外端点是什么」(GET …/endpoints/{port}),拿到端点再直连。出站面 egress sidecar(端口 18080)同理——先解析端点、再直连 sidecar(egress-api.yaml 的 Access Model 一节明说了这两步)。

2.2 四份规范各管什么(部件表)

规范文件管什么base URL / 位置鉴权头
specs/sandbox-lifecycle.yml控制面创建/列出/查询/删除沙箱,暂停/恢复,快照,续期,查端点http://localhost:8080/v1OPEN-SANDBOX-API-KEY
specs/execd-api.yaml数据面在沙箱内跑代码(有状态上下文)、跑 shell 命令、文件 CRUD、指标、SSE 流式输出沙箱内 :44772X-EXECD-ACCESS-TOKEN
specs/egress-api.yaml出站面运行时读/改沙箱的出站网络策略(egress sidecar)沙箱内 :18080OPENSANDBOX-EGRESS-AUTH(可选)
specs/diagnostic-api.yml诊断面尽力而为的排障描述符(日志/事件),内联文本或下载 URLhttp://localhost:8080/v1(与控制面同入口)OPEN-SANDBOX-API-KEY

引用:各规范的 servers:info.title 在文件头部——specs/sandbox-lifecycle.yml:40(http://localhost:8080/v1)、specs/execd-api.yaml:27(:44772)、specs/egress-api.yaml:30(:18080)、specs/diagnostic-api.yml:37(:8080/v1)。这套分层的官方说明见 docs/api/index.md:8,那里直接点名了这几个 base URL。

一个诚实的澄清:servers: 看,诊断面并不是一个独立端口——它和控制面共用 http://localhost:8080/v1(diagnostic-api.yml:37),鉴权头也和控制面一样。所以更准确的说法是「三个网络面 + 诊断作为控制面同入口下的一组只读排障接口」。之所以单列一份规范,是因为它刻意不承诺结构化可观测模型:diagnostic-api.yml:6 明说它「不是审计日志 API,也不定义 OpenSandbox 的规范可观测 schema」,payload 是给人看的 text/plain,客户端不应按稳定 schema 逐行解析。

2.3 为什么用 OpenAPI(3.1.0)而不是直接写代码

四份规范开头都是 openapi: 3.1.0。用规范先行有三个好处,后面各章会反复受益:

  • 多实现对齐:同一份 sandbox-lifecycle.yml,既约束 Python 服务端(server/),也生成各语言 SDK(sdks/);server/opensandbox_server/api/schema.py 顶部注释就写明「基于 OpenAPI 规范定义数据模型」。
  • 契约即测试基准:改了语义就要改规范,评审能盯住(见 specs/AGENTS.md)。
  • 状态语义集中定义:整个平台的「沙箱有哪几种状态、能怎么转」这件最核心的事,由 sandbox-lifecycle.yml 一处定义(下一节详解),各后端只能实现它、不能各自发明。

3. 核心机制一:沙箱生命周期状态机

这是全平台最该先记住的一张图。所有后端(Docker、K8s)、所有操作(create/pause/resume/delete)、所有轮询逻辑,都是围着这八个状态转。

3.1 它要解决的小问题

沙箱的每一步(拉镜像、起容器、暂停、销毁)都是异步的——你发一个 POST /pause 不会当场变成「已暂停」,而是「开始暂停中」。所以平台需要一套明确命名的中间态,让调用方能轮询「现在到哪一步了」。状态机就是这套命名 + 允许的流转。

3.2 八个状态 + 一张流转图

八个状态里,六个是「稳定/中间」态,Failed 是「任何关键错误的兜底态」,Terminated 是终态。

先给「怎么读这张图」:正常路径是从左到右一条主线;竖直向下的箭头都是异步操作的「进行中」中间态;最下面那条虚线表示几乎任何活动态出错都会掉进 Failed

create


┌────────┐ provisioning done ┌──────────┐
│Pending │ ────────────────────▶ │ Running │◀───────────┐
└────────┘ └──────────┘ │ resume done
│ │ ▲ │
│ pause req │ │ resume req ┌──────────┐
│ ▼ │ │ Resuming │
│ ┌─────────┐ │ └──────────┘
│ │ Pausing │ │ ▲
│ └─────────┘ │ │
│ │ │ pause done │ resume req
│ ▼ │ │
│ ┌─────────┐─┘ ┌──────────┐
│ │ Paused │ ─────────────▶│ Resuming │
│ └─────────┘ └──────────┘
│ │
│ kill / TTL 到期 / error │ kill / TTL 到期
▼ ▼
┌──────────────────────────────────────┐
│ Stopping │ ── 完成 ──▶ ┌────────────┐
└──────────────────────────────────────┘ │ Terminated │ (终态)
└────────────┘

⚠ Failed(兜底):Pending / Running / Paused / Resuming 任一态遇到关键错误 → Failed

3.3 每个状态一句话 + 允许的流转

状态含义触发它的动作
Pending正在拉镜像/起容器/从快照恢复,尚未就绪POST /sandboxes 后立即进入
Running已就绪,可接受数据面请求provisioning 完成;或 resume 完成
Pausing正在暂停(异步)POST /sandboxes/{id}/pause
Paused已暂停,保留状态,不计费/不占算力(取决于后端)Pausing 完成
Resuming正在从暂停恢复(异步)POST /sandboxes/{id}/resume
Stopping正在销毁(异步)DELETE /sandboxes/{id}、TTL 到期,或 error
Terminated已成功销毁(终态)Stopping 完成
Failed遇到关键错误(终态)任一活动态出错

允许的流转(规范原文):见 specs/sandbox-lifecycle.yml:1041-1048State transitions 列表,以及 info.description 里的生命周期概述 sandbox-lifecycle.yml:13-20。规范特意加了一句「未来可能新增状态值,客户端应优雅处理未知状态」(sandbox-lifecycle.yml:1050)——所以状态是开放字符串,不是封闭枚举(下一节会看到代码里也确实用 str)。

3.4 真实定义处:状态是「字符串」,由运行时实时推导

一个容易踩的认知点:沙箱状态不是数据库里存的一个字段,而是查询时从底层运行时的真实状态「翻译」出来的字符串。

规范侧,SandboxState 被定义成 type: string(不是 enum),把八个值写在描述里(sandbox-lifecycle.yml:1024)。服务端侧,SandboxStatus.state 也是 str:

# server/opensandbox_server/api/schema.py:365 (SandboxStatus.state)
state: str = Field(
...,
description="Current lifecycle state (Pending, Running, Pausing, Paused, "
"Resuming, Stopping, Terminated, Failed)",
)

这个字符串怎么来?Docker 后端读容器的 State 段实时映射:容器 Running && !Paused"Running",Paused"Paused",exited 且退出码非 0 → "Failed"(server/opensandbox_server/services/docker/docker_service.py:535 起的分支)。K8s 后端类似,还会把内部的 "Allocated" 归一成对外的 "Running"(server/opensandbox_server/services/k8s/status_helpers.py:20,_normalize_create_status)。

章节边界: 「Pod/容器的原生状态如何映射成这八个字符串」属于运行时后端的实现,细节在 03-runtime-backends.md。本章只需记住:对外契约是这八个态,后端负责把真实世界翻译成它。

3.5 快照有它自己的一套小状态机

别把快照状态和沙箱状态搞混。快照(Snapshot)是另一条独立生命线,只有四个态:

快照状态含义
Creating快照创建已受理,运行时正在捕获
Deleting删除已请求,清理中
Ready可用于恢复沙箱
Failed创建失败

定义处:schema.py:606(SnapshotStatus,state: str,描述列出四态)与规范 sandbox-lifecycle.yml:928-931。注意约束:只有 Running 的沙箱才能打快照(sandbox-lifecycle.yml:482;服务端在 services/snapshot_service.py:482 检查 state == "Running",否则报 SNAPSHOT::INVALID_SOURCE_STATE)。


4. 核心机制二:请求 / 响应数据模型

状态机是「动词」,数据模型是「名词」。控制面的所有名词都定义在一个文件里:server/opensandbox_server/api/schema.py(972 行)。它是 OpenAPI schema 的 Pydantic 实现,一处定义、请求校验和响应序列化共用。下面只列关键类,不全贴——要看全部字段请按引用打开该文件。

4.1 关键模型速查表

模型(类)定义处作用记住的点
CreateSandboxRequestschema.py:391创建沙箱的入参imagesnapshotId 二选一;字段用 camelCase 别名
Sandboxschema.py:565沙箱资源的完整表示内含 status: SandboxStatus
CreateSandboxResponseschema.py:537创建的精简返回不含 image/updatedAt
SandboxStatusschema.py:361状态 + reason + message + 转换时间承载第 3 节的八态字符串
Snapshot / SnapshotStatusschema.py:643 / 606快照资源与其四态独立生命线(见 3.5)
Endpointschema.py:814访问沙箱内服务的对外端点endpoint 字符串 + 可选 headers
SandboxFilter / SnapshotFilterschema.py:713 / 671列表过滤条件state(OR 逻辑)、metadata(AND 逻辑)
PaginationRequest / PaginationInfoschema.py:728 / 756分页入参 / 出参pageSize 默认 20、上限 200
ErrorResponseschema.py:834所有非 2xx 的统一错误体{ code, message } 两字段

4.2 CreateSandboxRequest:契约里最「重」的一个模型

创建请求是整份控制面契约里字段最多、校验最密的模型。抓住三条主干就够(细节字段留给后面各章):

(1) 来源二选一:镜像 或 快照。 image(ImageSpec,含 uri 和可选私仓 auth)与 snapshotId 互斥,model_validator 强制「恰好提供一个」:

# server/opensandbox_server/api/schema.py:516 (validate_source_and_entrypoint 片段)
if has_image == has_snapshot:
raise ValueError("Exactly one of image or snapshotId must be provided.")
if has_image and not self.entrypoint:
raise ValueError("Entrypoint is required when image is provided.")

这段校验器还处理了 poolRef(池化)、credentialProxy(凭证代理需配 networkPolicy)等交叉约束——这些字段各自属于后面章节的主题(池化/暂停恢复见 03,网络与凭证见 06),本章只标记「它们在创建契约里以可选字段存在」。

(2) 资源与时长。 resourceLimits(硬上限)、resourceRequests(预留下限)、timeout(秒,ge=60,省略/null 则不自动过期)。ResourceLimits 是个 Dict[str,str] 的 RootModel,形如 {"cpu":"500m","memory":"512Mi","gpu":"1"}(schema.py:75)。

(3) 编排杂项。 envmetadata(用于过滤/打标)、entrypointvolumesextensions(不透明的 provider 私有参数)。

4.3 分页与过滤:列表接口的统一形状

列表类接口(GET /sandboxesGET /snapshots)共用一套「过滤 + 分页」形状:

  • 过滤:SandboxFilter.state 是列表、OR 语义(?state=Running&state=Paused);metadataAND 语义(schema.py:717/722)。
  • 分页出参 PaginationInfototalItems/totalPages/hasNextPage,让客户端能翻页(schema.py:756)。

4.4 Endpoint:连接控制面与数据面的那把钥匙

第 2.1 节说过「数据面/出站面要先解析端点再直连」。这个「解析」的返回就是 Endpoint 模型:

# server/opensandbox_server/api/schema.py:814 (Endpoint)
class Endpoint(BaseModel):
endpoint: str # host[:port]/path,对外暴露的服务端点
headers: Optional[dict[str, str]] # 访问该端点需要携带的头(如基于头的路由)

对应控制面接口 GET /sandboxes/{sandboxId}/endpoints/{port}(sandbox-lifecycle.yml:623)。它是「控制面」交给你、去敲「数据面/出站面」的门牌号——headers 字段尤其关键:开了 secureAccess 或 egress 鉴权时,平台会在这里返回你必须回带的头。具体路由与网关模式见 06


5. 核心机制三:鉴权契约

四个面的鉴权不是一套,而是各面一套。这是「按面切分」的直接结果——不同网络位置、不同信任边界。

5.1 四面鉴权对照

securityScheme 定义处备注
控制面OPEN-SANDBOX-API-KEYsandbox-lifecycle.yml:686(apiKeyAuth,in: header)平台级 API Key
诊断面OPEN-SANDBOX-API-KEYdiagnostic-api.yml:184同控制面
数据面X-EXECD-ACCESS-TOKENexecd-api.yaml:1657(AccessToken)每沙箱一个 token,execd 强制要求
出站面OPENSANDBOX-EGRESS-AUTHegress sidecar 可选要求sidecar 需要时,由端点解析返回该头

5.2 控制面 API Key:头 vs 环境变量,别搞混两个环境变量

规范面向 SDK 的说法是:HTTP 头 OPEN-SANDBOX-API-KEY,或让 SDK 客户端从环境变量 OPEN_SANDBOX_API_KEY 自动取(sandbox-lifecycle.yml:26-38 的 Authentication 段;docs/api/index.md:43-44 也这么写)。注意区分方向:

  • OPEN_SANDBOX_API_KEY —— 客户端 / SDK 侧读取的环境变量(SDK 自动把它塞进 OPEN-SANDBOX-API-KEY 头)。
  • OPENSANDBOX_SERVER_API_KEY —— 服务端 侧配置有效 key 的环境变量(server/opensandbox_server/config.py:44,API_KEY_ENV_VAR)。

二者名字相近但角色相反:一个是「客户端拿去敲门的」,一个是「服务端用来配门锁的」。

5.3 服务端怎么校验(中间件)

服务端用一个 FastAPI 中间件做统一校验,逻辑很直白:取 OPEN-SANDBOX-API-KEY 头,和配置里的 key 集合比对,不匹配就 401。

# server/opensandbox_server/middleware/auth.py:108 (dispatch 片段)
api_key = request.headers.get(SANDBOX_API_KEY_HEADER) # "OPEN-SANDBOX-API-KEY"
if not api_key:
return JSONResponse(401, {"code": "MISSING_API_KEY", ...})
if self.valid_api_keys and api_key not in self.valid_api_keys:
return JSONResponse(401, {"code": "INVALID_API_KEY", ...})

几个契约细节值得记:

  • 豁免路径:/health/docs/redoc/openapi.json 不校验(auth.py:43)。
  • 代理路由豁免:形如 /sandboxes/{id}/proxy/{port}/… 的沙箱代理路由豁免 API Key,但用严格正则挡掉路径穿越(..)(auth.py:47 _PROXY_PATH_RE_is_proxy_path)。
  • 未配置 key = 不鉴权:若服务端没配任何 key(集合为空),中间件直接放行(auth.py:104)——方便本地开发,但生产必须配 key

5.4 错误码契约:两级命名空间

鉴权失败返回的 code(如 MISSING_API_KEY)和业务错误码,统一走 ErrorResponse { code, message } 形状(schema.py:834)。业务侧的错误码集中在一处、按子系统加命名空间前缀(DOCKER::KUBERNETES::VOLUME::SNAPSHOT::):

# server/opensandbox_server/services/constants.py:62 (SandboxErrorCodes 摘录)
SANDBOX_NOT_FOUND = "DOCKER::SANDBOX_NOT_FOUND"
SANDBOX_NOT_RUNNING = "DOCKER::SANDBOX_NOT_RUNNING"
INVALID_STATE = "KUBERNETES::INVALID_STATE" # 状态机非法流转的兜底码
# ... 以及 SnapshotErrorCodes.INVALID_SOURCE_STATE = "SNAPSHOT::INVALID_SOURCE_STATE"

其中和本章状态机直接相关的是 INVALID_STATE(constants.py:132)与 SNAPSHOT::INVALID_SOURCE_STATE(constants.py:138)——它们是「你在错误的状态上做了不允许的转换」时的返回码(比如对非 Running 沙箱打快照)。完整清单见 SandboxErrorCodes(constants.py:62)与 SnapshotErrorCodes(constants.py:135)。


6. 边界与局限(契约刻意不做什么)

诚实地划一下这层契约的边界,免得误读:

  • 诊断面不是可观测性方案。 它明确声明自己是「尽力而为的排障文本」,不定义结构化 schema、不承诺保留/过滤/流式(diagnostic-api.yml:6-11)。要长期遥测得另想办法。
  • 状态是开放字符串,不是封闭枚举。 规范预留了「未来加状态」的余地(sandbox-lifecycle.yml:1050),客户端必须容忍未知值——别把 state 硬编码成穷举枚举。
  • 状态由运行时实时推导,可能有映射细节差异。 不同后端把原生状态翻译成这八个态的规则不完全一样(Docker 看容器 State、K8s 看 Pod phase 并归一 Allocated)——契约保证的是对外的八个名字,不保证底层一一对应。细节见 03
  • null timeout(不自动过期)不是所有后端都支持。 CreateSandboxRequest.timeout 描述里就写明:K8s provider 在 workload provider 不支持长驻沙箱时,可能拒绝 null timeout(schema.py:413-423)。契约允许,后端未必都能兑现。
  • 本章不含转换/执行实现。 「create 请求如何落到真实容器」见 02/03;「execd 在沙箱内怎么跑代码/文件」见 04;嵌套隔离见 05;网络与凭证保险箱见 06

7. 代码地图(导航索引)

按符号名 grep 比按行号更抗漂移。下面每行是「你想搞清某件事时该打开的文件 + 符号」。

想搞清什么文件路径符号 / 锚点
控制面全部接口与状态机描述specs/sandbox-lifecycle.ymlSandboxState(:1024)、State transitions(:1041)
数据面接口 + 鉴权头specs/execd-api.yamlservers(:27)、AccessToken(:1657)
出站面接口 + 访问模型specs/egress-api.yamlservers(:30)、Access Model(:16)
诊断面接口(非可观测)specs/diagnostic-api.ymlinfo.description(:6)、apiKeyAuth(:184)
分层与 base URL 官方说明docs/api/index.mdbase URL 段(:8)
沙箱状态模型server/opensandbox_server/api/schema.pySandboxStatus(:361)
创建请求与其校验器server/opensandbox_server/api/schema.pyCreateSandboxRequest(:391)、validate_source_and_entrypoint(:493)
沙箱 / 快照资源模型server/opensandbox_server/api/schema.pySandbox(:565)、Snapshot(:643)
端点 / 分页 / 过滤 / 错误server/opensandbox_server/api/schema.pyEndpoint(:814)、PaginationInfo(:756)、SandboxFilter(:713)、ErrorResponse(:834)
Docker 原生状态 → 八态映射server/opensandbox_server/services/docker/docker_service.py状态分支(:535 起)
K8s 状态归一(Allocated→Running)server/opensandbox_server/services/k8s/status_helpers.py_normalize_create_status(:20)
控制面鉴权中间件server/opensandbox_server/middleware/auth.pyAuthMiddleware.dispatch(:83)、SANDBOX_API_KEY_HEADER(:31)
服务端 API Key 环境变量server/opensandbox_server/config.pyAPI_KEY_ENV_VAR(:44)
错误码命名空间server/opensandbox_server/services/constants.pySandboxErrorCodes(:62)、SnapshotErrorCodes(:135)