🔴 🔴 自動化 Chrome が本体を殺していた——バンドル ID 分離と6時間ごと自己修復の構成 — リーダー×
🔴

🔴 自動化 Chrome が本体を殺していた——バンドル ID 分離と6時間ごと自己修復の構成

#automation#claudecode#副業2026-09-17 · 約32分

月商0円から半年で月商120万まで届いた自律環境は、2026年9月1日 13:31 JST、open -a "Google Chrome" がエラーコード -600 を返して沈黙したことで、根本から設計し直すことになりました。

なぜこの仕組みが効くのか

個人開発の自動化が一定の規模を超えると、必ずぶつかる問題があります。「ジョブは動いているのに、いつからか自分の作業環境が壊れている」という状態です。

2026年9月1日の朝、Lily の Mac では headless の Chrome が3本、headful が1本、合計4プロセスが同時に走っていました。SNS の自動いいね、サムネイル生成、アカウント別のプロファイル管理、それぞれが独立したジョブとして Playwright を呼んでいました。その全員が /Applications/Google Chrome.app/Contents/MacOS/Google Chrome を直接 exec していた、それだけのことです。

macOS の LaunchServices は、同一の CFBundleIdentifier を持つアプリを「同じアプリの1インスタンス」として扱います。 Google Chrome の bundle ID は com.google.Chrome。自動化ジョブが先にこの bundle ID を持つプロセスを起動していると、Dock やスポットライトから open -a "Google Chrome" を打っても OS は「そのアプリはすでに起動中だから、既存の前面ウィンドウを出せばいい」と判断します。しかし自動化プロセスはウィンドウを持たない場合がほとんどです。人間が見る Chrome ウィンドウは返ってこない。これが -600 (procNotFound) の正体です。

この問題の厄介な点は、発生するまでまったく気づけないことです。自動化ジョブは正常に動いています。exit code は 0 です。ログもクリーンです。ただ、人間が Chrome を使おうとしたときだけ、何も起きません。


一般に、この問題は「自動化ジョブが増えるほど確率が上がる」という性質を持っています。ジョブが1本なら Chrome を掴んでいる時間は短い。3本、5本、10本と増えるにつれ、Chrome が常時いずれかのジョブに押さえられている状態が常態化します。月商を上げようとして自動化を積み増すほど、本体の Chrome を使えなくなる時間が伸びる。因果が逆転しています。

解決の方向は2つ考えられました。一つは自動化ジョブが Chrome を使い終わったら必ず kill する、もう一つは自動化専用のブラウザを人間用の Chrome と名前の段階から分離する。前者はジョブの終了タイミングを全件管理しなければならず、新しいジョブを追加するたびに漏れが生まれます。後者は一度作ってしまえば OS レベルで干渉が消える。恒久策は後者しかありません。

/Applications/Chrome Automation.app という名前の別バンドルを作り、bundle ID を com.google.ChromeAutomation に変更する。OS から見れば完全に別のアプリです。LaunchServices は com.google.Chrome と com.google.ChromeAutomation を別々に管理します。自動化が何本 Chrome Automation を立ち上げようと、人間の open -a "Google Chrome" は別の bundle ID を探すので影響を受けません。

この仕組みが効く理由を一行で言えば、「リソースの競合は、名前を分けた時点で消える」です。同じ名前を共有している間は、どちらかを殺せばもう一方も死ぬ構造になります。名前を分ければ、OS が代わりに隔離してくれます。


ただし、ここには一つ厄介な実装上の壁があります。

Playwright の channel: 'chrome' オプションは、起動するブラウザのパスを内部ファイル playwright-core/lib/coreBundle.js に書いています。このファイルは node_modules の中にあります。npm install を実行するたびに上書きされて /Applications/Google Chrome.app に戻ります。

つまり、パスを書き換えても定期的に元に戻る。この「戻る」という事実を前提に設計することが、安定運用の鍵になります。「戻ることを見越して、定期的に貼り直す」。それが6時間ごとの自己修復スクリプトの存在意義です。

全体の流れ

