Claude Codeセルフホスト環境をCIでE2Eテストする
Stop hookでの応答読み戻し、--environmentディスパッチ、CI認証の2方式、専用テスト環境の作り捨てまで、Runnerイメージを本番投入前に検証する手順です。
新しいRunnerイメージを本番に出す前に、セッションを1往復させる
セルフホスト環境では、Runnerイメージを自社で作って本番に配ります。新しいイメージを本番のEnvironmentへ出す前に、セッションを1件通して往復させる検証が要ります。プロンプトを送り、応答を読み、フォローアップを送り、その応答も読む流れです。これをスクリプト化すれば、Runnerイメージとgitアクセス、カスタムツールの動作をCIのスモークテストにできます。
セルフホスト環境はTeamとEnterpriseプラン向けのパブリックベータです。組織のOwnerが有効化していない状態では、後述のEnvironment作成も失敗します。
本記事は、応答をClaude Codeの外から読み戻す仕組みと、CIから認証する2つの方式が中心です。セルフホスト環境全体の構成要素はClaude Code self-hosted-runnerとは、本番投入時のハードニングはClaude Codeセルフホスト環境の本番運用にあります。
前提条件とテストの配置
クイックスタートでEnvironmentとRunnerを一度セットアップ済みであることが前提です。以下は、CIジョブがテスト用Runnerのプロセスと同じホスト上でスクリプトを実行する配置です。新しいRunnerイメージを検証するときにもっとも自然な形で、スクリプトが呼ぶAnthropic APIはセッション作成とフォローアップの2回のディスパッチだけになります。
テストRunnerが別のインフラにある場合はリモート基盤のRunnerを使う場合に進みます。
スクリプトを実行するマシンには、Claude Code v2.1.224以降が要ります。Runner自体の要求バージョンと同じで、--environmentと--refの両フラグがこの版から使えるためです。--refはclaude --help(v2.1.287)には表示されませんが、CLIリファレンスとテスト手順のページには載っています。
Stop hookで応答を読み戻す
読み戻しはClaude CodeのStop hook(ターン終了時に走るhookの一種)で動きます。Stop hookの設定形式やイベントの全体像はClaude Code Hooks完全ガイドにあります。ここではE2Eテスト向けの実装だけを扱います。
ターンが終わると、hookは最終的なアシスタントメッセージを標準入力のJSONでlast_assistant_messageとして受け取り、$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側で置き換えています。// emptyは、テキストを持たないターン(ツール呼び出しだけで終わるターンなど)でも文字列nullを書き込まず、0バイトの書き込みにするためのフィルターです。
Runnerに載せる前に、手元でhookだけ試す
Runnerを立てなくても、hookスクリプトの挙動は標準入力に疑似JSONを流して確かめられます。次はv2.1.287の環境で、jq 1.7.1と上のスクリプトを使って確かめた結果です。
$ export CLAUDE_CODE_REMOTE_SESSION_ID=cse_demo01
$ mkdir replies
$ echo '{"last_assistant_message":"ok: reachable"}' | E2E_REPLY_DIR=$PWD/replies sh hook.sh
$ echo '{"hook_event_name":"Stop"}' | E2E_REPLY_DIR=$PWD/replies sh hook.sh
$ echo '{"last_assistant_message":"x"}' | sh hook.sh # E2E_REPLY_DIR 未設定
$ ls replies && wc -c replies/*
session_demo01.txt
14 replies/session_demo01.txt
$ cat replies/session_demo01.txt
ok: reachable3回の実行はすべて終了コード0です。cse_demo01はsession_demo01.txtに変換され、2回目の「テキストなし」入力は何も書かず、3回目の環境変数未設定は何も起きません。14バイトはok: reachableと改行の分です。
hookが効く条件
このhookには2つの依存があります。
- Runnerを起動する前にインストールする: Runnerは
~/.claude/を起動時に一度だけスナップショットします。動いているRunnerに後からhookを足しても、次の再起動まで反映されません E2E_REPLY_DIRをRunnerプロセスの環境変数として渡す: 未設定またはディレクトリが存在しない間、hookは何もしません。systemdのunitファイルやPod仕様、CIのステップなど、Runnerを起動する場所で設定します。テストスクリプト側も同じ変数を読むので、両方に同じ値を渡します
本番Environment用のRunnerイメージにこのhookを持ち込むと、E2E_REPLY_DIRが誤って設定されたときに毎セッションの応答をディスクへ書き出します。テスト用Environmentを受け持つRunnerだけにインストールします。同じ~/.claude/へのhook配置は、ラッパースクリプトとフックでセッションを拡張するで扱うセッション拡張の仕組みと共通です。
テストループを実行する
hookを仕込み、このホストでRunnerを起動した状態で、テストスクリプトは次の4ステップを踏みます。
セッション1往復のテスト
- 1
セッションを作る
gitのチェックアウト内で
claude -p "<prompt>" --environment <environment-id> --output-format jsonを実行します。CLIがoriginリモートからリポジトリを自動検出するためです。--ref <branch>を足すと、ローカルのHEADではなく指定ブランチが基準になります。 - 2
最初の応答を待つ
Runner上のStop hookが
$E2E_REPLY_DIR/<session_id>.txtに応答を書くまで、スクリプトが待ちます。 - 3
フォローアップを送る
claude -p "<message>" --cloud <session_id> --output-format jsonで、既存セッションにユーザーイベントを投稿します。 - 4
2回目の応答を待つ
手順2と同じ方法で、フォローアップの応答を待ちます。
セッションIDは作成時のJSONから取ります。
create_json=$(claude -p "hello" --environment "$ENV_ID" --output-format json)
SESSION_ID=$(jq -er '.session_id' <<<"$create_json")--environmentディスパッチは、セッションを作成してIDとリンクを出力したらすぐ終了し、応答の完了を待ちません。remote.defaultEnvironmentId設定より優先されます。
フラグを混ぜたときのエラー文言
--environmentは新規セッションを作るためのフラグなので、再開系や出力形式のフラグと組み合わせるとCLIがその場で止まります。v2.1.287で、ログインしていない空の設定ディレクトリを使い、API呼び出しが起きない状態で確かめた文言は次のとおりです。
| 組み合わせ | 実際のメッセージ |
|---|---|
--resume / --continue / --teleport | 実際のメッセージError: --environment cannot be combined with --resume, --continue, or --teleport |
--session-id | 実際のメッセージError: --environment cannot be combined with --session-id |
--init-only | 実際のメッセージError: --environment cannot be combined with --init-only |
--cloud <session_id> | 実際のメッセージError: --environment creates a new session; it cannot be combined with --cloud <session_id|url> |
--output-format stream-json | 実際のメッセージError: --environment does not support --output-format stream-json |
| 未ログイン | 実際のメッセージError: Cloud sessions require a claude.ai login. Run /login to authenticate. |
--cloudは、非対話実行で説明文を伴う場合にも拒否されます。引数なしの--cloudは指定がないものとして扱われます。
最後の行は、組み合わせの問題ではなく認証の問題です。CIで最初にこれが出たら、次の認証の節に進みます。
実際のスクリプト
await_reply関数がポーリングで応答ファイルを待ち、90秒でタイムアウトします。公式の例にはさらに、タイムアウト時にE2E_REPLY_DIRの中身を表示する診断出力と、環境変数が未設定のときの早期失敗が入っています。ここでは骨格だけを示します。
#!/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: '$expect' not in $f within 90s" >&2
ls -la "$E2E_REPLY_DIR" >&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"
followup_json=$(claude -p "$TURN2" --cloud "$SESSION_ID" --output-format json)
jq -e '.ok == true' <<<"$followup_json" >/dev/null
await_reply "$SESSION_ID" "ok: followup"
echo "PASS: round-trip complete (session $SESSION_ID)"フォローアップ側は、応答を待つ前に戻りJSONの.okがtrueであることを確かめています。投稿そのものが失敗したのか、投稿は通ったのに応答が来ないのかを、タイムアウトを待たずに切り分けるためです。CLAUDE_TEST_ENVIRONMENT_IDにはccpool_で始まるEnvironment IDを入れます。旧名のCLAUDE_TEST_POOL_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キーはどちらの呼び出しでも使えません。トークンを用意する方法は2つあり、CI基盤の寿命で選ぶ側が変わります。
CIのトークンの置き場所
長期CIホスト
専用ユーザーでclaude auth loginを一度だけ対話実行します。保存先はmacOSがOSのkeychain、LinuxとWindowsが~/.claude/.credentials.jsonです。リフレッシュトークンは初回ログインから30日で失効するので、30日ごとにログインをやり直します。
使い捨てRunner
CLAUDE_CODE_OAUTH_REFRESH_TOKENとCLAUDE_CODE_OAUTH_SCOPESをシークレットとして注入します。ブラウザーなしでclaude auth loginにトークン交換だけをさせる形で、リフレッシュ権限には同じ30日の上限がかかります。
macOSでもKeychainに書き込めない環境では、トークンは~/.claude/.credentials.jsonに保存されます。SSHセッションでログインKeychainがロックされたままの状態が典型例です。CLIは短命のアクセストークンを呼び出しのたびに自動更新しますが、裏側のリフレッシュトークンの期限は更新されません。
CI向けの長期トークンはありません。リモートセッション操作を許可するスコープuser:sessions:claude_codeはサーバー側で30日に固定されています。1年有効な推論専用トークンを発行するclaude setup-tokenも、このスコープをカバーしません。Environment secretも使えません。Runnerを登録する権限しか持たず、セッションを作る権限が無いからです。人間のアカウントに紐づかないマシンアイデンティティが要る場合は、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は@-をヘッダー名の文字列として扱い、認証なしでリクエストを送ります。CIランナーのcurlバージョンは事前に確認します。
レスポンスのpool_secretは、EnvironmentへRunnerを登録できる長期の認証情報です。マスク付きのCIシークレットとして保存し、標準出力にはEnvironment IDだけを残します。
作成したEnvironmentには、SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET=$ENVIRONMENT_SECRETとしてこのシークレットを渡します。キャプチャ用hookとE2E_REPLY_DIRを設定したうえで、Runnerをこのホスト上で起動します。そのうえで「テストループを実行する」のスクリプトを走らせます。
実行が終わったら、同じヘッダーでプールのURLにDELETEを送ります。
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"組織のOwnerが管理画面で「Allow self-hosted environments」を有効化していないと、作成の呼び出しは403のpermission_errorで失敗します。メッセージはself-hosted runners are disabled by your organization's policyです。
テストが通らないときの切り分け
症状ごとに、原因を先に疑う順で並べます。
タイムアウトしてE2E_REPLY_DIRが空のまま: hookが一度も書いていません。hookを入れたあとにRunnerを再起動したか、E2E_REPLY_DIRをRunnerプロセスに渡したかを確認します。hook単体を手元で試して動くと分かっていれば原因を絞れます。
ファイルはあるのに期待文字列が無い: hookは動いていて、Claudeの応答が想定と違います。待機がgrep -Fの部分一致なので、プロンプトに「そのまま言う」指示を入れてあるかを見ます。
jqが入っていないRunnerイメージ: hookは内部でjqを使い、標準エラー出力を捨てています。そのためjqが無くても失敗は表に出ず、ただ何も書かれません。本番イメージには不要でも、テスト用イメージには足します。
フォローアップの.okがtrueにならない: 投稿の段階で失敗しています。セッションIDがsession_始まりで渡っているか、トークンがClaude.aiのOAuthかを確認します。
まとめ
応答の読み戻しは、Stop hook1本と--environment/--cloudの2回のディスパッチで足ります。Runnerへ載せるのは、hook単体を手元で試し終えてからにすると、失敗の原因を絞りやすくなります。