その週は、朝いちばんに届く無人タスクの失敗通知が3回続きました。原因はどれも同じで、自分用のプラグインカタログを置いている private リポジトリのトークンが失効していたことでした。
個人開発者として、私はアプリ側とサイト側で共通のスキルとプラグインを使っており、それらを private リポジトリにまとめて、複数のマシンとプロジェクトから取得しています。トークンは設定ファイルに直接書いていました。ローテーションのたびに置き場所を思い出し、書き換え、書き換え漏れに気づくのはいつも無人タスクが落ちた翌朝、という運用です。
Claude Code v2.1.238(8月20日)でプラグインマーケットプレイスに headersHelper が入り、この形をやめられるようになりました。カタログを取りに行くたびに HTTP ヘッダを生成するコマンドを走らせられるので、短命トークンを都度発行して渡せます。
移行そのものは30分ほどで終わるはずでした。実際には、ユーザースコープに置いたヘルパーは問題なく動くのに、プロジェクト配下に置いた途端に認証が通らなくなり、そこで半日溶かしています。原因が分かってしまえば設計意図として筋が通っているのですが、事前には予想していませんでした。
設定ファイルにトークンを書いていた頃、何が面倒だったのか
private リポジトリのカタログを使う構成では、カタログの JSON を取得するときと、そこから参照されるアーカイブを取得するときの両方に認証が要ります。従来はここに固定トークンを置いていました。
固定トークンの面倒さは、失効そのものよりも「失効に気づく経路が事故だけ」という点にあります。有効期限の長いトークンは、切れるまで何の兆候も出しません。切れた瞬間に、複数のマシンで、複数のプロジェクトが、同時に取得へ失敗します。
さらに、リポジトリに置く設定ファイルにトークンを書くと、そのファイル自体が持ち出しの対象になります。個人開発者ひとりで回している規模でも、.mcp.json のようなプロジェクト直下のファイルを git 管理から外し忘れる事故は起こり得ます。
headersHelper は、この2つをまとめて構造で解こうとしています。
headersHelper が実際に置き換えるのはどの部分か
headersHelper は、URL マーケットプレイスの定義とカタログ項目の両方に置けます。指定したコマンドが標準出力に JSON でヘッダを返すと、それがリクエストに乗ります。
| 置き場所 | 実行されるタイミング | 主な用途 |
| URL マーケットプレイス | カタログ取得と、同一オリジンのアーカイブ取得のたび | カタログ全体を守る |
| カタログ項目 | そのプラグインを install / update するときだけ | 項目ごとに配布元を分ける |
ここで大事なのは、カタログ項目に置いたヘルパーは黙って走らないということです。claude plugin install や claude plugin update はコマンドの内容を表示したうえで [y/N] を尋ねます。-y を付ければ省略できますが、省略するかどうかを人間が一度決める設計になっています。
無人実行を組んでいる場合、この一点は最初に確認しておいたほうがよいところです。対話なしで走らせる経路では -y を明示しない限り、確認待ちで止まります。私は最初これを見落とし、タスクがタイムアウトするまで気づきませんでした。
半日溶かした原因は「認証情報を継承しない」でした
ユーザースコープに置いたヘルパーは動くのに、プロジェクト配下に置くと落ちる。最初は信頼ダイアログの問題だと思い込んで、そちらばかり見ていました。
実際の原因は別のところにありました。プロジェクト・プラグイン・エージェントファイル由来の headersHelper は、継承した認証情報の環境変数を持たない状態で実行されます。ユーザースコープや managed スコープのヘルパーが Claude の config ディレクトリから実行されるのとは、前提が違います。
つまり「リポジトリに置かれた設定ファイルが、手元の鍵を持ったまま外部へ出ていく」経路をあらかじめ塞いでいるわけです。設計としては納得できます。ただ、環境変数からトークンを読む素朴なヘルパーを書いていると、この違いはユーザースコープで試している限り絶対に表面化しません。
手元で挙動を確かめるために、環境変数を落とした状態で同じスクリプトを走らせてみました。
# 環境変数からトークンを読む素朴なヘルパー
cat << 'EOF' > headers-env.sh
#!/usr/bin/env bash
set -euo pipefail
: "${CATALOG_TOKEN:?CATALOG_TOKEN is not set}"
printf '{"Authorization":"Bearer %s"}\n' "$CATALOG_TOKEN"
EOF
chmod +x headers-env.sh
# (1) 手元の環境で実行 — 通る
CATALOG_TOKEN=demo123 ./headers-env.sh
# => {"Authorization":"Bearer demo123"}
# (2) 継承環境を落として実行 — プロジェクト由来ヘルパーの近似
env -i HOME="$HOME" PATH="$PATH" ./headers-env.sh; echo "exit=$?"
# => headers-env.sh: line 3: CATALOG_TOKEN: CATALOG_TOKEN is not set
# => exit=1
env -i で継承環境を落とすと、同じスクリプトが exit=1 で落ちます。私の手元では実際にこの通りになりました。プロジェクト配下に置いたヘルパーの失敗と症状が一致したところで、ようやく原因にたどり着いています。
配布前にこの1行を通しておけば、半日は使わずに済みました。ヘルパーを書いたら、まず継承環境なしで走ることを確かめる。これが今回いちばん高くついた学びです。
資格情報の取り出しをヘルパーの外へ出す
対処は単純で、環境変数に依存するのをやめ、ヘルパー自身が資格情報ストアから取り出す形にします。
cat << 'EOF' > headers-store.sh
#!/usr/bin/env bash
set -euo pipefail
# 環境変数ではなく、ヘルパー自身がストアから取り出す
TOKEN=""
# macOS のキーチェーンがあればそちらを優先
if command -v security >/dev/null 2>&1; then
TOKEN="$(security find-generic-password -a "$USER" -s claude-catalog -w 2>/dev/null || true)"
fi
# 手元にキーチェーンがない環境(CI・コンテナ)向けのフォールバック
if [ -z "$TOKEN" ]; then
STORE="${HOME}/.config/claude-catalog/token"
TOKEN="$(cat "$STORE" 2>/dev/null || true)"
fi
if [ -z "$TOKEN" ]; then
echo "catalog token not found (keychain / ~/.config/claude-catalog/token)" >&2
exit 1
fi
printf '{"Authorization":"Bearer %s"}\n' "$TOKEN"
EOF
chmod +x headers-store.sh
# 継承環境なしでも通ることを確認する
env -i HOME="$HOME" PATH="$PATH" ./headers-store.sh; echo "exit=$?"
# => {"Authorization":"Bearer ..."}
# => exit=0
フォールバックのファイルには chmod 600 を掛けておきます。標準エラーへ出すメッセージに、探した場所を書いておくのも地味に効きます。失敗したときにヘルパーが黙って空を返すと、呼び出し側からは「認証に失敗した」としか見えず、原因の切り分けに時間がかかるためです。
なお、ここで command -v security を使って分岐しているのは、同じヘルパーを macOS の手元とコンテナの両方で使い回したかったからです。分岐せずにキーチェーン専用で書くと、コンテナ側で毎回書き換える羽目になります。
短命トークンを都度発行する形にする
固定トークンをストアに置き換えただけでは、失効の痛みは減っても寿命の問題は残ります。カタログの取得ごとにヘルパーが走るのですから、そこで短命の資格情報を発行してしまうほうが素直です。
手元のリポジトリ配布用に組んだのは、鍵で署名した5分間有効の JWT を毎回作る形です。
cat << 'EOF' > headers-jwt.sh
#!/usr/bin/env bash
set -euo pipefail
KEY="${CATALOG_SIGNING_KEY:-$HOME/.config/claude-catalog/key.pem}"
[ -f "$KEY" ] || { echo "signing key not found: $KEY" >&2; exit 1; }
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
NOW=$(date +%s)
HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64)
PAYLOAD=$(printf '{"iss":"catalog-client","exp":%d}' $((NOW + 300)) | b64)
SIG=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -sign "$KEY" | b64)
printf '{"Authorization":"Bearer %s.%s.%s"}\n' "$HEADER" "$PAYLOAD" "$SIG"
EOF
chmod +x headers-jwt.sh
exp を300秒にしているのは、カタログ取得とアーカイブ取得が連続で走ることを見込んでいるためです。ここを短くしすぎると、取得の途中で切れます。
署名鍵のパスを CATALOG_SIGNING_KEY で上書きできるようにしていますが、既定値をホーム配下の固定パスにしている点が肝心です。前節の理由から、環境変数が渡ってこない前提で既定値だけで動く必要があります。
実行コストを測ってから、どちらを使うか決める
ヘルパーは取得のたびに走ります。プラグインが増えるほど回数も増えるので、実行コストを一度測っておくと判断が楽になります。手元の Linux コンテナ(openssl 3 系)で、それぞれ20回ずつ実行した結果です。
| ヘルパー | 20回合計 | 1回あたり | 備考 |
| ストアから静的トークンを読む | 102 ms | 約 5 ms | プロセス起動とファイル読み出しのみ |
| RS256 で JWT を都度署名 | 428 ms | 約 21 ms | 2048bit 鍵・openssl dgst |
差は1回あたり16ミリ秒ほどです。プラグインを数十個抱えていても、体感に出る規模ではありませんでした。私が事前に警戒していたのは署名コストのほうでしたが、実際に効いてきたのは別の要素です。
外部サービスへ問い合わせてトークンを発行する形にすると、ここにネットワーク往復が丸ごと乗ります。取得のたびに走る場所なので、往復が入ると桁が変わります。手元で署名して済ませられるなら、そちらを選んでおくほうが安全だと感じました。
判断の目安は次のように置いています。
- 配布先が自分と手元のマシンだけなら、ストアからの静的読み出しで十分です
- 配布先が増える、あるいは取り消しを効かせたいなら、手元署名の短命トークンにします
- 外部の発行サービスを噛ませるのは、取り消しの即時性がどうしても要る場合に限ります
どのスコープのヘルパーが実際に応答したのかを知りたい場面もあります。ヘルパーの標準出力は取得処理がそのまま解釈するので、デバッグ用の出力を混ぜると壊れます。書き足すなら標準エラーの側です。
# 標準出力は JSON のみ。手がかりは標準エラーへ回す
echo "[headers-store] scope=$(basename "$PWD") store=keychain" >&2
$PWD はヘルパーが起動された場所を示すので、ユーザースコープから走ったのかプロジェクト配下から走ったのかの区別が付きます。私はこの1行を入れてから、スコープの取り違えで悩む時間がなくなりました。トークンそのものを標準エラーへ出さないことだけ注意してください。ログに残ります。
信頼ダイアログの承認が前提になった点
もう1つ、無人実行を組んでいる場合に効いてくる変更があります。プロジェクトの .mcp.json に置いた headersHelper と、プロジェクトや --add-dir のエージェントファイルに書いたインライン MCP サーバーは、そのフォルダの信頼ダイアログを承認済みであることが条件になりました。claude -p で走らせる場合も同じ扱いです。
私の手元では、CI 用に新しく切ったチェックアウト先で最初の1回だけ引っかかりました。人が一度承認すれば以降は通るので、恒久的な障害にはなりません。ただ、フォルダを毎回作り直す構成にしていると、毎回引っかかります。
対処としては、作業用のチェックアウト先を固定パスに寄せました。フォルダを使い捨てる設計と、フォルダ単位の信頼という前提は噛み合いません。ここは実行環境側を合わせにいくのが早いと判断しています。
| 置き場所 | 信頼ダイアログ | 認証情報の環境変数 |
| ユーザー / managed / claude.ai スコープ | 不要 | Claude config ディレクトリから実行 |
プロジェクトの .mcp.json | 承認済みであること | 継承しない |
| エージェントファイル内のインライン MCP | 承認済みであること | 継承しない |
この表を最初に持っていたら、私は半日を使わずに済みました。スコープごとに前提が違うという一点さえ押さえておけば、切り分けは数分で終わります。
次にやること
もし自分用のカタログを private リポジトリに置いていて、トークンを設定ファイルに直接書いているなら、まずヘルパーを1本だけ書いて env -i HOME="$HOME" PATH="$PATH" ./headers-store.sh を通してみてください。ここが通れば、スコープをどこに置いても動きます。通らなければ、環境変数に依存している箇所が必ずどこかに残っています。
移行そのものより、この確認を先に済ませておくことのほうが効きます。私自身、順番を逆にしたせいで半日を落としました。同じ穴を踏まずに済む方がいれば嬉しく思います。