跳到正文
FunCoding

搜索

搜索文档、智能体、博客、Skill 和 MCP

自托管环境:自定义会话

用包装脚本、生命周期 hooks(checkout、post-session、command)、按需 runner(spawn-runner hook)、MCP 服务器、内置会话工具开关、Stop hook 和权限配置定制自托管会话。

自托管环境目前处于 Team 和 Enterprise 套餐的公开测试阶段,由 Owner 在 Cloud environments 管理页打开 Allow self-hosted environments。本页假定你已有一个能工作的 runner,搭建见快速开始,机队方案见「部署到生产」。

自托管环境在你自己的基础设施上运行 Claude Code 云端会话,由你部署的 runner 进程执行。不做任何配置时,runner 克隆会话的仓库、启动 Claude Code 并清理。本页面向运营 runner 的平台工程师,讲默认行为不合适时的扩展点,从按会话签发凭据到完全替换检出。包装脚本和 hooks 是在 runner 主机(Linux 或 macOS)上运行的可执行文件,示例假定 POSIX shell。本页少数 hook 环境变量仍用 pool,如 CLAUDE_RUNNER_POOL_ID;CLI 标志和环境变量名用 environment,如 --environment-secret-file。

包装脚本

当每个会话需要 runner 自己做不了的准备时,用包装脚本:签发限定到会话创建者的短期凭据、导出特定环境的密钥、准备语言工具链,或给子进程加资源限制。runner 为每个会话启动一次你的包装脚本,代替 Claude Code 二进制。包装脚本要以 exec 进入 $CLAUDE_RUNNER_CLAUDE_BIN(runner 自己的二进制)结束,这样信号和退出码才能正确传播。启动 runner 时用 --exec-path(或 SELF_HOSTED_RUNNER_EXEC_PATH)指向它:

claude self-hosted-runner --environment-secret-file /etc/claude/environment-secret --exec-path /etc/claude/session-wrapper.sh

runner 在包装脚本的环境里设置:

变量说明
CLAUDE_CODE_SESSION_ACCESS_TOKEN会话 JWT,前缀 sk-ant-cc-;其 act 声明标识会话创建者,创建界面记录了创建者邮箱和上游身份提供商 subject 时也包含它们。值是生成时的令牌,刷新经子进程的 stdin 到达,所以包装脚本只看到初始值
CCR_SESSION_ACCOUNT_EMAIL会话创建者的邮箱,由 runner 从令牌的 act.email 声明预先提取(未验证签名),适合打标签(如提交 trailer);邮箱用于把关凭据签发时,要验证令牌并从中读取声明;令牌没有创建者邮箱时不设;视为个人身份信息
CLAUDE_RUNNER_CLIENT_PLATFORM创建会话的客户端入口,如 web_claude_ai、desktop_app、ios、claude_code_cli 或 scheduled_trigger。Anthropic 在会话创建时记录一次该值,所以包装脚本和每个生命周期 hook 看到的相同。只用于采用情况分析和打标签,不要作为授权信号;会话没有已记录或可识别的入口时不设,所以在 set -u 下写成 ${CLAUDE_RUNNER_CLIENT_PLATFORM:-}(需要 v2.1.229 及以上)
CLAUDE_RUNNER_CLAUDE_BINrunner 自己的 Claude Code 二进制的绝对路径;用 exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" 结束包装脚本,把控制权交给固定版本的二进制而不必硬编码安装路径
CLAUDE_CODE_REMOTE_SESSION_IDcse_... 形式的会话 ID;与生命周期 hooks 看到的 session_... 形式的 CLAUDE_RUNNER_SESSION_ID 是同一个会话,把 cse_ 前缀换成 session_ 就得到会话 URL 里显示的 ID
CLAUDE_CODE_REMOTE_SESSION_UUID规范 UUID 形式的同一个会话 ID,给按 UUID 建键的系统用
CLAUDE_SESSION_INGRESS_TOKEN_FILE保存当前会话 JWT 的每会话文件的绝对路径,随令牌刷新保持最新。shell 子进程下载用户添加到会话的附件时从它读取 Authorization 头的值。exec 自动保留该变量;重建子进程环境的包装脚本必须把它带过去,否则附件下载会静默失效
CLAUDE_CONFIG_DIR每会话的 Claude 配置目录,在会话开始时由 runner 启动时捕获的主机配置快照写成;在这里的写入与该会话隔离。除非用 --remove-session-state 启动 runner,会话结束后该目录仍留在 <base-dir>/_sessions/ 下
ANTHROPIC_BASE_URL子进程将使用的 API 基础 URL,由控制平面按会话下发,通常是 https://api.anthropic.com。不要覆盖它:会话的推理凭据是 Anthropic 签发的 OAuth 令牌,其他提供商不接受,所以自托管环境里的推理无法路由到别处
CLAUDE_CODE_OAUTH_TOKEN子进程用于模型推理的短期 OAuth 访问令牌,范围只限模型推理和文件上传,有效期约 30 分钟;runner 在到期前重新签发,并经子进程的 stdin 下发轮换,所以不保持 stdin 连接的包装脚本只看到初始值。不要指望组织的 IP 允许列表来限制该令牌的使用:把它当作泄漏后约 30 分钟内仍可用的 bearer 凭据,不要记录日志、写到磁盘或转发到会话容器之外

