OpenHands Canvas Live ACP e2e:用真实 Agent-Server 容器验证 ACP 凭证的 LookupSecret 全链路
本篇基于 Live ACP-in-Docker e2e 文档,讲解 OpenHands 前端仓库中一套"真容器 + 真凭证 + 真 API 调用"的端到端验证方案:它通过 Canvas 自身的代码路径,把 Codex / Claude Code / Gemini CLI 的凭证写入 agent-server 的 secret store,再以 LookupSecret 形式引用,最终断言拿到真实的 agent 回复。读完本文,你能掌握这套 e2e 的完整运行步骤(Docker 启动、脚本执行、环境参数)、三个 provider 的凭证采集实现,以及 buildStartConversationRequest 如何把每个保存的凭证发射为 LookupSecret 的源码级原理。
这套 e2e 验证的是什么
单元测试 __tests__/api/agent-server-adapter.test.ts 只断言"请求的形状"——即 buildStartConversationRequest 输出的 payload 结构是否符合预期;而 tests/e2e/live-acp/ 下的脚本断言的是"真的能跑通":针对一个真实运行的 agent-server 容器,发起真实的 provider API 调用,并检查 agent 的最终回复中包含约定的校验 token(如 ACPOK-CODEX)。
它验证的完整凭证链路是:
- onboarding 等价步骤:通过
SecretsService.createSecret(即应用 onboarding 中 "Set up credentials" 的同一个调用)把宿主机上采集到的凭证 upsert 进 agent-server 的 secret store(本地后端走PUT /api/settings/secrets); - 构建启动请求:调用 Canvas 自己的
buildStartConversationRequest,把每个已保存的凭证按名字引用为一个LookupSecret,secret 的值不出现在请求里; - 服务端解析:agent-server 在 ACP 进程冷启动(spawn)时,通过
GET /api/settings/secrets/<name>把值从 store 解析回来(日志中可见 200 响应),完成如 codex 的auth.json物化(Materialised ACP file-secret 'CODEX_AUTH_JSON' -> …/acp/codex/auth.json)或 Gemini 的 ADC 物化(GOOGLE_APPLICATION_CREDENTIALS_JSON -> …/acp/gemini-cli/gcloud-credentials.json)。
版本要求:需要
agent-server:1.25.0-python或更新(software-agent-sdk#3510)。ACP 凭证以 loopbackLookupSecret的形式传递,只有 #3510 之后才在事件循环之外解析它们;旧镜像会在第一轮对话时死锁。运行脚本推荐镜像为1.28.0-python(v1.28.0 新增了 Canvas 使用的client_toolsAPI,acp-docker-e2e.mts 头部注释明确要求该版本)。
该目录位于 tests/ 下,被 Vitest 排除,不属于 npm test——因为它需要一个正在运行的容器和宿主机上的真实凭证,只能手动执行。
运行步骤
1. 启动 agent-server 容器
# 1. Agent-server 容器。v1.28.0 新增 Canvas 使用的 client_tools API。
# 挂载 Python 目录保证迁移前的会话状态仍可加载。
docker run -d --name oh-acp -p 8010:8000 \
-v oh-acp-data:/workspace \
-v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \
ghcr.io/openhands/agent-server:1.28.0-python
几个挂载参数的含义:
-p 8010:8000:容器内 agent-server 监听 8000,映射到宿主机 8010,与 e2e 脚本默认ACP_E2E_BASE_URL=http://localhost:8010对应;-v oh-acp-data:/workspace:命名卷挂载到/workspace,作为所有会话working_dir的根(每个会话使用<base>/<id_hex>独立目录,以便 agent-server 为每个会话初始化独立的 git repo + worktree);-v "$(pwd)/tools:/canvas-tools:ro"+OH_EXTRA_PYTHON_PATH=/canvas-tools:把仓库的 tools/ 目录(含 canvas_ui_tool.py)只读挂入容器并注入 Python 路径,供 Canvas 的canvas_ui_controlclient tool 使用。
2. 运行 e2e 脚本
# 运行全部 provider,或指定子集
npx vite-node -c tests/e2e/live-acp/vite-node.config.mts \
tests/e2e/live-acp/acp-docker-e2e.mts -- codex claude gemini
acp-docker-e2e.mts 是"请求构建器路径"脚本,验证通过后还会配套运行应用编排器路径脚本 acp-docker-app-e2e.mts(一次一个 provider,因为 settings 在 agent-server 上是全局的,新进程可避免 SettingsService 缓存在 provider 之间串扰):
npx vite-node -c tests/e2e/live-acp/vite-node.config.mts \
tests/e2e/live-acp/acp-docker-app-e2e.mts -- codex
脚本通过 vite-node.config.mts 在完整 Vite/React-Router 管线之外运行:该最小配置只做了两件事——把 #/* 别名指向 src/*(应用平时靠 tsconfig-paths 解析,vite-node 不加载它),以及把 @openhands/typescript-client 设为 SSR inline,使其 ESM 解析方式与 Vitest 一致。
3. 清理(容器持有真实凭证)
docker rm -f oh-acp
宿主机凭证采集:三个 provider 的 collector
凭证从宿主机读取,全程不会打印。采集逻辑集中在共享的 harness.mts 中(harness.mts#L107-L156),任何 provider 的凭证缺失时该 provider 被跳过(跳过不算失败):
| Provider | acp_server |
默认模型(可覆盖) | 凭证来源 | 容器 secret 名 |
|---|---|---|---|---|
| Codex | codex |
gpt-5.5/medium(ACP_E2E_CODEX_MODEL) |
~/.codex/auth.json |
CODEX_AUTH_JSON |
| Claude Code | claude-code |
claude-opus-4-7(ACP_E2E_CLAUDE_MODEL) |
macOS 钥匙串(security find-generic-password -s "Claude Code-credentials" -w,取 claudeAiOauth.accessToken) |
CLAUDE_CODE_OAUTH_TOKEN |
| Gemini CLI | gemini-cli |
gemini-2.5-pro(ACP_E2E_GEMINI_MODEL) |
~/.config/gcloud/application_default_credentials.json + gcloud 默认项目(需先 gcloud auth application-default login) |
GOOGLE_APPLICATION_CREDENTIALS_JSON、GOOGLE_CLOUD_PROJECT、GOOGLE_CLOUD_LOCATION、GOOGLE_GENAI_USE_VERTEXAI=true |
两个值得注意的实现细节(均可在 harness.mts 中验证):
- Claude 故意不设置
ANTHROPIC_BASE_URL:继承的环境 base URL 会破坏 OAuth token 的 bearer 认证(harness.mts#L124-L128); - 非 macOS 上 Claude collector 直接返回 null:没有
security命令时execFileSync抛错被 catch 掉,runner 打印SKIP — credentials not present on host。
另外 harness.mts 的 registerDockerBackend() 把应用的后端注册表指向该容器(setRegisteredBackends + setActiveSelection),效果等同于用户在 backend selector 中手动添加——下游的 SecretsService、SettingsService 以及 buildStartConversationRequest 发出的 LookupSecret 鉴权头全部经由这一选择解析宿主地址。
源码纵深:LookupSecret 是如何发射的
SecretsService:与 onboarding 相同的写入路径
SecretsService.createSecret 对本地后端调用 SettingsClient.upsertSecret({ name, value, description }),即 agent-server 的 PUT /api/settings/secrets(按名字 upsert);云后端则走 saveCloudSecret。secret 名字有约束:字母开头,仅字母/数字/下划线,1–64 字符。列表接口 getSecrets() 只返回名字和描述,不返回值——值只存在于 agent-server 的 store 中。
buildStartConversationRequest:把名字变成 LookupSecret
在 agent-server-adapter.ts 中,LookupSecret 的定义是:
interface LookupSecret {
kind: "LookupSecret";
url: string; // 指向 secret store 的 loopback 地址
headers?: Record<string, string>;
description?: string;
}
buildStartConversationRequest(L1085)接受 customSecrets: Array<{ name; description? }>(L1076),并为每个名字生成一条 { kind: "LookupSecret", url, ... } 放入 payload 的 secrets 字段(L1239-L1251)。e2e 脚本正是利用这一点做不泄漏值的健全性检查:把 payload 中所有 secrets 打印出 kind,并断言全部为 LookupSecret——若不是,直接判 FAIL(acp-docker-e2e.mts#L104-L121)。
ACP 相关的启动参数通过 settings.agent_settings 传入:agent_kind: "acp"、acp_server(即上表中的注册表键)、acp_model,可选 acp_session_mode;会话侧 max_iterations: 8(acp-docker-e2e.mts#L54-L82)。
轮询与断言
harness.mts 提供两个共享 helper:
pollUntilTerminal(conversationId):每 2.5s 轮询GET /api/conversations/<id>直到execution_status进入终态集合{finished, error, stuck, stopped}或超时(默认ACP_E2E_TIMEOUT_MS=180000)。注意idle刻意不在终态集合中——新建会话在 agent 启动前会报idle,若把它当终态会在回复产生前就退出;fetchFinalReply(conversationId):拉取GET /api/conversations/<id>/agent_final_response,脚本断言其中包含对应 provider 的期望 token(ACPOK-CODEX/ACPOK-CLAUDE/ACPOK-GEMINI),任务指令就是Reply with exactly: <token>。
两条脚本路径覆盖的差异
| acp-docker-e2e.mts(请求构建器路径) | acp-docker-app-e2e.mts(应用编排器路径) | |
|---|---|---|
| 会话启动 | 直接调 buildStartConversationRequest,内联 agent_settings(acp_server/acp_model 显式传入) |
buildStartConversationRequestWithEncryptedSettings(L1290),基础 settings 从后端重新拉取 |
| Agent 选择 | 请求内联指定 | 先经 buildAcpAgentSettingsDiff(acpServer, { model }) 构造 diff,PATCH /api/settings(agent_settings_diff),模拟应用的 choose-agent 步骤 |
| 额外证明 | LookupSecret 从 store 解析、SDK 的 acp_file_secrets 物化端到端生效 |
保存的凭证往返经过后端 store,且编排器为每个已保存的 secret 名字发射正确的 LookupSecret(acp-docker-app-e2e.mts#L78-L102) |
| Provider 数量 | 可一次跑多个(-- codex claude gemini) |
每个进程一个 provider |
harness.mts 的注释点明了共享策略:provider 计划(模型、凭证采集器)与 HTTP/轮询 helper 由两个脚本共用——改模型默认值或凭证参数时只改 harness,不要分别改脚本,避免两侧漂移。
环境参数(Knobs)一览
| 环境变量 | 默认值 | 说明 |
|---|---|---|
ACP_E2E_BASE_URL |
http://localhost:8010 |
agent-server 容器地址 |
ACP_E2E_CODEX_MODEL / ACP_E2E_CLAUDE_MODEL / ACP_E2E_GEMINI_MODEL |
gpt-5.5/medium / claude-opus-4-7 / gemini-2.5-pro |
各 provider 的 ACP 模型,需是账户/Vertex 项目支持的模型 |
ACP_E2E_GEMINI_SESSION_MODE |
不设置(SDK 用 provider 注册表默认) | 设为 default 可绕过 gemini-cli ≥0.43 在 headless init 时 set_session_mode("yolo") 报错的 SDK 阻塞点 |
GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION |
从 gcloud 读取 / us-central1 |
Gemini Vertex 的项目与区域 |
ACP_E2E_TIMEOUT_MS |
180000 |
单轮会话轮询超时(harness 中定义) |
ACP_E2E_WORKING_DIR_BASE |
/workspace/acp-e2e |
请求构建器脚本的会话工作目录根 |
已验证结果与 Gemini 的三个前置条件
文档记录了 2026-06-07 针对 ghcr.io/openhands/agent-server:1.25.0-python(首个包含 software-agent-sdk#3510 的发布)在全新卷上的复验:每个凭证都从 secret store 种子注入、无残留状态;日志确认 agent-server 在 ACP 冷启动期间解析了 loopback LookupSecret(GET /api/settings/secrets/<name> 返回 200),无死锁、无 "Failed to start ACP server: timed out"——这正是 #3510 修复的问题。三个 provider 的真实回复:
| Provider | 结果 | 日志证据 |
|---|---|---|
| Codex | 真实回复 ACPOK-CODEX(两个脚本) |
Materialised ACP file-secret 'CODEX_AUTH_JSON' -> …/acp/codex/auth.json;codex-acp 0.15.0;Authenticating with ACP method: chatgpt |
| Claude Code | 真实回复 ACPOK-CLAUDE(两个脚本) |
claude-agent-acp 0.30.0;CLAUDE_CODE_OAUTH_TOKEN env 路径(未设 ANTHROPIC_BASE_URL) |
| Gemini CLI | 真实回复 ACPOK-GEMINI¹ |
Materialised ACP file-secret 'GOOGLE_APPLICATION_CREDENTIALS_JSON' -> …/acp/gemini-cli/gcloud-credentials.json;gemini-cli 0.45.1;Authenticating with ACP method: vertex-ai,在 gemini-2.5-pro 上发生真实 Vertex 推理 |
¹ Gemini 前置条件(三者缺一不可):
- 新鲜的宿主机 ADC——需重新执行
gcloud auth application-default login;过期的 ADC 会以invalid_rapt失败,这是凭证问题而非 Canvas 问题; - 非 flash 的
gemini-2.5-pro模型——gemini-cli 0.45.x 会在生成时把任何*-flashid 重新解析为当前默认 flash,在不提供该模型的 project 上会 404(software-agent-sdk#3532),这也是 Canvas 预置gemini-5.5-pro之外的gemini-2.5-pro的原因; ACP_E2E_GEMINI_SESSION_MODE=default——绕过 gemini-cli ≥0.43 的set_session_mode("yolo")headless-init 阻塞点。
acp-docker-e2e.mts 内置了对第三种情形的提示:若 gemini 在无 session mode 覆盖时报 error,脚本会提示"这大概率是 SDK/gemini-cli 的 set_session_mode('yolo') 阻塞,而非凭证问题,请用 ACP_E2E_GEMINI_SESSION_MODE=default 重跑以确认凭证链路端到端"(acp-docker-e2e.mts#L137-L147)。
小结:这套 e2e 的设计要点
- 走 Canvas 自己的代码:凭证写入用 onboarding 相同的
SecretsService.createSecret;请求构建用应用相同的buildStartConversationRequest/buildStartConversationRequestWithEncryptedSettings——单元测试无法覆盖的"它真的能用"检查由它补齐; - 凭证永不过客户端:值只存进 agent-server 的 secret store,请求里只有名字和 loopback
LookupSecret地址,脚本侧只打印kind做健全性检查; - 单点维护:模型默认值与凭证采集器统一收敛在 harness.mts,两条脚本路径共用;
- 可复现的版本前提:agent-server ≥1.25.0(#3510 的 off-loop 解析)且推荐 1.28.0(
client_toolsAPI);镜像、挂载与清理命令见上文运行步骤。
相关文件索引:README、harness.mts、acp-docker-e2e.mts、acp-docker-app-e2e.mts、vite-node.config.mts、agent-server-adapter.ts、secrets-service.ts、acp-providers.ts、单元测试对照。
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