大家好,我是小木头。上一期视频,我们用 OpenAI 官方托管的沙箱环境把 Agents API 跑通了:创建了 session、跑了命令、下载了 artifact,在任务执行中也加载了 Skill。官方托管的沙箱环境只是执行环境的一种,Agents API 还支持另一种环境类型叫 self_hosted。执行器可以跑在咱们自己的基础设施里,OpenAI 只负责调度。这一期我分享一个刚刚开源的小项目叫 apple-sandbox,它用 Apple 官方的 container 工具在 Mac 上给每个 session 起一个独立的轻量级的虚拟机,把 Agents API 的执行器跑在这里面。不需要云账号、不需要公网地址,也不需要 Docker。项目已经做过端到端的验证,在测试中能够看到真实的 session 的创建、真实的任务执行,结果文件也能够直接落到咱们本机的磁盘上。
今天的内容咱们分三块吧:先介绍这个项目是什么、架构是什么;然后重点说说它和 Agents API 之间的连接方式,尤其是没有公网地址的情况下,它是怎么和 OpenAI 协同工作的;最后用发布版本从安装到跑任务完整的演示一遍。apple-sandbox 是一个用 Go 语言编写的命令行工具,没有常驻进程,目前呢也没有守护程序。它只做一件事情:把 OpenAI 的 codex exec-server 这个执行器跑进一个隔离的虚拟机里。底层用的是 Apple 去年开源的 container 项目,它和 Docker 最大的区别是隔离级别:Docker 上所有容器共享一个 Linux 内核,Apple container 是每个容器单独起一个轻量级的 Linux 虚拟机。
基于 macOS 自带的 Virtualization.framework,所以在 apple-sandbox 里面一个 session 就是一个 OCI 容器,也就是一台独立的虚拟机。agent 在里面装软件、跑脚本都不会离开这台虚拟机。从下往上来看看整个结构,目前呢是 4 层:最底下是 Apple Silicon 的 Mac,装好 container 工具,执行 container system start 把它的服务启动起来;往上一层是 Apple container 本身,负责从 OCI 镜像启动虚拟机;再往上是执行器的镜像,基于 node 22 的精简版,安装了 CA 证书和 codex 这个 npm 包,入口脚本里面执行了 codex exec-server;最上面才是 apple-sandbox 这个 CLI,它负责创建工作目录、注入环境变量、调用 container run,然后呢就退出,把虚拟机留在后台跑。
有一个设计方面的细节需要单独聊聊:每个 session 在本机的 sandboxes 目录下有一个自己的子目录,直接挂载成虚拟机里的 /workspace。所以文件进出不需要任何上传下载接口:往那个目录里放一个文件,虚拟机里马上就能看到;agent 写出来的结果本机磁盘上也能马上读到。虚拟机是随时可以销毁的,磁盘上这个目录才是真正的状态。命令一共 8 个:doctor(检查环境)、build(构建执行器镜像)、run(启动一个 session)、logs(看日志)、ls(列出 session)、stop 和 rm(分别是停止以及删除),可以用 prune 命令按时间批量地清理已经停掉的 session。
再来看看它在整个链路里的位置:第一步,你的应用用普通的 API key 来调用 Agents API,创建一个 self_hosted 类型的 session。返回里除了 session ID,还有两个关键字段:environment.id 和一个远端的地址 remote_url。这两个值是给执行器用的。第二步,在你的 OpenAI 平台上单独建一个受限的 API key。拿普通的 API key 去注册执行器,API 会直接返回 403,通常会看到这么一个错误说缺少 api.agents.environments.connect 这个 scope。第三步是把这个受限的 API key 放进环境变量 OPENAI_EXECUTOR_API_KEY,执行 apple-sandbox run,传入 environment.id 和 remote_url。CLI 会创建工作目录,把这三个值作为环境变量注入,调用 container run 启动一台虚拟机。
虚拟机里的入口脚本把受限的 API key 转成 codex exec-server 自己要求的变量名 CODEX_API_KEY,然后呢启动 exec-server。第四步,exec-server 用这个 API key 主动向 remote_url 发起一个 WebSocket 连接完成注册。OpenAI 那边把 session 标记成活跃,之后每一轮任务都是 OpenAI 那边发命令,虚拟机在 /workspace 里执行结果呢再回传。你的应用通过 Agents API 提交任务、订阅事件流,看到的和 OpenAI 托管的 sandbox 完全一样。整个过程里 apple-sandbox 从头到尾没有和 OpenAI 通信,它只负责把三个参数变成一台跑着执行器的虚拟机,然后呢就退出了。
这段是这期里面我想再聊聊的一部分:首先看托管的 sandbox,什么都不用管,OpenAI 自己起环境。如果大家看过 Cloudflare 官方给 Agents API 写的参考实现,它是云端部署的典型形态:你部署一个 worker,公网地址有了。OpenAI 通过带签名的 webhook 把 session 的事件推送过来:有任务要执行、状态空闲、执行失败等等,这些呢都是事件会推送过来的。而 worker 收到事件再去启动或唤醒容器,容器里的执行器向 OpenAI 建立 WebSocket 连接。也就是说在那套方案里面 webhook 和 WebSocket 各管一件事:WebSocket 是执行器和 OpenAI 之间真正的交换命令和结果,永远都是从你这边主动出站;而 webhook 只解决一个问题:OpenAI 什么时候需要算力,怎么通知你把容器启动起来。
而 apple-sandbox 跑在一台个人的 Mac 电脑上,没有公网地址,也就没法给 OpenAI 提供一个接收 webhook 的地址。那怎么办呢?答案其实也在 OpenAI 自己文档里:self_hosted 环境(也就咱们这种自己运行的沙箱环境)有两种同样都可以认为是正式的启动方式。一种叫 webhook-managed,就是刚才咱们谈到的像 Cloudflare 那种:OpenAI 决定什么时候要算力,推送事件给你。另一种呢叫 application-managed:你的应用创建 session 的那一刻就已经拿到了环境的 ID(也就是 environment.id)和 remote_url,直接同步把算力呢启动起来,不需要等待 OpenAI 的通知。
Cloudflare、Vercel、Modal 的集成文档都把这两种并列成为选项,而不是主备关系。apple-sandbox 实现的正是 application-managed:创建 session、拿到两个参数,立即执行 apple-sandbox run。三步呢是在一起的:webhook 那条路解决的是规模化场景下按需启动算力的问题,一个开发者呢在自己的 Mac 上跑其实并没有很具体的这方面的问题。真正的数据通道两种方式是一样的:都是执行器主动出站,发起一个 WebSocket 连接;连接断了自己重连。OpenAI 从来不需要反向地连你的机器,所以少了公网地址少的只是提前收通知这件事。
那少了通知还剩一件事情要自己处理:session 什么时候结束?虚拟机什么时候该销毁?apple-sandbox 没有去轮询 OpenAI,也并不需要。exec-server 有个特性:连接临时断开它会重连,但 OpenAI 彻底关闭 session 之后它就退出了。容器随之呢就进入了 stopped 状态。所以 prune 命令只看一件事:哪些容器已经停了?prune --older-than 1h 把停掉超过一小时的清理掉。手动跑或者挂一个 cron 都行,它永远呢不会碰正在运行的容器。当然了目前 apple-sandbox 还处于非常早期,在后续的开发中还会逐渐完善这方面的功能使它越来越健壮。
把上面的机制串成状态图,一个 session 在沙箱里经历的状态大致是这样的:执行 apple-sandbox run,容器被创建出来这是 Created 状态;入口脚本启动 exec-server 向 OpenAI 注册,进入 Registering 状态。注册呢有两个分支:remote_url 或者 key 不对,exec-server 立即就拒绝进入 Rejected,容器随即就停掉。错误信息呢可以在 apple-sandbox logs 里看到;注册通过进入连接状态 Connected。之后 OpenAI 每下发一条命令就进入 Executing,执行完回到 Connected;中间 WebSocket 掉线进入 Reconnecting,exec-server 自己重连(apple-sandbox 并不参与);OpenAI 彻底关闭 session,exec-server 退出容器呢就进入 stopped 状态。最后通过 rm 或者 prune 把它删掉,最终达到了 Removed 状态。从 CLI 的角度看 ls 只报两种状态 running 和 stopped,中间那些状态全都在虚拟机里面发生。
下面我们用发布版本完整跑一遍:项目在 GitHub 上 sugarforever/apple-sandbox,在发布版本这里 releases 有一个 darwin_arm64 的压缩包。只有这个平台因为 Apple container 呢只能在 Apple Silicon 的 Mac 上跑。前置条件只有一个:brew install --cask container,装好 Apple 的 container 工具。然后按照 README 里的一条命令安装:从 GitHub Releases 拉取最新的压缩包,把二进制解压到用户目录下的 .local/bin。并不需要 sudo:执行器镜像的 Dockerfile 和入口脚本已经嵌在了二进制里,所以不需要克隆代码仓库。在任何一个空目录里都能操作:装好后先跑 apple-sandbox doctor 做 5 项检查,包含了像 Apple Silicon Hypervisor 支持、container 命令、container 的服务状态、磁盘空间,全部 PASS 就可以了。
如果服务状态检查失败那么尝试运行 container system start 启动一下再跑一次。然后 apple-sandbox build 来构建执行器的镜像:这一步要拉取 node 镜像,装 CA 证书、装 codex。第一次呢会花一点时间。镜像准备好了现在呢创建一个 session:向 /v1/agents/sessions 发一个 POST 请求,请求的头带上 agents=v1 这个 beta 标记以及 OpenAI 的 API key。请求体里面带上 agent 和 session,模型我选择了 gpt-6-astra,系统指令是让它在 /workspace 里工作。每一步跑了什么命令都展示出来:生成的文件在结束前自己检查一遍,environment 指定类型是 self_hosted,工作目录是 /workspace。API 响应应该是 201:来看看三个字段,一个呢是 ID(是 sess 开头的一个 session ID),另外一个是 environment.id,还包含一个 remote_url。
它是 api.openai.com 下面一个 connect 路径的地址:这两个值原样地传给沙箱就行,并不需要改动。把响应存到 session.json 后面呢用三个变量咱们把它取出来:受限的 API key 我已经在 OpenAI 平台上的 agents 部分创建好了,把它放在本机环境变量 OPENAI_EXECUTOR_API_KEY 里面。现在启动沙箱:运行 apple-sandbox run,给它 session ID、environment.id 和 remote_url。命令马上返回:session 的名字、容器的 ID、本机的工作目录,还有查看日志的命令。apple-sandbox ls 状态呢应该能看到它是 running;apple-sandbox logs -f 可以跟一下日志,能够看到 exec-server 启动向远程的 URL(也就是 remote_url)发起了注册。
顺便验证一下它真的是一台虚拟机:container exec 进去执行 uname -a,看到的是 Linux 内核跑在了一台 Mac 上。给它一个任务吧:先在本机 sandboxes 目录下这个 session 的子目录里放一个 CSV 文件,内容很简单:4 行数据 item 和 amount 两列。不需要任何上传接口,放进目录里实际上就放进了虚拟机里的 /workspace 目录。顺便说一句吧:OpenAI 托管的 sandbox 创建 session 的时候呢可以用 environment.files 这个字段内联地把文件带进去。同样的字段在 self_hosted 上 OpenAI 会返回 400,说 environment.files 是个未知参数。因此 self_hosted 类型的文件进出只能靠你自己去挂载,API 并不支持。
然后就提交任务吧:POST 到 session 的 events 接口,事件类型呢是 agent.session.input.message。内容是用 Python 读 /workspace/amounts.csv,验证 amount 列是数字、算行数和总数。把结果按固定格式写到 /workspace/outputs/summary.json,写完读回来验证报告。应该能够看到返回是 202:session 状态从 idle 变成了 in_progress。这时候呢可以看两个地方,一个是 apple-sandbox 的日志(通过 apple-sandbox logs 命令能看到执行器收到命令、执行命令),另一边是 Agents API 的事件流。等它跑完,直接用 cat 命令查看本机的 sandboxes 目录下的 summary.json 文件。
应该有 4 行,总和呢 75 verified 是标记为了 true:这是 Python 在虚拟机里执行通过挂载写到本机磁盘上的文件。没有 artifact 下载也没有轮询任何的接口:self_hosted 的产物这些 artifact 不会发布到 OpenAI 的 Artifacts 接口,文件呢就会在你自己的目录里。跑完了三步清理:apple-sandbox stop 停掉容器,apple-sandbox rm 删掉容器。再用 DELETE 请求把 OpenAI 那边的 session 删掉:如果不想一个一个地记 session ID,apple-sandbox prune --older-than 1h。先加一个 --dry-run 的选项看看会删哪些,再真正地去删掉它们。
这里呢有几点值得注意的:README 呢也已经写了,第一个出站网络没有做白名单。Apple container 的网络只有两种:完全内网或者完全开放,没有按域名放行的机制。因此现在执行器是全开放出站的:已经确认它需要访问 api.openai.com,注册之后还需不需要别的地址呢还没有验证。所以不建议拿它跑不受信任的任务:等加了代理或者白名单那一层咱们再说吧。第二点是只有 CLI,目前呢还没有 SDK(TypeScript 或者 Python 的封装)。其实呢就是包一层子进程调用,后面呢可能我们也会继续地添加。感兴趣的朋友呢也可以来自己封装一下。
第三 prune 判断 session 结束靠的是推断:容器停了就代表 session 结束了。这条推断依赖于 exec-server 在 OpenAI 彻底关闭连接之后会自己退出:目前文档呢是这么描述的,端到端测试当中也没有反例。
但连接中到停止这一步的转换,还没有被单独地去观测过。这一期我们把 Agents API 的 self_hosted 环境接到一台 Mac 电脑上,Apple container 提供虚拟机级别的隔离。执行器主动出站连接 OpenAI,本机目录直接就当做工作区,没有公网地址,靠的是 application-managed 正式的启动方式。缺的只是提前收到通知,用 prune 命令我们做了补全。项目呢开源在 GitHub,欢迎大家试用,也欢迎提 issue,欢迎 PR。如果这期视频对你有帮助,欢迎关注,也欢迎一键三连吧!那我们就下期再见。