乐于分享
好东西不私藏

79K+ Star!OpenHands源码解析(三):它用一个变量切三种沙箱

79K+ Star!OpenHands源码解析(三):它用一个变量切三种沙箱

第二篇讲到,一次 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
OpenHands 三种沙箱数据面

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 内存打爆。

OpenHands 三种沙箱后端差异

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