DEV Community

Lily
Lily

Posted on Originally published at dev.to

4 Automation Chrome Processes Were Killing My Real Browser: Bundle-ID Isolation and a 6-Hour Self-Repair Loop

At 13:31 JST on September 1, 2026, open -a "Google Chrome" returned error code -600 and nothing happened. Three headless Chrome processes and one headful one were already running on my Mac — all spawned by the same automation that had grown from ¥0 to ¥1.2M a month in six months. That afternoon, the whole setup got redesigned from the ground up.

Why This Setup Works

Once a solo developer's automation grows past a certain size, there's a problem you will inevitably hit: "the jobs are running fine, but at some point my own working environment broke."

On the morning of September 1, 2026, Lily's Mac had three headless Chrome processes and one headful one — four in total — running at the same time. Auto-liking on social media, thumbnail generation, per-account profile management: each was an independent job calling Playwright. And every one of them was directly exec-ing /Applications/Google Chrome.app/Contents/MacOS/Google Chrome. That was all it took.

macOS LaunchServices treats apps with the same CFBundleIdentifier as a single instance of the same app. Google Chrome's bundle ID is com.google.Chrome. If an automation job has already started a process with that bundle ID, then when you run open -a "Google Chrome" from the Dock or Spotlight, the OS decides "that app is already running, so I'll just bring the existing frontmost window forward." But the automation process usually has no window. The Chrome window a human expects never comes back. That is what -600 (procNotFound) really is.

The nasty part is that you can't notice this until it happens. The automation jobs are running normally. Exit code is 0. Logs are clean. Only when a human tries to use Chrome does nothing happen.


In general, this problem has the property that "the more automation jobs you add, the higher the probability." With one job, Chrome is held for a short time. As you go to three, five, ten jobs, Chrome being permanently held by one job or another becomes the normal state. The more automation you stack on to grow revenue, the longer your own Chrome is unusable. The causality is inverted.

There were two possible directions for a fix. One: make every automation job kill Chrome when it's done. Two: separate the automation browser from the human's Chrome at the level of its name. The former requires managing the shutdown timing of every job, and each new job creates a new gap. The latter, once built, eliminates the interference at the OS level. Only the latter is a permanent fix.

Create a separate bundle named /Applications/Chrome Automation.app and change its bundle ID to com.google.ChromeAutomation. From the OS's perspective it's a completely different app. LaunchServices manages com.google.Chrome and com.google.ChromeAutomation separately. No matter how many Chrome Automation instances the automation launches, a human's open -a "Google Chrome" looks for a different bundle ID and is unaffected.

If I had to explain in one line why this works: "Resource contention disappears the moment you separate the names." As long as they share a name, you have a structure where killing one kills the other. Split the name, and the OS isolates them for you.


There is, however, one awkward implementation wall.

Playwright's channel: 'chrome' option writes the path of the browser it launches into an internal file, playwright-core/lib/coreBundle.js. This file lives inside node_modules. Every time npm install runs, it gets overwritten and reverts to /Applications/Google Chrome.app.

In other words, even if you rewrite the path, it periodically reverts. Designing around the fact that "it reverts" is the key to stable operation. "Anticipate the revert and re-apply periodically." That's the reason the 6-hour self-repair script exists.

The Overall Flow

Here's the system architecture as a diagram.

  ┌──────────────────────────────────────────────────────────┐
  │  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(干渉なし)               │
  └──────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Let's look at the core parts of the script in real code.

Bundle Creation and Version Management

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
Enter fullscreen mode Exit fullscreen mode

The ver() function reads CFBundleShortVersionString (e.g. 127.0.6533.120) via PlistBuddy. When Google Chrome auto-updates, src_ver and dst_ver drift apart, so the next 6-hour cycle automatically rebuilds Chrome Automation.app.

The - (minus) in codesign --force --deep --sign - means an ad-hoc signature. The moment you rewrite the bundle ID in Info.plist, Google's signature becomes invalid. If you try to launch without re-signing, the helper processes die instantly with SIGKILL (exit 137). An ad-hoc signature is a self-signature with no trust chain, but for local-only execution that's fine.

Glob Search and Patching of playwright-core

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
}
Enter fullscreen mode Exit fullscreen mode

