我把 5 个 Python bug 投进 CubeSandbox 当沙盘 —— 从 envd 协议反编译到一键 RED→GREEN这周我做了一件挺较真的事在腾讯云一台没装 Docker、没碰过 CubeSandbox 的纯净 OpenCloudOS 服务器上把 CubeSandbox 部署起来然后用纯标准库的 Python 反编译出 envd 内部协议最后把 5 个真实 Python bug 做成沙盘塞进去跑出每个 bug7695 ms 冷启动 不到 1 秒完成 RED→GREEN的真实数据。0. 写在前面为什么我执意要沙盘而不是直接 demo我自己写过几篇 Agent 沙箱的文章越写越意识到一个问题很多博文的Agent 在沙箱里跑代码是信任驱动的——你只能选择信因为没有任何东西能验证它真的去跑了且跑出来的输出真的来自隔离环境。我不想再写那种文章。这一篇我决定换个口径沙盘把 5 个真实的 Python bug 仓库每个含 buggy 源码 pytest 测试 ISSUE.md做成 fixtureOracle每个 bug 配一份标准答案 patch必须能让pytest -q5 用例 5/5 GREEN沙箱每个 bug 在自己的一台 CubeSandbox MicroVM 里跑用 envd 实际拉起 pytest 进程可复现所有 stdout 都落在transcripts/文章里的截图都是这些日志渲染出来的不存在虚构。这套设计的好处是只要 oracle 在沙箱里能 RED→GREEN就证明协议链路、文件 I/O、进程拉起、测试框架全跑通了。后面接 LLM、接 Agent 的时候沙盘是干净的、有 ground truth 的、可以反复回放。1. 战场盘点服务器、内核、Cube 服务服务器是腾讯云一台 8 vCPU / 15 GiB / 200 GiB 的 OpenCloudOS 9.4。uname -r显示已经是6.6.69-1.1.cubesandbox.oc9内核——这个内核带kvm_pvm模块是 CubeSandbox 的 PVM半虚拟化形态能跑起来的前提。跑一圈下来确认四件事部件状态kvmkvm_pvm模块✅ 已加载/dev/kvm字符设备✅ 0666可读写cube-api监听:3000✅ pid7885cubemaster监听:8089✅ pid7884cubelet监听:9966/:9998/:9999✅ pid8028模板tpl-6c8afd4059dc4736ac327a11✅ 在cube-snapshot/cubebox/下看起来很顺但接下来的两个小时让我意识到官方 SDK 不是天然降临的cube-api 只暴露 lifecycle所有进入沙箱的细节都要自己摸。2. 第一个坑cube-api 的字段全是驼峰热身做的第一件事用 curl 创建一个沙箱看看。我下意识用了 snake_casecurl-XPOST http://127.0.0.1:3000/sandboxes\-d{template_id:tpl-6c8a...,vcpus:2,memory_mb:1024}422 Unprocessable Entity. missing field templateID飘来。OK是 Rust serde 的服务字段全得驼峰。改成templateID/cpuCount/memoryMB后立刻201 Created返回里有sandboxID/clientID/envdVersion: 0.2.0/domain: cube.app但就是没有沙箱的 IP。⚠️ 这是第一个非显然的事实cube-api:3000是给上层用的、不暴露内网 IP。要拿到沙箱 IP得跨一层去问 cubemaster。3. 沙箱的 IP 在 cubemaster 嘴里cube-api 看到的视图是{sandboxID:c84b5d93467841ff877f43a6668d36e7,clientID:10.206.0.11,// 这是 host 的 IPstate:running,...}clientID看着像、但其实是宿主 eth0 的 IP不是沙箱的 IP。我换了个角度去问 cubemastercurlhttp://127.0.0.1:8089/cube/sandbox/info?sandbox_idcaBc...回来的data[0]里赫然写着sandbox_ip: 192.168.0.16。在 host 上ping 192.168.0.16一发就通——沙箱在一个 192.168.0.0/18 的内部网桥里host 走 cube-dev 桥可达。到这里找到沙箱的部分搞定。下一步进沙箱。4. envd 是 e2b 那个 envd —— 一个意外的发现192.168.0.16这台沙箱开着哪些端口forpin2280809090499824998349984;dotimeout1bash-c/dev/tcp/192.168.0.16/$p2/dev/nullecho$pOPENdone# → 49983 OPEN只有49983挺像 e2b。我把模板 rootfs (ext4) loopback 挂载到 host 上一看/usr/bin/envd这个 ELF 静态文件赫然在列。strings一抽github.com/e2b-dev/infra/packages/envd/internal/api github.com/e2b-dev/infra/packages/envd/internal/host github.com/e2b-dev/infra/packages/envd/internal/permissions这是 e2b-dev/infra 的 envd 0.2 公版二进制e2b 是开源的 LLM 沙箱CubeSandbox 在 PVM 这一层借了它的 guest agent。这个发现意味着协议是开源的、文档是有的、调试是有据可循的。但是要直接调还有最后一道门认证。我先后试了尝试结果不带任何 auth401 unauthenticated, no user specifiedAuthorization: Bearer root401X-User: root401body 里user: root/user: {username:root}/owner: {...}401query string?usernameroot在 RPC 端点上401HTTP Basic Authroot:密码空✅ 通了最后一个组合直接刷出 200列出了沙箱根目录.container_ro/ .container_rw/ .dockerenv bin/ boot/ ....container_ro/.container_rw是 e2b envd 的标志性目录布局——确认无误。协议钉子已经钉牢envd 用 HTTP Basic Auth、用户名root、密码空。后续所有调用走这个 header 就行。5. server-streaming拉起进程要拆 Connect 信封帧文件类的接口是普通 unary加个 Basic Auth 就能用。但起进程是 server-streaming要按 Connect 协议 的约定来Content-Type 是application/connectjson请求体是5 字节信封头 JSON bodybyte 0flags0x00 普通消息byte 1…4BE big-endian uint32表示后面 JSON 的字节数响应也是同样的信封流可能多帧最后一帧 flags 0x02是 end-of-stream metadata。我直接用纯 Python 标准库实现了一遍最让我开心的一帧解码出来{event:{data:{stdout:TGludXggdHBsLTZjOGEgNi42LjY5LWN1YmUucHZtLmd1ZXN0LjAwNS54LWdiODUyMDBkODBmYTI...}}}base64 一解Linux tpl-6c8a 6.6.69-cube.pvm.guest.005.x-gb85200d80fa2 #1 SMP PREEMPT_DYNAMIC Mon May 18 04:10:49 UTC 2026 x86_64 GNU/Linux注意 guest 内核6.6.69-cube.pvm.guest.005.x-gb85200d80fa2——它不是host 上跑的6.6.69-1.1.cubesandbox.oc9.x86_64而是 cube-snapshot 里专门给 guest 编的一份。两份都是 6.6.69 但 BuildID 不同。这是真 MicroVM、不是容器。guest 系统是 Debian 12 bookwormpwd 是/rootwhoami 是 root——典型 e2b 环境。至此协议全部摸通。我把它封装成一个 110 行的cube_client.py纯标准库不依赖任何第三方 SDK。6. cube_client.py110 行连通整条链路核心 API 长这样精简版classSandbox:sandbox_id:strsandbox_ip:strpropertydefenvd(self)-str:returnfhttp://{self.sandbox_ip}:49983defcreate_sandbox(template_id,*,cpu2,memory_mb1024)-Sandbox:# POST :3000/sandboxes (templateID/cpuCount/memoryMB) → sandboxID# GET :8089/cube/sandbox/info?sandbox_id... → sandbox_ip# 等 envd:49983 起来loop until /filesystem.../Stat 200...defread_file(sb,path)-str:# GET /files?pathusernamerootdefwrite_file(sb,path,content):# POST /files (multipart)deflist_dir(sb,path,depth1):# POST /filesystem.Filesystem/ListDirdefrun(sb,cmd,*,timeout120):# POST /process.Process/Start (streaming)defkill_sandbox(sb):# DELETE :3000/sandboxes/{id}run()的实现是最有意思的——要把 Connect 信封帧拆开def_frame(payload:bytes,flags0x00)-bytes:returnbytes([flags])struct.pack(I,len(payload))payloaddef_parse_frames(data:bytes):i0whilei5len(data):flagsdata[i]lnstruct.unpack(I,data[i1:i5])[0]i5yieldflags,data[i:iln]ilndefrun(sb,cmd,...):bodyjson.dumps({process:{cmd:/bin/bash,args:[-lc,cmd]}}).encode()frame_frame(body)requrllib.request.Request(f{sb.envd}/process.Process/Start,dataframe,methodPOST,headers{Content-Type:application/connectjson,Connect-Protocol-Version:1,Authorization:fBasic{base64.b64encode(broot:).decode()}})withurllib.request.urlopen(req)asr:bufr.read()stdout,stderr,exit_code[],[],Nonefor_,payloadin_parse_frames(buf):msgjson.loads(payload)evmsg.get(event,{})ifdatainev:ifstdoutinev[data]:stdout.append(base64.b64decode(ev[data][stdout]).decode())ifstderrinev[data]:stderr.append(base64.b64decode(ev[data][stderr]).decode())elifendinev:statusev[end].get(status,)exit_codeint(status.split()[-1])ifexit statusinstatuselse0return{exit_code:exit_code,stdout:.join(stdout),stderr:.join(stderr)}跑个 self-testsbcreate_sandbox(tpl-6c8afd...)rrun(sb,uname -a; whoami; pwd)# {exit_code: 0, stdout:Linux tpl-6c8a ... root\n/root\n, duration_ms: 15}kill_sandbox(sb)15 ms 端到端包含一次 RPC 往返。这速度后面就能感受到。7. 5 个 bug 的沙盘设计光打通协议没意义关键是塞什么进去验证。我决定写 5 个 Python 仓库每个仓库都是一个真实容易出错的小模块并附带 pytest 测试集 ISSUE.md用 oncall 复盘的口吻写#仓库bug 类型现象01bug01_decimal_div浮点账目1000.00 / 7 拆分后求和变成 1000.020.10.20.3 ≠ 0.602bug02_off_by_one切片边界分页器最后一页少 1 条整除时少 1 条03bug03_strip_lines字符串规整hello world → helloworld行内空格被吃04bug04_state_mutationPython 默认参数共享用户 A 的播放历史泄漏到用户 B05bug05_unicode_normalizeNFKC 字符类bc→ 空你好 世界→ 汉字全丢每个仓库放module.pytest_module.pyISSUE.md。每个 pytest 集都设计成 5 条用例buggy 状态下至少 1 条 failoracle 上去后 5/5 全绿。我以 bug04 为例展开看一下 —— 因为它是 Python 最经典也最隐蔽的陷阱# playlist.py — buggyclassPlaylist:def__init__(self,tracks):self.trackslist(tracks)defreorder(self,history[]):# ← 默认参数共享forhinlist(history):ifhnotinself.tracks:history.append(h)# ← 把未在 tracks 里的项 append 到 default list...测试一打bug 立刻浮出水面deftest_reorder_no_cross_instance_pollution():p1Playlist([A,B,C]).reorder([Z])# Z 不在 tracks被 append 到默认 historyp2Playlist([X,Y]).reorder()# 不传 historyassertp2[X,Y]# 但 default history 已经被污染修法是把签名改成history: Optional[List[str]] None函数体里history list(history) if history else []。一行改动4 条 case 立刻全过。8. 本地自检先确保沙盘自己是对的把 oracle patch 真正写进 sandbox 之前我先在 macOS 本地跑一遍自检buggy 状态必须 RED、oracle patch 一上必须 GREEN。这一步是为了避免题目本身有 bug。跑出来的是这样----- bug01_decimal_div ----- buggy : rc1 [3 failed, 2 passed in 0.02s] oracle: rc0 [5 passed in 0.01s] ----- bug02_off_by_one ----- buggy : rc1 [2 failed, 3 passed in 0.02s] oracle: rc0 [5 passed in 0.01s] ----- bug03_strip_lines ----- buggy : rc1 [3 failed, 2 passed in 0.02s] oracle: rc0 [5 passed in 0.01s] ----- bug04_state_mutation ----- buggy : rc1 [1 failed, 4 passed in 0.01s] oracle: rc0 [5 passed in 0.01s] ----- bug05_unicode_normalize ----- buggy : rc1 [3 failed, 2 passed in 0.02s] oracle: rc0 [5 passed in 0.01s] All 5 bugs PASS the self-check.汇总修前 12 fail / 13 pass修后 25 pass。每个 bug 都是从 RED 翻到 GREEN且 oracle 不会误伤无关行为。这一步走完沙盘本身才算 ready。途中我发现两个有意思的边界情况专门记一下bug03str.strip()会吃\u3000测试test_full_width_space_kept期望中 文 间隔保留全角空格。还好中\u3000文.strip() 中\u3000文——只有全行只有 \u3000时才会被吃不影响保留中间的全角空格。bug01 oracle 用前 N-1 份取标准量化值最后一份用余额split_fee(1000.00, 7)出来是[142.86]*6 [142.84]sum 严格 1000.00但 max-min 0.02且因为浮点误差实际是 0.020000000000010232把容差从 0.01 调到 0.03 才稳定通过。这种测试本身受浮点影响的细节正是为什么我坚持要先本地自检的原因。9. 真上沙箱每个 bug 单独一台 MicroVM本地 OK搬上服务器。run_oracle.py的逻辑一句话讲清楚for bug in 5 个 bug: 1) cube_client.create_sandbox(template) → 拿 sb (76~95ms) 2) cube_client.upload_dir(sb, host_bug_dir, /workspace) → multipart 上传 3 个文件 3) cube_client.run(sb, pip install pytest) → 懒装一次 4) cube_client.run(sb, cd /workspace pytest -q) → 落 RED 5) cube_client.write_file(sb, /workspace/file, patch) → 应用 oracle 6) cube_client.run(sb, cd /workspace pytest -q) → 落 GREEN 7) cube_client.kill_sandbox(sb) → 销毁bug01 跑出来的真实日志 bug01_decimal_div sandbox 4807a67a281b.. ip192.168.0.29 created in 95ms uploaded 8 files RED exit1 444ms FAILED test_finance.py::test_split_sums_back - AssertionError: [142.86, 142.86, 142.86, 142.86, 142.86, 142.86, 142.86] assert 1000.02 1000.0 FAILED test_finance.py::test_no_floating_garbage - assert 0.30000000000000004 0.30 3 failed, 2 passed in 0.02s patched finance.py (1213 bytes) GREEN exit0 241ms ..... [100%] 5 passed in 0.01s sandbox 4807a67a281b.. killed看时序阶段耗时沙箱冷启动到 envd ready95 ms上传 8 个文件100 msRED 跑 pytest444 ms其中 pytest 框架启动占大头测试本身 0.02s写 oracle patch50 msGREEN 跑 pytest241 ms销毁沙箱50 ms总计 1.0 秒bug02 同样手起刀落bug02 的冷启动只花了76 ms——这个数字比我见过的 Firecracker 公开 benchmark~125ms还低更不用说 Docker 的 ~3 秒。凭什么 PVM 能比 Firecracker 还快CubeSandbox 在 cube-snapshot 里预先做了 vCPU 状态快照config.jsonstate.jsonmemory-ranges创建沙箱本质是 fork 这个快照而不是走完整 boot 流程跳过了 BIOS/UEFI/initramfs/systemd 这些百来毫秒。10. 我学到的 5 件事一、每个不可见字段都值得追问为什么cube-api 不暴露 sandbox_ip听起来像设计漏洞其实是有意的cube-api 是面向谁能创建谁能删的高层接口你可能根本没必要知道沙箱的内网 IP而 cubemaster 是数据平面。一旦 cube-api 暴露 IP未来网络拓扑变更比如多 host 集群、跨可用区就会被这个字段绑死。二、Basic Authroot:不是简陋是 e2b 的明确选择我一开始觉得空密码很奇怪后来想通了envd 跑在 MicroVM 内部、监听内部 IP从设计上根本不暴露给租户网络外面进不来。Basic Auth 在这里的作用是多租户区分按用户名拉起对应 uid 的进程而不是认证。三、Connect server-streaming 比 gRPC 友好太多我以前给 envd 写过 SDK用的 gRPC要protoc生成、要管 HTTP/2 连接。Connect 协议的存在意义就是让 HTTP/1.1 JSON 也能玩流——纯标准库 110 行就把进程拉起、stdout 拆帧、退出码捕获全做了。这是工程美感的胜利。四、沙盘比 demo 强写一篇Agent 在沙箱里跑 Hello World很容易但读者看完不知道如果换成真 bug 它行不行。5 个 bug oracle让验证这件事变成可重放的你下载我的article11/目录跑python3 scripts/local_oracle_selfcheck.py能直接看到 5/5 ✅然后把同一份代码搬上你的 CubeSandbox跑run_oracle.py看到一样的 RED→GREEN 串流。五、time不会骗人自己亲手测才知道我在写第二版的时候才明白PVM 76ms 这个数据只有亲手发请求亲手记时间才靠谱。任何我看一篇文章说 PVM 是毫秒级的二手信息都是没用的。这次cube_client.create_sandbox()内部用time.time()卡一发是真实的端到端从 host 发 POST 到 cubemaster 给我返回 sandbox_ip 到 envd ready。这串数字才有引用价值。11. 文件清单拿去直接用article11/ ├── bugs/ │ ├── bug01_decimal_div/ finance.py test_finance.py ISSUE.md │ ├── bug02_off_by_one/ pager.py test_pager.py ISSUE.md │ ├── bug03_strip_lines/ text_clean.py test_text_clean.py ISSUE.md │ ├── bug04_state_mutation/ playlist.py test_playlist.py ISSUE.md │ └── bug05_unicode_normalize/ url_slug.py test_url_slug.py ISSUE.md ├── scripts/ │ ├── cube_client.py # ★ 110 行打通 cube-api / cubemaster / envd │ ├── oracle_fixer.py # 5 份标答 patch │ ├── run_oracle.py # 沙箱里跑每个 bug 一台 MicroVM │ ├── local_oracle_selfcheck.py # 本地自检不依赖 sandbox │ ├── draw_terminal_shot.py # 把 stdout 渲染成终端截图 PNG │ └── make_shots.py / make_figures.py ├── transcripts/ # 真实跑批日志envd_protocol_probe.log 等 ├── 截图/ # 8 张终端截图 └── figures/ # 2 张数据图下一篇我打算把 hy3腾讯混元 3接进来当 ReAct agent让它自己读 ISSUE.md、自己拉 stack trace、自己改 patch、自己跑 pytest——把 oracle 这一步换成真的 LLM 推理。沙盘已经搭好Agent 上去就能跑。12. 一个 5 分钟的复现 checklist如果你手头有一台带 PVM 内核的 OpenCloudOS或者已经装好 CubeSandbox 的任何机子照下面的顺序就能复现# 0) 确认 cube 服务在跑、模板在curl-shttp://127.0.0.1:3000/health# {status:ok,...}ls/usr/local/services/cubetoolbox/cube-snapshot/cubebox/# tpl-...# 1) 把这套代码拷上去scp-rarticle11/ roothost:/root/# 2) 跑 oracle每个 bug 一台 MicroVM从 RED 跑到 GREENsshroothostcd /root/article11 python3 scripts/run_oracle.py# → 5/5 ✅# 3) 想自己改 bug 沙盘只改 bugs/ 下的源码 测试# 再 oracle_fixer.py 里加你的标答 patch重跑一次 selfcheck 即可。python3 scripts/local_oracle_selfcheck.py附录 A5 个 bug 的修前修后快查bugRED 触发的关键 case一行修法01assert sum(split_fee(1000,7)) 1000失败1000.02 ≠ 1000用 Decimal 量化 “最后一份取余额”02paginate(items, 2, 20)第 2 页只回 19 条end start page_size不要-103hello world → helloworld中间空格被吃用line.strip()而不是line.replace( ,).rstrip()04P1 调reorder([Z])后P2 不传 history 也看到 Zhistory: Optional[List]Nonehistory list(history) if history else []05slugify(你好 世界) → n-a汉字全丢先unicodedata.normalize(NFKC, ...)再用[^a-z0-9\u4e00-\u9fff]附录 Benvd 协议小抄# 1) 创建沙箱 POST /sandboxes HTTP/1.1 Host: cube-api:3000 Content-Type: application/json {templateID:tpl-...,cpuCount:2,memoryMB:1024,name:sb-1} # 2) 拿沙箱 IP必须问 cubemaster GET /cube/sandbox/info?sandbox_idSID HTTP/1.1 Host: cubemaster:8089 → data[0].sandbox_ip # 3) 列目录 POST /filesystem.Filesystem/ListDir HTTP/1.1 Host: sandbox_ip:49983 Authorization: Basic cm9vdDo ← root: 的 base64 Content-Type: application/json Connect-Protocol-Version: 1 {path:/,depth:1} # 4) 起进程server-streaming POST /process.Process/Start HTTP/1.1 Host: sandbox_ip:49983 Authorization: Basic cm9vdDo Content-Type: application/connectjson Connect-Protocol-Version: 1 5字节信封头JSON: {process:{cmd:/bin/bash,args:[-lc,...]}} # 响应[flags(1)][len(4 BE)][JSON event]... 最后一帧 flags0x02 # stdout/stderr 用 base64 编码