셀프호스티드 환경 세션 커스터마이즈 (Customize sessions)
기준일: 2026-11-03
공식 기준: Customize sessions in self-hosted environments
개요
셀프호스티드 환경은 Claude Code 클라우드 세션을 내 인프라에서, 내가 배포한 러너 프로세스로 실행합니다. 설정이 없으면 러너는 세션 저장소를 clone하고 Claude Code를 spawn한 뒤 정리합니다. 이 페이지는 러너를 운영하는 플랫폼 엔지니어를 위한 것으로, 기본 동작이 맞지 않을 때 쓰는 확장 지점(세션별 자격 증명 발급부터 checkout 전체 교체까지)을 다룹니다.
- Team·Enterprise 플랜 퍼블릭 베타입니다. Owner가 Cloud environments admin 페이지에서 Allow self-hosted environments를 켜야 합니다. 동작하는 러너가 있다고 가정하며, 설정은 Quickstart, 플릿 레시피는 Deploy to production을 보세요.
- Wrapper와 hook은 러너 호스트(Linux 또는 macOS)의 실행 파일로 돌며, 예제는 POSIX shell 기준입니다.
- 일부 hook 환경 변수는 아직
pool을 씁니다(예:CLAUDE_RUNNER_POOL_ID). CLI 플래그와 환경 변수 이름은environment를 씁니다(예:--environment-secret-file).
핵심 개념
| 확장 지점 | 설정 방법 | 실행 위치·시점 | 용도 |
|---|---|---|---|
| Wrapper script | --exec-path / SELF_HOSTED_RUNNER_EXEC_PATH |
세션마다 Claude Code 바이너리 대신 실행 | 세션별 자격 증명, 시크릿 export, 툴체인 준비, 리소스 제한 |
checkout hook |
--hooks-dir의 checkout |
저장소마다, 내장 clone·fetch 대신 | 미러 clone, 아카이브 시드, 세션별 git 인증 |
post-session hook |
--hooks-dir의 post-session |
세션마다, 자식 종료 후 workspace 정리 전 | 미커밋 작업 저장, 로그 보관, 종료 이벤트 |
command hook |
--hooks-dir의 command |
세션마다, checkout 후 내장 spawn 대신 | wrapper와 같음(hooks 디렉터리에 모아 둘 때) |
spawn-runner hook |
orchestrator의 --hooks-dir |
spawn 요청마다 orchestrator에서 | 세션당 러너 1개를 플랫폼에 제출 |
- Lifecycle hook은 러너에서 세션 바깥에 돌고, Claude Code hooks는 세션 안에서 돕니다. 서로 다른 것입니다.
- 세션 설정은 러너가 시작 시 한 번 찍은 호스트
~/.claude/스냅샷에서 시드됩니다. 호스트 설정을 바꾸면 러너를 재시작해야 반영됩니다.
상세
Wrapper script
세션마다 러너가 스스로 할 수 없는 준비가 필요할 때 씁니다. 세션 생성자 범위의 단기 자격 증명 발급, 환경별 시크릿 export, 언어 툴체인 준비, 자식 프로세스 리소스 제한 등입니다. 러너는 세션마다 한 번 Claude Code 바이너리 대신 wrapper를 실행합니다. 시그널과 종료 코드가 제대로 전파되도록 wrapper는 마지막에 러너 자체 바이너리인 $CLAUDE_RUNNER_CLAUDE_BIN으로 exec해야 합니다.
러너 시작 시 --exec-path(또는 SELF_HOSTED_RUNNER_EXEC_PATH)로 wrapper를 지정합니다. <writable-dir>는 Quickstart 설명대로 바꿉니다.
claude self-hosted-runner --environment-secret-file /etc/claude/environment-secret --base-dir '<writable-dir>' --exec-path /etc/claude/session-wrapper.sh
러너가 wrapper 환경에 설정하는 변수:
| 변수 | 설명 |
|---|---|
CLAUDE_CODE_SESSION_ACCESS_TOKEN |
sk-ant-cc- 접두사의 세션 JWT. act 클레임이 생성자를 식별하며 생성 surface가 기록했다면 이메일 포함. spawn 시점 값이며 갱신은 자식 stdin으로 오므로 wrapper는 초기값만 봄. Verify session identity 참고 |
CCR_SESSION_ACCOUNT_EMAIL |
러너가 토큰의 act.email에서 서명 검증 없이 미리 추출한 생성자 이메일. 커밋 트레일러 같은 라벨링용. 이메일로 자격 증명 발급을 결정한다면 토큰을 검증하고 거기서 읽을 것(생성자 범위 자격 증명). 토큰에 이메일이 없으면(예: 조직 서비스 신원 세션) unset. 개인식별정보로 취급 |
CLAUDE_RUNNER_CLIENT_PLATFORM |
세션을 만든 클라이언트 surface: web_claude_ai, desktop_app, ios, claude_code_cli, scheduled_trigger 등. 세션 생성 시 한 번 기록되어 wrapper와 모든 lifecycle hook이 같은 값을 봄. 도입 분석·라벨링 전용, 인가 신호로 쓰지 말 것. 기록·인식된 surface가 없으면 unset. Claude Code v2.1.229 이상 |
CLAUDE_RUNNER_CLAUDE_BIN |
러너 자체 Claude Code 바이너리의 절대 경로. 설치 경로를 하드코딩하지 말고 exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"로 끝낼 것 |
CLAUDE_CODE_REMOTE_SESSION_ID |
태그된 cse_... 형태 세션 ID. lifecycle hook이 session_... 형태 CLAUDE_RUNNER_SESSION_ID로 보는 세션과 같음. UUID 변수는 양쪽이 일치하고, cse_를 session_으로 바꾸면 세션 URL의 ID |
CLAUDE_CODE_REMOTE_SESSION_UUID |
같은 세션 ID의 정규 UUID 형태 |
CLAUDE_CODE_REMOTE_SLACK_THREAD_URL |
Slack 스레드 하나에 속한 Claude Tag 세션이면 그 스레드 링크. 다른 세션은 unset, 스레드 세션에서도 unset일 수 있음 |
CLAUDE_CODE_REMOTE_SLACK_THREAD_TS |
같은 경우 스레드의 Slack timestamp(예: 1700000000.000100). unset일 수 있고 URL 변수가 없을 때 설정될 수도 있으니 각각 따로 확인 |
CLAUDE_CODE_REMOTE_SLACK_CHANNEL_ID |
Claude Tag 세션이 서빙하는, 또는 그 스레드가 속한 Slack 채널·DM의 ID(예: C0123ABCDE). Slack timestamp는 한 채널 안의 메시지를 가리키므로 CLAUDE_CODE_REMOTE_SLACK_THREAD_TS와 함께 기록할 것. 다른 세션은 unset, Claude Tag 세션에서도 unset일 수 있음 |
CLAUDE_SESSION_INGRESS_TOKEN_FILE |
현재 세션 JWT를 담은 세션별 파일 절대 경로, 토큰 갱신 시에도 최신 유지. 사용자가 세션에 추가한 첨부 파일을 받을 때 shell 하위 프로세스가 Authorization 헤더로 읽음. exec은 자동 보존. 자식 환경을 재구성하는 wrapper는 반드시 넘겨야 하며, 아니면 첨부 다운로드가 조용히 멈춤 |
CLAUDE_CONFIG_DIR |
세션별 Claude config 디렉터리. 러너가 시작 시 찍은 호스트 config 스냅샷으로 세션 시작 때 작성(권한과 도구 승인). 여기 쓴 내용은 이 세션에만 적용. --remove-session-state로 시작하지 않으면 세션 종료 후에도 <base-dir>/_sessions/ 아래 남음(Reuse a pre-warmed checkout) |
CLAUDE_CODE_REMOTE_MCP_CONFIG |
컨트롤 플레인이 이 세션에 전달한 MCP 서버 정의를 담은 CLAUDE_CONFIG_DIR 안 파일 경로. 자식 환경을 재구성한다면 넘길 것. 없으면 컨트롤 플레인이 전달 서버를 갱신할 때, wrapper가 --mcp-config로 추가했지만 갱신에 없는 stdio·HTTP·SSE 서버를 자식이 종료함. Claude Code v2.1.299 이상 |
ANTHROPIC_BASE_URL |
자식이 쓸 API base URL. 컨트롤 플레인이 세션별로 전달하며 보통 https://api.anthropic.com. override 금지: 세션 추론 자격 증명은 다른 provider가 받지 않는 Anthropic 발급 OAuth 토큰 |
CLAUDE_CODE_OAUTH_TOKEN |
자식이 모델 추론에 쓰는 단기 OAuth access token(수명 약 30분). 러너가 만료 전에 재발급해 자식 stdin으로 전달하므로 stdin을 붙여 두지 않은 wrapper는 초기값만 봄. 조직 IP allowlist로 사용을 제한할 수 있다고 기대하지 말 것. 유출 시 약 30분간 쓸 수 있는 bearer 자격 증명으로 취급해 로그·디스크 기록·세션 컨테이너 밖 전달 금지 |
CLAUDE_RUNNER_ALIVE_EVERY_MS |
자식이 초기화되기 전에 턴이 시작될 때 자식이 살아 있다고 알리는 주기(밀리초). 러너가 설정함. 자식 환경을 재구성한다면 넘길 것. 없으면 그 턴 동안 러너가 세션을 release하고 no child output for <n> — releasing을 로그에 남길 수 있음. 러너와 자식 모두 v2.1.309 이상 |
- Wrapper는 서버 제공 환경 변수를 포함해 자식의 관리 환경 나머지도 상속합니다.
exec이면 전부 자동 전파되고, 다른 방식으로 자식을 띄운다면 환경 전체를 넘겨야 합니다. CLAUDE_CODE_REMOTE_SLACK_*변수는 wrapper·commandhook과 세션이 실행하는 것(shell 명령, git hook, Claude Code hook)에 전달됩니다.checkout,post-session,spawn-runnerhook에는 전달되지 않습니다.
unset일 수 있는 변수에 기본값 주기
CCR_SESSION_ACCOUNT_EMAIL, CLAUDE_RUNNER_CLIENT_PLATFORM, CLAUDE_CODE_REMOTE_SLACK_THREAD_URL, CLAUDE_CODE_REMOTE_SLACK_THREAD_TS, CLAUDE_CODE_REMOTE_SLACK_CHANNEL_ID는 각각 unset일 수 있습니다. 스크립트가 set -u를 쓰면 unset 변수 확장 시 Bash가 unbound variable로 멈추므로 "${CCR_SESSION_ACCOUNT_EMAIL:-}"처럼 기본값과 함께 확장하세요.
확장하는 인자와 변수 인용
러너가 wrapper나 command hook에 넘기는 인자와 변수는 모두 인용하세요. JSON, 문장 전체, Bash(npm run test *) 같은 permission rule을 담고 있어 shell이 해석하는 문자가 들어 있을 수 있습니다.
- 인자:
"$@"로 넘깁니다. - 변수:
"${CLAUDE_CODE_REMOTE_SLACK_THREAD_URL:-}"처럼 각각 인용합니다. 예를 들어 Slack 스레드 링크에는?와&가 있을 수 있습니다. eval·sh -c문자열: 인용하더라도 값을eval이나sh -c가 실행하는 문자열에 치환하지 마세요. 문자열이 변수를 참조하게 합니다.
stdin과 파일 디스크립터 3 유지
자식의 stdin은 러너의 제어 채널로, 토큰 rotate와 세션 종료 신호가 여기로 옵니다. 러너는 파일 디스크립터 3에 파이프를 열어 자식의 activity 신호를 읽고 유휴·시작 타임아웃을 판단합니다. 평범한 exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"는 둘 다 자동 보존합니다.
wrapper가 자식을 그냥 &로 백그라운드에 보내면 자식 stdin이 끊깁니다. 세션은 초기 OAuth 토큰 수명(약 30분)이 끝날 때까지 정상처럼 보이다가, 이후 그 토큰을 쓰는 모든 API 호출이 401 authentication_error로 실패합니다. teardown trap 유지 등으로 꼭 백그라운드가 필요하면 stdin을 파일 디스크립터 9에 저장해 명시적으로 다시 붙입니다.
exec 9<&0 # the runner uses 3, and from v2.1.309 reserves 4 and 5
"$CLAUDE_RUNNER_CLAUDE_BIN" "$@" <&9 9<&- &
CHILD=$!
trap 'teardown' EXIT
wait "$CHILD"
자식 stdout은 리다이렉트해도 됩니다. 파일 디스크립터 3과 stderr는 러너에 붙여 두세요.
- 파일 디스크립터 3: 자식 activity 신호를 러너로 전달합니다. wrapper에서 닫거나 재사용하지 마세요.
- 파일 디스크립터 4와 5: Claude Code v2.1.309 이상 러너는 이 둘을 자식용으로 예약합니다. wrapper에서 닫거나 재사용하지 마세요. stdin 저장 등으로 wrapper가 둘 중 하나를 쓰고 있다면 6
9번 중 하나로 옮기세요. 69번은 이전 버전에서도 비어 있습니다. - stderr: wrapper나 자식이 non-zero로 끝나면 러너가 stderr 마지막 줄들을 세션에 게시하고 자기 로그에도 출력합니다. 세션 사용자가 그 줄을 보므로 시크릿을 stderr에 출력하지 말고, 배포 전
set -x를 제거하세요. stderr를 리다이렉트해도 세션은 돌지만 러너는 종료 코드만으로 실패를 보고합니다.
system prompt 플래그 그대로 전달
컨트롤 플레인이 세션에 보내는 system prompt와 appended system prompt는 인라인 텍스트가 아니라 파일 경로로 wrapper에 옵니다. 러너가 각 프롬프트를 세션 config 디렉터리(CLAUDE_CONFIG_DIR)의 파일에 쓰고, wrapper가 받는 인자에 --system-prompt-file <path> 또는 --append-system-prompt-file <path>로 경로를 넘깁니다.
v2.1.281 이상 러너가 파일로 전달합니다. v2.1.281 이전에는 --system-prompt <text>, --append-system-prompt <text>로 넘겼습니다.
wrapper나 command hook에서는 다음처럼 다룹니다.
- 그대로 전달:
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"로 끝내면 파일 플래그가 다른 인자와 함께 전달됩니다. 빼거나 고치지 마세요. 세션이 프롬프트 파일 플래그를 잃으면 컨트롤 플레인이 보낸 지시 없이 실행됩니다. - v2.1.281 이상에서 덧붙인 파일 플래그는 서버 값을 대체하지, 추가하지 않음: 프롬프트 파일 플래그는 값 하나만 받고 마지막 것이 남으므로,
"$@"뒤에--append-system-prompt-file <path>를 붙이면 서버의 appended 지시가 내 파일 내용으로 바뀝니다. 서버 지시 위에 추가하려면 러너가 모든 세션의 user 수준 config로 시드하는 러너 이미지의CLAUDE.md에 넣으세요.
세션 시작 시 Argument list too long 해결
Linux에서 컨트롤 플레인의 프롬프트와 내 텍스트를 --append-system-prompt 인자 하나로 합치면, 인자가 128 KB에 이르는 순간 러너가 다음과 같은 줄을 남깁니다.
Session <id> finished: failed — reason: child exited 126: /opt/wrapper.sh: line 9: /usr/local/bin/claude: Argument list too long
Linux는 인자 하나를 메모리 페이지 32개, 흔한 4 KB 페이지로는 128 KB로 제한합니다. getconf ARG_MAX가 보여 주는 값(보통 2 MB)은 모든 인자와 환경 변수를 합친 별도 한도입니다.
Claude Code v2.1.283 이상에서는 파일 플래그를 "$@"에 그대로 두고 내 텍스트만 --append-system-prompt로 넘기세요. Claude Code는 둘 다 사용하며, 컨트롤 플레인의 프롬프트가 먼저, 내 텍스트가 그다음에 옵니다.
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" --append-system-prompt "$(cat /opt/team-instructions.md)"
Linux에서 내 텍스트만으로 128 KB에 이르면 여전히 인자 하나이므로 러너 이미지의 CLAUDE.md로 옮기세요.
v2.1.281·v2.1.282에서는 위 명령이 Cannot use both --append-system-prompt and --append-system-prompt-file로 끝납니다. 그보다 이전 버전에서는 오류 없이 내 텍스트가 컨트롤 플레인의 프롬프트를 대체합니다. 어느 경우든 먼저 업데이트하거나 내 텍스트를 CLAUDE.md로 옮기세요.
세션 생성자 범위 자격 증명 발급
세션 JWT의 클레임은 decode-token 하위 명령으로 읽습니다. 인자 → CLAUDE_CODE_SESSION_ACCESS_TOKEN → stdin 순으로 토큰을 읽으며, 무엇을 검사하는지는 세션 안에서 검증을 보세요. 아래 예제는 생성자 신원을 decode해 단기 AWS 자격 증명으로 교환하고 Claude Code로 exec합니다.
#!/bin/bash
# Key on the stable Anthropic user ID and require a human creator.
CREATOR_SUB=$("$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token \
| jq -re '.act.sub // "" | select(startswith("user:"))') \
|| { echo "decode-token: verification failed or no human creator" >&2; exit 1; }
creds=$(your-sts-helper assume-role --subject "$CREATOR_SUB") \
|| { echo "credential exchange failed" >&2; exit 1; }
eval "$creds"
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"
- 추출한 클레임이 인증 판단을 좌우하면
jq -r대신jq -re를 써서 클레임이 없을 때 문자열null을 넘기지 않고 non-zero로 끝나게 합니다. - 봇·에이전트 세션처럼 조직 서비스 신원이 만든 세션은
user:가 아니라agent:subject라 이 예제는 거부합니다. 환경이 그런 세션도 서빙한다면 종료 대신 기본 자격 증명으로 대체할지 명시적으로 정하세요. - 이메일이 필요한 교환이라면
.act.email을 읽고 부재를 처리하세요. 생성 surface가 기록했을 때만 토큰에 들어 있고 CLI로 디스패치한 세션에는 없을 수 있습니다. 전체 클레임과 러너 밖 서비스에서의 검증은 Verify session identity를 보세요.
Lifecycle hooks
Lifecycle hook은 러너의 세션별 파이프라인 단계를 내 스크립트로 교체합니다. --hooks-dir <path>(또는 SELF_HOSTED_RUNNER_HOOKS_DIR)로 hook 디렉터리를 지정하면 러너가 정해진 이름의 실행 파일을 찾습니다. 없는 hook은 내장 동작으로 넘어가므로 필요한 것만 작성하면 됩니다.
- Hook은 러너 자신의 권한으로 실행되고 세션 자식도 같은 UID를 쓰므로, 세션 코드가 수정하지 못하게 hooks 디렉터리를 read-only로 마운트하거나 이미지에 bake하세요(hardening).
- 세션 안에서 도는 Claude Code hooks와는 별개이며, lifecycle hook은 러너에서 세션 주위로 돕니다.
checkout
저장소마다 한 번, 러너의 내장 clone·fetch 대신 실행됩니다. HTTPS·SSH로 닿는 read-through 미러에서 clone, 아카이브에서 working tree 시드, 세션별 git 인증 적용에 씁니다. 러너는 다음 변수를 설정하며(표에 없는 CLAUDE_RUNNER_ 변수도 있을 수 있음):
| 변수 | 설명 |
|---|---|
CLAUDE_RUNNER_REPO_URL |
clone할 저장소 URL. --git-host-rewrite, --git-ssh-rewrite 적용 후 값 |
CLAUDE_RUNNER_REPO_REF |
세션이 요청한 체크아웃 대상: 브랜치, 태그, 커밋 SHA, 또는 refs/heads/<branch>·refs/pull/<number>/head 같은 full reference name. 비어 있으면 기본 브랜치 |
CLAUDE_RUNNER_CHECKOUT_PATH |
working tree를 남겨야 할 절대 경로 |
CLAUDE_RUNNER_SESSION_ID |
태그된 session_... 형태 세션 ID(로깅·상관관계용) |
CLAUDE_RUNNER_SESSION_UUID |
같은 세션 ID의 정규 UUID 형태 |
CLAUDE_RUNNER_API_BASE_URL |
세션 범위 호출용 Anthropic API base URL |
CLAUDE_RUNNER_CLIENT_PLATFORM |
세션을 만든 클라이언트 surface(web_claude_ai, desktop_app, ios 등). 없으면 unset이므로 set -u에서는 "${CLAUDE_RUNNER_CLIENT_PLATFORM:-}"로 참조. Claude Code v2.1.229 이상 |
CLAUDE_CODE_SESSION_ACCESS_TOKEN |
세션 범위 API 호출용 세션 access token |
GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n |
러너가 hook의 git에 고정하는 git 설정. lifecycle hook 안의 git 설정 참고. Claude Code v2.1.280 이상 |
- 스크립트는
CLAUDE_RUNNER_CHECKOUT_PATH에 요청한 revision으로 체크아웃된 working tree를 남겨야 합니다. 러너가 그 위에 세션 작업 브랜치를 만들므로 detached HEAD여도 됩니다. - hook이 끝나면 러너는
CLAUDE_RUNNER_CHECKOUT_PATH에.git이 있는지 확인합니다. Perforce나 풀어 놓은 tarball 같은 비-git 소스라면 러너 환경에CLAUDE_RUNNER_SKIP_GIT_VERIFY=1을 설정해 이 검사를 건너뜁니다. 작업 브랜치 생성·결과 push 같은 git 기반 흐름은 git checkout이 필요하므로, 비-git 트리의 결과는post-sessionhook으로 내보내세요.
hook에서 git 자격 증명 얻기
러너는 hook에 git 자격 증명을 넘기지 않습니다. checkout hook 환경에는 CLAUDE_RUNNER_CLAUDE_BIN이 없어 decode-token도 쓸 수 없습니다. 대신:
- 세션별 clone 자격 증명: 서비스에서 검증처럼 표준 JWT 라이브러리로
CLAUDE_RUNNER_API_BASE_URL아래 JWKS 엔드포인트에 대해CLAUDE_CODE_SESSION_ACCESS_TOKEN을 검증한 뒤, 토큰act클레임의 신원으로 자격 증명 서비스가 단기 clone 자격 증명을 발급하게 합니다.act.sub기준으로 발급하고act.email을 요구하지 마세요. - 호스트 git 인증: SSH agent, credential helper,
.netrc등 호스트에 이미 있는 git 인증을 씁니다.
hook이 실패할 때
non-zero로 끝나거나, 0으로 끝났지만 쓸 수 있는 checkout을 남기지 않으면 실패입니다.
- 세션이 결과를 push하는 저장소: 러너가 세션을 실패 처리하고, non-zero 종료면 스크립트 stderr 끝부분을 사용자에게 보여 줍니다. 이유를 stderr에 짧은 한 줄로 마지막에 쓰고 non-zero로 끝내세요. 시크릿을 출력하지 말고 배포 전
set -x를 제거합니다.- hook stderr 마지막 약 4 KB에 git의
couldn't find remote ref가 있으면 러너는 삭제된 브랜치로 보고합니다. 세션 오류 메시지가 브랜치가 remote에 더 이상 없다며 복구를 요청합니다. 브랜치가 실제로 있다면 A session fails withno longer exists on the remoteorcouldn't find remote ref를 보세요.
- hook stderr 마지막 약 4 KB에 git의
- 세션이 읽기만 하는 저장소(실행 중 세션에 추가한 저장소 등): 러너는 그 저장소를 건너뛰고 나머지로 계속합니다. 세션에
Skipped단계를 게시하고 hook이 checkout 경로에 남긴 것을 지웁니다.Skipped단계가 checkout hook을 언급하면 러너 로그의[runner:warn] checkout hook failed줄에서 상세를 찾습니다. 소스 ref가no longer exists on remote라고 하면 위 deploy 문서 항목을 보세요. 건너뛴 결과 저장소가 하나도 없으면 러너는 세션을 실패 처리합니다.
hook이 성공하면 러너는 세션 종료 후 checkout 경로를 지웁니다.
post-session
세션마다 한 번, Claude Code 자식이 끝난 뒤 러너가 workspace를 정리하기 전에 실행됩니다. 미커밋 작업을 저장할 유일한 기회입니다. --capacity가 1보다 크면 hook이 끝나자마자 세션별 worktree를 지우고, --capacity 1에서는 재사용하는 canonical clone을 다음 세션 시작 때 hard-reset하므로 어느 쪽이든 미커밋 tracked 변경은 남지 않습니다. 미커밋 변경의 스냅샷 브랜치 push, 로그 보관, 내 시스템으로 세션 종료 이벤트 전송에 씁니다.
자식 프로세스가 spawn된 모든 세션 종료에서, 원인과 무관하게 실행됩니다(아래 CLAUDE_RUNNER_EXIT_REASON 값 참고). VM preemption이나 전원 손실처럼 러너가 갑자기 죽으면 실행될 수 없으므로, 그런 경우까지 보장하려면 세션 안에서 Claude Code PostToolUse hook으로 주기적으로 스냅샷하세요. 러너가 설정하는 변수:
| 변수 | 설명 |
|---|---|
CLAUDE_RUNNER_SESSION_ID |
태그된 session_... 형태 세션 ID |
CLAUDE_RUNNER_SESSION_UUID |
같은 세션 ID의 정규 UUID 형태 |
CLAUDE_RUNNER_EXIT_REASON |
세션 종료 방식(아래 값) |
CLAUDE_RUNNER_WORKSPACE_PATHS |
세션 working tree들의 콜론 구분 절대 경로. 저장소 없는 세션은 빈 값 |
CLAUDE_RUNNER_DEBUG_LOG_PATH |
세션 디버그 로그 경로. hook 실행 중에는 디스크에 있음 |
CLAUDE_RUNNER_API_BASE_URL |
세션 범위 호출용 Anthropic API base URL |
CLAUDE_RUNNER_CLIENT_PLATFORM |
세션을 만든 클라이언트 surface. 없으면 unset이므로 set -u에서는 "${CLAUDE_RUNNER_CLIENT_PLATFORM:-}". Claude Code v2.1.229 이상 |
CLAUDE_CODE_SESSION_ACCESS_TOKEN |
세션 범위 API 호출용 세션 access token |
GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n |
러너가 hook의 git에 고정하는 git 설정. Claude Code v2.1.280 이상 |
CLAUDE_RUNNER_EXIT_REASON 값:
completed: 깔끔하게 종료. Claude Code 프로세스가 정상 종료했거나, 세션이 archive·삭제된 뒤 스스로 종료.failed: Claude Code 프로세스 crash, 또는 시작 후 setup 실패.interrupted: 러너가 세션을 중지. 슬롯 확보를 위한 release, 시작 타임아웃, 서버가 세션을 다른 러너로 이동, 프로세스 종료 전에 러너 폴링이 archive·삭제를 감지, 러너 드레인 중,--kill-session-after-min한도 초과.abandoned: 다른 러너가 claim한 세션용 예약 값. 현재 이 경우 hook은 실행되지 않습니다.
hook 수신 기록을 세션 라이프사이클 카운터와 비교하면 일부 interrupted가 카운터에서는 completed로 집계됩니다. 카운터는 release, 시작 타임아웃, 서버 이동, 러너 폴링이 먼저 감지한 archive·삭제를 러너가 슬롯을 깔끔하게 돌려준 것으로 보고 completed로 셉니다.
hook 종료 상태는 세션 결과에 영향을 주지 않으며 실패는 로그만 남기고 무시합니다. 러너는 러너 종료를 포함한 모든 세션 종료에서 --post-session-hook-timeout-sec(기본 60초)까지 기다립니다. 다음 예제는 미커밋 작업을 rescue 브랜치에 저장합니다.
#!/usr/bin/env bash
set -u
export GIT_ALLOW_PROTOCOL=${GIT_ALLOW_PROTOCOL:-https:http:ssh}
IFS=':'
# -c overrides beat repo-local settings, blocking session-written fsmonitor,
# hook-path, and gpg-program config from executing code with the hook's
# privileges. -c commit.gpgsign=false also leaves these rescue commits
# unsigned under --configure-git.
# Repo-local credential.helper and pushurl still apply, and on a runner
# before v2.1.280 so does core.sshCommand; see the note below the script
# before you give this push a credential.
g() { git -c core.fsmonitor=false -c core.hooksPath=/dev/null \
-c commit.gpgsign=false "$@"; }
for ws in $CLAUDE_RUNNER_WORKSPACE_PATHS; do
cd "$ws" 2>/dev/null || continue
[ -z "$(g status --porcelain 2>/dev/null)" ] && continue
g add -A
g commit -q -m "runner snapshot: $CLAUDE_RUNNER_SESSION_ID ($CLAUDE_RUNNER_EXIT_REASON)" || continue
g push -q origin "HEAD:refs/heads/rescue/$CLAUDE_RUNNER_SESSION_ID" || true
done
GIT_ALLOW_PROTOCOL줄은 git을 HTTPS·HTTP·SSH remote로 제한합니다. 러너 환경에 비어 있지 않은GIT_ALLOW_PROTOCOL이 이미 있으면 그 목록을 유지합니다.- hook은 러너 호스트에서 자기 환경에 있는 git 자격 증명으로 push합니다. Anthropic git proxy로 내장 clone하는 경우를 포함해 이미지에 자격 증명을 두지 않는 구성에서는 자격 증명이 없으므로, push 전에 hook 안에서 단기 push 자격 증명을 발급하세요. hook이 받는
CLAUDE_CODE_SESSION_ACCESS_TOKEN을 Verify session identity대로 검증해 내 토큰 서비스와 교환합니다. - hook이 git에 주는 자격 증명은 세션이 얻을 수 있다고 보고 이 push 이상은 못 하도록 발급하세요. hook의 git은 세션이 쓸 수 있는 설정 파일을 읽고, 거기 지정된 credential helper나 filter driver는 hook 권한으로 실행됩니다. 그 파일의 설정은 어떤 remote를 지정하든 push 대상을 바꿀 수도 있습니다. 러너가 고정하는 것과 남기는 것은 lifecycle hook 안의 git 설정을 보세요.
러너가 세션을 release할 때의 hook 시점
release된 세션은 다른 러너에서 재개될 수 있습니다. v2.1.236 이상 러너에서는 release 시점에 세션이 무엇을 하고 있었는지에 따라 hook이 끝나기 전에 재개될 수 있는지가 달라집니다.
| release 시점 상태 | 순서 | hook 실행 중 사용자 메시지 |
|---|---|---|
| 턴 후 유휴, 또는 시작 타임아웃 | 자식 중지 → hook 완료 → release | hook이 끝나기 전에는 다른 러너에서 재개 불가 |
| 권한 프롬프트 등 사용자 응답 대기 | release → hook 실행 | hook이 끝나기 전에 다른 러너에서 재개 가능 |
- 유휴 타임아웃,
--retire-at시각, 그리고 v2.1.260 이상에서는--kill-session-after-min한도 등 러너가 세션을 release하는 모든 경우에 적용됩니다. - 턴이 끝나고 백그라운드 태스크만 남은 세션은 여기서 유휴로 봅니다.
- v2.1.236 이전에는 두 경우 모두 release 후 hook을 실행했습니다.
SIGTERM드레인 중에는 hook이 끝날 때까지 러너가 세션 lease를 유지합니다(Shutdown timing).
lifecycle hook 안의 git 설정
checkout, post-session hook은 환경에 세션 access token을 가진 채 실행되고, hook의 git은 ~/.gitconfig나 checkout의 .git/config처럼 세션이 쓸 수 있는 설정 파일을 읽습니다. 두 hook이 실행되기 전에 러너는 아래를 포함한 git 설정을 GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n 쌍과 git 환경 변수로 hook 환경에 넣습니다. git은 이를 모든 설정 파일보다 우선하며, 세션 자체 git이 아니라 hook이 실행하는 git에만 적용됩니다. 러너는 시작 시 hooks path, 허용 프로토콜, gpg 프로그램, 서명 모드를 보여 주는 [runner:git] lifecycle hooks: 줄을 출력합니다. Claude Code v2.1.280 이상.
| 항목 | 러너가 고정하는 값 |
|---|---|
| Git hooks | 값을 주지 않으면 core.hooksPath는 /dev/null이라 저장소 .git/hooks와 ~/.gitconfig가 지정한 hooks 디렉터리를 건너뜀. 직접 주려면 러너 환경에 core.hooksPath를 GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n 쌍으로 export. 시스템 git 설정의 core.hooksPath도 읽지만, 러너 사용자가 그 파일·디렉터리·hook 파일에 쓸 수 없을 때만 사용. 무시한 값은 시작 시 [runner:warn] 줄에 값과 이유가 나옴 |
| File system monitor | core.fsmonitor가 비어 있어 설정 파일이 지정한 monitor 프로그램을 실행하지 않음 |
| Remote protocols | GIT_ALLOW_PROTOCOL이 https:http:ssh. 로컬 경로, file://, git:// URL을 쓰는 clone·fetch·push는 fatal: transport 'file' not allowed 또는 fatal: transport 'git' not allowed로 실패 |
| SSH 명령·credential prompt | 설정 파일의 core.sshCommand, core.askPass를 무시. 내 SSH 명령은 러너 환경의 GIT_SSH_COMMAND, prompt 프로그램은 GIT_ASKPASS로 설정. 세션이 러너 환경을 상속하므로 두 변수는 세션 git에도 전달됨. 자격 증명을 넣지 말 것 |
| gpg 프로그램 | gpg.program, gpg.openpgp.program, gpg.x509.program, gpg.ssh.program은 러너가 정한 경로이며 설정 파일 값이 아님 |
| 커밋 서명 | --configure-git이면 hook에서 만든 커밋이 세션으로 서명됨. 플래그가 없으면 commit.gpgsign, tag.gpgsign은 false |
설정을 바꾸려면 러너 환경이나 hook 안의 git -c 옵션을 씁니다.
- 설정 쌍: 러너 환경에 export한
GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n쌍은 같은 키의 러너 값을 대체합니다.0부터 번호를 매기고GIT_CONFIG_COUNT를 개수로 설정하세요. count가 알리는 마지막 쌍이 없으면 러너는 내 쌍을 모두 무시하고 시작 시[runner:warn]줄을 남깁니다. - git 환경 변수:
GIT_ALLOW_PROTOCOL,GIT_SSH_COMMAND,GIT_ASKPASS는 러너 환경에 설정한 대로 둡니다. git -c옵션: hook 안의git -c는 러너 것이든 내 것이든GIT_CONFIG_KEY_n쌍을 override합니다. git이 어떤 설정보다 먼저 읽는GIT_ALLOW_PROTOCOL,GIT_SSH_COMMAND,GIT_ASKPASS는 바꾸지 못합니다.
러너가 설정하지 않는 credential helper, url.*.insteadOf rewrite, filter driver 등은 세션이 쓸 수 있는 파일을 포함한 모든 설정 파일에서 여전히 읽힙니다. 그 파일에 지정된 credential helper나 filter driver는 hook 권한으로 실행되고, 그 설정은 명령줄에 넘긴 URL로의 push를 포함해 hook push의 대상을 바꿀 수 있습니다.
v2.1.280 이전에는 러너가 이 설정을 하나도 하지 않았고, --configure-git에서 hook이 만든 커밋은 -c commit.gpgsign=false를 넘기지 않으면 실패했습니다.
command
세션마다 한 번 checkout 후 내장 자식 spawn 대신 실행됩니다. wrapper script와 같은 환경을 받고 같은 방식으로 "$CLAUDE_RUNNER_CLAUDE_BIN"으로 exec해야 합니다.
- 모든 커스터마이즈를 hooks 디렉터리 하나에 두고 싶으면
commandhook, wrapper가 다른 곳에 있으면--exec-path를 씁니다. --exec-path도 설정되어 있으면 플래그가 우선하고commandhook은 무시됩니다.- PATH로 찾는
claude가 아니라 항상 러너 자체 바이너리로 exec하세요. 그렇지 않으면 버전 pin이 무력화됩니다.
On-demand 러너
고정 플릿 대신 세션마다 러너 하나를 띄울 수 있습니다. orchestrator는 별도의 stateless 하위 명령으로, Anthropic에서 spawn 요청(가용 러너 없이 큐에 있는 세션당 하나)을 폴링하고 요청마다 spawn-runner hook을 실행합니다. hook은 Kubernetes Job, EC2 인스턴스, Nomad dispatch 같은 워크로드를 플랫폼에 제출합니다.
On-demand 러너는 자격 증명 위생을 개선합니다. 고정 플릿에서는 environment secret이 사용자 세션을 실행하는 모든 러너 호스트에 있습니다. orchestrator를 쓰면 secret은 사용자 코드를 실행하지 않는 orchestrator 호스트에만 있고, spawn된 러너는 정확히 러너 하나를 등록하고 만료되는 1회용 work order를 받습니다.
environment secret과 실행 가능한 spawn-runner 스크립트가 있는 hooks 디렉터리를 넘겨 시작합니다.
claude self-hosted-runner orchestrator \
--environment-secret-file /etc/claude/environment-secret \
--hooks-dir /etc/claude/hooks
orchestrator는 폴링 사이 상태가 없으므로 가용성을 위해 같은 환경에 replica를 둘 이상 돌릴 수 있습니다. 각 spawn 요청은 서버 측에서 정확히 한 replica가 claim합니다. 모든 replica는 같은 --expected-spawn-seconds 값을 써야 합니다(hook 계약).
spawn-runner hook
orchestrator는 spawn 요청마다 ${hooks-dir}/spawn-runner를 한 번 실행합니다. hook은 러너 부팅을 기다리지 않고 비동기로 작업을 제출해야 하며, --hook-timeout(기본 60초) 안에 반환해야 합니다. hook이 받는 변수:
| 변수 | 설명 |
|---|---|
CLAUDE_RUNNER_WORK_ORDER_FILE |
새 러너가 등록에 쓸 서명된 work-order JWT가 든 임시 파일 경로. hook 종료 후 삭제. 내용을 로그에 남기지 말 것 |
CLAUDE_RUNNER_ORDER_ID |
불투명 idempotency key. spawn 요청마다 고유하고 Kubernetes 리소스 이름에 안전. provisioner의 dedup key로는 이것만 사용 |
CLAUDE_RUNNER_SESSION_ID |
이 요청의 세션(세션 URL의 session_... ID). 세션의 재요청마다 반복되므로 로깅·라우팅용이지 dedup key가 아님. --min-idle 설정 시 특정 세션 전에 대기 러너를 띄우는 pre-warming 요청에서는 비어 있으니 설정되어 있다고 가정하지 말 것 |
CLAUDE_RUNNER_SESSION_UUID |
같은 세션 ID의 정규 UUID 형태. pre-warming 요청에서는 빈 값 |
CLAUDE_RUNNER_ATTEMPT |
로깅용 세션별 카운터. 재시도·요청 횟수가 아님. pre-warming 요청은 0이지만 세션 요청도 0일 수 있음 |
CLAUDE_RUNNER_ORDER_SERVER_TIME |
폴링 응답 HTTP Date 헤더의 서버 시각. hook이 work-order JWT의 exp를 검증한다면 로컬 시계 대신 이 값과 비교해 시계 오차를 흡수. gateway가 헤더를 빼면 빈 값 |
CLAUDE_RUNNER_POOL_ID |
새 러너가 합류할 환경 ID(ccpool_...) |
CLAUDE_RUNNER_ACCOUNT_ID |
세션을 큐에 넣은 계정의 태그된 ID. 계정별 라우팅·quota·chargeback용이지 dedup key가 아님. 없으면 빈 값이며, 계정이 큐에 넣지 않는 Claude Tag 채널 세션에서는 항상 빈 값 |
CLAUDE_RUNNER_ACCOUNT_EMAIL |
세션을 큐에 넣은 계정의 이메일. 없으면 빈 값. 개인식별정보로 취급하고 로그에 남기지 말 것 |
CLAUDE_RUNNER_PRIMARY_REPO_URL |
세션 첫 git 소스 URL. 그 저장소가 pre-warm된 러너로 라우팅할 때 사용. git 소스가 없으면 빈 값 |
CLAUDE_RUNNER_PRIMARY_REPO_REVISION |
첫 git 소스의 revision(브랜치, SHA, 태그, full reference name). 미지정이면 빈 값 |
CLAUDE_RUNNER_REPO_SOURCES |
세션 모든 git 소스의 {url, revision} JSON 배열. 보조 저장소로 라우팅할 때 사용. 소스가 없으면 빈 값 |
CLAUDE_RUNNER_CORRELATION_ID |
세션 생성 시 제공된 correlation ID를 되돌려 work order와 세션 생성 요청을 매핑. 없으면 빈 값 |
CLAUDE_RUNNER_CLIENT_PLATFORM |
세션을 만든 클라이언트 surface(web_claude_ai, desktop_app, ios, scheduled_trigger 등), 도입 분석용. 기록·인식된 surface가 없거나 pre-warming 요청이면 unset. set -u에서도 안전한 [ -n "${CLAUDE_RUNNER_CLIENT_PLATFORM:-}" ]로 확인 |
spawn된 러너는 environment secret 대신 work order로 등록합니다.
- work order로 시작:
--environment-secret-file을 work-order JWT가 든 파일로 지정하거나SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET에 JWT 값을 설정합니다. - hook 종료 전에 JWT 복사: hook이 끝나면 orchestrator가 work-order 파일을 지우므로, 파일 경로를 넘기지 말고 제출하는 워크로드(예: spawn된 Job의 Kubernetes Secret)에 JWT를 복사하세요.
- spawn된 러너는
--capacity 1: 세션에 묶인 work order는 그 세션에 묶인 러너 하나만 등록하므로, capacity를 높이면 작업을 받지 않는 슬롯만 생기고 러너가 시작 시 경고를 남깁니다. - pre-warming work order는 묶이지 않은 채 등록: 대기 러너는 세션에 묶이지 않고 고정 플릿 러너처럼 큐의 작업을 claim합니다.
플랫폼과 무관하게 계약은 네 가지입니다.
CLAUDE_RUNNER_ORDER_ID로 idempotent하게. 같은 요청이 재전달되어도 러너는 최대 하나만 spawn해야 합니다. order ID에서 결정적 리소스 이름을 만들고 플랫폼이 중복을 거부하게 합니다.CLAUDE_RUNNER_SESSION_ID를 key로 쓰지 마세요. 세션의 재요청은 같은 세션 ID에 새 order ID를 달고 오므로, 세션 ID로 이름 짓거나 dedup한 워크로드는 그 세션에 대해 한 번만 생성되고 다시는 생성되지 않습니다.- 워크로드를 재시도하지 않기. order ID 하나에 생성 워크로드는 최대 하나입니다. 러너가 등록하지 않으면 Anthropic이
--expected-spawn-seconds후 새 order ID로 재요청합니다. - 종료 코드 계약 지키기.
- Exit 0: 제출 완료.
- Exit 1, 또는
--hook-timeout종료 시점까지 실행 중: 재시도 가능 실패. 세션이 back off 후 다시 제공됩니다. 연속 10번 실패하면 exit 2 이상일 때처럼 막힙니다. - Exit 2 이상: 재시도 불가 실패. 사용자가 새 메시지를 보내거나 Owner가 환경 Activity 탭에서 Retry를 누를 때까지 spawn이 막힙니다.
- back off 중인 세션은 Activity 탭에 spawn 오류 없이 Queued로 보이므로 orchestrator 로그에서 hook 출력을 읽으세요. 막히면 Failed로 표시되고, 펼치면 hook stderr 마지막 512자에서 가져온 spawn 오류가 나옵니다. stderr에는 조치 가능한 오류를 쓰고 시크릿은 쓰지 마세요. shell hook이라면 일시 실패를 재시도 가능하게 유지하세요.
- pre-warming 요청은 실패시킬 세션이 없어, orchestrator가 non-zero 종료를 로컬에만 기록하고 서버는
--expected-spawn-secondslease가 만료된 뒤 다시 요청합니다.
--expected-spawn-seconds를 spawn 요청부터 러너 등록까지의 p99 이상으로. orchestrator가 spawn 요청을 받은 시점부터 재고, 부팅 시간뿐 아니라 플랫폼의 용량 대기도 포함하세요. 이 값이 서버 측 lease이며 work order도 함께 만료되므로, 워크로드가 더 오래 걸리면 러너가 등록하지 못합니다. 모든 orchestrator replica가 같은 값을 써야 합니다.
- hook이 stdout·stderr에 쓰는 모든 내용은 자격 증명이 자동 마스킹된 채 orchestrator 로그에 나옵니다.
- 세션이 큐에 머물면 orchestrator
/healthz본문에서 큐 개수를 확인한 뒤, Cloud environments admin 페이지의 환경 Activity 탭을 엽니다. 실패한 세션을 펼쳐 spawn 오류를 보고 Retry로 재요청합니다.
다시 연 세션이 Queued에 머물거나 Failed로 보일 때
다시 연 세션이 Queued에 머물거나 Failed로 보이거나 hook이 그 세션에서 타임아웃된다면, hook이 세션 ID로 워크로드 이름을 짓거나 dedup하는 것이 한 가지 원인입니다. 러너가 종료한 뒤 사용자가 그 세션에 메시지를 보내면 세션이 다시 열립니다. 예를 들어 워크로드 이름을 이렇게 만드는 hook을 생각해 봅시다.
name="runner-${CLAUDE_RUNNER_SESSION_ID}"
- 세션의 첫 spawn 요청에서 hook이 그 이름으로 워크로드를 만들고 러너가 시작됩니다.
- 러너가 종료하고(예:
--release-idle-session-min후), 사용자가 나중에 세션을 다시 엽니다. - 다음 spawn 요청은 같은 세션 ID와 새 order ID를 달고 오므로 hook이 같은 이름을 만듭니다.
- 러너 종료 때 아무것도 워크로드를 제거하지 않아 플랫폼에 첫 요청의 워크로드가 남아 있으면, hook은 새 워크로드를 만들지 않고 러너도 시작되지 않습니다.
- 그 세션의 이후 spawn 요청도 모두 같은 식으로 끝납니다.
내 hook이 워크로드가 이미 있을 때(예: 플랫폼이 중복 이름을 거부할 때) 무엇을 하는지 확인하고, 아래 첫 열에서 찾으면 다시 연 세션이 Activity 탭에 어떻게 보이는지 알 수 있습니다.
| 워크로드가 이미 있을 때 hook이 | Queued가 Failed로 바뀌는 시점 | spawn 오류 |
|---|---|---|
| 아무것도 만들지 않고 exit 0 | 그런 요청이 연속 10번 뒤 | runner did not register within ETA after max attempts |
| exit 1 | 그런 요청이 연속 10번 뒤 | hook stderr 끝부분. stderr에 아무것도 쓰지 않았다면 exit 1 |
| exit 2 이상 | 즉시 | hook stderr 끝부분. 아무것도 쓰지 않았다면 exit과 코드(예: exit 2) |
--hook-timeout이 끝날 때까지 대기 |
그런 요청이 연속 10번 뒤 | hook stderr 끝부분. 아무것도 쓰지 않았다면 spawn-runner hook timed out after 60000ms |
마지막 행의 60000은 밀리초 단위 --hook-timeout입니다. hook이 --hook-timeout까지 기다리면 orchestrator 로그에도 timed out after 줄이 남습니다.
hook을 고치려면 hook 계약 1번 규칙대로 모든 이름·key를 CLAUDE_RUNNER_ORDER_ID로 만드세요. 예제라면 이름은 다음이 됩니다.
name="runner-${CLAUDE_RUNNER_ORDER_ID}"
hook이 기다리는 구조라면, 기다리는 hook은 orchestrator의 다음 hook을 늦추므로 워크로드를 제출하면 바로 종료하게 하세요. hook을 바꾼 뒤 Activity 탭에서 막혀 있던 세션의 상태를 확인합니다.
- Queued: 다음 spawn 요청을 스스로 받습니다.
- Failed: Owner로 Retry를 누르거나, 사용자가 새 메시지를 보내게 합니다.
on-demand 러너가 유휴 시 종료하게 하기
기본적으로 hook이 띄운 러너는 세션 턴이 끝나도 종료하지 않아 제출한 워크로드가 계속 돕니다. 종료하게 하려면 hook이 다음 러너 플래그를 넘기게 합니다.
| 플래그(예시 값) | 전달 대상 | 동작 |
|---|---|---|
--release-idle-session-min 30 |
hook이 띄우는 모든 러너 | 그 분만큼 유휴면 세션을 release하고 exit 0. 사용자의 다음 메시지에 세션이 재개되지만 push하지 않은 작업은 사라짐. 세션이 남겨 둔 프로세스가 있으면 세션을 붙잡아 둘 수 있음 |
--kill-session-after-min 480 |
hook이 띄우는 모든 러너 | 세션을 그 분으로 제한. 유휴로 집계되지 않는 세션의 안전장치이므로 가장 긴 예상 세션보다 크게 |
--exit-if-unused-min 60 |
pre-warming 요청으로 띄운 대기 러너 | 세션을 받지 못한 대기 러너가 그 분 뒤 종료. --min-idle을 설정했다면 잉여가 아닌 한 대체 러너가 뜨므로 부팅 시간보다 충분히 크게 |
러너가 종료하면 워크로드도 제거하세요. 예: Kubernetes Job에 ttlSecondsAfterFinished 설정, VM 시작 스크립트가 끝나면 VM 삭제.
백그라운드 프로세스 출력을 파일로
spawn-runner hook이 백그라운드 프로세스를 남긴다면 그 출력을 hook 사용자가 만들 수 있는 파일로 보내세요. 예를 들어 프로세스를 시작하는 명령의 & 앞에 >> "$HOME/claude-runner.log" 2>&1을 붙입니다(your-background-command는 hook이 백그라운드로 돌리는 명령 자리).
your-background-command >> "$HOME/claude-runner.log" 2>&1 &
리다이렉트가 없으면 프로세스가 hook의 stdout·stderr에 씁니다.
- hook 종료 약 1초 후 orchestrator가
exited, but a process it started still has the hook's stdout or stderr open를 로그에 남깁니다. 그 뒤로 해당 출력을[runner:hook:spawn-runner:after-exit jti=<order id>]아래 최대 64 KB 더 기록하고 나머지는 버립니다. - orchestrator가 종료하면 그 프로세스의 쓰기는 실패합니다.
hook이 직접 띄운 러너는 environment secret을 가진 orchestrator 호스트에서 세션을 실행한다는 점에 유의하세요.
v2.1.305 이전에는 hook이 끝났어도 시작한 프로세스가 hook의 stdout·stderr를 열어 두고 있으면 --hook-timeout에 걸렸고, 다음처럼 보였습니다.
- Activity 탭, 실패한 세션의 spawn 오류:
spawn-runner hook timed out after 60000ms또는 hook stderr 마지막 줄들 - orchestrator 로그:
timed out after 60000ms, sending SIGTERM to process tree
러너를 기다리지 말고 hook 종료
모든 on-demand 러너가 hook 시작 약 60초 후 멈추고 같은 세션에 대해 hook이 다시 실행된다면, hook이 러너를 기다리고 있는지 확인하세요. 60초는 기본 --hook-timeout이며, 직접 설정했다면 그만큼 후에 멈춥니다.
orchestrator 호스트에서 claude self-hosted-runner를 띄우고 foreground나 wait로 기다리는 hook은 --hook-timeout이 끝날 때도 실행 중입니다. orchestrator는 hook과 그 아래 프로세스(러너와 세션의 Claude 프로세스 포함)를 멈추므로 진행 중인 턴이 끊깁니다.
- 확인: orchestrator 로그에서 모든 spawn 요청마다
timed out after 60000ms, sending SIGTERM to process tree가 있는지 봅니다.60000은 밀리초 단위--hook-timeout입니다. - 수정: hook이 러너를 플랫폼에 제출하고 exit 0하게 합니다.
--hook-timeout을 늘리면 러너가 나중에 멈출 뿐입니다.
shell hook에서 일시 실패를 재시도 가능하게 유지
set -e를 쓰는 shell hook에서는 재시도로 해결됐을 실패가 세션을 막을 수 있습니다. hook은 실패한 명령에서 멈추고 그 명령의 종료 코드로 끝나며, orchestrator는 그 코드에 종료 코드 계약을 적용합니다. 명령이 설치되지 않았을 때의 127, HTTP 오류 시 curl --fail의 22처럼 많은 실패가 2 이상을 반환하므로 첫 실패에서 세션이 막힙니다.
이미 막힌 세션은 사용자가 새 메시지를 보내거나 Owner가 Activity 탭에서 Retry를 누를 때까지 막혀 있습니다.
이런 실패를 exit 1로 바꾸려면 hook의 #! 줄 바로 아래, 실패할 수 있는 어떤 것보다 위에 다음을 넣습니다.
set -e
PERMANENT=; permanent() { printf '%s\n' "$*" >&2; PERMANENT=1; exit 2; }
trap 'rc=$?; [ "$rc" -eq 0 ] || [ -n "${PERMANENT:-}" ] || exit 1' EXIT
이 줄은 hook 나머지의 동작을 바꾸므로 추가 후 다음 패턴을 점검하세요.
그냥
exit 2이상: trap 때문에 exit 1이 됩니다. 재시도로 고칠 수 없는 오류는permanent "namespace claude-runners does not exist"처럼 이유와 함께permanent를 호출하세요.$( ),( ), 파이프 안이 아니라 메인 shell에서 호출해야 합니다.exec:exec은 shell을 대체해 trap이 실행되지 않으므로 hook 마지막 명령을exec으로 시작하지 마세요.두 번째
EXITtrap: 두 번째trap ... EXIT는 첫 번째를 대체하므로 하나로 합치세요. 정리 명령을rc=$?;바로 뒤에 두고 각각|| true;로 끝내면 성공·실패 모두에서 정리가 실행되고, 정리 명령 실패가 종료 코드를 바꾸지 않습니다(your-cleanup-command는 내 명령 자리).trap 'rc=$?; your-cleanup-command || true; [ "$rc" -eq 0 ] || [ -n "${PERMANENT:-}" ] || exit 1' EXIT실패해도 되는 명령: 전에
set -e를 안 썼다면 이제 아무것도 못 찾은 조회처럼 non-zero를 반환하는 첫 명령에서 멈춥니다. 결과를 쓰는 경우 그 명령을if조건으로, 무시하는 경우 뒤에|| true를 붙이세요.워크로드를 제출하는 명령: 뒤에
|| true를 붙이지 마세요. 붙이면 quota 초과 등으로 플랫폼이 제출을 거부해도 hook이 exit 0하고, 세션은 시작되지도 않은 러너를 기다립니다.hook의 첫 번째 규칙대로 워크로드 이름을
CLAUDE_RUNNER_ORDER_ID로 지으면 already-exists 응답은 같은 요청의 재전달이므로 그것만 성공으로 처리합니다. 그 외에는 응답을 stderr에 출력하고 exit 1하거나, 재시도로 고칠 수 없으면permanent를 호출합니다. 워크로드만 담은job.yaml로kubectl create에 적용한 예:if ! out=$(kubectl create -f job.yaml 2>&1); then case "$out" in *AlreadyExists*) ;; *) printf '%s\n' "${out:-kubectl create failed}" >&2; exit 1 ;; esac fi그러면 orchestrator 로그에 그 메시지가 나옵니다. hook이 다른 객체도 만든다면 각각 order ID로 이름 짓고 명령을 따로 두며, 같은 방식으로 already-exists를 성공으로 처리하세요. 시크릿을 담는 객체는 명령 출력 대신 고정 메시지를 출력합니다.
trap 동작을 확인하려면 trap 줄 바로 아래에 no-such-command처럼 없는 명령을 호출하는 줄을 넣고, shell에서 hook 파일을 실행해 echo $?가 1인지 본 뒤 그 줄을 지우세요.
orchestrator 중지·재시작
실행 중인 spawn-runner hook을 끊지 않고 orchestrator를 멈추거나 재시작하려면 --shutdown-drain-sec로 시작하세요. 그러면 SIGTERM을 받았을 때 실행 중인 hook이 끝나도록 지정한 초만큼 기다립니다. 값을 정하고 프로세스 supervisor를 맞추는 방법은 아래 --shutdown-drain-sec과 stop timeout 정하기를 보세요.
다음 시작 명령은 70초를 줍니다.
claude self-hosted-runner orchestrator \
--environment-secret-file /etc/claude/environment-secret \
--hooks-dir /etc/claude/hooks \
--shutdown-drain-sec 70
- orchestrator는 이미 보낸 폴링과 모든 hook이 끝나면 종료합니다.
--shutdown-drain-sec이 다 지나면 아직 실행 중인 hook에SIGTERM을 보내고 종료합니다. 두 번째SIGTERM이나SIGINT는 기다리지 않고 바로 그렇게 합니다. [runner:orchestrator] stop summary:가 들어간 로그 줄이 hook과 spawn 요청을 끝난 방식별로 집계합니다.--shutdown-drain-sec이 없으면 orchestrator는SIGTERM이나SIGINT에 바로 멈춥니다. 실행 중인 hook마다SIGTERM이 가고 Anthropic에는 아무것도 알리지 않습니다. 그러면 러너가 시작되지 않은 세션은--expected-spawn-seconds(기본 120초)가 지나 Anthropic이 재요청할 때까지 큐에 머물 수 있습니다.--shutdown-drain-sec은 Claude Code v2.1.299 이상이 필요합니다. 이전 버전은 시작 시error: unknown flag --shutdown-drain-sec로 종료합니다.
--shutdown-drain-sec과 stop timeout 정하기
기본 설정이라면 --shutdown-drain-sec 70과 stop timeout 85초를 쓰세요. Kubernetes에서는 terminationGracePeriodSeconds: 85, systemd에서는 TimeoutStopSec=85입니다. --hook-timeout을 바꿨다면 둘 다 맞춰 늘립니다.
--shutdown-drain-sec: 최소--hook-timeout+ 10초.SIGTERM직전에 시작한 hook도 끝날 수 있게 합니다.- stop timeout: 최소
--shutdown-drain-sec+ 15초. orchestrator가 시작 시 이 값을 로그에 남기니shutdown drain:이 들어간 줄을 찾으세요.
두 번째 SIGTERM은 --shutdown-drain-sec 대기를 끊으므로, supervisor가 orchestrator 프로세스에만 SIGTERM을 한 번 보내게 하세요. systemd 기본값 KillMode=control-group을 그대로 두면 hook에도 SIGTERM이 가서, hook이 워크로드를 제출하기 전에 죽을 수 있습니다. KillMode를 바꾸기 전에 spawn-runner hook이 러너를 어떻게 띄우는지 확인하세요.
- hook이 워크로드를 플랫폼에 제출하고 종료한다면: orchestrator를 unit의 main process로 돌리고
KillMode=mixed를 설정합니다. 그 프로세스에만SIGTERM이 갑니다. - hook이 러너를 orchestrator unit 안의 프로세스로 띄운다면:
KillMode=mixed에서는 그 러너에SIGTERM이 가지 않고, 나중에 systemd가SIGKILL을 보내 러너가 세션을 드레인하거나post-sessionhook을 실행하지 못한 채 종료합니다.
모델 요청을 Bedrock·Agent Platform으로 보내기
조직이 모델 요청을 자체 AWS 또는 Google Cloud 계정으로 보내야 한다면 러너를 Amazon Bedrock 또는 Google Cloud Agent Platform(구 Vertex AI)용으로 구성합니다. 그 러너가 시작하는 모든 세션이 내 클라우드 계정의 모델을 내 클라우드 자격 증명으로 호출합니다. 이 구성이 없으면 세션은 Anthropic API로 모델 요청을 보냅니다.
- 러너는 여전히 Anthropic에서 세션을 폴링하고, 각 세션은 프롬프트·응답·툴 결과를 담은 이벤트 스트림을
api.anthropic.com으로 보냅니다. 가용성과 제약의 플랜 요건과 Zero Data Retention 제외도 그대로 적용됩니다. - 세션은 러너가 아니라 환경으로 라우팅되고, requeue·재개된 세션은 다른 러너에서 돌 수 있으니 환경 안 모든 러너를 똑같이 구성하세요. 시작 전에 Anthropic API 세션과 다른 점을 읽으세요.
1. 클라우드 계정과 egress 규칙 준비
- Amazon Bedrock: use case details 제출 후 IAM configuration의 정책을 만들되,
bedrock:InvokeModel과bedrock:InvokeModelWithResponseStream을 세션이 쓰는 inference profile과 그 뒤의 foundation model로 제한합니다. - Agent Platform: API 활성화와 모델 접근 요청 후 IAM configuration이 설명하는
aiplatform.endpoints.predict만 가진 custom role을 만듭니다. - Egress: provider 엔드포인트를 egress 규칙에 허용합니다(Network requirements). 세션이 닿지 못하면 Claude Code가 몇 시간 재시도한 뒤에야 세션에 오류가 보일 수 있습니다.
2. 세션에 좁은 범위 자격 증명 주기
1단계의 정책·role을 다른 일은 못 하는 신원에 붙입니다. Claude Code가 받는 방식은 Configure AWS credentials와 Configure GCP credentials를 보세요.
경고: 프롬프트 인젝션을 포함해 세션에서 코드를 실행시킬 수 있는 누구나 자격 증명이 유효한 동안 내 비용으로 이를 쓸 수 있습니다. Claude Code가 세션 안에서 돌므로 모델 호출 자격 증명은 세션에서 읽을 수 있어야 합니다. Claude가 실행하는 shell 명령, Claude Code hooks, stdio MCP 서버는 세션 환경을 상속하고 Claude Code와 같은 사용자로 돌기 때문에 자격 증명 변수와 파일을 읽을 수 있습니다. 배포를 hardening하면 호스트 자격 증명은 세션에서 뺄 수 있지만 이 자격 증명은 뺄 수 없습니다. 그 신원에는 1단계 정책·role 외에 아무것도 주지 마세요.
선택한 방식을 다음 러너 동작과 대조하세요.
- Metadata 엔드포인트: 세션의 클라우드 metadata 엔드포인트 접근을 완전히 막으면 instance profile처럼 거기서 제공되는 자격 증명도 Claude Code에 닿지 않습니다. Amazon EKS의 IAM Roles for Service Accounts(IRSA)나 Workload Identity Federation 자격 증명 파일 같은 파일 기반 web identity는 의존하지 않습니다.
- 갱신: 세션이 자격 증명보다 오래 갈 수 있으므로 파일 기반 web identity처럼 스스로 갱신되는 방식을 쓰세요.
- Wrapper script: 러너는 wrapper script를 세션당 한 번 실행하므로 거기서 export한 자격 증명은 갱신되지 않습니다. Claude Code는 환경에서 AWS 자격 증명을 읽으므로, wrapper가 다른 용도로 이미 AWS 자격 증명을 export한다면 Claude Code가 그것으로 모델 요청에 서명할 수 있습니다.
3. provider 하나의 변수를 러너 환경에 설정
컨테이너 spec이나 service unit처럼 러너의 다른 환경 변수를 두는 곳에 정확히 한 provider의 변수만 설정하고 러너를 재시작합니다. on-demand 러너라면 spawn-runner hook이 시작하는 워크로드에 설정합니다.
이 러너는 --confine-repo-settings enforce로 시작하세요. 커밋된 설정이 걸리는 저장소의 세션을 거부하므로, 먼저 기본 warn 모드로 돌려 로그에 나온 항목을 정리합니다.
Amazon Bedrock(region은 내 것으로):
export CLAUDE_CODE_USE_BEDROCK=1
export AWS_REGION=us-east-1
region 결정 방식은 Configure Claude Code, region별 inference profile 접두사는 Cross-region inference profile prefixes를 보세요.
Google Cloud Agent Platform(region과 project ID는 내 것으로):
export CLAUDE_CODE_USE_VERTEX=1
export CLOUD_ML_REGION=global
export ANTHROPIC_VERTEX_PROJECT_ID=YOUR-PROJECT-ID
region 선택은 Region configuration을 보세요.
4. 변수가 세션에 닿았는지 확인
호스트의 내 shell은 다른 프로세스이므로 세션 안에서 확인합니다. 환경에서 세션을 시작하고 Claude에게 다음을 실행하게 합니다.
env | grep -E 'CLAUDE_CODE_USE_(BEDROCK|VERTEX)'
CLAUDE_CODE_USE_BEDROCK또는CLAUDE_CODE_USE_VERTEX가1인 줄이 나오면 세션에 닿은 것입니다. 둘 다 나오면 Claude Code는 Amazon Bedrock을 씁니다. 출력이 없으면 둘 다 닿지 않은 것입니다.- 이 명령은 구성을 보여 줄 뿐 트래픽은 아닙니다. 실제 요청은 클라우드 계정의 메트릭이나 요청 로그에서 확인하세요. 첫 메시지가 실패하면 Amazon Bedrock 또는 Agent Platform 문제 해결을 보세요.
Anthropic API 세션과 다른 점
| 항목 | 차이 |
|---|---|
| claude.ai 정책 | server-managed settings가 전달되지 않고, Owner가 Claude Code admin 설정에서 정한 조직 정책도 세션 안에서 강제되지 않음. 의존하는 규칙은 러너 이미지의 managed settings 파일에 둘 것 |
| 계정 skills | 사용자 claude.ai 계정에 활성화된 skill을 내려받지 않음(세션 config 구성 방식) |
| 파일 | claude.ai·모바일·데스크톱 앱에서 첨부한 파일이 세션에 닿지 않고, Claude가 SendUserFile 도구로 파일을 돌려보낼 수 없음. 입력 파일은 저장소나 러너에 둘 것 |
| 모델 선택 | 컨트롤 플레인이 세션 모델을 보내고, 없으면 provider 기본값 사용. 러너 환경의 ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_MODEL로는 모델을 고를 수 없음(provider 페이지 예제가 ANTHROPIC_MODEL을 설정하더라도 러너가 세션 환경에서 제거). Bedrock·Agent Platform의 Pin model versions에 있는 family별 pin 변수는 세션에 닿으며, opus 같은 alias가 무엇으로 해석될지를 정함(full model ID에는 영향 없음) |
| 계정이 제공하지 않는 모델 | 모델 이름이 들어간 오류로 메시지가 실패할 수 있음. 개발자가 고를 수 있는 모델, Pin model versions에 나오는 background 모델, auto mode가 쓰는 classifier 모델을 활성화하고, Bedrock이면 정책에서 각각 허용 |
| Web search·fast mode | web search는 Amazon Bedrock에서 불가, fast mode는 두 provider 모두 불가. 그 밖의 차이는 CLI capabilities that vary by provider |
MCP 서버
모든 세션에서 MCP 서버를 쓰려면 데스크톱 설치와 같은 claude mcp add 명령으로 이미지 빌드 시 추가합니다. 러너가 컨테이너가 아닌 bare 프로세스라면 호스트에서 러너 사용자로 같은 명령을 실행하고 러너를 재시작합니다(호스트 config는 시작 시 한 번 읽음). --scope user 플래그가 필수입니다. 기본 local scope는 러너가 세션에 시드하지 않는 디렉터리별 key 아래에 씁니다. Dockerfile 예:
RUN claude mcp add --scope user sidecar -- /usr/local/bin/mcp-sidecar
RUN claude mcp add --scope user --transport http internal http://mcp-gateway.svc.cluster.local:8080
- 러너 시작 시 스냅샷은 호스트
.claude.json(~/.claude/안이 아니라 옆에 있음)의mcpServerskey를 담고, 러너는 그 key만 각 세션의 격리된 config에 시드합니다. 계정 상태와 프로젝트 기록은 버립니다. - 확인하려면 환경에서 세션을 시작해 Claude에게 MCP 도구 목록을 물어보세요. 러너는
type을 인식하지 못한 항목에 대해 시작 시 경고를 남기고 그 항목을 버리므로, 왜 세션에 없는지 알 수 있습니다. SELF_HOSTED_RUNNER_HOST_CONFIG_DIR가 설정되면 그 디렉터리의.claude.json을 읽으므로, 빈 디렉터리를 지정하면 MCP 시드도 꺼집니다.
Claude Code는 다른 출처에서도 MCP 서버를 읽습니다.
- 엔터프라이즈 scope managed MCP 파일: 표준 시스템 경로(Linux 러너 호스트
/etc/claude-code/managed-mcp.json, macOS 호스트/Library/Application Support/ClaudeCode/managed-mcp.json). 관리자가 나열한 서버만 허용하는 잠긴 플릿용이며 우선순위는 exclusive control with managed-mcp.json 항목을 보세요. 이 파일이 러너 호스트에 있으면 Claude Code는 claude.ai connector를 포함해 컨트롤 플레인이 전달한 MCP 서버를 건너뛰고 세션 자식 stderr 경고에 이름을 남기며, 러너는 이를debug로그 레벨로 기록합니다. v2.1.229 이전에는 이런 세션이You cannot dynamically configure MCP servers when an enterprise MCP config is present로 시작 시 종료했습니다. - 러너 호스트 managed settings의
managedMcpServerskey: exclusive control 없이 HTTP·SSE 서버를 제공하므로 다른 출처의 서버도 로드됩니다. Claude Code v2.1.259 이상. <repo>/.mcp.json: project scope. project 스레드 밖에서는 저장소에 커밋하면 클라우드 세션에서 자동 승인됩니다. 저장소가 여럿인 세션에서는 최대 한 저장소의 파일만 로드됩니다.- wrapper script의
--mcp-config파일:exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" --mcp-config /etc/claude/mcp.json처럼"$@"뒤에 붙입니다. 세션 중 전달 서버가 갱신되어도 그 파일의 서버를 유지하려면exec처럼 전체 환경을 자식에 넘기세요. v2.1.299 이전에는 그 파일의 stdio·HTTP·SSE 서버가 자기 이름이 없는 첫 갱신에서 종료했습니다.
조직에 connector 전달이 활성화되어 있으면, 컨트롤 플레인이 claude.ai에 구성한 connector를 대화형으로 만든 세션에 서버 제공 MCP 구성으로 전달하며 api.anthropic.com을 경유합니다. CLI 디스패치처럼 프로그래밍 방식으로 만든 세션은 connector를 받지 않으니 위의 다른 출처로 MCP 서버를 주세요. 자식의 OAuth 토큰에는 connector를 직접 가져올 scope가 없어 자식이 스스로 가져오지 않으며, 전달은 서버 주도입니다.
connector를 받은 세션에서는 호스트
.claude.json이나 저장소.mcp.json의 같은 이름 서버가 연결되지 않습니다. 둘 다 쓰려면 내 서버 이름을 바꾸세요.
settings.json은 MCP 서버 정의를 담지 않으며 설정 스키마에 최상위mcpServers필드도 없습니다. managed settings에서는managedMcpServerskey를 쓰세요.- 세션이 러너 환경을 상속하므로, 러너 환경에
ENABLE_TOOL_SEARCH를 설정하면 러너가 띄우는 모든 세션의 MCP tool search를 제어합니다(값은 MCP 페이지 참고).
첫 턴 전에 MCP 서버 기다리기
셀프호스티드 세션은 아직 연결 중인 MCP 서버를 두 지점에서 잠깐 기다립니다. 대기를 놓친 서버는 첫 턴 시작 시 도구가 없지만, 따로 조치하지 않아도 나중에 사용 가능해집니다.
| 대기 지점 | 동작 | 조정 |
|---|---|---|
| 세션 시작 | 도구 목록을 처음 가져오기 전, alwaysLoad: true인 HTTP·SSE 서버를 기본 최대 5초 대기. 러너 환경에 MCP_CONNECTION_NONBLOCKING=0이면 모든 서버 대기. 그 외 HTTP·SSE 서버는 백그라운드 연결. 대기 동안 세션 초기화가 느려짐 |
MCP_CONNECT_TIMEOUT_MS로 5초 기본값 변경 |
| 첫 턴 | 메시지 도착 후 연결 중인 stdio 서버를 최대 2초 대기. 대기 동안 첫 응답이 느려짐 | 러너 환경의 CLAUDE_CODE_MCP_STARTUP_WAIT_MS로 대기 시간 변경(대상 서버는 그대로). Claude Code v2.1.274 이상 |
claude mcp add에는 alwaysLoad 플래그가 없습니다. 서버 JSON으로 key를 받아 .claude.json에 쓰는 claude mcp add-json을 쓰세요. Dockerfile 예:
RUN claude mcp add-json core '{"type":"http","url":"https://mcp.example.com/mcp","alwaysLoad":true}' --scope user
이후 턴에서도 서버 도구가 안 보이면 MCP 서버 절대로 서버가 세션에 도달했는지부터 확인하세요.
내장 세션 도구 끄기
컨트롤 플레인은 클라우드 세션에 Claude Code Remote라는 자체 MCP 서버를 붙입니다. Claude는 이 서버 도구로 클라우드 예약 작업 생성, 다른 클라우드 세션 시작·조종, 저장소 추가, PR 활동 추적을 합니다.
서버 전체를 끄려면 설정에 서버 수준 deny rule을 추가합니다. 컨트롤 플레인은 세션 생성 방식에 따라 세 이름 중 하나로 서버를 등록하고, Claude Code는 rule 이름을 대소문자까지 정확히 매칭하므로 이름마다 한 번씩 씁니다.
{
"permissions": {
"deny": [
"mcp__Claude_Code_Remote",
"mcp__claude-code-remote",
"mcp__bf7c680d-5fdc-5ef4-b4a0-abadb619bf0a"
]
}
}
- 서버 전체를 지정한 rule은 서버가 나중에 얻는 도구도 막습니다.
- 도구 하나만 끄려면 각 rule 뒤에 밑줄 두 개와 도구 이름을 붙입니다. 예:
mcp__Claude_Code_Remote__add_repo. - 도구 제거가 아니라 서버 연결 자체를 막으려면
mcp__접두사 없이 세 이름을deniedMcpServers아래serverName항목으로 추가합니다. - 러너 변경 없이 세션에 적용하려면 server-managed settings에, 아니면 러너의
~/.claude/settings.json에 넣습니다. Bedrock·Agent Platform으로 모델 요청을 보내는 러너는 server-managed settings가 닿지 않으므로 그 파일을 쓰세요. 러너 설정이 세션에 닿는 방식은 권한과 도구 승인에 있습니다. - 확인하려면 환경에서 세션을 시작해 Claude에게 MCP 도구 목록을 물어보세요. 거부된 도구는 Claude 컨텍스트에서 제거되므로 답에 나오지 않습니다.
세션이 작업을 push하도록 유도
Anthropic 호스티드 세션은 Claude가 응답을 마칠 때 실행되는 Claude Code Stop hook으로 Claude에게 commit·push를 유도합니다. 러너는 이를 설치하지 않습니다. 없으면 미커밋 변경으로 끝난 세션의 작업은 러너 디스크에만 남고, 브랜치가 remote에 생길 때까지 claude.ai/code의 Create PR 버튼이 비활성 상태로 남습니다.
참조 구현은 두 부분입니다. 설정 블록을 러너 호스트 ~/.claude/settings.json(러너가 모든 세션에 시드)에 병합하고, 스크립트를 러너 호스트 ~/.claude/hooks/stop-hook-nudge.sh로 저장해 실행 권한을 줍니다.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"timeout": 10,
"command": "\"$CLAUDE_CONFIG_DIR/hooks/stop-hook-nudge.sh\""
}
]
}
]
}
}
#!/bin/sh
# Stop-hook reference implementation for self-hosted runners.
#
# Nudges Claude once per turn if the project directory has uncommitted
# changes OR unpushed commits, so work isn't lost when an idle session
# is released and so the "Create PR" button on claude.ai/code lights up.
#
# Runner-level (no repo changes): drop this file at ~/.claude/hooks/ on
# the runner host and merge the accompanying Stop-hook settings block
# into ~/.claude/settings.json — the runner seeds both into every session.
# Repo-level alternative: commit to <repo>/.claude/hooks/ and change the
# settings.json command path to $CLAUDE_PROJECT_DIR/.claude/hooks/.
#
# stdin: hook JSON payload (see https://code.claude.com/docs/en/hooks)
# stdout: {"decision":"block","reason":"..."} to nudge, or nothing to allow stop.
# Re-entry guard: the harness sets stop_hook_active=true when re-invoking
# the Stop hook after a block. Bail so we only nudge once per turn. The
# harness emits compact JSON (no space after the colon), which this
# pattern relies on; use jq if you need a whitespace-tolerant check.
in=$(cat)
case "$in" in *'"stop_hook_active":true'*) exit 0 ;; esac
d="$CLAUDE_PROJECT_DIR"
# Hardening: this script runs outside Claude Code's Bash sandbox, so its git
# calls skip some features that can start a program named in the checkout's
# git settings. They don't skip them all, so don't count on the sandbox alone.
GIT_OPTIONAL_LOCKS=0
export GIT_OPTIONAL_LOCKS
git() { command git -c core.fsmonitor= -c core.hooksPath=/dev/null "$@"; }
# Not a git repo → nothing to nudge.
git -C "$d" rev-parse --git-dir >/dev/null 2>&1 || exit 0
# No remote → "push to the remote" is unsatisfiable; bail.
[ -z "$(git -C "$d" remote 2>/dev/null)" ] && exit 0
# Uncommitted changes (staged, unstaged, or untracked). Exclude .claude/
# entirely — operator-seeded settings and CLI-written runtime state
# (scheduler lock, worktrees, routine state) live there and neither is
# "uncommitted work" the model needs to push.
s=$(git -C "$d" status --porcelain --ignore-submodules=dirty -- . ':(exclude).claude/' 2>/dev/null)
if [ -n "$s" ]; then
printf '{"decision":"block","reason":"There are uncommitted changes in the repository. Please commit and push these changes to the remote branch."}'
exit 0
fi
# Unpushed commits. Count commits on HEAD not reachable from any
# remote-tracking ref or FETCH_HEAD. This works uniformly for:
# - init+fetch checkouts (runner default: only FETCH_HEAD exists)
# - clone-based checkouts (origin/* exist)
# - the runner default: the child starts on the session's outcome
# branch, which the runner creates after checkout
# - detached HEAD, when a custom setup skips that branch creation
# With no reference point at all (never fetched), stay silent rather
# than false-positive on a read-only turn.
base=""
git -C "$d" rev-parse --verify -q FETCH_HEAD >/dev/null && base="FETCH_HEAD"
if [ -z "$base" ] && [ -z "$(git -C "$d" for-each-ref --count=1 refs/remotes/origin 2>/dev/null)" ]; then
exit 0
fi
# shellcheck disable=SC2086 # $base is either "" or "FETCH_HEAD", intentional word-split
unpushed=$(git -C "$d" rev-list HEAD --not $base --remotes=origin --count 2>/dev/null) || unpushed=0
if [ "$unpushed" -gt 0 ]; then
branch=$(git -C "$d" symbolic-ref --short -q HEAD)
if [ -n "$branch" ]; then
# $branch is attacker-influenced — git-check-ref-format(1) allows `"`
# in ref names. `\` is forbidden (rule 10) but escaped anyway as cheap
# defense-in-depth.
# Escape JSON metacharacters before interpolating into the hand-built
# payload so a branch like x","continue":false can't inject keys into
# the hook-output JSON the harness parses. $unpushed is safe — the
# -gt guard above rejects anything that isn't a plain integer.
branch_esc=$(printf '%s' "$branch" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '{"decision":"block","reason":"There are %s unpushed commit(s) on branch '\''%s'\''. Please push these changes to the remote repository."}' "$unpushed" "$branch_esc"
else
printf '{"decision":"block","reason":"There are %s unpushed commit(s) on a detached HEAD. Please create a branch and push it to the remote repository."}' "$unpushed"
fi
exit 0
fi
exit 0
hook은 세션이 끝나기 전에 Claude에게 commit·push를 유도하고, 디렉터리가 git 저장소가 아니거나 remote가 없으면 조용히 있습니다. 저장소가 여럿인 세션은 $CLAUDE_PROJECT_DIR가 가리키는 곳을 보세요.
Stop hook은 유도만 합니다. commit·push가 실제로 되려면 다음을 갖추세요.
git identity: 러너 이미지에
user.name,user.email을 설정하거나--configure-git으로 러너를 시작push 자격 증명: Anthropic-managed git을 쓰거나 Ship git config in your image대로 자격 증명 제공
재개된 세션: 환경 안 모든 러너에
--push-outcome-on-release를 설정하면 재개된 세션이 Claude가 세션 브랜치에 push한 커밋에서 시작합니다. 플래그의 한계는 Resumed sessions lose unpushed work를 보세요.Stop hook은 턴당 최대 한 번 유도합니다. commit·push가 실패해도 턴은 끝나므로, 세션의 git 오류를 보고 원인을 고치세요.
큰 checkout에서는 Stop hook 설정의
timeout을 올리고 러너를 재시작하세요. 스크립트의git status가core.fsmonitor를 건너뛰어 설정된 10초보다 오래 걸릴 수 있고, timeout이 나면 유도 없이 턴이 끝납니다.
서브모듈과 Stop hook
Stop hook은 서브모듈 안의 파일이 아니라 각 서브모듈이 가리키는 커밋을 확인합니다.
| 서브모듈 상태 | 동작 |
|---|---|
| 서브모듈에 새 커밋 | 상위 저장소가 기록할 때까지 유도. .gitmodules가 그 서브모듈에 ignore = all을 설정해도 마찬가지 |
| 서브모듈의 미커밋 파일 | 유도하지 않음. 그 작업은 러너 디스크에만 남음 |
| 서브모듈의 미push 커밋 | push 여부를 확인하지 않아, 상위가 아무도 fetch할 수 없는 커밋을 가리킬 수 있음 |
이전 Stop hook 스크립트 교체
내 stop-hook-nudge.sh에서 GIT_OPTIONAL_LOCKS를 검색하세요. 없으면 이전 사본입니다. 설치한 모든 곳에서 현재 스크립트로 바꾸고 러너를 재시작합니다. Stop hook 설정은 큰 checkout에서 timeout을 올리는 것 외에는 그대로 둡니다.
- 언제: 세션이 Bash sandbox를 켠 채 돈다면 지금 바로, 아니면 다음 이미지 빌드나 러너 재시작 때.
- 무엇이 바뀌나: 서브모듈의 미커밋 파일은 더 이상 유도하지 않고,
.gitmodules가ignore = all로 표시한 서브모듈의 새 커밋은 이제 유도합니다.
경고: Hook은 Bash sandbox 밖에서 실행되므로, 현재 스크립트의 git 호출은 checkout의 git 설정에 지정된 프로그램을 시작할 수 있는 git 기능 일부를 건너뛰지만 전부는 아닙니다. sandbox만 믿지 말고 Harden your deployment대로 세션을 격리하세요.
권한과 도구 승인
셀프호스티드 세션에는 터미널이 붙어 있지 않아, 응답하지 않은 권한 프롬프트는 사용자가 UI에서 응답할 때까지 턴을 멈춥니다. 컨트롤 플레인은 작업 payload와 함께 세션의 도구 목록과 권한 규칙을 보냅니다. 기본 구성은 Bash를 포함한 일상적 도구 호출을 사전 승인하고, 클라우드 세션은 모드와 무관하게 파일 편집을 사전 승인합니다. 아무것도 사전 승인하지 않은 호출은 세션 UI로 프롬프트합니다.
default-deny 네트워크 egress와 나머지 hardening을 갖춘 세션 컨테이너의 환경에서만 auto mode를 pin하세요. 기본 사전 승인 도구 세트와 auto mode 모두에서
Bash네트워크 요청을 포함한 일상 도구 호출이 사람 개입 없이 실행되므로, 그 호출이 어디까지 닿을지를 제한하는 것은 네트워크 경계입니다.
컨트롤 플레인이 무엇을 보내든 프롬프트를 최소화하려면 wrapper나 command hook에서 auto mode를 pin합니다. auto mode에서는 별도 classifier 모델이 실행 전 동작을 검토해 거부한 것을 막고, 명시적 ask rule은 여전히 프롬프트를 띄웁니다(classifier가 검사하는 내용은 permission modes 페이지 참고). 러너는 wrapper 호출 전에 서버 계산 플래그를 붙이고, --permission-mode 같은 단일 값 플래그는 마지막 것을 따르므로 "$@" 뒤에 붙인 플래그가 서버 값을 override합니다.
#!/bin/bash
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" --permission-mode auto
- 특정 도구만 사전 승인하려면
--allowed-tools "Bash(bazel *) Bash(yarn *) mcp__internal__*"처럼--allowed-tools를 붙입니다. --allowed-tools,--disallowed-tools같은 목록 플래그는 override가 아니라 누적되므로 내 규칙이 컨트롤 플레인 규칙 위에 더해집니다. 좁히려면 다른 rule이 허용해도 거부하는--disallowed-tools를 붙이세요.
세션 config 구성 방식
러너는 세션마다 config 디렉터리를 주며, 러너 시작 시 한 번 찍은 호스트 ~/.claude/ 스냅샷으로 시드합니다. 러너 이미지의 settings.json, CLAUDE.md, hooks, agents, commands, skills가 모든 세션의 user 수준 기준선이 됩니다. 실행 중 호스트의 config를 바꾸면 러너 재시작 후에야 적용됩니다.
SELF_HOSTED_RUNNER_HOST_CONFIG_DIR로 다른 경로에서 시드하거나, 빈 디렉터리를 지정해 시드를 끕니다.- 세션은 다음 설정 파일도 읽습니다.
- Project settings: project 스레드 밖에서는 저장소에 커밋된
.claude/settings.json이 user 기준선 위에 얹힙니다. 저장소가 여럿이면 최대 한 저장소의 파일만 적용됩니다. - Managed settings: 러너 이미지의 표준 시스템 경로에 있는
managed-settings.json을 읽습니다. server-managed settings와 함께 적용되는지는 how Claude Code combines managed sources를 보세요.
- Project settings: project 스레드 밖에서는 저장소에 커밋된
- 적용 순서는 settings precedence를 보세요.
컨트롤 플레인이 세션에 Claude Code hooks를 제공하면 러너는 내 구성을 덮지 않고 나란히 설치합니다. Claude Code v2.1.229 이상이며, 이전 러너는 제공된 hook을 무시합니다. 제공된 hook은 python3를 실행하므로 러너 이미지에 python3를 설치하세요.
- 위치: 제공된 hook 스크립트를 세션 config 디렉터리의 예약 하위 디렉터리
hooks/.ccr-launcher/에 쓰고,--settings로 넘기는 별도 설정 파일에 등록합니다. 시드된settings.json과hooks/<name>의 내 스크립트는 건드리지 않습니다. 예약 디렉터리는 세션마다 다시 만들며, 호스트의~/.claude/hooks/.ccr-launcher/내용은 세션에 시드하지 않습니다. - 작성 주체: 컨트롤 플레인이 자기 배포의 고정 상수로 채우며, 세션별·제3자 입력을 쓰지 않습니다.
- 여전히 적용되는 통제:
--settings로 전달된 hook은 managed tier가 아니라 일반 병합 hook 구성에 들어가므로 managed settings가 그대로 적용됩니다.disableAllHooks로 꺼지고,allowManagedHooksOnly가 남겨 두는 범주에 속하지 않습니다.
사용자가 직접 세션을 시작하면 Claude Code는 그 사람의 claude.ai 계정에 활성화된 skill을 세션 config 디렉터리에 내려받습니다. 클라우드 예약 작업 실행은 소유자의 skill을 받지 않고, Bedrock·Agent Platform으로 모델 요청을 보내는 세션은 어떤 skill도 내려받지 않습니다. 그런 세션에 필요한 skill은 러너 이미지에 넣거나, project 스레드 밖이라면 저장소 .claude/skills/에 커밋하세요.
Claude Tag 세션을 제외하면 셀프호스티드 환경 세션은 기본적으로 auto memory가 꺼져 있습니다. 세션 간에 이어져야 할 지시는 러너 이미지나 저장소의 CLAUDE.md를 쓰세요. 러너의 호스트 ~/.claude/ 스냅샷은 projects/ 디렉터리를 제외하는데, auto memory 기본 저장 위치가 그 아래이므로 거기 둔 memory 파일은 세션에 시드되지 않고 auto memory를 켜지도 않습니다.
server-managed settings를 못 받아 온 세션 멈추기
server-managed settings를 받아 오는 세션이 그 fetch에 실패했을 때 시작하지 않게 하려면, 러너 이미지의 managed-settings.json에 forceRemoteSettingsRefresh를 true로 설정합니다. 조직에 server-managed settings가 없다는 Anthropic API 응답도 성공한 fetch로 칩니다.
key를 넣기 전에 환경이 Claude Tag 채널 세션을 돌리는지, 모델 요청을 Bedrock·Agent Platform으로 보내는지 확인하세요.
- Claude Tag 채널 세션: Owner가 Claude Tag 채널을 라우팅하는 환경의 러너에는 이 key를 넣지 마세요. Anthropic API가 이 세션들의 server-managed settings 요청을 거부하므로, key가 있으면 시작하지 못합니다. 이 세션들의 규칙은 여전히 읽는 러너 이미지의
managed-settings.json에 두세요. 다른 세션에 key를 쓰려면 그 세션들을 별도 환경에서 돌립니다. - Bedrock·Agent Platform: 이 provider의 세션은 server-managed settings를 받아 오지 않으므로(Anthropic API 세션과 다른 점), key와 상관없이 시작합니다. 규칙은 같은
managed-settings.json으로 강제하세요.
key를 설정하는 managed-settings.json:
{
"forceRemoteSettingsRefresh": true
}
claude.ai admin 콘솔에서도 설정했더라도 이 파일에 key를 넣으세요. 새 세션에는 server-managed settings의 캐시 사본이 없으므로, 콘솔에만 설정하면 세션은 아직 그 key를 모릅니다. 그 상태에서 fetch가 실패하면 나중 fetch가 성공할 때까지 그 설정 없이 돕니다.
settings fetch 실패 때문에 세션이 시작하지 않았는지 확인하려면, 세션이나 러너 로그에서 Claude Code의 마지막 출력(위 stdin과 파일 디스크립터 3 유지의 stderr 참고)을 보세요. Your organization requires remote managed settings to load, but they could not be loaded.가 들어 있습니다.
저장소가 여럿인 세션의 저장소 설정
project 스레드에서는 스레드가 어디서 시작하든 어떤 저장소의 .claude/settings.json도 project settings로 적용되지 않고, 어떤 저장소의 .mcp.json이나 skill도 로드되지 않습니다. 그런 스레드에 skill을 주려면 러너 이미지에 넣으세요.
그 밖에 저장소가 여럿인 세션에서 Claude Code는 세션이 시작하는 디렉터리에서 project settings를 읽으므로, 최대 한 저장소의 .claude/settings.json만 project settings로 적용됩니다. 다른 저장소 파일의 hook은 실행되지 않고, deny rule도 적용되지 않으며, env도 설정되지 않습니다.
| 구성 | 세션 시작 위치 | 결과 |
|---|---|---|
--capacity 1(기본) + 내장 checkout |
저장소 목록의 첫 저장소 | 그 저장소의 .claude/settings.json이 project settings로 적용되고 .mcp.json이 로드됨. 다른 저장소 것은 안 됨 |
--capacity 1 초과, 또는 checkout hook |
checkout들을 담은 세션별 디렉터리 | 어떤 저장소의 .claude/settings.json도 project settings로 적용되지 않고 .mcp.json도 로드되지 않음. hook 명령의 $CLAUDE_PROJECT_DIR는 checkout이 아니라 그 디렉터리 |
- 각 저장소의
CLAUDE.md는 세션이 어디서 시작하든 로드되고, project 스레드 밖이라면 skill도 로드됩니다. - 러너는 모든 저장소를 Claude Code에 additional directory로 넘기므로, Claude Code는 각 저장소
.claude/settings.json의enabledPlugins,extraKnownMarketplaceskey도 읽습니다. - 모든 세션에서 hook을 실행하거나 권한 규칙을 적용하려면 러너 호스트의
~/.claude/settings.json에 넣으세요. 러너가 호스트 파일을 모든 세션에 시드하므로 시작 위치와 무관합니다.Read,Editrule의 경로는//절대 경로나~/홈 기준 패턴으로 쓰세요. 다른 패턴은 설정 출처나 현재 디렉터리를 기준으로 고정됩니다.
저장소에 커밋된 권한 규칙
- 저장소에 커밋된
permissions.allow에 그냥"Edit","Write","NotebookEdit"항목을 넣지 마세요. 경로 없는 파일 도구 rule은 경로와 무관하게 도구에 매칭되어 workspace만이 아니라 호스트 어디에나 쓰기를 허용하므로, 러너의 write-scope confine guard가 세션을 표시합니다.--confine-repo-settings enforce에서는 로그 후 계속하는 대신 세션 spawn을 거부합니다(hardening). - 저장소에는 파일 도구 rule이 전혀 필요 없습니다. 클라우드 세션은 모드와 무관하게 파일 편집을 사전 승인합니다. 굳이 커밋한다면
"Edit(/**)"처럼 workspace로 범위를 정하세요. 앞의 단일 슬래시는 세션 workspace인 project root 기준입니다. 운영자의 호스트 수준settings.json은 저장소에 커밋되지 않으므로 경로 없는 rule도 괜찮습니다. defaultMode의auto는 이미지 전역 또는 user 수준 설정 파일에서만 인정되므로, checkout한 저장소가 스스로 auto mode를 부여할 수 없습니다. 클라우드 세션이 받는 모드와 전체 rule 문법은 permission modes를 보세요.
체크리스트
- Wrapper와
commandhook은exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"로 끝나며 PATH의claude를 쓰지 않는다. - 자식을 백그라운드로 돌린다면 stdin을 fd 6~9(예:
exec 9<&0)로 저장해 다시 붙였고, fd 3과 stderr를 러너에 붙여 두었으며 v2.1.309+가 예약한 fd 4·5를 쓰지 않는다. - Linux에서
--append-system-prompt인자 하나가 128 KB를 넘지 않는다(넘으면CLAUDE.md로). - 자식 환경을 재구성한다면
CLAUDE_SESSION_INGRESS_TOKEN_FILE,CLAUDE_CODE_REMOTE_MCP_CONFIG,CLAUDE_RUNNER_ALIVE_EVERY_MS를 넘긴다. -
set -u스크립트는 unset일 수 있는 변수를:-기본값으로 확장하고 모든 인자·변수를 인용한다. - system prompt 파일 플래그를 빼거나 덧붙여 덮어쓰지 않고, 추가 지시는 이미지의
CLAUDE.md에 둔다. - 인증 판단은 검증된 토큰의
act.sub에 기반하고jq -re를 쓰며CCR_SESSION_ACCOUNT_EMAIL에 의존하지 않는다. - hooks 디렉터리를 read-only로 마운트하거나 이미지에 bake했다.
-
checkouthook 실패 시 stderr 마지막 줄에 짧은 이유를 쓰고 시크릿·set -x를 제거했다. -
post-sessionhook의 push 자격 증명은 이 push만 가능하도록 단기 발급한다. -
spawn-runnerhook은CLAUDE_RUNNER_ORDER_ID로 idempotent하고, 러너를 기다리지 않고 종료하며, 종료 코드 계약(0/1/2+)을 지킨다. - 모든 orchestrator replica가 p99 이상의 같은
--expected-spawn-seconds를 쓴다. - orchestrator를
--shutdown-drain-sec(기본 설정이면 70)으로 띄우고 stop timeout을 그보다 15초 이상 길게,SIGTERM은 한 번만 보낸다. - on-demand 러너에
--release-idle-session-min,--kill-session-after-min, (대기 러너)--exit-if-unused-min을 넘기고 종료 후 워크로드를 지운다. - Bedrock·Agent Platform을 쓴다면 환경 내 모든 러너를 같은 provider 변수로 구성하고, 좁은 범위의 자동 갱신 자격 증명과
--confine-repo-settings enforce를 적용했다. - MCP 서버는
--scope user로 이미지에 추가했고 세션에서 도구 목록으로 확인했다. - commit-nudge Stop hook(현재 버전,
GIT_OPTIONAL_LOCKS포함)과 git identity·push 자격 증명을 갖췄다. - 저장소 커밋
permissions.allow에 경로 없는Edit/Write/NotebookEdit가 없다. - settings fetch 실패 시 세션을 막으려면 이미지의
managed-settings.json에forceRemoteSettingsRefresh를 넣었다(Claude Tag 채널 환경 제외).
다음 단계
- Reference에서 모든 CLI 플래그, 환경 변수, 메트릭을 확인합니다.
- Verify session identity에서 러너 밖 서비스의 세션 토큰 검증을 봅니다.
- Test end to end에서 커스터마이즈한 러너 이미지를 CI로 검증합니다.
기준일
이 문서는 2026-11-03에 공식 Customize sessions in self-hosted environments 페이지를 기준으로 작성했습니다. 퍼블릭 베타 기능이라 hook 변수와 버전 요건이 자주 바뀌므로 공식 원문을 함께 확인하세요.