This sweeps the repositories under ~/dev and ~/content with glob patterns up to four levels deep. Walking the entire tree with find . -name "coreBundle.js" takes 4 minutes; hitting the layouts that actually exist (the two patterns */node_modules/playwright-core/... and */node_modules/playwright/node_modules/playwright-core/...) directly with globs brings the runtime down to 0.95 seconds.

The patch step is an in-place substitution with 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
Enter fullscreen mode Exit fullscreen mode

patched is the number of files rewritten this run; already is the number already patched. If both are 0, the search path is broken and nothing was found. To prevent "zero results but treated as success," it explicitly fails with exit 1. This fail-loud design prevents the later question, "why did it go six hours unfixed without anyone noticing?"

Scheduled Execution via 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>
Enter fullscreen mode Exit fullscreen mode

StartInterval: 21600 is 21600 seconds = 6 hours. With RunAtLoad: true, it also runs once the moment you launchctl load it. Nice: 10 and LowPriorityIO: true lower CPU and disk priority so it doesn't interfere with human work in the background.

The meaning of the 6-hour interval is an SLA: "auto-repair within at most 6 hours after an npm install runs." In practice the automation jobs rarely run npm install, so in most cases the patch doesn't revert before the next cycle. Even if it does, it's fixed within 6 hours. This design accepts the "length of the repair window" as a known risk.

Keychain Gate Detection

At the top of the script there's a detection routine called 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
}
Enter fullscreen mode Exit fullscreen mode

Because Chrome Automation.app is ad-hoc signed, even if you grant "Always Allow" on the "Chrome Safe Storage" ACL in macOS Keychain, the code hash changes every time a Chrome update triggers a rebuild, and the permission is invalidated. Any script that launches headless without --use-mock-keychain --password-store=basic causes a Keychain access dialog to pop up every time. Since this leads to "silent failures" in automation, there's a gate that detects and warns — but does not fix.

In this round of work, the repositories fixed came to 16 locations where Playwright's channel:'chrome' resolves, 10 files with direct shell and Python invocations, and 8 repositories committed. The larger the scale, the less "I fixed one place" is the end of it. I needed to propagate the fix across every repository, and have a mechanism that automatically restores the fix even when npm install wipes it out. That's why this setup exists.

Implementation Details

The pgrep Guard — Don't Rebuild While Running

The bundle-creation logic shown earlier has one more safety valve. When a version mismatch is detected, if Chrome Automation.app is running, skip the rebuild.

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
Enter fullscreen mode Exit fullscreen mode

cp -R takes 15–30 seconds. If Chrome Automation.app's Contents/MacOS/Google Chrome is read mid-copy, a half-written binary gets executed and the whole job breaks. pgrep -f "Chrome Automation.app/Contents/MacOS" looks for running processes, and if any exist, the design is to "wait until the next 6-hour cycle."

Even when the rebuild is skipped, the patched/already counting still runs afterward. The bundle itself may be stale, but as long as the path in coreBundle.js is correct, jobs keep working.

launchd's Shell Doesn't Have a Human's PATH

The plist contains an EnvironmentVariables block.

<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>
Enter fullscreen mode Exit fullscreen mode

The shell launchd starts only inherits its environment from /etc/launchd.conf; it reads neither ~/.zshrc nor ~/.zprofile. Unless you set PATH explicitly, only /usr/bin:/bin is available, and codesign and PlistBuddy in /opt/homebrew/bin can't be found. HOME is set explicitly so that the ${HOME}/dev expansion is resolved via EnvironmentVariables rather than as a shell variable. LANG is insurance so grep doesn't mangle filenames containing Japanese.

The log destinations being split into StandardOutPath and StandardErrorPath is for the same reason. launchd writes stdout and stderr to separate files, both landing in ~/.claude/logs/. If you only look at one, it can appear as though "nothing is happening."

Reading the keychain_gate Pipeline

The keychain_gate() implementation has an unusual structure: three chained greps.

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' {}
Enter fullscreen mode Exit fullscreen mode

Stage 1 finds "files that directly write the Chrome binary path." node_modules, .git, profiles, logs, venv, and tests are excluded from the search.

Stage 2 keeps only files containing --headless. Scripts that launch with a GUI are fine even if the Keychain dialog appears.

Stage 3 keeps only files that do not contain use-mock-keychain (-L is the "list non-matching files" flag).

