Skip to content
FunCoding

Search

Search docs, agents, posts, Skills and MCP servers

自托管环境:端到端测试

从 CI 验证 runner 镜像:在测试 runner 上安装 Stop hook 捕获 Claude 回复,用 CLI 派发会话并发后续消息,脚本化完整循环;远程测试 runner、CI 认证与专用测试环境的创建删除。

自托管环境在 Team 和 Enterprise 套餐上处于公开 beta;启用路径见自托管环境概览页的「可用性与限制」。本页是 CI 测试配方:设置见快速开始,机队部署方案见「部署到生产」。下面的 beta 头、令牌期限等以官方为准。

在自托管环境里,Claude Code 云端会话运行在你自己构建和维护的 runner 镜像上。在把新镜像推广到生产环境之前,用脚本对着测试环境驱动一个完整会话:创建会话、读取 Claude 的回复、发送后续消息并读取那条回复。这是 CI 冒烟测试的形状,在你推广改动之前验证 runner 镜像、git 访问和任何自定义工具。

这个配方假定你已经设置好环境和 runner,并且你的 CI 作业在与测试脚本相同的主机上启动 runner 进程(测试新 runner 镜像的自然设置)。你安装在 runner 上的 Stop hook 把每一轮的最终回复写入本地文件,脚本从那里读取,所以对 Anthropic API 的调用只有两次派发本身。如果你的测试 runner 在单独的基础设施上,见「远程测试 runner」。

在测试 runner 上安装捕获 hook

读回通过 Claude Code 的 Stop hook 工作:当 Claude 完成一轮时,hook 在它的 stdin JSON 里收到作为 last_assistant_message 的最终助手消息,并把它追加到 $E2E_REPLY_DIR/<session_id>.txt。安装方式与「提示会话推送它们的工作」里的提交提醒 Stop hook 相同:放在 runner 主机的 ~/.claude/ 上,runner 会把它注入每个会话。

保存 hook 文件

在 runner 主机上保存下面两个文件:设置块合并进 runner 主机上的 ~/.claude/settings.json;脚本保存为 runner 主机上的 ~/.claude/hooks/e2e-stop-hook-capture.sh 并使它可执行。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "timeout": 10,
            "command": "\"$CLAUDE_CONFIG_DIR/hooks/e2e-stop-hook-capture.sh\""
          }
        ]
      }
    ]
  }
}
#!/bin/sh
# 用于端到端测试自托管环境的 Stop hook:把每一轮最终的助手回复写入
# $E2E_REPLY_DIR/<session_id>.txt,让同机的测试驱动无需调用 Anthropic API 就能读取。
# 只安装在测试 runner 上。需要 jq。
# 除非驱动在监听,否则什么也不做;永远不让这一轮失败。
[ -n "${E2E_REPLY_DIR:-}" ] && [ -d "$E2E_REPLY_DIR" ] || exit 0

# CLAUDE_CODE_REMOTE_SESSION_ID 以 cse_... 形式导出;派发 CLI 打印的会话
# id 是 session_... 形式。同一个 id,不同的前缀。
sid=$(printf '%s' "${CLAUDE_CODE_REMOTE_SESSION_ID:-}" | sed 's/^cse_/session_/')
[ -n "$sid" ] || exit 0

# 最终助手轮没有文本时(如只有工具调用的轮)没有 last_assistant_message。
# `// empty` 过滤器让这种情况写入零字节,而不是字面字符串 "null"。
jq -r '.last_assistant_message // empty' >> "$E2E_REPLY_DIR/$sid.txt" 2>/dev/null
exit 0

启动 runner 之前

hook 有这些要求:

  • 在启动 runner 之前安装它。runner 在启动时对 ~/.claude/ 做一次快照,所以加到运行中 runner 上的 hook 只有重启后才生效。
  • 把 E2E_REPLY_DIR 导出给 runner 进程。变量未设置或目录不存在时 hook 什么都不做,所以在你启动 runner 的任何地方设置它,如 systemd unit、pod 规范或 CI 步骤;下面的测试脚本也需要它。

