跳到主要内容

沙箱与安全边界

这章放大顶层主线的第 2 步。要解决的问题:agent 能跑任意命令、改任意文件,怎么不让它祸害你的机器? 答案是把它关进沙箱,并只通过受控的端口 + 门票与之通信。

1. 沙箱是什么

sandbox/README.md 一句话说清:

Since agents can do things that may harm your system, they are typically run inside a sandbox (like a Docker container).

所以沙箱 = 一个隔离的执行环境,里面跑着 agent-server。控制中心永远不直接在宿主上替 agent 执行动作,而是让动作发生在沙箱内。

2. 抽象:一个接口,多种后端

SandboxService 是抽象基类,定义沙箱的全生命周期方法,具体实现可以是 Docker / 远程 / 进程:

方法干什么
start_sandbox开一个新沙箱(没选 spec 用默认)
resume_sandbox唤醒已暂停的沙箱
wait_for_sandbox_running轮询到 RUNNING + agent-server 活着
pause_sandbox / delete_sandbox暂停 / 删除
pause_old_sandboxes超过上限时回收最老的几个

sandbox/sandbox_service.py:30(SandboxService)。实现类如 DockerSandboxService(sandbox/docker_sandbox_service.py:86)、RemoteSandboxServiceProcessSandboxService 等并存,靠注入选择(见 04-extensibility.md)。

3. 端口暴露:控制中心怎么找到沙箱里的服务

沙箱里其实跑了好几个服务,每个用一个命名端口暴露给宿主。常量定义在 sandbox/sandbox_models.py:27-30:

名字暴露什么
AGENT_SERVER真正的 agent-server(控制中心 POST 会话到这里)
VSCODE浏览器里的 VS Code 编辑器
WORKER_1 / WORKER_2额外的工作端口

SandboxInfo.exposed_urls 是一组 ExposedUrl{name, url, port}。控制中心要连 agent-server 时,就按名字 AGENT_SERVER 挑出对应 URL:

# live_status_app_conversation_service.py:1026(_get_agent_server_url)
agent_server_url = next(
exposed_url.url
for exposed_url in exposed_urls
if exposed_url.name == AGENT_SERVER
)

这个「按名字找端口」的设计让控制中心不关心沙箱在宿主上实际绑了哪个随机端口——Docker 实现会自己把容器内 8000 之类的端口映射到一个空闲宿主端口,再回填进 exposed_urls

4. 门票:session_api_key

每个沙箱有一个 session_api_key,它是访问该沙箱里 agent-server 的唯一凭证。控制中心每次调 agent-server 都带 X-Session-API-Key 头(见第 2 章第 ⑦ 步)。

它通过环境变量注入容器(常量 SESSION_API_KEY_VARIABLE = 'OH_SESSION_API_KEYS_0',见 sandbox/sandbox_service.py:25),Docker 实现在读取容器信息时再从环境变量里把它读回来(docker_sandbox_service.py_get_container_env_vars / env.get(SESSION_API_KEY_VARIABLE),约 :163-164)。

反向也用它做鉴权查找:给一个 key 能反查是哪个沙箱(get_sandbox_by_session_api_keyget_sandbox_record_by_session_api_key,sandbox/sandbox_service.py:46-63)——后者特意只查本地 DB、不打 runtime,省一次往返。

5. 等待就绪:别在 agent-server 还没起来时就发请求

容器状态 RUNNING ≠ 里面的 agent-server 已经能接请求。wait_for_sandbox_running 因此做双重确认:状态到 RUNNING, /alive 健康检查通过,才返回:

# sandbox/sandbox_service.py:128-136(节选)
if sandbox.status == SandboxStatus.RUNNING:
if httpx_client and sandbox.exposed_urls:
if await self._check_agent_server_alive(sandbox, httpx_client):
return sandbox
# agent-server 还没 ready,继续轮询
else:
return sandbox

健康检查打的是 {agent_server_url}/alive(_check_agent_server_alive,:142)。超时(默认 120s)就抛 SandboxError。这条消除了一类常见竞态:容器报 RUNNING 但服务未就绪导致首个请求失败。

6. 空闲回收

多人 / 多会话环境里容器会堆积。pause_old_sandboxes(max_num_sandboxes) 翻完所有 RUNNING 沙箱,按创建时间排序,暂停最老的几个,把活跃数压回上限以内;某个暂停失败不影响继续暂停其它:

# sandbox/sandbox_service.py:226-242(节选)
running_sandboxes.sort(key=lambda x: x.created_at) # 最老的在前
num_to_pause = len(running_sandboxes) - max_num_sandboxes
for sandbox in running_sandboxes[:num_to_pause]:
try:
if await self.pause_sandbox(sandbox.id):
paused_sandbox_ids.append(sandbox.id)
except Exception:
pass # 单个失败不阻断整体回收

pause_old_sandboxes(sandbox/sandbox_service.py:203)。

7. Docker 实现的几个现实细节

  • Docker SDK 是同步的。 文件头注释直言 Docker API 不支持 async,部分操作会阻塞;因为它本就面向单机本地使用,作者认为可接受。见 docker_sandbox_service.py:86-90(DockerSandboxService docstring)。
  • host network 与端口映射两条路径。 容器若用 host network,端口直接在宿主可达;否则读 NetworkSettings 里的端口绑定把容器端口映射成宿主端口。两条分支都在 _container_to_sandbox_info 里处理(docker_sandbox_service.py:170-218)。
  • localhost 改写。 控制中心若也在容器里,localhost 指的不是宿主——replace_localhost_hostname_for_docker 负责把地址改写成容器能访问的形式(utils/docker_utils.py,在多处被调用,如 :1035)。

8. 边界

  • 隔离强度取决于后端。 Docker 容器是默认的安全边界,但**「不带沙箱」模式**(agent-canvas 直接在宿主跑 agent-server)会让 agent 拥有完整文件系统权限——README.md 对此有醒目 WARNING。沙箱不是强制的,是推荐的。
  • 门票泄露 = 沙箱失守。 session_api_key 是唯一凭证,谁拿到谁就能驱动该沙箱里的 agent;它经环境变量注入、经 header 传递,务必走 HTTPS / 受控网络。