包装脚本还继承子进程的其余托管环境(包括服务端提供的任何环境变量)。exec 自动传播这一切;如果包装脚本用别的方式派生子进程,要转发完整的环境。

保持 stdin 和文件描述符 3 连接

子进程的 stdin 是 runner 的控制通道,令牌轮换和会话结束信号都经它到达。runner 还在文件描述符 3 上打开一个管道,并从中读取子进程的活动信号来驱动空闲和启动超时。普通的 exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" 自动保持两者。

如果包装脚本用裸的 & 把子进程放到后台,会切断子进程的 stdin:会话看起来正常,直到初始 OAuth 令牌约 30 分钟的寿命到期,之后每个 API 调用都以 401 authentication_error 失败。包装脚本必须把子进程放到后台时(比如为了让清理 trap 保持存活),把 stdin 保存到文件描述符 4 或更高,再显式重新接上:

exec 4<&0
"$CLAUDE_RUNNER_CLAUDE_BIN" "$@" <&4 4<&- &
CHILD=$!
trap 'teardown' EXIT
wait "$CHILD"

不要在包装脚本里关闭或复用文件描述符 3;重定向子进程的 stdout 和 stderr 没问题。

按会话创建者签发凭据

用 decode-token 子命令读取会话 JWT 里的声明。它依次从参数、CLAUDE_CODE_SESSION_ACCESS_TOKEN 或 stdin 读取令牌(它检查什么,见「验证会话身份」)。下面的例子解码创建者身份,换成短期 AWS 凭据,再 exec 进入 Claude Code:

#!/bin/bash
# 以稳定的 Anthropic 用户 ID 为键,并要求创建者是真人。
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 -re 而不是 jq -r,这样声明缺失会以非零退出,而不是把字面字符串 null 传给下游。由组织服务身份创建的会话(如机器人和智能体会话)带的是 agent: subject 而不是 user:,所以这个例子会拒绝它们;如果你的环境服务这类会话,要明确决定包装脚本是退出还是回退到默认凭据。当你的凭据交换需要 SSO subject 或邮箱时,读 .act.attested_by.sub 或 .act.email 并处理它们缺失的情形:只有创建入口记录了它们时令牌才带,CLI 派发的会话可能两者都没有。完整的声明参考和从 runner 之外的服务验证,见「验证会话身份」。

生命周期 hooks

生命周期 hooks 用你自己的脚本替换 runner 每会话流水线的各个阶段。用 --hooks-dir <path>(或 SELF_HOSTED_RUNNER_HOOKS_DIR)让 runner 指向 hooks 目录,runner 查找带固定名字的可执行文件;不存在的 hook 退回内置行为,所以只需写你要的。hooks 以 runner 自己的权限运行,而会话子进程共用同一个 UID,所以要把 hooks 目录以只读挂载或烘进镜像,会话代码才无法修改它(见加固一节)。这些 hooks 不同于在会话内部运行的 Claude Code hooks:生命周期 hooks 运行在 runner 上,围绕会话。

checkout

对每个仓库运行一次,代替 runner 内置的克隆和获取。用该 hook 从只读穿透镜像克隆、从归档里播种工作树,或应用每会话的 git 认证。runner 设置:

变量说明
CLAUDE_RUNNER_REPO_URL要克隆的仓库 URL,已应用 --git-host-rewrite 和 --git-ssh-rewrite
CLAUDE_RUNNER_REPO_REF要检出的修订:会话请求的分支、标签或提交 SHA;为空表示仓库默认分支
CLAUDE_RUNNER_CHECKOUT_PATH工作树必须留下的绝对路径
CLAUDE_RUNNER_SESSION_IDsession_... 形式的会话 ID,用于日志和关联
CLAUDE_RUNNER_SESSION_UUID规范 UUID 形式的同一个会话 ID
CLAUDE_RUNNER_API_BASE_URL会话范围调用用的 Anthropic API 基础 URL
CLAUDE_RUNNER_CLIENT_PLATFORM创建会话的客户端入口,如 web_claude_ai、desktop_app 或 ios;没有已记录或可识别入口时不设
CLAUDE_CODE_SESSION_ACCESS_TOKEN会话访问令牌,用于会话范围的 API 调用