システム全体の構成を図で示します。

  ┌──────────────────────────────────────────────────────────┐
  │  npm install(いつ走るかわからない)                          │
  │     ↓                                                     │
  │  playwright-core/lib/coreBundle.js                        │
  │     "...Google Chrome.app/Contents/MacOS/Google Chrome"  │ ← 元に戻る
  └──────────────────────────────────────────────────────────┘
                    ↑最大6時間以内に検知
  ┌──────────────────────────────────────────────────────────┐
  │  launchd  com.shun.chrome-automation-repair              │
  │  StartInterval: 21600(6時間ごと)RunAtLoad: true          │
  │     ↓                                                     │
  │  ~/.claude/scripts/chrome-automation-repair.sh           │
  │                                                           │
  │  1. keychain_gate() ─ --use-mock-keychain 未設定を警告      │
  │                                                           │
  │  2. バージョン比較                                           │
  │     src_ver(Google Chrome.app)                           │
  │     dst_ver(Chrome Automation.app)                      │
  │        一致 → スキップ                                      │
  │        不一致 or 不在 → rebuild                            │
  │            cp -R /Applications/Google Chrome.app         │
  │                   /Applications/Chrome Automation.app    │
  │            PlistBuddy: CFBundleIdentifier                │
  │                        → com.google.ChromeAutomation     │
  │            PlistBuddy: CFBundleName                      │
  │                        → Chrome Automation               │
  │            codesign --force --deep --sign -              │ ← ad-hoc 再署名
  │                                                           │
  │  3. playwright-core を glob で検索(find より速い)           │
  │     ~/dev/*/node_modules/playwright-core/lib/coreBundle.js│
  │     ~/content/*/node_modules/playwright-core/...         │
  │     ※ネスト4階層まで対応                                     │
  │        sed: Google Chrome.app のパス → AUTO_BIN に置換      │
  │        patched=N  already=M                               │
  │        patched+already==0 → exit 1(無音成功を防ぐ)          │
  │                                                           │
  │  ログ: ~/.claude/logs/chrome-automation-repair.log        │
  └──────────────────────────────────────────────────────────┘
                    ↓ パッチ済み
  ┌──────────────────────────────────────────────────────────┐
  │  playwright channel:'chrome' の解決先                      │
  │  /Applications/Chrome Automation.app/                    │
  │              Contents/MacOS/Google Chrome                │ ← 自動化バンドル
  └──────────────────────────────────────────────────────────┘
         ↕ 完全に独立
  ┌──────────────────────────────────────────────────────────┐
  │  open -a "Google Chrome"                                 │
  │  com.google.Chrome ← 人間の Chrome(干渉なし)               │
  └──────────────────────────────────────────────────────────┘

スクリプトの核となる部分を実コードで確認しましょう。

バンドル作成とバージョン管理

SRC="/Applications/Google Chrome.app"
DST="/Applications/Chrome Automation.app"
BUNDLE_ID="com.google.ChromeAutomation"
AUTO_BIN="$DST/Contents/MacOS/Google Chrome"

ver() { /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" \
        "$1/Contents/Info.plist" 2>/dev/null; }

src_ver="$(ver "$SRC")"
dst_ver="$(ver "$DST")"

if [ ! -d "$DST" ] || [ "$src_ver" != "$dst_ver" ]; then
  cp -R "$SRC" "$DST"
  /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" \
      "$DST/Contents/Info.plist"
  /usr/libexec/PlistBuddy -c "Set :CFBundleName Chrome Automation" \
      "$DST/Contents/Info.plist"
  codesign --force --deep --sign - "$DST"
fi

ver() 関数は PlistBuddy で CFBundleShortVersionString(例:127.0.6533.120)を読み出します。Google Chrome が自動アップデートすると src_ver と dst_ver がズレるため、次の6時間サイクルで自動的に Chrome Automation.app を作り直します。

codesign --force --deep --sign - の -(マイナス)はad-hoc 署名を意味します。Info.plist の bundle ID を書き換えた時点で Google が付けた署名は無効になります。再署名なしで起動しようとするとヘルパープロセスが SIGKILL(exit 137)で即死します。ad-hoc 署名は信頼チェーンを持たない自己署名ですが、ローカル実行のみなら問題ありません。

playwright-core の glob 検索とパッチ

SCAN_ROOTS=("${HOME}/dev" "${HOME}/content")

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
}

~/dev と ~/content 以下のリポジトリを最大4階層の glob パターンで舐めます。find . -name "coreBundle.js" で全ツリーを走査すると4分かかるところを、実際に存在するレイアウト(*/node_modules/playwright-core/... か */node_modules/playwright/node_modules/playwright-core/... の2パターン)を直接 glob で叩くことで実行時間を 0.95秒 に抑えています。

パッチ適用部分は sed -i '' によるインプレース置換です:

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)