Files that pass all three stages are in the state of "using Chrome headless but without mock-keychain." Each time they run, the Keychain "Allow?" dialog pops up. When a dialog appears mid-automation, every process stalls until the next click. The one file excluded via grep -v, scent-media/scripts/ensure_chrome.sh, is deliberately designed to use the real Keychain for a different reason.

The script does not auto-fix on this detection. Persistent profiles like Instagram encrypt their session cookies with a Keychain key, and if the key changes, the login is wiped out. Log a warning and leave the decision to a human — that's the design choice.

Final Verification Commands

Once the setup is in place, confirm it actually works.

# 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"
Enter fullscreen mode Exit fullscreen mode

Number 2's --args --version passes the --version flag to Chrome, making it print the version string at startup (e.g. Google Chrome 127.0.6533.120). If you get the string back without a window opening, the bundle is in a launchable state. Number 4 is the final confirmation: if the human's Chrome opens without returning -600, the isolation is working.


Where I Got Stuck

find Was Taking 4 Minutes

The first script I wrote searched with find.

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

When I ran it, it took 4 minutes 15 seconds to complete. There are several hundred node_modules directories under ~/dev, and find walks every one of them. A 4-minute process every 6 hours wastes CPU and disk even in the background, and worse, I hadn't noticed the "it's slow when you actually run it" fact until the first implementation.

The layouts where coreBundle.js actually exists are just two patterns. For a directly installed playwright-core: */node_modules/playwright-core/lib/coreBundle.js. For a nested install via playwright: */node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js. Hitting these two patterns directly with globs down to two levels of repository depth brought the runtime to 0.95 seconds.

Globs are fast because filesystem traversal collapses to OS directory-entry lookups. Where find walks every node, a glob only checks "does an entry matching this pattern exist?" If your search space fits a known structure, glob is clearly superior as a find replacement.

Forgot the Ad-Hoc Signature, Got exit 137

My first implementation had cp -R and the PlistBuddy rewrite, but no codesign. When I tried to launch Chrome Automation.app in a test, the helper processes died instantly with exit 137.

Exit 137 is SIGKILL (128 + 9). The process didn't exit on its own; the OS forcibly killed it. When macOS Library Validation determines that "the binary exists but its signature doesn't match the bundle ID in Info.plist," it SIGKILLs the helper. Google's signature was issued for com.google.Chrome, and the moment you change CFBundleIdentifier in Info.plist to com.google.ChromeAutomation, it's invalid.

Adding codesign --force --deep --sign - and re-running made it launch. --force overwrites the existing signature, --deep re-signs not just the main binary but all frameworks, helpers, and plugins, and - (minus) specifies an ad-hoc signature without an Apple certificate. Gatekeeper doesn't "trust" this signature, but it will launch for local execution. The first time, you get a "developer cannot be verified" confirmation dialog, but after pressing "Open Anyway" once under System Settings → Privacy & Security, it's no longer needed.

Zero Results Still Exited 0 as Success

Early on, I misconfigured SCAN_ROOTS. I wrote ~/Development instead of ~/dev, and since the directory didn't exist, list_core_bundles returned nothing.

The script ran to completion and returned exit 0. patched=0 and already=0, but neither condition tripped, and the log just said playwright patched=0 already=0 rebuilt=0. launchd only looks at the job's exit code, so it kept recording "success" every 6 hours while nothing was being fixed.

This is where the worst case happens: after npm install reverts the path, it can sit unrepaired for up to 6 hours. The code that explicitly fails with exit 1 when patched + already == 0 was born from this failure.

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

When the exit code is non-zero, launchd records it to StandardErrorPath and re-runs on the next cycle. Make failures loud. That's the correct design.

One Deeply Nested Repo Slipped Through

The first glob pattern only covered one level.

