前回、Manusのアーカイブをvaultへ取り込む話を書きました。今回は毛色が違って、Playwrightで自動化用Chromeを掴もうとして3層で壊れ続けた話です。
困りごと:channel: 'chrome' が毎回もとに戻る
Playwrightで channel: 'chrome'(バンドル版Chromiumではなく手元の本物のChromeを使うオプション)を指定すると、解決先は node_modules/playwright-core/lib/coreBundle.js の中にパスとしてハードコードされています。これを自動化専用のChromeバンドルへ向けてパッチしても、npm install が走ればそのファイルは元のPlaywrightのものに戻ります。node_modules配下を直接書き換えている以上、これは仕様上避けられません。
なぜ自動化専用バンドルなんてものを作る必要があったのか、から話す必要があります。Playwrightに /Applications/Google Chrome.app を直接execさせると、LaunchServicesが com.google.Chrome を「起動中」と誤認し、Dockや open -a "Google Chrome" からの通常起動が error -600 (procNotFound) で失敗するようになります。手元のChromeと自動化用Chromeを別バンドルIDに分離するしかありません。
ここまでは「コピーしてbundle IDを変えれば終わり」に見えます。実際にはここから2層、勝手に壊れます。
層2:Info.plistを書き換えると署名が壊れる
Google Chrome.app をコピーして Chrome Automation.app を作り、CFBundleIdentifier を書き換えます。
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$DST/Contents/Info.plist" >/dev/null || exit 1
/usr/libexec/PlistBuddy -c "Set :CFBundleName Chrome Automation" "$DST/Contents/Info.plist" >/dev/null
# Info.plist を書き換えるとGoogleの署名が無効になり、Library Validation で
# ヘルパープロセスが即SIGKILLされる。バンドル全体をad-hoc再署名して揃える。
codesign --force --deep --sign - "$DST" >/dev/null 2>&1 || { log "ERROR: 署名失敗"; exit 1; }
Info.plist はGoogleの署名対象に含まれているので、1バイトでも書き換えれば署名は無効になります。無効な署名のまま起動すると、macOSのLibrary Validationがヘルパープロセス(GPUプロセスやレンダラ)を問答無用でSIGKILLします。本体だけ立ち上がってタブが一切描画されない、という壊れ方をするので原因が見えにくいところでした。対処は「バンドル全体をad-hoc署名(--sign -)で揃え直す」だけです。Googleの署名は失われますが、少なくとも内部の署名検証が一致する状態には戻せます。
層3:ad-hoc署名はビルドごとにcdhashが変わる
これで動くようになりますが、ad-hoc署名にはGoogleの正規署名にない弱点があります。Chromeが更新されてバンドルを作り直すたびに、cdhash(コード署名のハッシュ)が変わります。macOSのKeychainは「このアプリに常に許可」をcdhash単位で覚えるので、再ビルドした瞬間にその許可は無効化され、Keychainパスワード要求ダイアログが復活します。
ここで「じゃあKeychainのACLも自動で書き換えればいい」とはしませんでした。理由はスクリプト内のコメントに書いています。
# --- Keychainゲート(2026-09-02) ---
# 自動化バンドルはad-hoc署名(cdhash)なので、Keychainの「Chrome Safe Storage」ACLに
# 「常に許可」しても再ビルド(Chrome更新)のたびに無効化され、パスワード要求ダイアログが再発する。
# 直接execする headless 起動は必ず --use-mock-keychain --password-store=basic を付ける。
# 付いていない起動サイトを検知して警告する(自動修正はしない。IG等の永続プロファイルは鍵が変わるとセッションが飛ぶ)。
Keychainの暗号鍵を自動で差し替えると、その鍵で暗号化されたセッション(Instagram等の永続プロファイルのログイン状態)が丸ごと飛びます。壊れたKeychain ACLを直すコストより、ログイン済みセッションを失うコストの方が高いので、**「自動修正」ではなく「検知して警告するだけ」**に倒しました。実際のkeychain_gateはこう動きます。
keychain_gate() {
local hits
hits=$(grep -rIl --include='*.sh' --include='*.py' --include='*.js' --include='*.mjs' \
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=profiles --exclude-dir=.profiles \
--exclude-dir=logs --exclude-dir=state --exclude-dir=.build --exclude-dir=venv --exclude-dir=tests \
-E 'Chrome Automation\.app/Contents/MacOS|Google Chrome\.app/Contents/MacOS' \
"${SCAN_ROOTS[@]}" "${HOME}/.claude/scripts" 2>/dev/null \
| xargs -I{} grep -l -e '--headless' {} 2>/dev/null \
| xargs -I{} grep -L -e 'use-mock-keychain' {} 2>/dev/null \
| grep -v 'scent-media/scripts/ensure_chrome.sh' || true)
if [[ -n "$hits" ]]; then
log "WARNING: --use-mock-keychain 無しでChromeをheadless起動しているファイルがある(Keychainダイアログ再発の元):"
echo "$hits" | sed 's/^/ /'
else
log "keychain_gate: OK(mock無しの直接起動なし)"
fi
}
「headlessでChromeを直接execしているのに --use-mock-keychain が付いていないファイル」をgrepで洗い出して警告するだけです。直しません。直す先を間違えると、直したこと自体がインシデントになるからです。
自動修復スクリプトを書くとき、「検知して直す」と「検知だけして人間に投げる」は同じ重みの選択肢です。今回は後者を選びました。理由は壊れたときの被害が非対称だから——ad-hoc署名を直さないコストは「毎回パスワードダイアログが出る」程度ですが、Keychainの鍵を自動で書き換えるコストは「永続プロファイルのログインが全部飛ぶ」です。壊れているものを直すより、壊れていることに気づけるだけの方が安全な場合があります。
実装:node_modules全体を舐めずにglobで直接叩く
肝心のパッチ本体です。node_modules配下をfindで全探索すると数分かかるので、実在するレイアウトのパターンだけをglobで直接叩きます。
list_core_bundles() {
local root sub
shopt -s nullglob
for root in "${SCAN_ROOTS[@]}"; do
for sub in \
"$root"/*/node_modules/playwright-core/lib/coreBundle.js \
"$root"/*/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js \
"$root"/*/*/node_modules/playwright-core/lib/coreBundle.js \
"$root"/*/*/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js
do
[ -f "$sub" ] && echo "$sub"
done
done
shopt -u nullglob
}
見つけた coreBundle.js それぞれについて、元のChromeパスを自動化バンドルのパスへsedで置換します。
patched=0; already=0
while IFS= read -r f; do
if /usr/bin/grep -q "$SRC/Contents/MacOS/Google Chrome" "$f" 2>/dev/null; then
/usr/bin/sed -i '' "s|$SRC/Contents/MacOS/Google Chrome|$AUTO_BIN|g" "$f" && patched=$((patched+1))
elif /usr/bin/grep -q "$AUTO_BIN" "$f" 2>/dev/null; then
already=$((already+1))
fi
done < <(list_core_bundles)
log "playwright patched=$patched already=$already rebuilt=$rebuilt"
# 1件も見つからない=探索パスが壊れている。黙って成功扱いにしない。
if [ $((patched + already)) -eq 0 ]; then
log "ERROR: playwright-core が1件も見つからない。SCAN_ROOTS を確認せよ。"
exit 1
fi
最後の「0件チェック」が地味に重要です。globパターンを1本間違えるだけで対象が全部見つからなくなり、それでもスクリプトはexit 0で終わってしまいます。パッチが0件=成功、ではなく異常としてexit 1で落とすようにしています。
もう一点、リビルド自体もガードしています。稼働中の自動化Chromeをrm -rfで消してしまうと、その最中に走っているジョブが壊れます。
if [ ! -d "$DST" ] || [ "$src_ver" != "$dst_ver" ]; then
if pgrep -f "Chrome Automation.app/Contents/MacOS" >/dev/null 2>&1; then
log "SKIP rebuild: 自動化Chromeが稼働中 (src=$src_ver dst=${dst_ver:-none})"
else
log "rebuild: src=$src_ver dst=${dst_ver:-none}"
rm -rf "$DST" || { log "ERROR: 旧バンドル削除に失敗"; exit 1; }
cp -R "$SRC" "$DST" || { log "ERROR: コピー失敗"; exit 1; }
...
Chromeがバージョンアップされてバンドルが古くなっていても、動いているプロセスがあればリビルドをスキップしてそのランを守ります。次の6時間おきの実行で改めて拾えばいいので、無理に今すぐ直しにいく必要はありません。
スケジュール:launchdでStartInterval=21600(6時間)
このスクリプトを回すlaunchdの設定はこうなっています。
<key>Label</key><string>com.shun.chrome-automation-repair</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/.claude/scripts/chrome-automation-repair.sh</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>~</string>
<key>LANG</key><string>en_US.UTF-8</string>
<key>PATH</key><string>~/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StartInterval</key><integer>21600</integer>
<key>RunAtLoad</key><true/>
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>
<key>ProcessType</key><string>Background</string>
StartInterval=21600(6時間)を選んだのは、npm installのタイミングを予測できないからです。日中にリポジトリをいじっている時間帯だけを狙う必要はなく、「壊れてから次の巡回までの最大放置時間」を許容できる範囲に抑えるのが目的です。6時間なら、その日のうちに自動化ジョブが一度も動かずに終わることはまずありません。RunAtLoadも立てているので、Macが再起動した直後の初回実行でも即座に1回走ります。LowPriorityIOとNice 10は、バックグラウンドの定期実行が他の作業を邪魔しないための配慮で、優先度を明示的に下げています。
この1本のバンドルに何本の自動化ジョブが乗っているか
なぜここまで丁寧に自己修復させているかというと、このChrome Automation.appは開発用の使い捨てブラウザではなく、複数の自動化レーンが同時に叩きにいく共有リソースだからです。同時実行の調停は別スクリプトbrowser-slot.shが担っていて、使い方はこうです。
usage: browser-slot.sh <slot-label> [--group NAME --group-max M] [--profile CHROME_USER_DATA_DIR] -- <command> [args...]
グローバルの同時実行上限はSLOT_MAX="${BROWSER_SLOT_MAX:-5}"。ジョブごとに--groupで属するレーンを分け、投稿系やアウトリーチ系には予約枠を切って、いいね・フォローのような取り返しがつく作業と、投稿のような一度枠を落とすと戻らない作業を別扱いにしています。つまりこの1本のChromeバンドルが壊れると、そこに乗っているすべてのレーンが同時に沈む構造です。6時間おきの自己修復は、この共有リソースを"個人の手動メンテ"に依存させないための保険です。
踏んだ落とし穴
-
Playwrightの
channel:'chrome'解決先はnode_modules直下にハードコード →npm installのたびに元へ戻るので、パッチは1回きりでなく定期実行が前提 - Info.plistを書き換えるとGoogleの署名が丸ごと無効化→Library Validationでヘルパープロセスが即SIGKILL。バンドル全体をad-hoc再署名して揃えるまでが1セット
-
ad-hoc署名はビルドごとにcdhashが変わり、Keychainの「常に許可」は次のリビルドで無効化される→鍵を自動で書き換える方向には倒さず、
--use-mock-keychain漏れを検知して警告するだけに留めた -
findでnode_modules全体を探索すると数分かかる→実在するレイアウトのパターンだけをglobで直接叩く -
パッチ0件でも黙って
exit 0すると壊れた探索パスに気づけない→0件は異常としてexit 1 -
稼働中の自動化Chromeを
rm -rfで潰すと走っているジョブが壊れる→リビルド前にpgrepで稼働確認し、動いていればスキップ
まとめ
- Playwrightの
channel:'chrome'は3層で勝手に壊れる:node_modulesのパッチはnpm installで戻る/Info.plist書き換えは署名を壊す/ad-hoc署名はKeychain許可を毎回無効化する - 署名破壊はad-hoc再署名で揃え直す、Keychain破壊は自動修正せず検知だけにする——直す/直さないは被害の非対称性で決める
- 探索は
findではなく実在レイアウトのglob直叩き。0件は成功ではなく異常として落とす -
launchdのStartInterval=21600(6時間)+RunAtLoadで、npm installのタイミングを予測せずに放置時間の上限だけを縛る - この自己修復は個人の作業効率化ではなく、
browser-slot.shで調停されている複数の自動化レーンの共有リソースを守るための保険
環境は、こちらが何もしなくても勝手に壊れます。次はこの自己修復系のスクリプトが増えすぎて、どれが何を直しているのか自分でも把握できなくなった話を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!