if [ $((patched + already)) -eq 0 ]; then
  log "ERROR: playwright-core が1件も見つからない。SCAN_ROOTS を確認せよ。"
  exit 1
fi

patched は今回書き換えた件数、already はすでにパッチ済みの件数です。両方が0の場合、探索パスが壊れていて何も見つかっていないことを意味します。「0件だけど成功扱い」を防ぐために exit 1 で明示的に失敗させます。このfail-loud 設計は後になって「なぜ直っていないのに6時間気づかなかったのか」という疑問を防ぎます。

launchd による定期実行

<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>
<key>StandardOutPath</key>
  <string>~/.claude/logs/chrome-automation-repair.log</string>

StartInterval: 21600 は 21600秒 = 6時間です。RunAtLoad: true なので launchctl load した瞬間にも一度走ります。Nice: 10 と LowPriorityIO: true で CPU・ディスクの優先度を下げ、バックグラウンドで人間の作業を妨げない設定にしています。

6時間という間隔の意味は、「npm install が走ってから最大6時間以内に自動修復する」という SLA です。実際に自動化ジョブが npm install を叩く頻度は低いため、ほとんどの場合は次のサイクルが来る前にパッチが戻ることはありません。万が一戻っても6時間後には直っている。この「直る窓の長さ」を既知のリスクとして受け入れた設計です。

Keychain ゲートの検知

スクリプトの冒頭に keychain_gate() という検知処理があります:

keychain_gate() {
  local hits
  hits=$(grep -rIl --include='*.sh' --include='*.py' --include='*.js' \
      --exclude-dir=node_modules ... \
      -E 'Chrome Automation\.app/Contents/MacOS|Google Chrome\.app/Contents/MacOS' \
      "${SCAN_ROOTS[@]}" ~/.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起動している..."
    echo "$hits" | sed 's/^/  /'
  fi
}

Chrome Automation.app は ad-hoc 署名なので、macOS Keychain の「Chrome Safe Storage」ACL から「常に許可」を付けても、Chrome アップデートで再ビルドするたびに code hash が変わって許可が無効になります。headless 起動時に --use-mock-keychain --password-store=basic を付けていないスクリプトは、Keychain へのアクセスダイアログが毎回ポップアップする原因になります。これが自動化の「無音失敗」に繋がるため、修正はしないが検知して警告するゲートを入れています。

今回の対応で修正したリポジトリは Playwright の channel:'chrome' 解決先が 16箇所、shell と Python の直叩きが 10ファイル、コミットしたリポジトリは 8本になりました。規模が大きくなるほど「1箇所直した」では終わらない。全リポジトリを横断して修正を波及させ、かつその修正が npm install で消えても自動で復活する仕組みが必要だったのが、この構成を作った理由です。

実装の詳細

pgrep ガード——稼働中は再ビルドしない

前半で示したバンドル作成ロジックには、もう一段の安全弁が入っています。バージョン不一致を検知したとき、Chrome Automation.app が動いていたら再ビルドをスキップする処理です。

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; }
  ...
fi

cp -R は15〜30秒かかります。コピー中に Chrome Automation.app の Contents/MacOS/Google Chrome が読まれると、バイナリが中途半端な状態で実行され、ジョブ全体が壊れます。pgrep -f "Chrome Automation.app/Contents/MacOS" で起動中のプロセスを探し、居たら「次の6時間サイクルまで待つ」設計にしています。

再ビルドをスキップしても patched/already カウントは後続で実行されます。バンドルの実体は古いままでも、coreBundle.js のパスが正しければジョブは動き続けます。

launchd のシェルは人間の PATH を持たない

plist には EnvironmentVariables ブロックが入っています。

<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>

launchd が起動するシェルは、/etc/launchd.conf から環境を引き継ぐだけで、~/.zshrc も ~/.zprofile も読みません。PATH を明示しなければ /usr/bin:/bin しか通っておらず、/opt/homebrew/bin にある codesign や PlistBuddy が見つからない、という状態になります。HOME を明示するのは ${HOME}/dev という展開がシェル変数ではなく EnvironmentVariables 経由で解決されるようにするためです。LANG は grep が日本語パスを含むファイル名を壊さないための保険です。

ログの出力先が StandardOutPath と StandardErrorPath に分かれているのも同じ理由です。launchd は標準出力と標準エラーを別ファイルに書き出し、どちらも ~/.claude/logs/ に落とします。片方だけ見ていると「何も起きていない」ように見えることがあります。

