第二篇讲到,一次 OpenHands 对话不是一条记录,而是一条启动流水线。
那条流水线里最重的一步,是沙箱。
用户点“开始”之后,控制面要先找到或启动一个运行环境,再把 agent-server、工作目录、仓库、密钥、MCP、skills 和回调地址装进去。真正写代码、跑命令、开服务的地方,不在 app_server 里,而在沙箱背后的数据面。
第三篇就看这个数据面怎么被换掉。
OpenHands 这里最有意思的设计是:它没有为 docker、process、remote 三种运行方式写三套 conversation 逻辑。conversation service 只认一个 SandboxService 抽象,底下由配置装配出不同实现。
也就是说,同一个“启动对话”流程,背后可以落到三种完全不同的数据面:
Docker:本机拉起 agent-server 容器。 Process:本机拉起 agent-server 进程。 Remote:调用远端 runtime API 拉起运行体。
切换入口甚至只是一个环境变量:
RUNTIME=remoteRUNTIME=processRUNTIME=local
1. 先看抽象:conversation 不关心你怎么跑
SandboxService 抽象里列出的能力很克制:
搜索沙箱。 按 id 查询沙箱。 按 session key 查询沙箱。 启动、恢复、暂停、删除沙箱。 等待沙箱进入 running。 在删除前归档 conversation workspace。
这里没有 Docker 专属概念,也没有远端 runtime 专属概念。conversation service 拿到的是统一的 SandboxInfo:id、状态、exposed URLs、session key、创建者、spec id。
这就把边界分出来了。
app conversation 关心的是:
我有没有一个可用的 agent-server URL? 我能不能带着 X-Session-API-Key调它?它现在是不是 running? 删除前要不要先 capture workspace?
至于这个 agent-server 是容器、进程,还是远端运行体,conversation 链路不需要知道。
这就是可插拔沙箱后端的价值。
2. 一个变量切两套注入器
真正的装配发生在 app server config。
逻辑很直接:
if RUNTIME == "remote": sandbox = RemoteSandboxServiceInjector(...)elif RUNTIME in ("local", "process"): sandbox = ProcessSandboxServiceInjector()else: sandbox = DockerSandboxServiceInjector(...)但注意,它不只切 sandbox。
后面还有一段:
if RUNTIME == "remote": sandbox_spec = RemoteSandboxSpecServiceInjector()elif RUNTIME in ("local", "process"): sandbox_spec = ProcessSandboxSpecServiceInjector()else: sandbox_spec = DockerSandboxSpecServiceInjector()这意味着 RUNTIME 切的是两件事:
用哪套 SandboxService管运行体。用哪套 SandboxSpecService管运行体模板。
service 负责“怎么起、怎么停、怎么查”;spec service 负责“起什么镜像/命令/工作目录/初始环境”。
这个拆法很重要。
如果只抽象 service,spec 仍然硬编码在 Docker 里,那 process 和 remote 会很快被迫走特例。OpenHands 这里把“运行体实例”和“运行体模板”一起抽出来,后面才有可能继续做用户默认 spec、远端 warm sandbox、自定义镜像、不同资源规格。
3. Docker:最像本地产品形态
默认路径是 Docker。
Docker 后端启动沙箱时,会先解析 sandbox spec,然后生成 sandbox id 和 session api key。接着构造容器环境变量:
env_vars[OH_SESSION_API_KEYS_0] = session_api_keyenv_vars[OH_WEBHOOKS_0_BASE_URL] = "http://host.docker.internal:.../api/v1/webhooks"这两个变量把边界接起来了:
session key 让 app_server 可以授权访问 agent-server。 webhook base URL 让 agent-server 可以把事件回流到 app_server。
Docker 实现还会处理端口映射。agent-server 默认在容器内 8000,VS Code server 在 8001,worker 端口从 8011、8012 开始。容器启动后,service 会把这些端口转成 exposed URLs,再交给 conversation service 使用。
这个实现很适合本机和自托管:隔离比 process 强,部署比 remote 简单,控制面可以直接通过 Docker API 管生命周期。
但它的安全人格也很直接:session key 是明文环境变量,查询时会遍历容器环境变量找匹配项。
这不是说 Docker 后端一定不安全,而是说它更偏“本机可信控制面 + 本机运行体”的模型。密钥边界靠容器隔离、session key、网络边界和上层能力票据共同完成。
4. Process:最轻,也最像开发模式
RUNTIME=process 或 RUNTIME=local 会走 Process 后端。
它不拉容器,而是在本机起一个 Python 子进程:
python -m openhands.agent_server --port <port>每个沙箱都有一个独立目录、一个端口、一个 session api key。service 还维护了一个进程表,记录 pid、port、working_dir、user_id、created_at 和 sandbox_spec_id。
Process 后端的好处很明显:
启动轻。 调试直观。 不依赖 Docker。 本地开发时少一层容器网络问题。
但它也最不像生产隔离环境。
暂停就是 process.suspend();恢复就是 process.resume();删除时 terminate,必要时 kill,然后删 working directory。
从工程定位看,Process 更像开发者友好的本地数据面。它能复用同一套 conversation 控制面,但不应该被误读成强隔离沙箱。
5. Remote:云形态的安全边界更硬
Remote 后端是另一种人格。
它不直接拥有容器或进程,而是调用远端 runtime API:
POST /startPOST /pausePOST /resumePOST /stop启动时,app_server 先在自己的数据库里写一条 StoredRemoteSandbox,再把镜像、命令、工作目录、环境变量、session_id、资源规格、运行用户等信息发给 runtime API。
runtime API 返回运行体信息和 session api key 后,OpenHands 做了一件很关键的事:
它不把 session key 明文存进 app_server 数据库,而是存 SHA-256 hash。
这和 Docker / Process 很不一样。
Docker 的 key 在容器环境变量里;Process 的 key 在内存进程表里;Remote 的 key 在运行体返回后,只以 hash 形态留在 app_server 本地表里。
暂停时,Remote 会先清掉 session_api_key_hash,让已有 key 失效。
恢复时,runtime API 会生成新的 session key,OpenHands 再把新 key 的 hash 更新回来。
删除时更激进:它会在停止 runtime 之前先清掉 hash。如果停止远端运行体时遇到暂时错误,代码还会主动 commit 这个 key invalidation,避免调用方回滚把刚撤销的 key 又恢复出来。
这就是远端沙箱的核心差异:它不是只把 Docker API 换成 HTTP API,而是把“谁保存 key、什么时候失效、失败时怎么保证撤销优先”都重新设计了一遍。
6. 工作区归档:删除前的最后一件事
第二篇已经讲过,删除 conversation 不等于立刻删除 sandbox。
第三篇要补上的是:远端数据面里,workspace capture 本身也被做成了一条独立链路。
SandboxService 抽象里有一个默认 no-op 方法:
archive_conversation_workspace(...)Docker 和 Process 可以不实现它,默认返回 True。Remote 会覆写它。
Remote 的注释说得很清楚:这是 app-server 显式删除路径下的唯一 capture path。finalizer 会在 runtime 还活着的时候,为每个 conversation 调它,然后只在归档允许继续时才 tear down sandbox。
真正归档时,app_server 会去 agent-server 拉:
GET /api/file/archive它支持两种格式:
git-delta:紧凑,适合记录工作区变化。 tar.gz:完整,自包含,能保住 gitignored 文件和构建产物。
默认格式是 both,也就是两个都抓。
这里还有一个很实在的工程细节:归档不是把整个压缩包读进 app_server 内存,而是从 HTTP response 流式写到 tempfile,再从 tempfile 上传到对象存储。
这避免了大仓库、多并发删除时把 app_server 内存打爆。

