셀프호스티드 환경 E2E 테스트 (Test self-hosted environments end to end)
기준일: 2026-10-23
공식 기준: Test self-hosted environments end to end
개요
셀프호스티드 환경에서는 Claude Code 클라우드 세션이 내가 빌드·관리하는 러너 이미지에서 실행됩니다. 새 이미지를 프로덕션 환경에 내보내기 전에 테스트 환경을 대상으로 스크립트에서 세션 전체를 돌려 봅니다. 세션 생성 → Claude 응답 읽기 → 후속 메시지 전송 → 그 응답 읽기. 이것이 러너 이미지, git 접근, 커스텀 도구를 검증하는 CI 스모크 테스트의 형태입니다.
- Team·Enterprise 플랜 퍼블릭 베타입니다. 활성화 경로는 가용성과 제약을, 설정은 Quickstart를, 플릿 레시피는 Deploy to production을 참고하세요.
- 이 레시피는 환경과 러너를 이미 설정했고, CI job이 테스트 스크립트와 같은 호스트에서 러너 프로세스를 띄운다고 가정합니다(새 러너 이미지를 테스트할 때 자연스러운 구성).
- 러너에 설치한 Stop hook이 턴마다 최종 응답을 로컬 파일에 쓰고 스크립트가 그 파일을 읽으므로, Anthropic API 호출은 디스패치 두 번뿐입니다. 테스트 러너가 별도 인프라에 있다면 원격 테스트 러너를 보세요.
핵심 개념
| 구성 요소 | 역할 |
|---|---|
| 캡처 Stop hook | 턴 종료 시 stdin JSON의 last_assistant_message를 $E2E_REPLY_DIR/<session_id>.txt에 append |
E2E_REPLY_DIR |
hook이 쓰고 스크립트가 읽는 디렉터리. 러너 프로세스와 스크립트 양쪽에 필요 |
claude -p ... --environment <id> |
테스트 환경에 세션을 생성하고 바로 종료 |
claude -p ... --cloud <session_id> |
기존 세션에 user 이벤트를 보내고 종료 |
| claude.ai OAuth 토큰 | 두 디스패치 모두의 인증 수단. API 키는 불가 |
| 환경 생성/삭제 API | CI 실행마다 깨끗한 테스트 환경을 만들고 지움 |
상세
테스트 러너에 캡처 hook 설치
응답 회수는 Claude Code Stop hook으로 동작합니다. Claude가 턴을 마치면 hook은 stdin JSON의 last_assistant_message로 최종 assistant 메시지를 받아 $E2E_REPLY_DIR/<session_id>.txt에 덧붙입니다. commit-nudge Stop hook과 같은 방식으로, 러너가 모든 세션에 시드하는 러너 호스트의 ~/.claude/에 설치합니다.
hook 파일 저장
러너 호스트에 두 파일을 둡니다.
- 설정 블록: 러너 호스트의
~/.claude/settings.json에 병합 - 스크립트: 러너 호스트의
~/.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 for testing a self-hosted environment end to end: writes each
# turn's final assistant reply to $E2E_REPLY_DIR/<session_id>.txt so a
# co-located test driver can read it without calling the Anthropic API.
# Install on the TEST runner only. Requires jq.
# No-op unless the driver is listening. Never fail the turn.
[ -n "${E2E_REPLY_DIR:-}" ] && [ -d "$E2E_REPLY_DIR" ] || exit 0
# CLAUDE_CODE_REMOTE_SESSION_ID is exported in cse_... form; the session
# id the dispatch CLI prints is in session_... form. Same id, different
# prefix.
sid=$(printf '%s' "${CLAUDE_CODE_REMOTE_SESSION_ID:-}" | sed 's/^cse_/session_/')
[ -n "$sid" ] || exit 0
# last_assistant_message is absent when the final assistant turn had no
# text, such as a tool-use-only turn. The `// empty` filter makes that a
# zero-byte write rather than the literal string "null".
jq -r '.last_assistant_message // empty' >> "$E2E_REPLY_DIR/$sid.txt" 2>/dev/null
exit 0
러너 시작 전 요건
- 러너를 시작하기 전에 설치합니다. 러너는 시작 시
~/.claude/를 한 번만 스냅샷하므로, 실행 중인 러너에 추가한 hook은 재시작 후에야 적용됩니다. E2E_REPLY_DIR를 러너 프로세스에 export합니다. 변수가 없거나 디렉터리가 없으면 hook은 아무것도 하지 않습니다. systemd unit, pod spec, CI step 등 러너를 띄우는 곳에 설정하세요. 아래 테스트 스크립트도 이 변수가 필요합니다.
이 hook은 테스트 환경을 서빙하는 러너에만 설치하세요.
E2E_REPLY_DIR가 존재하면 모든 세션의 최종 응답을 디스크에 쓰므로, 일회용 CI 러너에서는 무해하지만 변수가 실수로 설정될 수 있는 프로덕션 환경 러너 이미지에 가져가면 안 됩니다.
테스트 루프 실행
--environment, --ref 디스패치 플래그는 스크립트를 실행하는 머신에 Claude Code v2.1.224 이상이 필요합니다(러너 자체와 같은 하한). hook이 준비되고 이 호스트에 러너가 떠 있으면 테스트 스크립트는 다음 순서로 동작합니다.
claude -p "<prompt>" --environment <environment-id> --output-format json으로 테스트 환경에 세션을 만듭니다. CLI가originremote에서 저장소를 자동 감지하도록 git checkout 안에서 실행합니다. 선택 사항인--ref <branch>는 세션 checkout을 로컬 HEAD 대신 지정한 ref 기준으로 만듭니다. 명령은 Claude 응답을 기다리지 않고 종료하며, 출력으로 결과를 알 수 있습니다.- 세션 생성 성공:
{"ok":true,"session_id":"session_...","title":"...","url":"...","pool_id":"..."}같은 JSON 한 줄 - 세션 생성 실패:
{"ok":false,"error":"..."}한 줄, 종료 코드 1 - 그보다 앞선 일부 오류(조직에서 클라우드 세션 사용 불가, 프롬프트 누락 등): JSON 없이 stderr에 오류, 종료 코드 1
- 세션 생성 성공:
- 턴이 끝나면 러너의 Stop hook이 쓰는
$E2E_REPLY_DIR/<session_id>.txt에 응답이 나타날 때까지 기다립니다. claude -p "<message>" --cloud <session_id> --output-format json으로 후속 메시지를 보냅니다(CLI에서 후속 메시지 보내기). 기존 세션에 user 이벤트를 게시하고 종료합니다.- 2단계와 같은 방식으로 후속 응답을 기다립니다.
--environment 디스패치 동작
Claude Code는 세션을 만들고 세션 ID와 링크를 출력한 뒤 종료합니다.
remote.defaultEnvironmentId설정보다 우선합니다.--output-format stream-json을 지원하지 않습니다.- 세션을 resume·attach·사전 구성하는 플래그(
--resume,--continue,--teleport,--session-id,--init-only등)와 함께 쓸 수 없습니다. --cloud에 세션 ID나 URL을 주면 거부되고, 비대화형 실행에서 description을 붙여도 거부됩니다. 값 없는--cloud는 없는 것으로 취급합니다.- 터미널에서는 positional 프롬프트 대신
--clouddescription으로 작업을 넘길 수 있습니다.
예제 스크립트
스크립트는 테스트 러너와 같은 머신에서 실행합니다. 실행 전에 다음을 준비하세요.
| 준비 항목 | 내용 |
|---|---|
| 저장소 checkout | 세션이 작업할 저장소의 git checkout 안에서 스크립트 실행 |
| 러너 | 캡처 hook을 설치하고 E2E_REPLY_DIR를 export한 러너를 이 호스트에서 시작 |
| 로그인 | CI에서 인증대로 스크립트 실행 머신에서 claude.ai 계정으로 로그인 |
| 환경 ID | CLAUDE_TEST_ENVIRONMENT_ID에 테스트 환경의 ccpool_... ID 설정(admin 페이지 환경 상세 대화상자 또는 환경 생성 호출 응답에서 확인) |
아래 스크립트는 $CLAUDE_TEST_ENVIRONMENT_ID를 대상으로 전체 루프를 돌리고 각 응답의 sentinel 문구를 검사합니다.
#!/usr/bin/env bash
# End-to-end test against a self-hosted environment, using Stop-hook read-back.
# Prereqs: `claude auth login` has been run on this machine (see "Authenticate
# from CI" below); jq is installed; CLAUDE_TEST_ENVIRONMENT_ID names an
# environment whose runner is the one on this host, with the capture hook
# installed and E2E_REPLY_DIR in its environment.
set -euo pipefail
: "${CLAUDE_TEST_ENVIRONMENT_ID:=${CLAUDE_TEST_POOL_ID:-}}" # CLAUDE_TEST_POOL_ID is the legacy spelling
: "${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
}
# Waits until $E2E_REPLY_DIR/<session_id>.txt contains $2, or fails after
# 90 seconds. Tune the timeout to your environment's cold-start time. The
# file is written by the Stop hook on the runner.
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. Create the session on the test environment. Run from a git checkout
# so the CLI can auto-detect the repo. --ref pins the checkout to a named
# ref regardless of local 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 < /dev/null)
echo "create: $create_json"
SESSION_ID=$(jq -er '.session_id' <<<"$create_json")
# 2. Wait for the turn-1 reply.
await_reply "$SESSION_ID" "$EXPECT1"
echo "turn-1 reply ok"
# 3. Post a follow-up via the 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 < /dev/null)
echo "followup: $followup_json"
jq -e '.ok == true' <<<"$followup_json" >/dev/null
# 4. Wait for the turn-2 reply.
await_reply "$SESSION_ID" "$EXPECT2"
echo "turn-2 reply ok"
echo "PASS: test-environment round-trip (session $SESSION_ID)"
TURN1/TURN2 프롬프트와 EXPECT1/EXPECT2 sentinel은 내 구성을 실제로 검증하는 내용으로 바꾸세요. 예를 들어 커스텀 MCP 도구 하나를 실행하게 하고 그 출력을 검사합니다. 90초 대기 시간은 환경의 cold start 시간에 맞게 조정합니다.
원격 테스트 러너
CI job과 파일시스템을 공유할 수 없는 상주 Kubernetes 플릿처럼 테스트 러너가 별도 인프라에 있다면, Stop hook의 파일 쓰기를 드라이버가 수신하는 엔드포인트로의 POST로 바꿉니다.
#!/bin/sh
# Variant of the capture hook for runners on separate infrastructure.
# Set E2E_REPLY_URL on the runner to an endpoint the driver controls.
[ -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 job 안의 작은 HTTP 리스너, 이미 운영 중인 webhook 수신기 등). hook은 내 인프라에서 실행되므로 엔드포인트는 러너에서만 닿으면 됩니다.
CI에서 인증
claude -p ... --environment와 claude -p ... --cloud는 모두 claude.ai OAuth 토큰으로 인증합니다. sk-ant-xxxxx 같은 API 키는 둘 다 받지 않습니다. CI에 토큰을 두는 방법은 두 가지입니다.
상주 CI 호스트
- 스크립트를 실행하는 머신에서 자동화 전용 사용자 계정으로
claude auth login을 한 번 대화형으로 실행합니다. - 토큰 저장 위치: macOS는 OS keychain, Linux·Windows는
~/.claude/.credentials.json. Keychain에 쓸 수 없는 macOS 호스트(로그인 Keychain이 잠긴 SSH 세션이 전형적)에서도~/.claude/.credentials.json에 저장합니다. Credential management를 참고하세요. - CLI는 호출마다 단기 access token을 자동 갱신하지만, refresh-token grant는 최초 로그인부터 30일로 제한됩니다. 30일마다 그 호스트에서
claude auth login을 다시 대화형으로 실행하세요.
일회용 CI 러너
- 현재 장기 CI 토큰은 없습니다. 클라우드 세션 제어 권한을 주는 scope
user:sessions:claude_code는 서버 측에서 30일로 제한되어, 1년짜리 추론 전용 토큰을 발급하는claude setup-token으로는 커버되지 않습니다. - environment secret도 받지 않습니다. 러너가 환경에 등록하는 것만 허가하며 세션 생성 권한은 없습니다.
- 저장된 로그인을 일회용 러너에 넣으려면
CLAUDE_CODE_OAUTH_REFRESH_TOKEN과CLAUDE_CODE_OAUTH_SCOPES를 설정해claude auth login이 브라우저 없이 토큰을 교환하게 합니다. refresh grant의 30일 제한은 똑같이 적용됩니다. - 사람 계정에 묶이지 않은 machine-identity 경로가 필요하면 Anthropic 계정 팀에 문의하세요.
전용 테스트 환경 만들기
CI 실행마다 깨끗한 환경을 쓰도록 환경을 프로그래밍 방식으로 만들고 지웁니다. CI job이 띄운 러너는 새 환경에 등록됩니다. 아래 생성·삭제 호출은 claude.ai Cloud environments admin 페이지가 쓰는 것과 같은 엔드포인트이며 anthropic-beta: ccr-byoc-2025-07-29 헤더가 필요합니다.
admin 토큰 발급
$ADMIN_TOKEN은 Owner 역할을 가진 계정의 claude.ai OAuth access token이며 CI에서 인증과 같은 방식으로 얻습니다.
- 발급: Owner 역할 계정으로
claude auth login을 실행한 뒤, 상주 CI 호스트에 적힌 저장 위치에서 현재 access token을 읽습니다. - 매 실행마다 새로 읽기: CLI가 access token을 rotate하고 30일 refresh-grant 제한도 같으므로 사본을 저장하지 마세요.
- stdin으로 전달: 예제처럼 하면 토큰이 curl 인자 목록이나 빌드 로그에 남지 않습니다.
환경 생성
응답을 출력하지 말고 캡처하세요. pool_secret은 환경에 러너를 등록할 수 있는 장기 자격 증명이므로 마스킹된 CI secret으로 저장하고 환경 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로 러너를 시작하고, 캡처 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"
체크리스트
- 캡처 Stop hook 설정과 스크립트를 테스트 러너에만 설치했고, 러너 시작 전에 설치했다.
-
E2E_REPLY_DIR를 러너 프로세스와 테스트 스크립트 양쪽에 설정했고 디렉터리가 존재한다. - 스크립트 실행 머신의 Claude Code가 v2.1.224 이상이다.
- 스크립트를 대상 저장소의 git checkout 안에서 실행한다.
- claude.ai OAuth 로그인을 사용하며(API 키 아님) 30일 refresh 제한에 맞춰 재로그인 계획이 있다.
-
pool_secret을 출력하지 않고 마스킹된 CI secret으로 다룬다. - curl이 7.55 이상이라
-H @-로 토큰이 안전하게 전달된다. - 실행 후 테스트 환경을 삭제한다.
- sentinel 프롬프트가 실제 커스텀 도구·git 접근을 검증하도록 바꿨다.
다음 단계
- Customize sessions에서 commit-nudge Stop hook, wrapper script, lifecycle hook을 봅니다.
- Deploy to production에서 검증한 이미지를 플릿에 배포합니다.
- Reference에서 러너 플래그와 환경 변수를 확인합니다.
기준일
이 문서는 2026-10-23에 공식 Test self-hosted environments end to end 페이지를 기준으로 작성했습니다. 퍼블릭 베타 기능이므로 API 헤더와 인증 제약은 공식 원문을 함께 확인하세요.