keychain_gate のパイプライン読み解き

keychain_gate() の実装は、grep を3段チェーンする珍しい構造をしています。

grep -rIl ... \
  -E 'Chrome Automation\.app/Contents/MacOS|Google Chrome\.app/Contents/MacOS' \
  "${SCAN_ROOTS[@]}" "${HOME}/.claude/scripts" \
| xargs -I{} grep -l -e '--headless' {} \
| xargs -I{} grep -L -e 'use-mock-keychain' {}

1段目は「Chrome のバイナリパスを直接書いているファイル」を探します。node_modules・.git・profiles・logs・venv・tests は探索から除外されています。
2段目は --headless を含むファイルだけを残します。GUI 起動しているスクリプトは Keychain ダイアログが出ても問題ありません。
3段目は use-mock-keychain がないファイルだけを残します(-L は「マッチしないファイル一覧」フラグ)。

この3段を通ったファイルは「headless で Chrome を使っているのに mock-keychain を付けていない」という状態のファイルです。そのたびに Keychain の「許可しますか?」ダイアログがポップアップします。自動化中にダイアログが出ると、次の click まで全プロセスが止まります。scent-media/scripts/ensure_chrome.sh だけ grep -v で除外しているのは、この1件が別の理由で敢えて本物の Keychain を使う設計になっているためです。

スクリプトはこの検知で自動修正はしません。Instagram 等の永続プロファイルはセッションクッキーを Keychain の鍵で暗号化しており、鍵が変わるとログインが吹き飛ぶからです。警告をログに流して人間に判断を委ねる、それが設計の選択です。

最終確認コマンド

構成を載せたら、ここまで実際に動くか確認します。

# 1. launchd の登録確認
launchctl list | grep chrome-automation-repair

# 2. バンドルが生きているか
open -a "Chrome Automation" --args --version

# 3. playwright が正しいパスを見ているか
grep -r "Chrome Automation" ~/dev/*/node_modules/playwright-core/lib/coreBundle.js 2>/dev/null | head -5

# 4. 人間の Chrome は独立して起動できるか
open -a "Google Chrome"

2番の --args --version は Chrome に --version フラグを渡して起動直後のバージョン文字列(Google Chrome 127.0.6533.120 など)を出力させます。ウィンドウが開かずに文字列だけ返ってきたら、バンドルが正常に起動できる状態にあると判断できます。4番は -600 を返さずに人間の Chrome が開けば、束縛の隔離が効いていることの最終確認になります。


私が詰まった話

find が4分かかっていた

最初に書いたスクリプトは find で探索していました。

find "${HOME}/dev" "${HOME}/content" \
  -name "coreBundle.js" \
  -path "*/playwright-core/lib/*"

実行してみると、完了まで 4分15秒かかりました。~/dev 配下には node_modules が数百個あり、find はそのすべてのディレクトリを舐めます。6時間ごとに4分かかる処理はバックグラウンドとはいえ CPU とディスクを無駄に消費しますし、何より「実際に走らせたら遅い」という事実に最初の実装まで気づいていませんでした。

coreBundle.js が存在するレイアウトは実際には2パターンだけです。直接インストールした playwright-core の場合は */node_modules/playwright-core/lib/coreBundle.js、playwright 経由でネストしている場合は */node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js。この2パターンをリポジトリ深さ2段まで glob で直叩きにしたところ、実行時間は 0.95秒になりました。

glob が速い理由は、ファイルシステムの走査を OS のディレクトリエントリ参照に落とせるからです。find が全ノードをたどるのに対し、glob は「このパターンに一致するエントリが存在するか」だけを確認します。探索範囲が既知の構造に収まっているなら、glob は find の代替として明確に優れています。

ad-hoc 署名を忘れて exit 137

最初の実装では cp -R と PlistBuddy で書き換えるところまで作り、codesign を入れていませんでした。テストで Chrome Automation.app を起動しようとすると、ヘルパープロセスが exit 137 で即死しました。

exit 137 は SIGKILL(128 + 9)です。プロセスが自分から終了するのではなく、OS が強制的に殺しています。macOS の Library Validation が、「バイナリは存在するが署名が Info.plist の bundle ID と一致しない」と判断したとき、ヘルパーを SIGKILL します。Google が付けた署名は com.google.Chrome に対して発行されたもので、Info.plist の CFBundleIdentifier を com.google.ChromeAutomation に変えた時点で無効になります。