7. 双快照:初始态和终态
归档不只发生在删除时。
OpenHands 还准备了 initial workspace snapshot。
它发生在 conversation 启动阶段,setup script 修改 workspace 之前。设计目标是记录“agent 开始动手前”的真实起点。
但 initial snapshot 是 best-effort:
默认关闭。 单独由 RUNTIME_FILE_ARCHIVE_INITIAL_ENABLED控制。有 deadline,不能无限拖慢 conversation 启动。 失败只记录日志,不阻断启动。
final archive 则更像耐久性保护:
由 RUNTIME_FILE_ARCHIVE_ENABLED控制。可设置 RUNTIME_FILE_ARCHIVE_REQUIRED。REQUIRED 时,如果归档失败或无法确认 capture,就不允许继续删除 sandbox。
这就是“工作区双快照”:
initial:启动前,记录起点,不阻塞主流程。 final:删除前,保护结果,必要时阻塞销毁。
这个设计很适合云端 Agent 产品。因为用户真正关心的不只是 agent 有没有跑完,还包括它改过的代码、生成的文件、测试产物、失败现场能不能被保留下来。
8. 亮点和隐患
亮点一:抽象边界干净。
conversation service 不知道 Docker API、psutil、remote runtime API 这些细节。它只依赖 SandboxService 和 SandboxSpecService。这让同一套启动长任务可以落到三种数据面。
亮点二:Remote 的 key 生命周期明显更硬。
只存 hash、pause 清 key、resume 轮换 key、delete 先撤销 key。尤其是删除失败时仍优先提交 key invalidation,这说明代码把“撤销访问权”放在“清理运行体”之前。
亮点三:归档链路考虑了大仓库现实。
git-delta 加 tar.gz 的双格式、conversation 维度的对象路径、tempfile 流式上传、REQUIRED 失败阻断删除,这些都是从生产场景长出来的设计。
隐患也很明显。
第一,三种数据面的安全边界不一致。Docker/Process 能直接拿明文 session key 做查询,Remote 只存 hash。作为读者,必须意识到 SandboxService 是同一抽象,但不同实现的威胁模型并不相同。
第二,Process 后端容易被误用。它很好调试,但进程级隔离和容器/远端 runtime 不是一个等级。如果产品文档或部署脚本没有讲清楚,用户可能把开发便利当成生产沙箱。
第三,归档只在部分后端真正工作。抽象上有 archive_conversation_workspace,但默认是 no-op。读代码时如果只看接口,会以为所有后端都有删除前归档;实际主要是 Remote 后端承担这件事。
结语:同一套控制面,三种运行现实
这一篇其实是在回答第二篇留下的问题:
conversation 启动后,数据面到底是什么?
答案不是一个。
本机用户可能跑 Docker;开发者可能跑 Process;云端产品可能跑 Remote runtime。OpenHands 把这些差异收进 SandboxService / SandboxSpecService,再用 RUNTIME 在启动时装配出来。
所以 OpenHands 的控制面不是“调用某个固定沙箱”。它是在一套统一生命周期里,选择一套运行现实。
问题继续往下走:
agent-server 运行起来以后,事件、状态、标题、终态、离线消息,又是怎么回到 app_server,并最终变成前端看到的会话流的?
相关阅读
(一)78K+ Star!OpenHands源码解析(一):它正在拆掉自己
(二)78K+ Star!OpenHands源码解析(二):它不是在新建一条记录
源码参考:GitHub: https://github.com/OpenHands/OpenHands
夜雨聆风