"$root"/*/node_modules/playwright-core/lib/coreBundle.js
Enter fullscreen mode Exit fullscreen mode

This single level doesn't catch ~/dev/social-autolike/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js. That's the case where playwright is installed containing playwright-core inside it. social-autolike had installed playwright directly, so this nesting occurred.

As a result, while the other 15 locations pointed at Chrome Automation.app, social-autolike alone kept directly hitting Google Chrome.app. When the same problem happened later in dokuji-rosen-sns (described below), the habit of first checking "has a new nesting pattern appeared?" came from this failure.

preflight Was pkill -9-ing the Human's Chrome Too

metrics-hub had a script called preflight_chrome.sh whose job was to clean up stale Chrome processes before a job started. It was implemented like this.

pkill -9 "Google Chrome"
Enter fullscreen mode Exit fullscreen mode

pkill does partial matching on the process name. The string Google Chrome appears in the process names of both com.google.Chrome and com.google.ChromeAutomation. Every time an automation job ran preflight, every Chrome window the human had open was SIGKILLed.

Worse, this breaks in a way that's hard to trace back to a cause. Jobs using Chrome Automation.app run normally. The only remaining symptom is that the human's Chrome suddenly crashes. Nothing shows up in the job logs. Only after the vague awareness of "Chrome keeps crashing" accumulates do you realize preflight is the culprit.

The fix is to filter by binary path instead of process name.

pkill -9 -f "Chrome Automation.app/Contents/MacOS"
Enter fullscreen mode Exit fullscreen mode

-f matches against the process's full command line. By targeting only processes whose command line contains the path Chrome Automation.app/Contents/MacOS, the implementation never touches the human's Chrome.

A 4th Repo Fell Into the Same Hole a Month Later

In the September 1 fix, I changed 16 coreBundle.js locations and 10 direct-invocation files, and committed 8 repositories. At that point, I believed "everything is covered."

On September 11, it came out that tools/lib/browser.mjs in dokuji-rosen-sns was still on channel:'chrome'. Even after social-autolike migrated to the bundled chromium on August 12, dokuji-rosen-sns remained on channel:'chrome'. Only between 8:00 and 10:40 in the morning, when it competed with other jobs for Chrome, startup exceeded 180 seconds and timed out — and this continued for 5 days. 39 hangs in 5 days, and the 125 likes in the 8:00 slot vanished every day, yet the alert was skimmed past as a single line: "will auto-recover on the next cycle."

This lesson overlaps with the general form I left in the wiki: "A fix to a shared function means nothing unless it propagates to every call site that uses it." Even if you fix the script, as long as another repo written in the same pattern remains, the fix isn't complete.

Having both ~/dev and ~/content in SCAN_ROOTS of chrome-automation-repair.sh is also to avoid missing a repo that exists in only one of them. Checking "does this repo use channel:'chrome'?" every time a new repo is created is a human task. But if the script runs every 6 hours and patched becomes 1 or more, at least the scenario of "a month passes without noticing a miss" is prevented. For now, as long as the script keeps returning patched=0 already=N, I judge that everything is pointing the right way.

Pitfalls

Here's a complete list of the landmines I actually stepped on. Most of "I set it up but it doesn't work" is one of these.

  • Forgetting codesign after rewriting Info.plist. The moment you change CFBundleIdentifier to com.google.ChromeAutomation, Google's signature is invalid. Launch without re-signing and Library Validation rejects it; helper processes die instantly with exit 137 (SIGKILL). The --deep in codesign --force --deep --sign - is mandatory. With --force alone, only the main binary is updated, leaving frameworks, helpers, and plugins with the old signature.

  • Full-walking node_modules with find. find ~/dev ~/content -name "coreBundle.js" took 4 minutes 15 seconds to finish, because there are several hundred node_modules under ~/dev. coreBundle.js only exists in two layouts: */node_modules/playwright-core/lib/ and */node_modules/playwright/node_modules/playwright-core/lib/. Hit those directly with globs and the runtime drops to 0.95 seconds. There's no need to full-walk a known structure.

  • Writing only one nesting pattern. Projects that install playwright directly have */node_modules/playwright-core/lib/coreBundle.js. Projects where playwright-core comes in via playwright have the nested form */node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js. Write only one in the glob and you miss the other kind of repo every time. I hit this in social-autolike: I thought I'd fixed 16 locations, but one was still pointing at the old path.

  • Zero results still exits 0 as success. Put a nonexistent path in SCAN_ROOTS and list_core_bundles returns nothing, exiting normally with patched=0 already=0. launchd records exit 0 as "success," and a "do-nothing job" quietly runs every 6 hours. Only by explicitly failing with exit 1 when patched + already == 0 does an ERROR land in the log and the job re-run on the next cycle.

  • pkill -9 "Google Chrome" kills the human's Chrome too. Partial matching on the process name hits both com.google.Chrome and com.google.ChromeAutomation. preflight_chrome.sh in metrics-hub was exactly this: every time it tried to clean up the automation bundle, it SIGKILLed every tab the human had open. Filter on the full command line with pkill -f "Chrome Automation.app/Contents/MacOS" and the implementation never touches the human's Chrome.

  • launchd's shell doesn't read ~/.zshrc. The PATH of the shell launchd starts is only /usr/bin:/bin. Neither codesign nor PlistBuddy in /opt/homebrew/bin is visible. Unless you explicitly set PATH, HOME, and LANG in the plist's EnvironmentVariables, the script silently dies with "command not found." If LANG isn't en_US.UTF-8, grep may also mangle filenames containing Japanese. It ends with exit 127 and nothing in the log, so discovery is delayed.

  • Chrome Automation.app's binary gets read mid-rebuild. cp -R /Applications/Google\ Chrome.app /Applications/Chrome\ Automation.app takes 15–30 seconds in the real environment. If an automation job tries to exec Chrome Automation.app/Contents/MacOS/Google Chrome during that window, it grabs a half-copied binary and crashes. Check for running processes with pgrep -f "Chrome Automation.app/Contents/MacOS", and if any exist, defer to the next 6-hour cycle.

  • Keychain ACLs are invalidated by Chrome updates. Chrome Automation.app is ad-hoc signed, so its code hash changes with every rebuild. Even if you set "Always Allow" on "Chrome Safe Storage" in macOS Keychain, when Chrome auto-updates and a rebuild runs, the ACL is invalidated and a "Allow password access?" dialog appears on every headless launch. The right answer is to add --use-mock-keychain --password-store=basic to every headless launch. However, persistent profiles like Instagram encrypt session cookies with the Keychain key, so switching to mock wipes the login. That's why keychain_gate() only detects and warns without auto-fixing.

  • Fixing just one repository means the neighbor hits the same hole a month later. Scripts that directly specify channel:'chrome' are scattered across every repository in ~/dev and ~/content. Fix one, and another repository's browser.mjs keeps pointing at the old path. dokuji-rosen-sns actually hit this: only between 8:00 and 10:40 in the morning, competing with other jobs for Chrome, it hung for over 180 seconds — for 5 days straight. For the record, 39 times in 5 days, with the 125 likes in the 8:00 slot vanishing every day, and the alert was skimmed as a single line: "auto-recovers on next tick." A fix is only one complete unit once you've cross-checked every repository in SCAN_ROOTS.

  • Unless you look at both StandardOutPath and StandardErrorPath, it looks like "nothing is happening." launchd writes stdout and stderr to separate files. If an error only appears in .err.log and you only check .log, you won't notice. Build the habit from the start of checking both tail -f ~/.claude/logs/chrome-automation-repair.log and tail -f ~/.claude/logs/chrome-automation-repair.err.log, and discovering silent failures shrinks from hours to minutes.

  • A Gatekeeper dialog appears on Chrome Automation.app's first launch. Since an ad-hoc signature isn't on Apple's trust chain, the first time you get "cannot be opened because the developer cannot be verified." Press "Open Anyway" once under System Settings → Privacy & Security and it won't appear again. Easy to forget right after moving the environment to a new Mac or after a major Chrome update (when the bundle is rebuilt).


