OpenAI Agents API 的自托管模式中,executor 是纯 Outbound WebSocket 客户端,无法被外部主动终止;作者通过维护待处理任务队列实现了可靠的停止机制。
OpenAI 于 9 月 10 日向公众开放了 Agents API。值得细读的不是概述页面,而是自托管沙箱指南——它描述了当你拒绝 OpenAI 的托管容器时,什么代码会跑在你的硬件上。你在自己的环境里运行 codex exec-server。它"运行 shell 命令、读写文件,并使用本地 MCP 服务器"。它"使用环境 ID 和一个受限 API key 向 API 注册",然后"通过 WebSocket 连接来接收命令并返回结果"。然后还有一句:"所有连接都是外向的。如果连接断开,executor 会重新连接。"
这句话说明了很多。OpenAI 侧不会在你的机器上打开任何入站连接。executor 是一个不断拨回主站的客户端,整套测试框架想在你的机器上执行的每个操作都经由这条连接。每个 session 拥有自己的环境 ID,且需要各自的 executor。executor 持有的 key"仅允许连接环境",文档对此的解释很直白:"agent 生成的代码可以读取 executor 的 key。"
文档没有描述的是如何停止。概述页面走完了四个步骤:创建 session、分配任务、通过流或 webhook 跟踪进度,然后继续下一个任务或给当前任务提供指导。但其中没有 stop 调用。session 被定义为"一个持续运行的 agent 实例,负责处理任务并响应输入"。这个运行在你硬件上的持久化实体如何被告知终结,则留给了监管 executor 的上层来回答。
shell.online 的架构形态完全相同——一个只向外拨号的本地进程——所以 stop 问题同样需要我们自己来回答。今天合并到 main 的 PR #88,把答案放到了看板视图和 session 详情页上,此前这两处都没有这个功能,只在 sessions 列表表里存在。
shell.online 的一个 session 是某台机器上的一个进程,它服务自己的本地 socket 并持有一个独立的终端 key。账户服务转发的是一个它无法解密且没有任何路径进入机器的密封 session 密码。要让一台已签入的机器从浏览器端可访问,整台机器要运行一个 daemon。daemon.go 顶部的注释解释了它存在的原因:"shell.online 的其余部分没有常驻进程。" daemon 拒绝启动,除非该机器是通过 shell login --allow-remote-start 签入的,且每台机器只会运行一个 daemon。
在此之前,Stop 只存在于表里。没有任何 stop 按钮,session 页面既没有 stop 也没有 remove——而在手机上,用户实际面对的恰恰是这个页面。PR #88 两处都加上了,但受到与 session-view.ts 中已有的相同两条规则约束:
canStop:你拥有这个 session,且 session 有 device id。满足这两个条件才有资格。被分配到一个 session 使你可以在里面输入内容。成为组织管理员使你可以整理列表。但两者都不能让你杀掉别人笔记本上的进程。服务端注释直接说了:"组织角色永远不应悄然扩展为远程进程管理。"
canRemove:所有者,或组织 owner 或管理员。按钮的 tooltip 载明了规则的其余部分:"从列表中移除。机器本身不受影响。"
按下 Stop 向 /api/commands 发送一个 kind 为 kill 的 POST。服务端 app.ts 随后按顺序做了五件事:
检查调用者是否拥有该 session。其他人收到 403:"只有 session 所有者可以停止其进程。"
在请求机器之前先写入"stopped"审计条目。注释写道:"队列即决策;进程是否还活着并收到了它,是关于机器的事实。"
从 session 记录的 device 而非浏览器持有的副本中解析目标机器。如果该设备行被重新登录替换了,它会跟随机器转移到当前持有它的那个设备。
如果 daemon 过去 15 秒(AGENT_ONLINE_MS)没有轮询过,返回 409,说明 stop 无法送达。不为不在监听的机器排队任何命令。
否则将命令入队并返回 202。
接下来是机器侧。daemon 每两秒请求一次 GET /api/agent/commands(agent_loop.go 中的 agentPollInterval)。当它认领一个 kill 时,以子进程方式运行 shell kill <id>。该命令向 session 的本地控制 socket 写入"stop"。session 进程向其进程组发送 SIGTERM,等待两秒,然后发送 SIGKILL。daemon 将完成状态发回服务端,服务端在收到确认后关闭 session 行——而不是等 session 自己报告退出,因为 session 可能在机器解除关联之前就启动了,已经撤销了凭证,永远无法再报告任何事情。
最坏情况下,那是两秒的轮询等待加上 SIGTERM 到 SIGKILL 之间的两秒。session 页面在点击后 1.5 秒重新加载,旁边那个计时器的注释坦言这一行"很快就会跟上"。延迟是单向出站设计的代价。它换来的是 OpenAI executor 同样在买的东西:没有入站端口、没有服务能伸进机器,以及当没有人监听时在源头拒绝 stop——而不是让它永远排在队列里。从按钮到信号的全路径可以在 shell.online 仓库的 app.ts、agent_loop.go 和 session_unix.go 中从头到尾读完。
Pilot Protocol 以不同的方式回答了可达性这半个问题,因为它处在不同的层面。我们的 IETF 草案及其开源实现为 agent 提供了一个永久虚拟地址和在 A2A、MCP 等应用协议之下的双向信任模型,通过 STUN、NAT 打洞和为对称 NAT 准备的 relay 回退来实现。这个 peer 是否可以访问那个 agent,是由信任关系在数据包送达之前就决定的,而不是由 daemon 轮询的队列来决定。可达性与向进程发送信号的权限是两个独立的事实,而今天的 PR 处理的是第二个。
OpenAI 的 executor 和我们的 daemon 都是只向外拨号然后等待指令的进程。这使得 stop 成为一个消息而非信号,而消息有所有者、有路由、有队列、有延迟。今天的改动只是确保这条消息可以从手机用户正站在的那个页面发送出去。