脚本必须在 CLAUDE_RUNNER_CHECKOUT_PATH 留下检出到所请求修订的工作树。分离 HEAD 没问题,runner 会在其上创建会话的工作分支。runner 随后会验证该路径含有 .git;如果你的 hook 物化的是非 git 源(如 Perforce 或解包的 tarball),在 runner 环境里设 CLAUDE_RUNNER_SKIP_GIT_VERIFY=1 跳过该检查。工作分支创建和推送结果这类基于 git 的流程需要 git 检出,所以从非 git 树导出结果要用 post-session hook。

runner 不给 hook 传 git 凭据。要根据会话身份铸造每会话的克隆凭据:用标准 JWT 库对 CLAUDE_RUNNER_API_BASE_URL 下的 JWKS 端点验证 CLAUDE_CODE_SESSION_ACCESS_TOKEN(如「从你的服务验证令牌」所述),再让你的凭据服务为令牌 act 声明里的身份签发短期克隆凭据。checkout hook 的环境里没有设 CLAUDE_RUNNER_CLAUDE_BIN,所以这里不能用 decode-token 子命令。也可以退回主机已有的任何 git 认证,如 SSH agent、凭据 helper 或 .netrc。

hook 以非零退出,或退出 0 却没留下可用检出时,runner 怎么做取决于仓库:

  • 会话向其推送结果的仓库:runner 让会话失败,非零退出时把脚本 stderr 的末尾展示给用户。
  • 会话只读取的仓库(如添加到运行中会话的仓库):runner 记一行 [runner:warn] 和失败详情,向会话发一个 Skipped 步骤,删除 hook 在检出路径留下的东西,并继续处理其余仓库;无法立即删除路径时,在会话结束时重试删除;如果跳过使会话完全没有仓库,runner 仍让会话失败。

v2.1.228 之前,runner 对任何仓库的 hook 失败都让会话失败,所以 hook 服务不了的只读仓库会在会话恢复到的每个新 runner 上再次让会话失败。会话结束后 runner 会删除检出路径。

post-session

每个会话运行一次,在 Claude Code 子进程退出之后、runner 拆除工作区之前。这个 hook 是保存未提交工作的唯一机会:--capacity 大于一时,runner 在 hook 返回后立即删除每会话 worktree;--capacity 1 时,复用的规范克隆在下一个会话开始时被硬重置,所以两种路径上未提交的已跟踪改动都不会保留。典型用途是推送未提交改动的快照分支、归档日志,或向你自己的系统发出会话结束事件。

该 hook 在每个生成了子进程的会话结束时触发,不论原因(见下面 CLAUDE_RUNNER_EXIT_REASON 的取值);runner 突然终止(如 VM 被抢占或断电)时它无法触发,需要对突然终止的保证时,用 Claude Code 的 PostToolUse hook 在会话内定期做快照。runner 设置:

变量说明
CLAUDE_RUNNER_SESSION_IDsession_... 形式的会话 ID
CLAUDE_RUNNER_SESSION_UUID规范 UUID 形式的同一个会话 ID
CLAUDE_RUNNER_EXIT_REASON会话如何结束,取值见下
CLAUDE_RUNNER_WORKSPACE_PATHS冒号分隔的会话工作树绝对路径;零仓库会话为空
CLAUDE_RUNNER_DEBUG_LOG_PATH会话调试日志的路径,hook 运行期间仍在磁盘上
CLAUDE_RUNNER_API_BASE_URL会话范围调用用的 Anthropic API 基础 URL
CLAUDE_RUNNER_CLIENT_PLATFORM创建会话的客户端入口;没有已记录或可识别入口时不设(需要 v2.1.229 及以上)
CLAUDE_CODE_SESSION_ACCESS_TOKEN会话访问令牌,用于会话范围的 API 调用

CLAUDE_RUNNER_EXIT_REASON 取四个值之一:completed——会话干净结束,Claude Code 进程正常退出,或会话在运行时被归档或删除;failed——Claude Code 进程崩溃,或启动后设置失败;interrupted——runner 停止了会话:为腾出槽位释放了它、会话启动超时、服务端把会话移出这个 runner、runner 正在排空,或会话超过了 --kill-session-after-min 限制;abandoned——为另一个 runner 认领的会话保留,目前在这种情况下 hook 不触发。会话生命周期计数器把释放、启动超时和服务端移走计为 completed 而不是 interrupted,因为 runner 是干净地交还了槽位;拿 hook 回执与计数器比较时要预期这种差异。