codesign --force --deep --sign - を追加して再実行すると起動しました。--force は既存の署名を上書き、--deep はメインバイナリだけでなくフレームワーク・ヘルパー・プラグインすべてを再署名、-(マイナス)は Apple の証明書を持たない ad-hoc 署名を指定します。Gatekeeper はこの署名を「信頼」しませんが、ローカル実行であれば起動できます。初回だけ「開発元を確認できない」という確認ダイアログが出ますが、システム設定の「プライバシーとセキュリティ」から「このまま開く」を一度押せば以降は不要です。

探索0件でも exit 0 で成功扱いになっていた

初期実装で SCAN_ROOTS を間違って設定したことがあります。~/dev ではなく ~/Development と書いてしまい、ディレクトリが存在しないため list_core_bundles が1件も返さない状態になりました。

このとき、スクリプトは最後まで正常終了して exit 0 を返しました。patched=0・already=0 ですが、どちらの条件にも引っかからず、ログには playwright patched=0 already=0 rebuilt=0 と出るだけです。launchd はジョブの exit code しか見ていないので、何も修正されていないのに6時間ごとに「成功しました」と記録され続けました。

npm install でパスが戻った後、修復されないまま最大6時間放置されうる、という最悪ケースがここで発生します。patched + already == 0 の場合に exit 1 で明示的に失敗させるコードは、この失敗から生まれています。

if [ $((patched + already)) -eq 0 ]; then
  log "ERROR: playwright-core が1件も見つからない。SCAN_ROOTS を確認せよ。"
  exit 1
fi

launchd は exit code が 0 以外のとき StandardErrorPath に記録し、次のサイクルでも再実行します。失敗は騒々しくする。これが正しい設計です。

ネストが深いリポを1件取りこぼしていた

最初の glob パターンは1階層しかカバーしていませんでした。