Best Practices

Here's the setup above distilled into general principles. Not limited to Chrome bundle isolation — these are design guidelines for any long-running automation on macOS.

1. Separate human resources from unattended-job resources at the level of their "name."
Bundle ID, profile path, port number, PID file. The moment any one of these is shared, you get a structure where "killing one kills the other." Just split the names and the OS isolates them for you. The design cost is one-time; the OS manages it afterward.

2. Make "npm install will always revert it" a design premise.
Don't rely on the fact that a patch was applied. Build "anticipate the revert and re-apply periodically" into the design from the start. A window of up to 6 hours where it reverts to the old path remains, but that's an acceptable known risk. "Fixed" and "stays fixed" are different states.

3. Count patch results and make zero fail-loud.
Keep two variables, patched=N already=M, and exit 1 if patched + already == 0. "Searched but found nothing" is equivalent to "did nothing." Silently exiting 0 means a misconfiguration quietly repeats every 6 hours.

4. Ensure idempotency with version comparison and skip unnecessary rebuilds.
Compare src_ver and dst_ver via /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" and implement "same version, do nothing," and the 6-hour cycle becomes nearly free. A rebuild only runs when Google Chrome auto-updates.

5. Put a pgrep guard before rebuilding.
Check for running processes with pgrep -f "Chrome Automation.app/Contents/MacOS", and if any exist, defer to the next cycle. The problem of a binary being grabbed mid-copy is completely prevented by this guard alone. A mechanism that can decide "don't touch it right now" is what creates long-running stability.