只在服务于你测试环境的 runner 上安装这个 hook。只要 E2E_REPLY_DIR 存在,它就把每个会话的最终回复写到磁盘上,这在一次性的 CI runner 上无害,但不要把它带进生产环境的 runner 镜像,那里变量可能被意外设置。

运行测试循环

--environment 和 --ref 派发标志需要运行脚本的机器上是 Claude Code v2.1.224 或更高,与 runner 本身的下限相同。hook 就位且 runner 已在这台主机上启动后,测试脚本:

  1. 用 claude -p "<prompt>" --environment <environment-id> --output-format json 在测试环境上创建会话,在 git 检出里运行,使 CLI 能从 origin 远程自动检测仓库。可选的 --ref <branch> 让会话的检出基于某个命名的引用而不是本地 HEAD。该命令创建会话,打印一行含 session_id 的 JSON,并在不等待 Claude 回复的情况下退出。
  2. 等待回复出现在 $E2E_REPLY_DIR/<session_id>.txt 里,由 runner 上的 Stop hook 在该轮完成时写入。
  3. 用 claude -p "<message>" --cloud <session_id> --output-format json 发送后续消息(见云端会话页「从 CLI 发送后续消息」),它向已有会话发布一个用户事件并退出。
  4. 以与第 2 步相同的方式等待后续消息的回复。

--environment 派发行为

Claude Code 创建会话,打印会话 ID 和指向它的链接,然后退出。该标志优先于 remote.defaultEnvironmentId 设置。它不支持 --output-format stream-json,也不能与恢复、附加或预配置会话的标志组合,如 --resume、--continue、--teleport、--session-id 或 --init-only。带会话 ID 或 URL 的 --cloud 会被拒绝,非交互运行里带描述的 --cloud 也会被拒绝;单独的 --cloud 被视为不存在。在终端里,你可以把任务作为 --cloud 的描述传入,而不是位置参数提示词。

示例脚本

下面的脚本对着 $CLAUDE_TEST_ENVIRONMENT_ID(你测试环境的 ccpool_... ID,显示在管理页环境的详情对话框里,或由创建环境调用返回)运行完整循环,并对每个回复里的哨兵短语做断言。在你想让会话工作的仓库的 git 检出里运行它,且要先在这台主机上启动了安装有捕获 hook 并导出了 E2E_REPLY_DIR 的 runner。

#!/usr/bin/env bash
# 对自托管环境做端到端测试,用 Stop hook 读回。
# 前提:已在这台机器上运行过 `claude auth login`(见下面的「从 CI 认证」);
# 已安装 jq;CLAUDE_TEST_ENVIRONMENT_ID 指向一个 runner 在这台主机上的环境,
# 该 runner 安装了捕获 hook 且环境里有 E2E_REPLY_DIR。
set -euo pipefail

: "${CLAUDE_TEST_ENVIRONMENT_ID:=${CLAUDE_TEST_POOL_ID:-}}"  # CLAUDE_TEST_POOL_ID 是旧的拼写
: "${CLAUDE_TEST_ENVIRONMENT_ID:?set CLAUDE_TEST_ENVIRONMENT_ID to a ccpool_... id served by a runner on this host}"
: "${E2E_REPLY_DIR:?set E2E_REPLY_DIR to the directory the Stop hook on your test runner writes to, and export it to the runner process}"
: "${TEST_REPO_REF:=main}"

[ -d "$E2E_REPLY_DIR" ] || {
  echo "FAIL: E2E_REPLY_DIR ($E2E_REPLY_DIR) does not exist. The Stop hook on the runner needs it." >&2
  exit 1
}