hook 的退出状态从不影响会话结果,失败只记录并忽略。runner 在每次会话结束(包括 runner 关闭)时最多等 --post-session-hook-timeout-sec,默认 60 秒。下面的例子把未提交的工作保存到一个救援分支:

#!/usr/bin/env bash
set -u
IFS=':'
# 固定会话可能已植入检出的 .git/config 里的配置:
# -c 覆盖优先于仓库本地设置,阻止会话写入的 fsmonitor、
# hook 路径和 gpg-program 配置以 hook 的权限执行代码。
# 仓库本地的 credential.helper、core.sshCommand 和 pushurl
# 仍然适用;如果 hook 持有会话没有的凭据,
# 也要固定推送 URL 和 helper(见脚本下面的说明)。
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

hook 用它在 runner 主机上自己环境里可用的任何 git 凭据推送。在「镜像里不放凭据」的姿态下(包括内置克隆经 Anthropic git 代理时),没有这样的凭据,所以要在推送前在 hook 里铸造一个短期推送凭据:用 hook 在 CLAUDE_CODE_SESSION_ACCESS_TOKEN 里收到的会话令牌向你自己的令牌服务交换,并按「验证会话身份」所述验证它。当 hook 持有会话没有的凭据时,还要固定它推送到哪里:把 origin 换成运营者提供的 URL,并传 -c credential.helper= 加上你自己的 helper,这样会话写入的仓库本地配置就无法重定向带凭据的推送。

runner 释放会话时 hook 的时序

被释放的会话可以在另一个 runner 上恢复。在 v2.1.236 及以上的 runner 上,会话在释放时在做什么决定了它能否在这个 hook 结束前恢复:一个轮次后空闲,或启动时超时——runner 停止子进程并把这个 hook 运行完,然后才释放会话,hook 运行期间用户发来的消息无法让会话在另一个 runner 上在 hook 结束前恢复;在等用户回答提示(如权限提示)——runner 先释放会话,再运行这个 hook,hook 运行期间发来的用户消息可以让会话在另一个 runner 上在 hook 结束前恢复。这适用于 runner 释放会话的任何时候:在空闲超时、--retire-at 时间,以及(v2.1.260 及以上的 runner 上)会话的 --kill-session-after-min 限制处。轮次已结束且只持有后台任务的会话在这里算空闲。v2.1.236 之前,两种情况下 runner 都是先释放会话再运行这个 hook。SIGTERM 排空期间,runner 持有会话租约直到 hook 结束,见「关闭时序」。

command

每个会话在检出之后运行一次,代替内置的子进程生成。hook 收到与包装脚本相同的环境,并应同样 exec 进入 "$CLAUDE_RUNNER_CLAUDE_BIN"。想把所有定制放在一个 hooks 目录里时用 command hook;包装脚本在别处时用 --exec-path。如果同时设了 --exec-path,标志优先,command hook 被忽略。始终 exec runner 自己的二进制,而不是经 PATH 解析的 claude,否则会破坏版本固定。

按需 runner

除了运行固定机队,也可以为每个会话启动一个 runner。编排器是单独的无状态子命令,它轮询 Anthropic 获取生成请求(每个排队且没有可用 runner 的会话一个),并为每个请求运行你的 spawn-runner hook。你的 hook 向你的平台提交一个工作负载:Kubernetes Job、EC2 实例、Nomad dispatch。

按需 runner 改善凭据卫生:在固定机队上,环境密钥存在于每个 runner 主机上,而这些主机也运行用户会话;用了编排器后,环境密钥只留在编排器主机上,它从不运行用户代码;每个生成的 runner 收到一个一次性使用的工单,只注册恰好一个 runner 然后过期。启动编排器时,传环境密钥和含可执行 spawn-runner 脚本的 hooks 目录:

claude self-hosted-runner orchestrator \
  --environment-secret-file /etc/claude/environment-secret \
  --hooks-dir /etc/claude/hooks

编排器在轮询之间不保存状态,所以可以对同一个环境运行两个或更多副本以保证可用性;每个生成请求由服务端恰好让一个副本认领。所有副本必须使用相同的 --expected-spawn-seconds 值。

spawn-runner hook

编排器对每个生成请求运行一次 ${hooks-dir}/spawn-runner。hook 必须异步提交工作,不要等 runner 启动,并在 --hook-timeout(默认 60 秒)内返回。hook 收到:

变量说明
CLAUDE_RUNNER_WORK_ORDER_FILE临时文件路径,含新 runner 用来注册的已签名工单 JWT;hook 退出后删除;不要记录文件内容
CLAUDE_RUNNER_ORDER_ID不透明的幂等键,每个生成请求唯一,可安全用于 Kubernetes 资源名;只用订单 ID 作为你的供应器的去重键
CLAUDE_RUNNER_SESSION_ID该请求所属的会话;会话的每次重新请求都会重复它,所以用于日志和路由,不要用作去重键。预热请求(设了 --min-idle 时,在任何具体会话之前启动待命 runner)为空,所以不要假定该变量已设置
CLAUDE_RUNNER_SESSION_UUID规范 UUID 形式的同一个会话 ID;预热请求为空
CLAUDE_RUNNER_ATTEMPT该会话已有多少次生成请求;预热请求为 0
CLAUDE_RUNNER_ORDER_SERVER_TIME来自轮询响应 HTTP Date 头的服务端时间;hook 验证工单 JWT 的 exp 时,与该值而不是本地时钟比较以容忍偏差;网关省略该头时为空
CLAUDE_RUNNER_POOL_ID新 runner 应加入的环境 ID,ccpool_... 形式
CLAUDE_RUNNER_ACCOUNT_ID把会话入队的账号的标记 ID,用于按账号路由、配额或回收费用;不可用时为空,Claude Tag 频道会话(没有账号入队)总为空
CLAUDE_RUNNER_ACCOUNT_EMAIL把会话入队的账号的邮箱;不可用时为空;视为个人身份信息,不要记录
CLAUDE_RUNNER_PRIMARY_REPO_URL会话第一个 git 源的 URL,用于路由到已预热该仓库的 runner;会话没有 git 源时为空
CLAUDE_RUNNER_PRIMARY_REPO_REVISION会话第一个 git 源的修订:分支、SHA 或标签;未指定时为空
CLAUDE_RUNNER_REPO_SOURCES会话所有 git 源的 {url, revision} JSON 数组,供按次要仓库路由的 hook 用;没有源时为空
CLAUDE_RUNNER_CORRELATION_ID会话创建时提供的关联 ID,原样回传,让 hook 能把这个工单映射到创建会话的请求;会话没有时为空
CLAUDE_RUNNER_CLIENT_PLATFORM创建会话的客户端入口,如 web_claude_ai、desktop_app、ios 或 scheduled_trigger,用于采用情况分析;没有已记录或可识别入口时以及预热请求时不设,用在 set -u 下仍安全的 [ -n "${CLAUDE_RUNNER_CLIENT_PLATFORM:-}" ] 检查

生成的 runner 用工单代替环境密钥注册:用工单启动它——把 --environment-secret-file 指向含工单 JWT 的文件,或把 SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET 设为 JWT 值;在 hook 退出前复制 JWT——编排器在 hook 退出后删除工单文件,所以要把 JWT 复制进你提交的工作负载(如生成的 Job 上的 Kubernetes Secret),而不是传文件路径;生成的 runner 用 --capacity 1——会话绑定的工单只注册恰好一个绑定到该会话的 runner,更高的容量只会增加永远收不到工作的槽位,runner 会在启动时记录警告;预热工单以未绑定方式注册——待命 runner 不绑定到会话,像固定机队的 runner 一样认领排队的工作。

契约有四条与供应器无关的规则:

  1. 对 CLAUDE_RUNNER_ORDER_ID 幂等。 同一请求的重新投递最多生成一个 runner。从订单 ID 推导确定性的资源名,让你的平台拒绝重复。不要改用 CLAUDE_RUNNER_SESSION_ID 做键:会话的每次重新请求带相同的会话 ID 和新的订单 ID,所以按会话 ID 命名或去重的工作负载只会创建一次,之后对该会话再也不会创建。
  2. 不要重试工作负载。 一个订单 ID 最多创建一个工作负载。如果 runner 一直没注册,Anthropic 会在 --expected-spawn-seconds 之后用新的订单 ID 重新请求。
  3. 遵守退出码契约。 退出 0 表示已提交;退出 1 表示可重试失败,会话退避后重新提供;退出 2 或更高表示不可重试,会话被阻止再次生成,直到 Owner 在环境的 Activity 标签页对它选择 Retry。非零退出时,hook stderr 的末尾会作为失败原因显示在那里,所以把可操作的错误写到 stderr,绝不要写密钥。对预热请求没有会话可以失败:编排器只在本地记录非零退出,服务端在租约之后重新请求生成。
  4. 把 --expected-spawn-seconds 设为至少你的 p99 启动时间。 这是服务端租约,所有编排器副本必须使用相同的值。