6. Always write PATH, HOME, and LANG in the launchd plist's EnvironmentVariables.
launchd reads none of your interactive shell's configuration. Most cases of "the script works locally but silently dies under launchd" are this. Just writing them in the plist from the start brings this class of trouble to zero.

7. Use glob over find and hit known layouts directly.
If you control the structure of what you're searching, a full walk is always overkill. playwright-core lives in only two patterns. Hitting them directly with globs finishes in 1/270th the time of find.

8. Use codesign with the three-option set --force --deep --sign -.
--force overwrites the existing signature, --deep applies to all frameworks, helpers, and plugins, - is an ad-hoc signature. Miss even one and Library Validation SIGKILLs. Remember them as a set of three and you won't get stuck.

9. Add --use-mock-keychain --password-store=basic to headless Chrome.
Only jobs with persistent profiles (Instagram etc.) are the exception: using mock wipes the session, so keep the real Keychain for them. Know which category applies, and for the majority of headless launches, adding mock is the correct setting.

10. Use -f with pkill for full-path matching. Don't use partial process-name matching.
pkill "Google Chrome" kills both even though the bundle IDs differ. Filter on the full command line with -f "Chrome Automation.app/Contents/MacOS" and the implementation never touches the human's Chrome.

11. After a fix, cross-check every repository in SCAN_ROOTS.
Fixing one repository isn't complete while another repository written in the same pattern remains. Surface the misses with grep -r "channel:'chrome'" ~/dev ~/content --include="*.mjs" --include="*.js" 2>/dev/null, and only after handling all of them is it done.

12. Keep the final verification commands together with the script.
Decide up front what you'll verify with after setup. For this setup, it's these four commands.

# 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"
Enter fullscreen mode Exit fullscreen mode

If number 2's --args --version returns Chrome's version string (e.g. Google Chrome 127.0.6533.120) and number 4 opens without -600, the isolation is working correctly. Thinking of "the setup plus its verification commands as one unit" lowers the cost of reproducing the same environment six months later.

13. Collect logs in ~/.claude/logs/ and check both stdout and stderr.
Look at only one and you'll keep not noticing that an error is in .err.log. Set things up so tail -f ~/.claude/logs/*.log ~/.claude/logs/*.err.log gives you every job's status at a glance, and the cost of discovering silent failures drops.

14. Choose the repair window (SLA) deliberately and record the rationale.
StartInterval: 21600 (6 hours) is an SLA: "even if npm install runs, it's repaired within at most 6 hours." Shorter raises certainty but increases CPU and disk impact. It's important to deliberately choose the maximum your environment can tolerate and leave the rationale in a comment in the plist.


Summary

If I had to state in one line what remained as a design from that September 1 afternoon when open -a "Google Chrome" returned -600: "Contention only happens while resources are shared. Split the name, and the OS isolates them for you."

With one automation, the problem is invisible. As you stack up two, five, ten, human resources and automation resources occupy the same namespace, and a structure where killing one breaks the other quietly forms. The more automation you add to grow revenue, the more your own working environment breaks — noticing that paradox was the starting point of this design.

The mechanism stabilized the moment I stopped viewing "npm install reverts it" as a "problem that must be fixed" and accepted it as "periodic repair on the premise that it reverts." Even if the patch reverts, it's fixed within 6 hours. Building that repair window into the design as a known risk is the key to long-running operation.

Now that chrome-automation-repair.sh runs every 6 hours and keeps returning patched=0 already=16 rebuilt=0, the automation jobs use Chrome Automation.app and the human's Chrome runs independently. Mornings like September 1 don't come anymore.

What's the one shared resource in your automation setup that you and your jobs are still fighting over — and how long has it been silently losing?


The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are compiled in a paid note (Japanese).

📕 How to actually earn with a Claude Code autonomous environment — mechanism, real examples, getting started, and support


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)