# 等到 $E2E_REPLY_DIR/<session_id>.txt 包含 $2,或 90 秒后失败。
# 按你环境的冷启动时间调整超时。该文件由 runner 上的 Stop hook 写入。
await_reply() {
  local expect="$2" f="$E2E_REPLY_DIR/$1.txt"
  local deadline=$(($(date +%s) + 90))
  while :; do
    if [ -f "$f" ] && grep -qF -- "$expect" "$f"; then
      return
    fi
    [ "$(date +%s)" -lt "$deadline" ] || {
      echo "FAIL: '$expect' not in $f within 90s. The Stop hook on the runner did not write it." >&2
      echo "-- $E2E_REPLY_DIR contents --" >&2; ls -la "$E2E_REPLY_DIR" >&2
      [ -f "$f" ] && { echo "-- $f --" >&2; cat "$f" >&2; }
      exit 1
    }
    sleep 1
  done
}

# 1. 在测试环境上创建会话。在 git 检出里运行,让 CLI 能自动检测仓库。
# --ref 把检出固定到命名的引用,不论本地 HEAD 是什么。
TURN1="e2e-probe-$(date +%s)-$$: say exactly 'ok: custom tools are reachable' and nothing else"
EXPECT1="ok: custom tools are reachable"
create_json=$(claude -p "$TURN1" --environment "$CLAUDE_TEST_ENVIRONMENT_ID" \
  --ref "$TEST_REPO_REF" --output-format json)
echo "create: $create_json"
SESSION_ID=$(jq -er '.session_id' <<<"$create_json")

# 2. 等待第 1 轮的回复。
await_reply "$SESSION_ID" "$EXPECT1"
echo "turn-1 reply ok"

# 3. 通过 CLI 发送后续消息。
TURN2="e2e-probe-followup-$(date +%s): say exactly 'ok: follow-up delivered' and nothing else"
EXPECT2="ok: follow-up delivered"
followup_json=$(claude -p "$TURN2" --cloud "$SESSION_ID" --output-format json)
echo "followup: $followup_json"
jq -e '.ok == true' <<<"$followup_json" >/dev/null

# 4. 等待第 2 轮的回复。
await_reply "$SESSION_ID" "$EXPECT2"
echo "turn-2 reply ok"

echo "PASS: test-environment round-trip (session $SESSION_ID)"

把 TURN1/TURN2 提示词和 EXPECT1/EXPECT2 哨兵换成能检验你设置的任何东西,例如让 Claude 运行你的某个自定义 MCP 工具并对它的输出做断言。

远程测试 runner

如果你的测试 runner 在单独的基础设施上(如你的 CI 作业无法与之共享文件系统的持久 Kubernetes 机队),把 Stop hook 里的文件写入换成对你驱动监听的端点的 POST:

#!/bin/sh
# 用于单独基础设施上 runner 的捕获 hook 变体。
# 在 runner 上把 E2E_REPLY_URL 设为驱动控制的端点。
[ -n "${E2E_REPLY_URL:-}" ] || exit 0
sid=$(printf '%s' "${CLAUDE_CODE_REMOTE_SESSION_ID:-}" | sed 's/^cse_/session_/')
[ -n "$sid" ] || exit 0
jq -r '.last_assistant_message // empty' | \
  curl -fsS -X POST --data-binary @- "$E2E_REPLY_URL/$sid" >/dev/null 2>&1
exit 0

在驱动这一侧,运行任何接受该 POST 并保留回复直到测试请求它的东西,例如 CI 作业内部的小型 HTTP 监听器,或你已经在运行的 webhook 接收器。hook 在你的基础设施上运行,所以该端点只需要能从你的 runner 访问。

从 CI 认证

claude -p ... --environment 和 claude -p ... --cloud 都用 claude.ai OAuth 令牌认证;API key(如 sk-ant-xxxxx)对这两个调用都不被接受。有两种办法让令牌在 CI 里可用。

长期存在的 CI 主机