"$root"/*/node_modules/playwright-core/lib/coreBundle.js

この1段では ~/dev/social-autolike/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js が引っかかりません。playwright が playwright-core を内包する形でインストールされているケースです。social-autolike は playwright を直接インストールしていたため、このネストが発生していました。

結果として、他の15箇所は Chrome Automation.app に向いているのに、social-autolike だけが Google Chrome.app を直叩きし続けていました。修正後に dokuji-rosen-sns でも同じ問題が起きたとき(後述)、真っ先に「ネストのパターンが増えていないか」を確認する習慣がついたのはこの失敗のおかげです。

preflight が人間の Chrome ごと pkill -9 していた

metrics-hub には preflight_chrome.sh というスクリプトがあり、ジョブ開始前に古い Chrome プロセスを掃除する役割を持っていました。このスクリプトの実装が次のようになっていました。

pkill -9 "Google Chrome"

pkill はプロセス名に部分一致します。Google Chrome という文字列は com.google.Chrome も com.google.ChromeAutomation も両方のプロセス名に含まれます。自動化のジョブが preflight を叩くたびに、人間が開いていた Chrome ウィンドウが全部 SIGKILL で殺されていました。

さらに悪いことに、これは修正のきっかけをつかみにくい壊れ方をします。Chrome Automation.app を使っているジョブは正常に動きます。しかし人間の Chrome が突然落ちる、という現象だけが残ります。ジョブのログには何も残りません。「Chrome が落ちるな」という認識が蓄積されて初めて、preflight が犯人だと気づきます。

修正は、プロセス名の代わりにバイナリパスで絞り込む形に変えることです。

pkill -9 -f "Chrome Automation.app/Contents/MacOS"

-f はプロセスの起動コマンドライン全体に対して一致を取ります。Chrome Automation.app/Contents/MacOS というパスを含むプロセスだけを対象にすることで、人間の Chrome には触れない実装になります。

4本目のリポが1ヶ月遅れで同じ穴を踏んだ

9月1日の修正で、16箇所の coreBundle.js と10ファイルの直叩きを変更し、8リポジトリをコミットしました。その時点で「全件対応した」と思っていました。

9月11日、dokuji-rosen-sns の tools/lib/browser.mjs が channel:'chrome' のままになっていたことが発覚します。social-autolike が8月12日に chromium 同梱版へ移行した後も、dokuji-rosen-sns は channel:'chrome' のまま残っていました。他のジョブと Chrome を取り合う朝8時〜10時40分だけ、起動が180秒を超えてタイムアウトする現象が5日間続いていました。5日で39回のハング、8:00枠の125いいねが毎日消えていたにもかかわらず、アラートは「次のサイクルで自動復帰」の1行で読み流されていました。

この教訓は wiki に残した一般形と重なります。「共通関数の修正は、その関数を使っている全ての呼び出し元へ伝播しないと意味がない」。スクリプトを直しても、同じパターンで書かれた別リポが残っている間は修正が完了したとは言えません。

chrome-automation-repair.sh の SCAN_ROOTS に ~/dev と ~/content の両方を入れてあるのも、片方だけにしか存在しないリポを見落とさないためです。リポを新しく作るたびに「このリポは channel:'chrome' を使っているか」を確認するのは人間の作業です。ただ、スクリプトが6時間ごとに走り patched が1以上になれば、少なくとも「取りこぼしに気づかないまま1ヶ月が経つ」事態は防げます。今のところ、スクリプトが patched=0 already=N を返し続けているうちは全件が正しく向いていると判断しています。

つまずきポイント

実際に踏んだ地雷を網羅します。「設定してみたら動かない」の大半はここのどれかです。

  • Info.plist を書き換えたあと codesign を忘れる。 CFBundleIdentifier を com.google.ChromeAutomation に変えた時点で Google が付けた署名は無効になります。再署名なしで起動すると Library Validation に弾かれ、ヘルパープロセスが exit 137SIGKILL)で即死します。codesign --force --deep --sign - ぎ --deep は必須です。--force だけではメインバイナリしか更新されず、フレームワーク・ヘルパー・プラグインが古い署名のまま残ります。

  • find で node_modules を全走査する。 find ~/dev ~/content -name "coreBundle.js" は完了まで4分15秒かかりました。~/dev 以下の node_modules が数百個あるためです。coreBundle.js が存在するレイアウトは「*/node_modules/playwright-core/lib/」と「*/node_modules/playwright/node_modules/playwright-core/lib/」の2パターンだけです。これを glob で直叩きにすると実行時間が 0.95秒になります。既知の構造を全走査する必要はありません。

  • ネストパターンを1種類しか書かない。 playwright を直接インストールしたプロジェクトは */node_modules/playwright-core/lib/coreBundle.js。playwright 経由で playwright-core が入っているプロジェクトは */node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js のネスト形です。片方しか glob に書かないと、もう一方のリポジトリを毎回取りこぼします。social-autolike でこれを踏み、16箇所直したつもりが1件だけ旧パスを向いたままになっていました。

  • 探索0件でも exit 0 で成功扱いになる。 SCAN_ROOTS に存在しないパスを書くと list_core_bundles が1件も返さず、patched=0 already=0 のまま正常終了します。launchd は exit 0 を「成功」として記録し、6時間ごとに「何もしない処理」が静かに走り続けます。patched + already == 0 のとき exit 1 で明示的に失敗させてはじめて、ログに ERROR が残り次サイクルでも再実行されます。

  • pkill -9 "Google Chrome" が人間の Chrome ごと殺す。 プロセス名の部分一致は com.google.Chrome も com.google.ChromeAutomation も両方にヒットします。metrics-hub ぎ preflight_chrome.sh がまさにこれで、自動化バンドルを掃除しようとするたびに人間の開いているタブを全部 SIGKILL していました。pkill -f "Chrome Automation.app/Contents/MacOS" と起動コマンドライン全体で絞ることで、人間の Chrome には触れない実装になります。

  • launchd のシェルは ~/.zshrc を読まない。 launchd が起動するシェルの PATH は /usr/bin:/bin だけです。/opt/homebrew/bin ぎ codesign も PlistBuddy も見えません。plist ぎ EnvironmentVariables き PATHポHOMEポLANG を明示しないと、スクリプトが「コマンドが見つからない」で黙って落ちます。LANG を en_US.UTF-8 にしないと grep が日本語を含むファイル名を壊す場合もあります。ログに何も出ないまま exit 127 で終わるため、発見が遅れます。

  • 再ビルド中に Chrome Automation.app のバイナリを読まれる。 cp -R /Applications/Google\ Chrome.app /Applications/Chrome\ Automation.app は実環境で15〜30秒かかります。その間に自動化ジョブが Chrome Automation.app/Contents/MacOS/Google Chrome を exec しようとすると、コピー途中のバイナリを掴んでクラッシュします。pgrep -f "Chrome Automation.app/Contents/MacOS" で稼働確認し、居たら次の6時間サイクルに延期します。

  • Keychain ACL は Chrome アップデートで無効になる。 Chrome Automation.app は ad-hoc 署名なので code hash が再ビルドのたびに変わります。macOS Keychain の「Chrome Safe Storage」に「常に許可」を付けても、Chrome が自動アップデートして再ビルドが走ると ACL が無効になり、headless 起動のたびに「パスワードを許可しますか?」ダイアログが出ます。headless 起動には全件 --use-mock-keychain --password-store=basic を付けるのが正解です。ただし Instagram 等の永続プロファイルはセッションクッキーを Keychain の鍵で暗号化しているため、mock に切り替えるとログインが吹き飛びます。keychain_gate() が検知して警告するだけで自動修正しない設計は、この理由によります。

  • 修正を1リポジトリだけに留めると、隣が1ヶ月後に同じ穴を踏む。 channel:'chrome' を直接指定しているスクリプトは ~/dev と ~/content の全リポジトリに散在します。1件直したところで、別リポジトリの browser.mjs が古いパスを向いたまま残り続けます。実際に dokuji-rosen-sns がこれを踏み、他ジョブと Chrome を取り合う朝8〜10時40分だけ180秒超ハングする現象が5日続きました。5日で39回・8:00枠の125いいねが毎日消えていたのに、アラートが「次 tick で自動復帰」の1行で読み流されていたのも記録しておきます。修正は SCAN_ROOTS の全リポジトリを横断確認するまでが1セットです。

  • StandardOutPath と StandardErrorPath を別々に見ないと「何も起きていない」に見える。 launchd は stdout と stderr を別ファイルに書き出します。エラーが .err.log だけに出ていても .log しか確認していなければ気づきません。tail -f ~/.claude/logs/chrome-automation-repair.log と tail -f ~/.claude/logs/chrome-automation-repair.err.log を両方確認する習慣を最初から持つと、無音失敗の発見が数時間から数分に縮まります。

  • Chrome Automation.app の初回起動に Gatekeeper ダイアログが出る。 ad-hoc 署名は Apple の信頼チェーンに乗っていないため、初回だけ「開発元を確認できないため開けません」が出ます。システム設定→プライバシーとセキュリティ→「このまま開く」を一度押せば以降は出ません。新しい Mac に環境を移した直後や、Chrome の大幅アップデート後(バンドルを作り直したタイミング)に忘れがちです。


