DEV Community

Lily
Lily

Posted on

6 Pitfalls: My Automation Chrome Killed the Chrome in My Dock (bundle id split + ad-hoc re-signing)

Nothing was open. No Chrome window, no Chrome in the Dock, no visible process — and macOS still insisted Chrome was running. Clicking the Dock icon just failed. Last time I wrote about building automatic resume after a rate limit. This one is another entry in the "Claude Code automation environment" series: the accident where Playwright exec'ing Chrome directly broke the Chrome in my Dock, and the permanent fix.

The problem: the Dock's Chrome dies during hours when only automation is running

On 2026-09-01, during a window when only automation scripts were running, opening "Google Chrome" from the Dock started failing. The error from open -a "Google Chrome" was error -600 (procNotFound). Not a single GUI Chrome window was open, yet macOS was convinced Chrome was already running.

The cause was Playwright. When you specify channel: 'chrome', Playwright directly execs the binary inside /Applications/Google Chrome.app. LaunchServices interprets this as "com.google.Chrome has launched," and that belief lingers after the automation process exits, so launches from the Dock or Spotlight no longer go through.

Note: As long as the Chrome used for automation and the Chrome the user touches run under the same bundle id, this class of interference never goes away. Killing the process papers over it, but the next Playwright run brings it right back.

The fix: split off a dedicated automation bundle

What I did is simple: copy /Applications/Google Chrome.app wholesale and re-register it as a separate app with a different bundle id. This is the core of ~/.claude/scripts/chrome-automation-repair.sh.

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

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

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
    rm -rf "$DST"
    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" >/dev/null 2>&1 || { log "ERROR: 署名失敗"; exit 1; }
    rebuilt=1
  fi
fi
Enter fullscreen mode Exit fullscreen mode

Just rewriting CFBundleIdentifier to com.google.ChromeAutomation is enough for LaunchServices to register it as an app distinct from regular Chrome (you can confirm with lsappinfo list). From then on, whatever the automation side does, it never interferes with com.google.Chrome on the Dock side.

If the automation Chrome is currently running, the rebuild is skipped and deferred to the next cycle — copying over it would mean replacing its own binary out from under it, so this branch is a mandatory safety check.

Pitfall 1: rewriting Info.plist breaks the signature

Copying and changing the bundle id isn't enough on its own. The moment you rewrite Info.plist, Google's signature becomes invalid, and Library Validation kills the helper processes instantly with SIGKILL (exit 137). The last line is what avoids that.

codesign --force --deep --sign - "$DST" >/dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

Re-signing the whole bundle ad-hoc with --deep (including Chrome's helper binaries) restores internal consistency. Because it's an ad-hoc signature (-sign -), it is obviously not Apple's official signature, but for local personal use it launches just fine.

Pitfall 2: node_modules patches evaporate on npm install

Splitting the bundle id alone leaves Playwright still pointing at the main Chrome. The resolution target for channel:'chrome' is hardcoded as a path in playwright-core/lib/coreBundle.js, and environment variables can't override it. So I rewrite the files under node_modules directly with sed.

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

Naturally, redoing npm install or playwright install wipes this patch out. I accepted that anything under node_modules is volatile by nature and moved to an operational model that re-applies the patch every 6 hours via launchd.

<key>StartInterval</key><integer>21600</integer>
<key>RunAtLoad</key><true/>
Enter fullscreen mode Exit fullscreen mode

A plist placed in ~/Library/LaunchAgents/ (the actual filename is withheld) runs every 6 hours (21600 seconds) plus immediately at login. The script itself finishes in 1 second, so raising the frequency costs almost nothing.

If zero patch targets are found, that's a sign the scan paths are broken, so instead of quietly reporting success it errors out immediately.

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

Side effect: the Keychain "Chrome Safe Storage" dialog

The day after I put the separation fix in (2026-09-02), a different symptom showed up. In the middle of a login session, a Keychain password dialog saying "Google Chrome wants to use Chrome Safe Storage" started appearing out of nowhere.

The cause: a one-shot script that invokes the automation bundle directly with --headless from shell/python (a thumbnail generation job that runs daily at 12:44) didn't have --use-mock-keychain. When creating a fresh profile, going through Playwright always uses the mock keychain, but that naive direct-exec script had forgotten to add it.

The nasty part is that Keychain's "Always Allow" doesn't work. Because the automation bundle is ad-hoc signed, the ACL is bound to a fixed cdhash. Every time Chrome updates and the bundle is rebuilt, the cdhash changes, the "Always Allow" setting is invalidated along with it, and you're back to square one. The only fix was "don't let it touch the Keychain at all."

So I added a gate to chrome-automation-repair.sh that statically detects scripts launching Chrome directly.

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

What it does is a three-stage pipe: ① find files containing a line that directly execs the Chrome binary → ② narrow to the ones that pass --headless → ③ keep only those that don't have --use-mock-keychain. It doesn't auto-fix anything; it only emits a WARNING.

The reason is that there are exceptions. scent-media/scripts/ensure_chrome.sh uses a persistent profile for Instagram, and its cookies are already encrypted with a real Keychain key. Switching that to mock changes the key, the cookies become unreadable, and the entire login session is gone. So that one file is explicitly excluded from the grep results, and the gate stops at "detect and warn," leaving the decision about whether to fix it to me.

Pitfalls I hit

  • Playwright directly execs the main Chrome → LaunchServices misreads it and launching from the Dock fails with error -600. Tracking down the cause took a while
  • Rewriting CFBundleIdentifier in Info.plist breaks the signature → without re-signing via codesign --force --deep --sign -, the helpers die instantly with exit 137
  • Rebuilding while the automation Chrome is running means rewriting the binary under itself → check with pgrep, skip, and defer to the next cycle
  • A direct sed patch into node_modules evaporates on npm install → assume it's volatile and re-apply it every 6 hours via launchd
  • "Always Allow" doesn't work on an ad-hoc signed bundle → because the cdhash changes on every rebuild. There is no solution other than designing so it never touches the Keychain
  • Applying the grep gate mechanically everywhere breaks some profiles → persistent profiles that depend on a real Keychain key go on an explicit exclusion list

Summary

  • Playwright's channel:'chrome' direct exec cannot coexist with the regular Chrome you use from the Dock unless you split the bundle id
  • The split is three steps: "copy → rewrite CFBundleIdentifier → re-sign with codesign --deep". Without re-signing, the helpers die instantly
  • The Playwright resolution-target patch is a direct sed into node_modules, so assume it evaporates on npm install and maintain it with a periodic launchd run
  • The Keychain dialog that came as a side effect is not fixed by "Always Allow." Statically grep for headless launches missing the mock keychain and stamp them out
  • Blanket auto-fixing is dangerous. Exclude exceptions like persistent profiles explicitly

What's the sneakiest cross-process interference your automation has caused on a machine you also use by hand?


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

Top comments (0)