在执行脚本的机器上,用专门用于自动化的用户账号,交互式地运行一次 claude auth login。Claude Code 把令牌存在 macOS 的操作系统钥匙串里,或 Linux 和 Windows 上的 ~/.claude/.credentials.json 里;在钥匙串无法写入的 macOS 主机上(SSH 会话里登录钥匙串保持锁定时很典型),Claude Code 也把令牌存在那里的 ~/.claude/.credentials.json 里。见认证页的凭据管理。CLI 在每次调用时自动刷新短期的访问令牌,但底层的刷新令牌授权自初次登录起最长 30 天,所以每 30 天在那台主机上交互式地重新运行 claude auth login。

临时的 CI runner

目前没有为此提供的长期 CI 令牌。授予云端会话控制权的范围 user:sessions:claude_code 在服务端被限制为 30 天,所以签发一年期仅推理令牌的 claude setup-token 不能覆盖它。环境密钥也不被接受,因为它只授权 runner 向环境注册,而不是创建会话。要把已存储的登录配置到临时 runner 上,设置 CLAUDE_CODE_OAUTH_REFRESH_TOKEN 和 CLAUDE_CODE_OAUTH_SCOPES,使 claude auth login 无需浏览器就交换令牌;同样的 30 天上限适用于刷新授权。如果你需要不绑定到人类账号的机器身份路径,联系你的 Anthropic 客户团队。

创建专用的测试环境

以编程方式创建和删除环境,使每次 CI 运行都得到干净的环境;你的 CI 作业启动的 runner 向全新的环境注册。下面的创建和删除调用与 claude.ai 上 Cloud environments 管理页使用的是同样的端点,并要求 anthropic-beta: ccr-byoc-2025-07-29 头。

签发管理员令牌

$ADMIN_TOKEN 是持有 Owner 角色的账号的 claude.ai OAuth 访问令牌,签发方式与「从 CI 认证」相同:用持有 Owner 角色的账号运行 claude auth login,然后从「长期存在的 CI 主机」所说的 Claude Code 存储位置读取当前的访问令牌;每次运行都重新读取它(CLI 会轮换访问令牌,同样的 30 天刷新授权上限适用,所以不要存副本);像例子里那样通过 stdin 传递,使令牌永远不会出现在 curl 的参数列表或你的构建日志里。

创建环境

捕获响应但不回显它:pool_secret 是一个能向环境注册 runner 的长期凭据,所以把它存为被掩码的 CI 密钥,只打印环境 ID。让令牌不出现在进程列表里的 -H @- 形式需要 curl 7.55 或更高;更老的 curl 把 @- 当作字面头,不带授权发送请求。

create=$(curl -fsS -X POST -H @- \
  -H "anthropic-beta: ccr-byoc-2025-07-29" -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"name":"ci-test-environment"}' \
  https://api.anthropic.com/v1/code/runners/self-hosted/pools \
  <<<"Authorization: Bearer $ADMIN_TOKEN")
ENVIRONMENT_ID=$(jq -er .pool.pool_id <<<"$create")
ENVIRONMENT_SECRET=$(jq -er .pool_secret <<<"$create")

在 Owner 为组织打开 Allow self-hosted environments 之前,该调用以 403 permission_error 失败,消息是 self-hosted runners are disabled by your organization's policy。在这台主机上用 SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET=$ENVIRONMENT_SECRET 启动 runner,再按「在测试 runner 上安装捕获 hook」装上捕获 hook 和 E2E_REPLY_DIR,然后运行测试脚本。

删除环境

运行结束时删除环境,使每次 CI 运行都从干净的状态开始:

curl -fsS -X DELETE -H @- \
  -H "anthropic-beta: ccr-byoc-2025-07-29" -H "anthropic-version: 2023-06-01" \
  "https://api.anthropic.com/v1/code/runners/self-hosted/pools/$ENVIRONMENT_ID" \
  <<<"Authorization: Bearer $ADMIN_TOKEN"