ベストプラクティス

ここまでの構成を汎用原則として整理します。Chrome バンドル分離に限らず、macOS 上の長期稼働自動化全般に使える設計指針です。

1. 人間リソースと無人ジョブのリソースは「名前」の段階から分ける。 bundle ID・プロファイルパス・ポート番号・PID ファイル。どれか一つでも共有した瞬間に「どちらかを殺すともう一方も死ぬ」構造が生まれます。名前を分けるだけで OS が代わりに隔離してくれます。設計のコストは一度きりで、以降は OS が管理します。

2. 「npm install で必ず戻る」を設計の前提にする。 パッチを当てた事実を根拠にしない。「戻ることを見越して定期的に貼り直す」を当初から設計に組み込みます。最大6時間は旧経路に戻る窓が残りますが、それは既知のリスクとして受け入れられます。「直した」と「直り続ける」は別の状態です。

3. パッチ結果をカウントし、0件を fail-loud にする。 patched=N already=M の2変数を持ち、patched + already == 0 なら exit 1。「探索したが何もなかった」は「何もしなかった」と同義です。黙って exit 0 にすると、設定ミスが6時間ごとに静かに繰り返されます。

4. バージョン比較で冪等性を確保し、不要な再ビルドを省く。 /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" で src_ver と dst_ver を比較して「同一バージョンなら何もしない」を実装すると、6時間ごとのサイクルがほぼノーコストになります。Google Chrome が自動アップデートしたときだけ再ビルドが走ります。

5. 再ビルド前に pgrep ガードを入れる。 pgrep -f "Chrome Automation.app/Contents/MacOS" で稼働確認し、居たら次のサイクルに延期します。コピー中のバイナリを掴まれる問題は、このガードだけで完全に防げます。「今は触れない」と判断できる仕組みが長期稼働の安定を作ります。

6. launchd plist の EnvironmentVariables に PATH・HOME・LANG を必ず書く。 インタラクティブシェルの設定を launchd は一切読みません。手元で動くスクリプトが launchd では黙って死ぬ、という現象の大半はこれです。最初から plist に書いておくだけで、この種のトラブルはゼロになります。

