在 container 裡跑 Claude Code:安裝、認證與 hooks 通知
把 Claude Code 裝進 container 當遠端 agent 工作機時,真正要解的兩件事是:認證怎麼活過 container 重建、以及任務結束怎麼主動通知。這篇聚焦 Claude Code 本身在這個情境下的安裝、認證模型與 hooks 配置——遠端 agent 工作機實作記錄(連線、session、隔離三層怎麼疊起來)有完整的端到端脈絡,這裡把其中 Claude Code 相關的機制單獨講清楚,重點是它的認證模型——env-var token 注入、與登入態解耦。
安裝:一個 npm 全域套件
Claude Code 是 npm 套件,需要 Node runtime。在 container 裡最省事的是用官方 node base image、直接全域安裝:
1FROM node:22-bookworm-slim
2RUN npm install -g @anthropic-ai/claude-code用 node base 而非在別的 base 上自己裝 Node,少一層版本漂移的風險。base image 的 tag 要釘住(見 Image Tag Pinning),讓 image 可重現。
認證:setup-token 是 env-var 注入模型
對無人值守的容器化 agent,claude setup-token 給的認證形態是「長效 token 的環境變數注入」、而不是「一次登入、狀態存在本地之後都在」。
setup-token 走一次互動登入(需要真 TTY、docker run -it),完成後印出一個 sk-ant-oat01- 開頭的長效 token(宣告有效約一年)。關鍵是:它不會把這顆 token 寫進 ~/.claude、只把它印出來、明示你設成環境變數 CLAUDE_CODE_OAUTH_TOKEN。所以持久化的責任在你——把 token 存成 host 側的機密、在 docker run 時注入:
1# 存成 host 的 gitignored 機密(不進 image 也不進 git)
2printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "$TOKEN" > ~/.env && chmod 600 ~/.env
3
4# 每次 run 注入
5docker run --rm --env-file ~/.env <image> claude -p "任務" --dangerously-skip-permissions這個「認證走環境變數注入的機密、不烤進 image 也不進 repo」正是 機密 runtime 注入 的實例。好處是認證跟 image、container 生命週期完全解耦:rebuild image 幾次都不影響認證、換憑證只改注入的檔。
認證綁 token 注入、不綁 session
env-var 模型的核心是:能不能認證,取決於這次 run 有沒有注入 token、跟 session 或登入態無關。這帶來兩個直接後果:
- 直接打
claude(沒注入 token)即使在一個還活著的多工器(tmux / zellij)session 裡,也會要求重新認證——因為它沒拿到憑證。 - 在一個
--rm的臨時 container 裡走一次互動登入,憑證寫進容器的~/.claude、容器一結束就蒸發(除非登入時掛了 volume 讓它落在持久儲存)。等於把憑證寫進一個即將被回收的容器,它跟著容器一起消失、留不到下一次。
可靠的做法是不依賴任何登入態、每次用同一條 docker run --env-file 指令把 token 注入。要驗證認證確實純綁 token:不掛任何 volume(排除一切存檔登入)、只注入 token 即認證成功;不注入則回 Not logged in——這證明認證來源純粹是注入的 token。
GitHub 認證:正交的第二顆機密、同一個注入模式
agent 要 clone / push 私有 repo,需要的是一顆 GitHub token,跟前面認證 Claude Code 本身的 CLAUDE_CODE_OAUTH_TOKEN 是兩件事:前者讓 git 對 GitHub 證明身分(能不能 clone / push 私有 repo),後者讓 agent 對 Anthropic 取得運作授權(能不能運作)。這兩顆機密職責正交、彼此無關,但持久化與注入走同一套機制——gitignored 檔案存機密、docker run 時用環境變數注入、不烤進 image 也不進 repo,正是 機密 runtime 注入 的另一個實例。
這顆 token 決定 container 對私有 repo 的存取範圍:有它才能 clone / push 私有 repo,缺了只能匿名讀 public repo。在無人值守(非互動)的 container 裡,私有 repo 的 clone / push 會直接失敗於 could not read Username for 'https://github.com'(互動終端下則是提示你輸入帳密、而非直接報這行錯)。作法是產一顆 fine-grained PAT(GitHub 較新的 token 格式,建立時逐 repo 勾選授權範圍與權限,把爆炸半徑收到最小)注入環境變數 GH_TOKEN,並讓 image 內的 git 用 gh 的 credential helper 現讀它:
1RUN git config --global credential."https://github.com".helper '!gh auth git-credential'git 走 HTTPS 時把 GH_TOKEN 當密碼、x-access-token 當使用者名帶進請求;token 從不寫進 .gitconfig 或 gh 的登入檔,每次現讀環境變數。這跟 setup-token 的模型一致——認證綁「這次 run 有沒有注入機密」、不綁存檔登入。gh CLI 本身也讀同一顆 GH_TOKEN,所以 gh pr create 這類指令不需 gh auth login 的互動登入(gh 同時認 GH_TOKEN 與 GITHUB_TOKEN、前者優先;若這個 container 也在 CI runner 裡跑、環境可能已自帶 GITHUB_TOKEN,注入的 GH_TOKEN 會蓋過它、不會兩顆打架)。
GH_TOKEN 跟 Claude Code 的 token 放同一個 .env、由同一條 docker run 一起注入,不需要分開的機制:
1# 兩顆機密進同一個 gitignored 機密檔(600),runtime 一起注入
2printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\nGH_TOKEN=%s\n' "$OAUTH" "$PAT" > ~/.env && chmod 600 ~/.env
3
4# clone 私有 repo:git 走 credential helper 現讀注入的 GH_TOKEN
5docker run --rm --env-file ~/.env -v "$PWD:/work" <image> \
6 git clone https://github.com/org/private-repo /work/repo這個範例用 git clone(讀)驗證注入通了,但只驗到讀路徑:fine-grained token 的 Contents 權限分 read / write 兩級,只給 Read 的 token clone 得動、卻會在 agent 真正 git push 時才卡。要 agent 自動 push,建 token 時 Contents 要給到 Read and write——別把「clone 成功」當成「push 也就緒」。
GitHub 認證看起來失敗時,錯誤訊息本身就是最快的診斷——兩種訊號指向不同病灶、修法也不同,關鍵是把「認證管線通不通」跟「這個 repo 有沒有授權」分開判讀:
could not read Username for 'https://github.com':git 手上根本沒有憑證,GH_TOKEN沒注入或為空——把有值的.env用--env-file帶進 runtime 就補齊了。403 Write access to repository not granted(或gh api repos/<owner>/<repo>回404 Not Found):token 已經被 git 帶到 GitHub、身分也驗過了——拿到 403 而不是要求輸入帳號,本身就反證 credential helper 這條路是通的,缺的是授權。這個 403 是 push(寫) 被拒的訊息;同一個授權缺口換成git clone(讀)不到的 repo,git 印的是remote: Repository not found、不是 403——同樣是 scope / 授權問題,換個操作換一張臉。最常見的成因是這顆 fine-grained token 的 Repository access 不含這個 repo,修法是編輯同一顆 token 的 Repository access 把該 repo 加進來(token 值不變、注入的.env不用改)。同一個 403 也可能來自其他授權面——repo 在範圍內但缺 Contents 的 write 權限、org 開了 SAML SSO 而 token 未 authorize、org 的 IP allowlist 擋掉——這些各自要對應的授權設定才修得好、不是加 repo 能解(token 過期或撤銷則是回401 Bad credentials、不是 403)。fine-grained token 對授權外的 repo 一律回 404 / 403、不會退回匿名讀,所以連公開 repo 都可能在注入 token 後反而被擋(這條診斷邏輯建立在 fine-grained token 上;沿用舊的 classic PAT 則 scope 較粗、公開 repo 永遠可讀、不會出現「授權外全擋」)——判讀時記得這是 scope / 授權問題、不是 helper 壞了。
安全邊界跟前一節那顆長效 token 相同:PAT 一樣在 container 的環境變數裡、程序讀得到自己的 /proc/self/environ,在 skip-permissions 疊開放 egress 下可被外洩(見下方 --dangerously-skip-permissions 段的三個邊界內風險)。所以用 fine-grained、最小 repo 範圍、短輪替,把 blast radius 壓到最小。
狀態的兩個位置:~/.claude 與 .claude.json
Claude Code 的狀態分兩處放,持久化邊界不同:
~/.claude/(目錄):放設定settings.json(含 hooks)等。掛成 named volume 就跨 container 重建持久化。$HOME/.claude.json(單一檔):放專案信任、onboarding 狀態這類頂層設定。它不在~/.claude/目錄裡,所以掛~/.claude的 volume 不會涵蓋它——重建後會出現「configuration file not found」的非致命警告。
判讀原則是分清缺的是「認證」還是「設定」:認證缺了(沒注入 token)agent 直接無法運作;.claude.json 缺了只是回到預設狀態、用 token + --dangerously-skip-permissions 的無人值守流程照跑。要保留專案級狀態(逐專案信任、MCP 設定)才需要額外把 .claude.json 也掛成持久檔。把 ~/.claude 掛成 volume 時、還要注意掛載點 owner(見 Docker named volume 掛載點 owner)——空 volume 預設 root-owned、非 root 使用者寫不進憑證與設定。
hooks:任務結束推通知
Claude Code 的 hooks 讓你在特定事件觸發外部指令。把工作流從「掛在終端上等」翻成「離開、跑完被叫回來」的關鍵是 Stop hook——它在每次回應結束時觸發,對應「一輪任務跑完」這個要通知的時機。設定寫在 ~/.claude/settings.json。這個檔在掛成 named volume 的 ~/.claude 裡、host 上沒有對應路徑,要寫它有兩條路:docker run 進容器用 cat > ~/.claude/settings.json 或容器內編輯器寫(改動落在 volume、跨重建保留),或啟動時另外 bind-mount 一份 host 上的 settings.json 蓋過去。內容如下:
1{
2 "hooks": {
3 "Stop": [
4 { "hooks": [
5 { "type": "command",
6 "command": "curl -s -H 'Title: 任務完成' -d 'agent 跑完了' \"https://ntfy.sh/$NTFY_TOPIC\"" }
7 ] }
8 ]
9 }
10}這個範例把兩類值分開處理:$NTFY_TOPIC 是機密(猜到就能發/收你的通知——topic 名稱本身就是密碼),走跟認證 token 同一套注入模式——填進 host 側的 .env、docker run 時注入環境變數;hook 命令由 Claude Code 經 shell 執行,依子程序繼承環境的標準行為拿到 container 的環境變數、$NTFY_TOPIC 由該 shell 展開,settings.json 因此不含機密、可以進版控。(這條依賴「hook 以繼承環境的 shell 執行」這個執行模型,實際設 hook 前先確認你的 topic 有正確展開。)Title: header(任務完成)反過來——它是會被公共 server 看到的顯示字、不是機密、直接寫死,重點是別把敏感內容放進去。
觸發事件的選擇有語意差別:Stop 是「這一輪跑完了」,另一個候選 Notification 是 agent 主動要求關注時觸發、語意是「需要你介入」。兩者可並存但對應不同時機。推播服務本身(ntfy topic 是機密、不進 git)見 ntfy 推播通知服務。
要留意 ntfy 這條通知鏈沒有投遞保證:公共 ntfy.sh 掛掉、手機離線或開勿擾時,推播會靜默漏掉、沒有 ack 或重送——而整套工作流的賣點正是「離開、跑完被叫回來」,漏一則就變成空等。在意就別只靠一則 fire-and-forget 推播:手機端保留主動查狀態的路徑(連進 session 看、或 poll ntfy 的訊息歷史)。公共 server 也會看到完成訊息的 metadata(超過「topic 被猜到」的曝露面),敏感內容別寫進推播標題。
hook 的第一個除錯檢查點是「hook 指令依賴的工具在 container 裡存不存在」:上面的 hook 用 curl,而多數 slim base image(node:slim 這類)不內建 curl——少了它、hook 的指令會找不到執行檔而靜默失效,表現為「手動 curl 通、hook 卻不發訊」。修法是把 curl 加進 image 的套件安裝。
–dangerously-skip-permissions 在 container 下的定位
無人值守跑 claude -p 時通常要加 --dangerously-skip-permissions,在容器化這個架構下這是正確選擇、不是偷懶:container 邊界本身就是權限邊界。agent 只碰得到掛進去的工作目錄(掛載清單即授權清單)、爆了困在 cgroup 的資源上限內、看不到未掛載的 host 路徑。既然容器已經把 agent 圈在一個受限的沙盒裡,容器內再逐次確認檔案權限是重複的一層。把信任邊界劃在容器邊界(mount 清單 + 資源上限),而不是容器內的每次操作確認,才對得上這個架構。
這個論證只對「host 檔案系統與資源的 blast radius」成立,邊界內還有三個風險要清醒面對:掛進去的 /work 是真實專案目錄、不是副本,agent 有無人監督的寫入權、可以改壞或重寫檔案(要保護就掛副本、或用 git worktree 隔離);container 的對外網路預設全開,skip-permissions 疊上開放 egress 再疊上 prompt injection,理論上能把資料送出去(要收緊就設 egress allowlist);CLAUDE_CODE_OAUTH_TOKEN 就在 container 的環境變數裡、跑在其中的程序讀得到自己的 /proc/self/environ,所以那顆長效 token 在「skip-permissions + 開放 egress」下是可被外洩的——這也是它該用短期輪替、不該長放的理由。容器邊界擋得住對 host 的破壞,擋不住這三者,值得在放手無人值守前各自評估。這三項是本文示範設定本身會暴露的風險面、不是 container 風險的全集:若另外掛了 docker socket、或跑在共享的網路 namespace,還有本文未涵蓋的橫向移動風險要另外評估。
下一步路由
- 完整的端到端脈絡(連線 / session / 隔離三層怎麼疊起來):遠端 agent 工作機實作記錄
- 機器該放家用還是 VPS、隔離層的信任邊界判讀:遠端 agent 工作機選型
- 推播通知服務的架構與自架取捨:ntfy 推播通知服務
- 相關術語卡:機密 runtime 注入、git credential helper、Docker named volume 掛載點 owner