Claude Codeセルフホスト環境をCIでE2Eテストする
Stop hookでの応答読み戻し、--environmentディスパッチ、CI認証の2方式、専用テスト環境の作り捨てまで、Runnerイメージを本番投入前に検証する手順です。
Claude Codeセルフホスト環境のE2Eテストとは
セルフホスト環境では、Runnerイメージを自社で作って本番に配ります。新しいイメージを本番のEnvironmentへ展開する前に、セッションを1件通して往復させる検証が要ります。プロンプトを送り、応答を読み、フォローアップを送り、その応答も読む。この一連の流れをスクリプト化すれば、Runnerイメージとgitアクセス、カスタムツールの動作をCIのスモークテストとして自動化できます。
本記事は、応答をClaude Codeの外から読み戻す仕組みと、CIから安全に認証する2つの方式を中心にまとめます。セルフホスト環境全体の構成要素はClaude Code self-hosted-runnerとは、本番投入時のハードニングはClaude Codeセルフホスト環境の本番運用で扱っています。
前提条件
クイックスタートでEnvironmentとRunnerを一度セットアップ済みであることが前提です。以下の手順は、CIジョブがテスト用Runnerのプロセスと同じホスト上でテストスクリプトを実行する構成、つまり新しいRunnerイメージを検証するときにもっとも自然な配置を想定します。テストRunnerと別のインフラ上でスクリプトを走らせる場合は後述のリモート基盤のRunnerを使う場合を参照します。
--environmentと--refディスパッチフラグには、スクリプトを実行するマシン側でClaude Code v2.1.224以降が要ります。Runner自体の要求バージョンと同じです。
Stop hookで応答を読み戻す
読み戻しはClaude CodeのStop hook(ターン終了時に走るhookの一種)で動きます。Stop hookの設定形式やイベントの全体像はClaude Code Hooks完全ガイドにまとめているので、ここではE2Eテスト向けの実装だけに絞ります。ターンが終わると、hookは最終的なアシスタントメッセージを受け取ります。標準入力のJSONにlast_assistant_messageとして渡され、hookはそれを$E2E_REPLY_DIR/<session_id>.txtに追記します。インストール先はRunnerホストの~/.claude/です。Runnerは起動のたびにこのディレクトリの内容をセッションへ引き継ぐので、事前に置いておく必要があります。
次の2ファイルをRunnerホストに保存します。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"timeout": 10,
"command": "\"$CLAUDE_CONFIG_DIR/hooks/e2e-stop-hook-capture.sh\""
}
]
}
]
}
}上を~/.claude/settings.jsonにマージし、下を~/.claude/hooks/e2e-stop-hook-capture.shとして保存して実行権限を付けます。
#!/bin/sh
# E2Eテスト用のStop hook。各ターンの最終応答を
# $E2E_REPLY_DIR/<session_id>.txt に書き出す。jqが必要。
# テスト用Runnerにのみインストールすること。
[ -n "${E2E_REPLY_DIR:-}" ] && [ -d "$E2E_REPLY_DIR" ] || exit 0
sid=$(printf '%s' "${CLAUDE_CODE_REMOTE_SESSION_ID:-}" | sed 's/^cse_/session_/')
[ -n "$sid" ] || exit 0
jq -r '.last_assistant_message // empty' >> "$E2E_REPLY_DIR/$sid.txt" 2>/dev/null
exit 0CLAUDE_CODE_REMOTE_SESSION_IDはcse_で始まる形式ですが、ディスパッチCLIが出力するセッションIDはsession_始まりです。同じIDのプレフィックス違いなので、hook側で置き換えています。
このフックには2つの依存があります。1つは、Runnerを起動する前にインストールしておくことです。Runnerは~/.claude/を起動時に一度だけスナップショットするため、動いているRunnerに後からhookを足しても次の再起動まで反映されません。もう1つはE2E_REPLY_DIRをRunnerプロセスの環境変数として渡すことです。未設定またはディレクトリが存在しない間、hookは何もしません。systemdのunitファイルやPod仕様、CIのステップなど、Runnerを起動する場所で設定します。
本番Environment用のRunnerイメージにこのhookを持ち込むと、E2E_REPLY_DIRが誤って設定されたときに毎セッションの応答をディスクへ書き出してしまいます。テスト用Environmentを受け持つRunnerだけに限定してインストールします。同じ~/.claude/へのhook配置は、ラッパースクリプトとフックでセッションを拡張するで扱っているセッション拡張の仕組みと共通です。
テストループを実行する
hookを仕込み、このホストでRunnerを起動した状態で、テストスクリプトは次の4ステップを踏みます。
- gitのチェックアウト内から
claude -p "<prompt>" --environment <environment-id> --output-format jsonでセッションを作成する。CLIがoriginリモートからリポジトリを自動検出できるよう、gitチェックアウト内で実行する。--ref <branch>を足すとローカルのHEADではなく指定したブランチを基準にできる $E2E_REPLY_DIR/<session_id>.txtに応答が書き込まれるまで待つ(Runner上のStop hookが書く)claude -p "<message>" --cloud <session_id> --output-format jsonでフォローアップを送る- フォローアップの応答も同じ方法で待つ
create_json=$(claude -p "hello" --environment "$ENV_ID" --output-format json)
SESSION_ID=$(jq -er '.session_id' <<<"$create_json")--environmentディスパッチは、セッションを作成してIDとリンクを出力したらすぐ終了します。応答の完了を待ちません。remote.defaultEnvironmentId設定より優先され、--output-format stream-jsonには非対応です。セッションを再開・アタッチ・事前設定するフラグとも共存しません。対象は--resume・--continue・--teleport・--session-id・--init-onlyです。
実際のスクリプト全体は次の形です。await_reply関数がポーリングで応答ファイルを待ち、90秒でタイムアウトします。
#!/usr/bin/env bash
set -euo pipefail
: "${CLAUDE_TEST_ENVIRONMENT_ID:?environment idを設定}"
: "${E2E_REPLY_DIR:?Stop hookの書き出し先ディレクトリを設定}"
: "${TEST_REPO_REF:=main}"
await_reply() {
local expect="$2" f="$E2E_REPLY_DIR/$1.txt"
local deadline=$(($(date +%s) + 90))
while :; do
[ -f "$f" ] && grep -qF -- "$expect" "$f" && return
[ "$(date +%s)" -lt "$deadline" ] || { echo "FAIL: timeout waiting for $expect" >&2; exit 1; }
sleep 1
done
}
TURN1="probe-$(date +%s): say exactly 'ok: reachable' and nothing else"
create_json=$(claude -p "$TURN1" --environment "$CLAUDE_TEST_ENVIRONMENT_ID" \
--ref "$TEST_REPO_REF" --output-format json)
SESSION_ID=$(jq -er '.session_id' <<<"$create_json")
await_reply "$SESSION_ID" "ok: reachable"
TURN2="followup-$(date +%s): say exactly 'ok: followup' and nothing else"
claude -p "$TURN2" --cloud "$SESSION_ID" --output-format json >/dev/null
await_reply "$SESSION_ID" "ok: followup"
echo "PASS: round-trip complete (session $SESSION_ID)"プロンプトと期待文字列は、実際のセットアップに合わせて差し替えます。カスタムMCPツールをClaudeに呼ばせて、その出力をアサートする形にすれば、hookやツール接続まで含めた検証になります。
リモート基盤のRunnerを使う場合
CIジョブとファイルシステムを共有できないKubernetesの常設フリートのように、テストRunnerが別インフラにある場合は、hookのファイル書き込みをHTTP POSTに差し替えます。
#!/bin/sh
[ -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での認証 — 長期ホストと使い捨てRunnerの使い分け
--environmentも--cloudも、claude.aiのOAuthトークンで認証します。sk-ant-xxxxxのようなAPIキーはどちらの呼び出しでも使えません。CIでトークンを用意する方法は2つあり、CI基盤の性質によって選ぶ側が変わります。
| 方式 | 認証情報の保存場所 | 有効期限の運用 | 向く場面 |
|---|---|---|---|
| 長期CIホスト | 認証情報の保存場所OS keychain(macOS)または~/.claude/.credentials.json(Linux/Windows) | 有効期限の運用リフレッシュトークンは初回ログインから30日で失効。30日ごとにclaude auth loginを再実行 | 向く場面専用ユーザーで動かし続ける常設CIランナー |
| 使い捨てRunner | 認証情報の保存場所CLAUDE_CODE_OAUTH_REFRESH_TOKENとCLAUDE_CODE_OAUTH_SCOPESをシークレットとして注入 | 有効期限の運用同じ30日の上限がリフレッシュ権限にかかる | 向く場面Kubernetesのジョブのように毎回使い捨てるCI環境 |
長期CIホストでは、自動化専用のアカウントで一度だけclaude auth loginを対話実行します。macOSのKeychainが書き込めない環境では~/.claude/.credentials.jsonに保存されます。SSHセッションでログインKeychainがロックされたままの状態が典型例です。CLIは短命のアクセストークンを呼び出しのたびに自動更新します。ただし裏側のリフレッシュトークン自体は初回ログインから30日で失効するため、そのホストで定期的にログインをやり直します。
使い捨てRunnerには、今日時点で長期のCIトークンという選択肢がありません。リモートセッション操作を許可するスコープuser:sessions:claude_codeはサーバー側で30日に固定されています。1年有効な推論専用トークンを発行するclaude setup-tokenも、このスコープはカバーしません。Environment secretも使えません。Runnerを登録する権限しか持たず、セッションを作る権限は無いためです。ブラウザーなしでログインを済ませるには、CLAUDE_CODE_OAUTH_REFRESH_TOKENとCLAUDE_CODE_OAUTH_SCOPESを渡します。claude auth loginにトークン交換だけをさせる形です。同じ30日の上限がここにも掛かります。人間のアカウントに紐づかないマシンアイデンティティが要る場合は、Anthropicのアカウントチームへの相談が必要です。
専用のテスト環境をCIごとに作り捨てる
CIの実行ごとにクリーンなEnvironmentを用意するなら、作成と削除をプログラムから行います。管理画面の「Cloud environments」ページが使っているのと同じエンドポイントで、anthropic-beta: ccr-byoc-2025-07-29ヘッダーが必須です。
Ownerロールを持つアカウントでclaude auth loginを実行し、そこから読んだアクセストークンを$ADMIN_TOKENとして使います。トークンはCLIがローテーションするため保存せず、実行のたびに読み直します。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")-H @-でトークンをプロセスリストから隠す書き方は、curl 7.55以降が必要です。古いcurlは@-をヘッダー名の文字列として扱ってしまい、認証なしでリクエストが送られます。レスポンスのpool_secretは、EnvironmentへRunnerを登録できる長期の認証情報です。マスク付きのCIシークレットとして保存し、標準出力にはEnvironment IDだけを残します。
作成したEnvironmentに対しては、SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET=$ENVIRONMENT_SECRETとしてこのシークレットを渡し、前述のキャプチャ用hookとE2E_REPLY_DIRを設定した状態でRunnerをこのホスト上で起動します。そのうえで「テストループを実行する」で組んだテストスクリプトを走らせます。
実行後は同じ要領でDELETEを叩き、Environmentを消してから次のCI実行に備えます。Ownerが管理画面で「Allow self-hosted environments」を有効化していないと、この呼び出しは失敗します。返るのは403のpermission_errorで、メッセージはself-hosted runners are disabled by your organization's policyです。
よくあるつまずき
hookを入れた直後にRunnerを再起動していない: ~/.claude/のスナップショットはRunner起動時の一度きりです。動作中のRunnerにhookファイルを追加しても、次に再起動するまで一切反映されません。
E2E_REPLY_DIRをRunnerプロセスに渡し忘れる: hook自体をインストールしても、変数が未設定かディレクトリが存在しない間は何もしません。テストスクリプト側も同じ変数を参照するので、両方に同じ値を渡します。
jqが入っていないRunnerイメージで検証する: 読み戻しのhookは内部でjqを使います。本番イメージには不要でも、テスト用イメージにだけ足しておく必要があります。
--environmentと--resumeのような再開系フラグを混ぜる: 新規セッションの作成専用のフラグなので、既存セッションへのアタッチや再開を意図したフラグとは共存できません。組み合わせるとエラーで止まります。
古いcurlで-H @-を使う: 7.55より前のcurlは、この書き方だとヘッダーではなく認証なしのリクエストとして送ってしまいます。CIランナーのcurlバージョンを事前に確認します。
まとめ
セルフホスト環境のE2Eテストは、Runnerイメージ・gitアクセス・カスタムツールを本番投入前に自動で確かめる仕組みです。核になるのはStop hookでの応答読み戻しと、--environment/--cloudディスパッチの組み合わせだけなので、CIのステップとしては小さく収まります。難所はむしろ認証の側です。長期CIホストなら30日ごとの再ログイン、使い捨てRunnerならCLAUDE_CODE_OAUTH_REFRESH_TOKENによるトークン交換と、CI基盤の寿命に合わせて方式を選びます。専用Environmentの作り捨てまで組み込めば、CI実行同士が状態を持ち越さない検証パイプラインになります。