7. find より glob を使い、既知のレイアウトを直接叩く。 探索対象の構造が自分でコントロールできているなら、全走査は常にやり過ぎです。playwright-core の置き場は2パターンしかありません。glob で直叩きにすると find の270分の1の時間で終わります。

8. codesign は --force --deep --sign - の3オプションセットで使う。 --force で既存署名を上書き、--deep でフレームワーク・ヘルパー・プラグイン全体に適用、- で ad-hoc 署名。1つでも欠けると Library Validation が SIGKILL します。3つセットで覚えておけばハマりません。

9. headless Chrome には --use-mock-keychain --password-store=basic を付ける。 永続プロファイルを持つジョブ(Instagram 等)だけは例外として、mock を使うとセッションが吹き飛ぶので本物の Keychain を残します。どちらが該当するかを把握した上で、headless 起動の大半は mock を付けるのが正しい設定です。

10. pkill は -f でフルパス一致にする。プロセス名の部分一致は使わない。 pkill "Google Chrome" は bundle ID が違っていても両方殺します。-f "Chrome Automation.app/Contents/MacOS" と起動コマンドライン全体で絞ることで、人間の Chrome に触れない実装になります。

11. 修正したら SCAN_ROOTS の全リポジトリを横断確認する。 1リポジトリ直しても、同じパターンで書かれた別リポジトリが残っている間は修正が完了していません。grep -r "channel:'chrome'" ~/dev ~/content --include="*.mjs" --include="*.js" 2>/dev/null で漏れを出し、全件対応してはじめて完了です。

12. 最終確認コマンドをスクリプトとセットで残す。 設定後に何で確認するかを最初から決めておきます。今回の構成は次の4コマンドです。

# 1. launchd への登録確認
launchctl list | grep chrome-automation-repair

# 2. バンドルが起動できる状態か
open -a "Chrome Automation" --args --version

# 3. playwright が正しいパスを見ているか
grep -r "Chrome Automation" ~/dev/*/node_modules/playwright-core/lib/coreBundle.js 2>/dev/null | head -5

# 4. 人間の Chrome は独立して起動できるか
open -a "Google Chrome"

2番の --args --version が Chrome のバージョン文字列(例:Google Chrome 127.0.6533.120)を返し、4番が -600 なしで開けば、隔離が正しく効いています。「構成を入れたら確認コマンドまで込みで1セット」という発想で、半年後に同じ環境を再現するコストを下げます。

13. ログは ~/.claude/logs/ に集め、stdout と stderr を両方確認する。 どちらかだけ見ていると、エラーが .err.log に出ているのに気づかない状態が続きます。tail -f ~/.claude/logs/*.log ~/.claude/logs/*.err.log で全ジョブの状態を一覧できるようにしておくと、無音失敗の発見コストが下がります。

14. 修復窓(SLA)を意図的に選び、その根拠を残す。 StartInterval: 21600(6時間)は「npm install が走っても最大6時間以内に修復される」という SLA です。短くすれば確実性が上がる一方、CPU とディスクへの影響も増えます。自分の環境で許容できる最大値を意図的に選んで、その根拠を plist のコメントに残しておくことが重要です。


まとめ

open -a "Google Chrome" が -600 を返した9月1日の午後から、設計として残ったものを一行で言えば、「競合はリソースを共有している間だけ起きる。名前を分ければ OS が代わりに隔離してくれる」です。

自動化が1本のときは問題が見えません。2本、5本、10本と積み上がるにつれて、人間用リソースと自動化リソースが同じ名前空間を占有し合い、どちらかを殺せばもう一方も壊れる構造が静かに出来上がります。月商を伸ばすために自動化を積み増すほど、自分の作業環境が壊れる——その逆説に気づいたのが、この設計のスタート地点でした。

npm install で戻るという事実を「直さなければならない問題」として見るのをやめ、「戻ることを前提にした定期修復」として受け入れた時点で、仕組みは安定しました。パッチが戻っても6時間以内に直る。その修復窓を既知のリスクとして設計に組み込むことが、長期稼働の鍵です。

chrome-automation-repair.sh が6時間ごとに走り、patched=0 already=16 rebuilt=0 を返し続ける今、自動化ジョブは Chrome Automation.app を使い、人間の Chrome は独立して動いています。9月1日のような朝は、もう来ません。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

  • AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
  • OSS: github.com/bokuwalily 🐙
  • 最新情報・お問い合わせは X @bokuwalily へ🌍
  • AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬

皆さんの ❤️ やシェアが励みになります!