hook 写到 stdout 或 stderr 的一切都会出现在编排器日志里,凭据自动脱敏。会话一直排队时,先看编排器 /healthz 正文里的队列计数,再打开 Cloud environments 管理页上你环境的 Activity 标签页:展开失败的会话看它的生成错误,选择 Retry 重新请求。如果会话一直排队、Activity 标签页里又没有生成错误,可能是 hook 按会话 ID 建键了:确认你的平台是否有该会话第一个生成请求的工作负载而没有重新请求的;若是,就改按 CLAUDE_RUNNER_ORDER_ID 建键。

MCP 服务器

要让每个会话都有 MCP 服务器,在镜像构建时用与桌面安装相同的 claude mcp add 命令添加。如果 runner 是裸进程而不是容器,就以 runner 的用户在主机上运行同一命令,再重启 runner:它只在启动时读取一次主机配置。必须带 --scope user 标志:默认的 local 范围写到按目录的键下,runner 不会把它播种进会话。例如在 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

runner 在启动时对主机配置做一次快照。快照捕获主机 .claude.json(它在 ~/.claude/ 旁边而不是里面)的 mcpServers 键,runner 只把这个键播种进每个会话的隔离配置,账号状态和项目历史被丢弃。要确认服务器到达了会话,在该环境上启动一个会话并让 Claude 列出它的 MCP 工具;runner 还会为 type 它不认识的捕获条目在启动时记录警告并丢弃该条目,这样你能看到为什么该服务器没出现在会话里。设了 SELF_HOSTED_RUNNER_HOST_CONFIG_DIR 时,runner 改从该目录读取 .claude.json,所以把变量指向空目录也会禁用 MCP 播种。Claude Code 还从其他来源加载 MCP 服务器:

  • 企业范围的托管 MCP 文件,在其标准系统路径:Linux runner 主机上是 /etc/claude-code/managed-mcp.json,macOS 主机上是 /Library/Application Support/ClaudeCode/managed-mcp.json。用于只允许管理员列出的服务器加载的锁定机队(优先级规则见 managed-mcp.json 的独占控制)。该文件在 runner 主机上时,Claude Code 跳过 Anthropic 控制平面送到会话的 MCP 服务器(包括 claude.ai 连接器),并在会话子进程的 stderr 上的警告里点出它们,runner 在 debug 日志级别记录该警告;v2.1.229 之前,这类会话在启动时以 You cannot dynamically configure MCP servers when an enterprise MCP config is present 退出。
  • runner 主机上托管设置里的 managedMcpServers 键:提供 HTTP 和 SSE 服务器而不取得独占控制,所以来自其他来源的服务器仍会加载(需要 v2.1.259 及以上)。
  • <repo>/.mcp.json:项目范围。把文件提交进仓库,它的服务器在云端会话里自动批准。

为你的组织启用连接器交付时,Anthropic 控制平面会通过服务端提供的 MCP 配置,把你在 claude.ai 上配置的连接器交付给交互式创建的会话,经 api.anthropic.com 路由。以编程方式创建的会话(如 CLI 派发)不接收连接器交付,要通过本节列出的其他来源给它们 MCP 服务器。子进程的 OAuth 令牌没有直接获取连接器的范围,所以子进程自己不尝试获取,交付是服务端驱动的。settings.json 不承载 MCP 服务器定义,设置 schema 里也没有顶层 mcpServers 字段;在托管设置里要用 managedMcpServers 键提供服务器。会话继承 runner 的环境,所以在那里设 ENABLE_TOOL_SEARCH 就能控制该 runner 生成的每个会话的 MCP 工具搜索(取值见 MCP 页)。

关闭内置会话工具

Anthropic 控制平面给云端会话附加自己的 MCP 服务器,名为 Claude Code Remote。Claude 用该服务器的工具来安排例程、启动和引导其他云端会话、附加更多仓库以及跟踪拉取请求活动。要关闭整个服务器,在设置里加服务器级的拒绝规则。控制平面根据会话的创建方式用三个名字之一注册该服务器,Claude Code 在规则里按名字精确匹配(包括大小写),所以每个名字都要写一条:

{
  "permissions": {
    "deny": [
      "mcp__Claude_Code_Remote",
      "mcp__claude-code-remote",
      "mcp__bf7c680d-5fdc-5ef4-b4a0-abadb619bf0a"
    ]
  }
}

