OpenHands Agent Canvas:容器化 Agent Server 驱动 ACP 代理(Codex / Claude Code / Gemini CLI)实战指南
本文基于仓库中的 examples/acp-docker 快速上手文档 展开,讲解如何在本地用 Docker Compose 拉起一个容器化的 OpenHands Agent Server,让 Agent Canvas 在没有任何宿主机登录态的情况下驱动 Codex、Claude Code、Gemini CLI 这三个 ACP 代理:读完你将掌握镜像版本固定(pin)机制、Canvas 指针对接方式、通过 UI 注入凭据的完整流程,以及各凭据字段背后的 LookupSecret 解析原理与常见陷阱。
1. 背景:为什么 ACP 代理需要“容器化 + UI 凭据”两条腿
Agent Canvas 可以用内置的 OpenHands 代理驱动会话,也可以通过 Agent Client Protocol(ACP) 驱动外部代理:Agent Server 不直接调 LLM,而是把代理自带的 CLI(如 claude-agent-acp、codex-acp、gemini-cli --acp)作为子进程 spawn 出来,在 stdio 上以 JSON-RPC 转发每一轮对话。完整原理见 docs/ACP_AGENTS.md。
本地后端跑在开发者自己的机器上时,代理 CLI 可以直接复用宿主机上已登录的订阅态(macOS Keychain、~/.codex/auth.json 等)。但 容器是全新环境,没有任何宿主登录态,所以凭据必须从你这里来:要么在 Canvas 的 onboarding “Set up credentials” 步骤里填写(推荐),要么通过 .env 烘焙进容器(适合非交互 / CI 场景)。examples/acp-docker/ 就是这个本地 Docker 路径的现成脚手架,与云部署路径互为对应。
examples/acp-docker/ 目录包含三个文件:
- docker-compose.yml —— 服务编排;
- .env.example —— 可选的凭据/镜像模板;
- README.md —— 本文对应的快速上手说明。
2. 第一步:拉起容器化的 agent-server
2.1 零配置路径
cd examples/acp-docker
docker compose up
这会在 http://localhost:8010 启动 ghcr.io/openhands/agent-server:latest-python,并挂载一个持久化 acp-data 卷。该镜像预装了 ACP CLI wrapper(claude-agent-acp / codex-acp / gemini),SDK 会在容器内把 Canvas 默认下发的 npx -y <pkg> 命令改写到这些预装固定版本二进制上,因此 Canvas 侧无需改动任何启动命令。
2.2 compose 文件的逐项解读
docker-compose.yml 中的关键配置值得逐项理解:
| 配置 | 取值 | 说明 |
|---|---|---|
image |
${AGENT_SERVER_IMAGE:-ghcr.io/openhands/agent-server:latest-python} |
默认回落 latest-python(始终不低于 Canvas 兼容下限);可被 .env 中的 AGENT_SERVER_IMAGE 覆盖为固定版本 |
container_name |
oh-acp |
固定容器名,便于 docker logs oh-acp 排查 |
ports |
8010:8000 |
宿主机 8010 映射到容器内 agent-server 的 8000 端口,Canvas 把 VITE_BACKEND_BASE_URL 指向 8010 |
environment.OH_EXTRA_PYTHON_PATH=/canvas-tools |
— | 把仓库 tools/ 目录以只读卷挂到 /canvas-tools 并加入 Python 路径:新会话用 client_tools 定义 canvas_ui_control,但旧版本持久化的会话仍需要导入旧的 Python 模块才能恢复 CanvasUIAction / CanvasUIObservation 事件 |
environment.OH_SECRET_KEY |
注释掉的可选项 | 不设置时 ACP 会话也能工作(Canvas 把 ACP 凭据作为回环 LookupSecret 从 agent-server 自身的 secret store 解析,且不标记 secrets_encrypted);设置它用于 (a) 跨容器重启持久化已保存的 secrets,(b) 启用非 ACP 的 OpenHands 会话所用的加密设置路径。可用 python -c "import secrets;print(secrets.token_urlsafe(32))" 生成 |
environment.ANTHROPIC_API_KEY 等 |
可选透传 | ANTHROPIC_API_KEY、CLAUDE_CODE_OAUTH_TOKEN、OPENAI_API_KEY、GEMINI_API_KEY、GOOGLE_CLOUD_PROJECT、GOOGLE_CLOUD_LOCATION、GOOGLE_GENAI_USE_VERTEXAI 均从你的 shell / .env 透传,未设置的不会导出——仅供“烘焙凭据”场景 |
volumes.acp-data |
acp-data:/workspace |
持久化两类状态:会话历史,以及 SDK 落盘的凭据文件(Codex 的 auth.json 位于 CODEX_HOME 下、Gemini 的 ADC/SA JSON) |
volumes.tools |
../../tools:/canvas-tools:ro |
只读挂载,相对 compose 文件位置解析 |
restart |
unless-stopped |
守护式重启策略 |
镜像的 CORS 已允许 localhost 来源,所以浏览器可以直连容器,不需要任何额外代理配置。
2.3 可复现的固定镜像路径(推荐)
latest-python 适合尝鲜,但团队协作用固定版本更稳妥。仓库把版本固定收敛到单一事实源 config/defaults.json:其中 versions.agentServer 当前为 1.44.0,兼容下限 compatibility.minimumAgentServer 为 1.28.0,镜像仓库地址在 images.agentServer。
在仓库根目录执行:
npm run example:acp-docker:env # 写入 examples/acp-docker/.env
cd examples/acp-docker && docker compose up
该 npm script 对应 scripts/gen-acp-docker-env.mjs,其工作方式是:
- 读取
config/defaults.json,由computeAgentServerImage()拼出ghcr.io/openhands/agent-server:<versions.agentServer>-python这样的完整 tag; - 用
upsertEnvLine()把AGENT_SERVER_IMAGE=...一行幂等地写入/替换进examples/acp-docker/.env——已有该行就原位替换,没有就追加,其余行(如你手填的凭据)原样保留。
这条链路有专门的漂移检测测试 tests/scripts/acp-docker-env-sync.test.ts,它强制三条不变量:生成的 pin 必须等于 versions.agentServer;无配置 compose 回落必须保持 latest-python;固定 tag 不得低于 compatibility.minimumAgentServer(否则会渲染 “Disconnected”)。
版本兼容性提示:如果你手里有一份早年手写的
.env且包含旧的AGENT_SERVER_IMAGE,请重跑npm run example:acp-docker:env或删掉该覆盖项,避免示例一直钉在compatibility.minimumAgentServer之下。
想手工钉一个更新的发布版或 main 分支构建,也可以直接:
AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:$(gh api repos/OpenHands/software-agent-sdk/commits/main --jq '.sha[0:7]')-python docker compose up
2.4 可选:把凭据烘焙进 .env
如果不想在 Canvas 里填凭据(例如非交互 / CI 环境),先复制模板:
cd examples/acp-docker && cp .env.example .env
然后在 .env.example 中填入对应 provider 的环境变量;compose 文件只会透传“有值”的变量。注意:推荐路径仍然是 Canvas 内填写(凭据随 start 请求以 secrets 形式下发),.env 烘焙是替代方案,且可能过不了 onboarding 登录探测——这一点见下文第 5 节的警告。
3. 第二步:让 Canvas 指向容器
回到仓库根目录:
cd ../.. # repo root
VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend
由于镜像 CORS 允许 localhost,浏览器会直接和容器通信。也可以在 Canvas 的后端选择器(backend selector)里把它添加为一个 backend,host 填 http://localhost:8010,效果等价。
4. 第三步:在 UI 中完成凭据注入
在 onboarding 里选择 ACP provider,进入 Set up credentials 步骤。在容器化后端上这一步是必填的(没有宿主登录可回退)。需要粘贴的内容按 provider 如下:
| Provider | 需要粘贴/填写的内容 |
|---|---|
| Codex(订阅) | CODEX_AUTH_JSON —— ~/.codex/auth.json 的完整内容 |
| Claude Code(订阅) | CLAUDE_CODE_OAUTH_TOKEN —— 你的 Pro/Max OAuth token |
| Gemini CLI(Vertex) | GOOGLE_APPLICATION_CREDENTIALS_JSON(SA / ADC JSON)+ GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION + GOOGLE_GENAI_USE_VERTEXAI=true |
每个 provider 也接受 API key 路径(OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY)。
4.1 凭据字段的来源:getAcpProviderSecrets
上述字段清单不是文档里的口头约定,而是由 Canvas 源码 src/constants/acp-providers.ts 单一来源生成的:
ACP_RESERVED_CREDENTIALS定义了每个 provider 的“容器凭据”:Codex 的CODEX_AUTH_JSON(多行文本,提示粘贴~/.codex/auth.json)、Claude Code 的CLAUDE_CODE_OAUTH_TOKEN、Gemini 的GOOGLE_APPLICATION_CREDENTIALS_JSON(多行,提示粘贴~/.config/gcloud/application_default_credentials.json)加三个 GCP 标量字段;getAcpProviderSecrets()按“容器订阅/Vertex 凭据 → API key → 可选 base URL”的顺序拼装字段列表,其中 API key / base URL 的变量名直接取自 SDK 注册表(经@openhands/typescript-client镜像),避免前端与 agent-server 环境变量漂移;- 字段
name同时就是全局 secret 名和 agent-server 导出到 ACP 子进程的环境变量名——保持同名正是已保存密钥能真正到达 provider CLI 的关键。onboarding 表单的状态逻辑由 src/hooks/use-acp-credential-form.ts 复用(含已存 secret 查询、冲突检测与保存流程)。
4.2 从 UI 到子进程:LookupSecret 解析链
每个凭据被存为 agent-server secret store 里的全局 secret(与 Settings → Secrets 里添加完全等价,可随时编辑/删除)。会话启动请求并不携带明文凭据,而是把每个字段引用为一个 LookupSecret(ACP 与非 ACP 会话统一如此);agent-server 在 spawn 子进程时从自己的 store 回查取值。从源码注释与文档交叉印证看,ACP 场景下该回查特意运行在事件循环之外(对应 software-agent-sdk 的 #3510 修复),避免回环 HTTP 请求自死锁。
拿到值之后,SDK 的 acp_file_secrets 默认行为负责“落地”:
CODEX_AUTH_JSON被还原为CODEX_HOME下的auth.json,并把 Codex 指向它;GOOGLE_APPLICATION_CREDENTIALS_JSON被写成一个文件,由GOOGLE_APPLICATION_CREDENTIALS指向,路由 Gemini 走 Vertex AI;- 其余值(
CLAUDE_CODE_OAUTH_TOKEN、project/location、各 API key)直接作为环境变量导出给 CLI。
也就是说 Canvas 只负责发送 secrets,文件物化完全由 SDK 完成——这也是为什么 acp-data 卷要把 /workspace 持久化:物化出来的凭据文件重启后仍在。
4.3 凭据冲突警告
表单内置了冲突检测(ACP_CREDENTIAL_CONFLICTS / getAcpCredentialConflicts,同样位于 src/constants/acp-providers.ts):不要与 Claude OAuth token 一起设置 ANTHROPIC_BASE_URL。被继承的 LiteLLM base URL 会悄悄破坏 bearer 认证——Canvas 从不替你设置 base URL,但你自己保存过的 ANTHROPIC_BASE_URL secret 会随每个 start 请求搭便车,所以表单会对这一对组合发出警告。同理,CLAUDE_CODE_OAUTH_TOKEN 与 ANTHROPIC_API_KEY 同时存在时 token 会静默压过 key,SDK 侧也会剥离冲突项(对应 SDK 的 _ENV_CONFLICT_MAP,#3588)。
5. 三个必须知道的警告
(1)Gemini Vertex 的 ADC 必须新鲜。 复制 ADC 前执行 gcloud auth application-default login——过期 token 会返回 invalid_rapt,这是凭据问题而非 Canvas bug。另外按 docs/ACP_AGENTS.md 的说明,Gemini 请选非 flash 模型:gemini-cli 0.45.x 会在生成时把任意 *-flash 模型 id 重新解析为“当前默认 flash”,导致固定 gemini-2.5-flash 实际跑了不存在的 flash 模型而 404,因此 Canvas 对 Gemini 预选 gemini-2.5-pro(源码中的 ACP_VERTEX_SAFE_MODEL 常量,src/constants/acp-providers.ts)。若某轮 Gemini 报错 Publisher Model … was not found,先检查所选模型是否碰巧是 flash id。
(2).env 烘焙的凭据可能过不了 onboarding 门禁。 登录探测检查的是 CLI 的登录状态(claude auth status / codex login status / Gemini 的 OAuth 凭据文件),而不是容器环境变量——只有 GEMINI_API_KEY 烘焙进 .env 的容器通常仍会被探测为“未登录”,凭据步骤会卡住 “Next”。在 UI 里(重新)填写一次凭据即可继续;烘焙的环境变量对 agent 本身依然生效。
(3)同容器并发会话共享 HOME。 同一 provider 的并发会话共用 HOME,可能在 CLI 的 auth/config/lock 文件上互相竞态。SDK 已支持按会话隔离数据目录(acp_isolate_data_dir,#3492),但当前发布的 @openhands/typescript-client 尚未在 ACPAgentSettings 上暴露该字段,Canvas 无法安全下发(跟踪于 agent-canvas#1019)。本地临时避免办法是错峰使用同一 provider 的多个会话。
6. 收尾与拆除
docker compose down # 保留 volume(会话 + 物化凭据)
docker compose down -v # 同时删除 credentials/conversations
down 不带 -v 时 acp-data 卷保留,SDK 物化的凭据文件与会话历史在下次 up 后原样可用;带 -v 则是彻底清场,等价于一个全新的容器环境,onboarding 凭据步骤需要重新走一遍。
7. 小结:这个示例沉淀的工程实践
examples/acp-docker/ 的价值不只是“能跑”,它还示范了几条可复用的工程约定:
- 双轨版本策略:零配置走
latest-python(永远满足兼容下限),可复现走config/defaults.json→npm run example:acp-docker:env生成的精确 tag,两条轨都由 acp-docker-env-sync 测试 锁住不漂移; - 凭据不落镜像、不落 Canvas 前端:一律经 agent-server 的 secret store +
LookupSecret在 spawn 时解析,文件物化交给 SDK 的acp_file_secrets; - 持久化边界清晰:
acp-data卷同时承载会话与物化凭据,/canvas-tools只读挂载只负责旧会话的可恢复性,职责互不干扰。
按 examples/acp-docker/README.md 的三步(docker compose up → VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend → UI 填凭据)即可在本地跑通完整的容器化 ACP 链路;更深入的 ACP 概念、认证优先级(订阅登录优先于 API key)与后续切换 agent/模型的说明,可继续阅读 docs/ACP_AGENTS.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00