点名整个服务器的规则也覆盖服务器以后新增的工具。要只关一个工具而保留其余,在每条规则后追加两个下划线和工具名,如 mcp__Claude_Code_Remote__add_repo。要阻止服务器连接而不是移除它的工具,则改在 deniedMcpServers 下以 serverName 条目加入不带 mcp__ 前缀的三个名字。把规则放在服务器托管设置里可以无需改动 runner 就到达每个会话,也可以放在 runner 上的 ~/.claude/settings.json。要确认规则生效,在该环境上启动会话并让 Claude 列出它的 MCP 工具;Claude Code 会把被拒绝的工具从 Claude 的上下文里移除,所以被拒的工具不会出现在回答里。

提示会话推送它们的工作

Anthropic 托管的会话会运行一个 Stop hook(Claude 完成响应时运行的 Claude Code hook),提示 Claude 提交并推送它的工作;runner 不安装这个 hook。没有它,以未提交改动结束的会话会让这些工作只留在 runner 的磁盘上,claude.ai/code 里的 Create PR 按钮在分支出现在远端之前保持不可用。

下面的参考实现分两部分:把设置块合并进 runner 主机上的 ~/.claude/settings.json(runner 会把它播种进每个会话),并把脚本保存为 runner 主机上的 ~/.claude/hooks/stop-hook-nudge.sh 并设为可执行:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "timeout": 10,
            "command": "\"$CLAUDE_CONFIG_DIR/hooks/stop-hook-nudge.sh\""
          }
        ]
      }
    ]
  }
}
#!/bin/sh
# 自托管 runner 的 Stop hook 参考实现。
#
# 如果项目目录有未提交改动或未推送的提交,每个轮次提醒 Claude 一次,
# 这样空闲会话被释放时工作不会丢失,claude.ai/code 上的 "Create PR"
# 按钮也能亮起来。
#
# runner 级别(不改仓库):把这个文件放到 runner 主机的 ~/.claude/hooks/,
# 并把随附的 Stop-hook 设置块合并进 ~/.claude/settings.json,
# runner 会把两者都播种进每个会话。
# 仓库级替代方案:提交到 <repo>/.claude/hooks/,并把 settings.json 里的
# 命令路径改为 $CLAUDE_PROJECT_DIR/.claude/hooks/。
#
# stdin:hook 的 JSON 载荷
# stdout:{"decision":"block","reason":"..."} 表示提醒,什么都不输出表示允许停止。
# 重入保护:harness 在 block 之后重新调用 Stop hook 时会设
# stop_hook_active=true。此时直接退出,使每个轮次只提醒一次。
# harness 输出紧凑 JSON(冒号后无空格),下面的匹配依赖这一点;
# 需要容忍空白的检查时请用 jq。
in=$(cat)
case "$in" in *'"stop_hook_active":true'*) exit 0 ;; esac
d="$CLAUDE_PROJECT_DIR"
# 不是 git 仓库 → 无需提醒。
git -C "$d" rev-parse --git-dir >/dev/null 2>&1 || exit 0
# 没有远端 → "推送到远端"无法满足;退出。
[ -z "$(git -C "$d" remote 2>/dev/null)" ] && exit 0
# 未提交的改动(已暂存、未暂存或未跟踪)。完全排除 .claude/——
# 运营者播种的设置和 CLI 写入的运行时状态(调度器锁、worktree、例程状态)
# 都在那里,二者都不是模型需要推送的"未提交工作"。
s=$(git -C "$d" status --porcelain -- . ':(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
# 未推送的提交。统计 HEAD 上不能从任何远程跟踪引用或 FETCH_HEAD
# 到达的提交。这对下列情形一致有效:
#   - init+fetch 检出(runner 默认:只有 FETCH_HEAD)
#   - 基于克隆的检出(存在 origin/*)
#   - runner 默认:子进程在检出之后由 runner 创建的会话结果分支上启动
#   - 自定义设置跳过分支创建时的分离 HEAD
# 完全没有参照点(从未 fetch)时保持沉默,而不是对只读轮次误报。
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 要么为空要么是 "FETCH_HEAD",有意按词拆分
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 受攻击者影响——git-check-ref-format(1) 允许引用名里有 `"`。
    # `\` 被禁止(规则 10)但仍转义,作为廉价的纵深防御。
    # 在插入手工构造的载荷之前转义 JSON 元字符,使类似
    # x","continue":false 的分支名无法向 harness 解析的 hook 输出 JSON
    # 注入键。$unpushed 是安全的——上面的 -gt 守卫拒绝任何非纯整数。
    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 提交并推送,目录不是 git 仓库或没有远端时保持沉默。

权限与工具批准

自托管会话没有连接终端,所以未回答的权限提示会让轮次停住,直到用户在界面里回应。Anthropic 控制平面随工作负载发送每个会话的工具列表和权限规则;默认配置预批准常规工具调用(包括 Bash),云端会话不论模式都预批准文件编辑;没有被预批准的调用通过会话界面提示。

注意:只在会话容器运行默认拒绝网络出口并落实了加固一节其余内容的环境上才固定 auto 模式。常规工具调用(包括 Bash 的网络请求)在默认预批准工具集和 auto 模式下都无人介入地运行,所以网络边界才是限制这些调用能到哪里的东西。

要不论控制平面发来什么都把提示降到最少,从包装脚本或 command hook 里固定 auto 模式。auto 模式让会话运行时没有常规权限提示:一个独立的分类器模型在动作运行前审查并阻止它拒绝的,显式的 ask 规则仍会强制提示(分类器检查什么见权限模式页)。runner 在调用包装脚本之前追加服务端计算的标志,对 --permission-mode 这类单值标志,解析器采用最后一次出现,所以你在 "$@" 之后追加的标志会覆盖服务端发来的值:

#!/bin/bash
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" --permission-mode auto

要改为预批准特定工具,追加带你的规则的 --allowed-tools,例如 --allowed-tools "Bash(bazel *) Bash(yarn *) mcp__internal__*"。--allowed-tools 和 --disallowed-tools 这类列表标志在多次出现之间累积而不是覆盖,所以你的规则叠加在控制平面发来的任何规则之上。要收窄,追加 --disallowed-tools,即使别的规则允许也会拒绝这些工具。

每个会话的配置如何组装

runner 给每个会话自己的配置目录,由 runner 在启动时一次性捕获的主机 ~/.claude/ 快照播种:runner 镜像里的 settings.json、CLAUDE.md、hooks、agents、commands 和 skills 都作为用户级基线适用于每个会话。在运行中的主机上改配置,只有重启 runner 后才生效。设 SELF_HOSTED_RUNNER_HOST_CONFIG_DIR 可以从别的路径播种,或指向空目录禁用播种。

仓库提交的 .claude/settings.json 作为项目设置叠加在上面。会话还会从 runner 镜像里的标准系统路径读取 managed-settings.json;其键是否与服务器托管设置并存,取决于 Claude Code 如何合并托管来源:默认情况下,当你的组织送达任何服务器托管键时,会话会忽略 runner 镜像里的文件,只保留 Claude Code 从每个管理员来源读取的键,如 env 块、沙盒锁、沙盒二进制路径和 forceRemoteSettingsRefresh(见设置优先级)。

Anthropic 控制平面给会话提供 Claude Code hooks 时,runner 把它们安装在你自己配置的旁边而不是覆盖它(需要 v2.1.229 及以上):落在哪里——runner 把每个提供的 hook 脚本写到会话配置目录里保留的 hooks/.ccr-launcher/ 子目录,并在它用 --settings 传给会话的单独设置文件里注册这些脚本,不动已播种的 settings.json 和你放在 hooks/<name> 的脚本;runner 为每个会话重新创建该保留子目录,不会把主机 ~/.claude/hooks/.ccr-launcher/ 下的内容播种进会话;谁来编写——控制平面从它自己部署里的固定常量填充脚本,从不来自每会话或第三方输入;仍由什么管辖——经 --settings 送达的 hooks 进入普通的合并后 hook 配置,不在托管层,所以你的托管设置仍然适用:disableAllHooks 会禁用它们,它们也不属于 allowManagedHooksOnly 保持加载的类别。

仓库提交的权限规则

不要在仓库提交的 permissions.allow 里放裸的 "Edit"、"Write" 或 "NotebookEdit" 条目。裸的文件工具规则不论路径都匹配该工具,授予的是主机上任何地方的写权限而不是仅工作区,所以 runner 的写范围限制护栏会标记该会话;用 --confine-repo-settings enforce 时它会拒绝生成该会话而不是记录后继续(见加固一节)。仓库根本不需要文件工具规则:云端会话不论模式都预批准文件编辑。如果你确实要提交规则,限定到工作区,如 "Edit(/**)";单个前导斜杠相对于项目根,也就是会话的工作区。运营者主机级的 settings.json 里可以放裸的文件工具规则,因为该文件不是仓库提交的。defaultMode 为 auto 只在镜像范围或用户级设置文件里才被采纳,所以被检出的仓库无法授予自己 auto 模式。云端会话接受哪些模式和完整规则语法,见权限模式。

下一步

  • 参考:每个 CLI 标志、环境变量和指标
  • 验证会话身份:从 runner 之外的服务验证会话令牌