Every macOS reboot, the same ritual. Open the Playwright-controlled Chrome window, see seven publishing tabs all logged out, and spend the next ten minutes typing passwords and tapping the Google account picker. Zenn, dev.to, note, Substack, X, LinkedIn, the Google Search Console dashboard. All gone, all needing the same Google SSO dance through my corevice.com workspace account.
I run a one-person GTM operation for Codens and the publishing pipeline is entirely Playwright-driven. npx @playwright/cli@latest opens a real Chrome with a persistent profile, and a stack of small scripts paste titles and bodies into each editor. It works beautifully until the host reboots and the user-data-dir at /tmp/chrome-pw-corevice evaporates with the rest of /tmp.
I finally sat down and fixed it. The result is a thirty-line shell script that clones my daily-driver Chrome profile into the Playwright tmpdir on every launch, with two non-obvious tricks that make the cookies actually decrypt. This post is about those two tricks.
Why the obvious copy doesn't work
The first thing anyone tries is the obvious thing.
cp -r ~/Library/Application\ Support/Google/Chrome/Default \
/tmp/chrome-pw-corevice/Default
Run it, fire up Playwright, and Chrome opens looking like it has my profile. History is there. Bookmarks are there. Extensions are there. But every site is logged out, and the cookie jar in DevTools is either empty or full of cookies that don't authenticate anything.
The reason is that Playwright launches Chrome with two flags I didn't know about until I started digging:
--use-mock-keychain
--password-store=basic
Those flags tell Chrome to bypass the macOS Keychain entirely and use a hardcoded mock encryption key for cookies and the password store. From Playwright's point of view this is the right default. CI runners don't have a real keychain. Headless containers don't have a real keychain. The mock makes Chrome boot reliably in places where Keychain Access doesn't exist.
But for me, this is exactly wrong. The cookies my daily-driver Chrome wrote to disk were encrypted with the real keychain key, the one Chrome stored under "Chrome Safe Storage" in my login keychain on first install. The cookies that just got copied over are still encrypted with that real key. Playwright's Chrome boots with the mock key, tries to decrypt them, gets garbage, and silently treats every cookie as invalid.
I tried storageState first, which is the documented Playwright path for this. Export cookies and localStorage from one context, inject into another. It works for some sites and dies for others. Substack stalled at the Google SSO redirect and never finished the auth handshake. Note's editor wanted a CSRF token tied to a session cookie that storageState had captured but which the server no longer accepted, presumably because the session was bound to the original UA fingerprint. After the third site failed in a different way I gave up on storageState and went back to cloning the whole profile.
So: two real fixes are needed. Make Playwright's Chrome speak the same encryption language as my daily Chrome, and copy the cookie database in a way that doesn't corrupt it.
Fix one, patch the keychain flag
Playwright's CLI bundles its Chrome launch arguments inside playwright-core/lib/coreBundle.js. When you run npx @playwright/cli@latest, npm caches that file under ~/.npm/_npx/<hash>/node_modules/playwright-core/lib/coreBundle.js. The file is huge and minified, but the two strings I care about appear verbatim:
"--use-mock-keychain",
"--password-store=basic"
A sed rewrite is enough. Swap the first to --use-real-keychain and the second to --password-store=keychain. Chrome on macOS recognizes both, and once they're in place the launched Chrome reads its encryption key from the same login keychain entry as my daily-driver Chrome. The cookies decrypt. SSO holds.
The patch wants to be idempotent because npx happily re-extracts the package if it gets purged from the cache, and I don't want to re-edit the file by hand each time. So the script does three things. It locates the bundle with find. It checks whether the bundle still contains --use-mock-keychain, which means it hasn't been patched yet. If so, it makes a .bak copy on first patch and runs sed -i '' in place.
The .bak is the escape hatch. If a future Playwright update changes those flags or relies on the mock keychain elsewhere and my patch breaks something, I can mv coreBundle.js.bak coreBundle.js and be back to stock in one command.
The first time you launch the patched Chrome, macOS will pop a Keychain Access dialog asking you to allow access to "Chrome Safe Storage." Click Always Allow. After that, no prompts.
Fix two, SQLite backup for the cookie file
With the keychain flag patched, the next failure mode is more subtle. Sometimes the cookies decrypt, sometimes they don't, and when they don't, the SQLite file looks corrupt. Chrome refuses to read it and silently starts a fresh empty cookie jar.
Chrome's Cookies file is a SQLite database. My daily-driver Chrome is almost always running, which means it's holding write locks on that database, and depending on timing it may have a partial write in progress when cp reads the file. The result is a torn copy: the bytes are physically there, but the SQLite page checksums don't match the WAL log, and SQLite refuses to open it.
The right tool for snapshotting a live SQLite database is the .backup command:
sqlite3 "${SOURCE_PROFILE}/Default/Cookies" \
".backup ${PW_PROFILE_DIR}/Default/Cookies"
This isn't just a smarter copy. It uses SQLite's online backup API, which acquires a read lock, copies pages in a way that's transactionally consistent with the source database's current state, and produces a target file that opens cleanly. You can run it while Chrome is actively writing to the source. The output is always a valid database.
The script removes the stale Cookies and Cookies-journal files first, then runs .backup on every launch. That way the cookie jar is always fresh, even if I haven't rebooted but I have used my daily Chrome to log into a new site since the last Playwright session.
The script
The whole thing is at runbooks/launch/playwright-launch.sh in my GTM repo. Roughly thirty lines if you don't count comments.
#!/usr/bin/env bash
# Launch playwright-cli against the corevice.com Chrome profile copy.
# Idempotent: if profile copy missing, re-creates it; if patch missing, re-applies.
# Usage: ./playwright-launch.sh open <url>
# ./playwright-launch.sh <command> [args...]
set -euo pipefail
PW_CACHE_BASE="${HOME}/.npm/_npx"
PW_PROFILE_DIR="/tmp/chrome-pw-corevice"
SOURCE_PROFILE="${HOME}/Library/Application Support/Google/Chrome"
ensure_profile_copy() {
if [ ! -d "${PW_PROFILE_DIR}/Default" ] || [ ! -f "${PW_PROFILE_DIR}/Default/Cookies" ]; then
echo "[setup] copying profile..."
mkdir -p "${PW_PROFILE_DIR}/Default"
rsync -a \
--exclude='Cache' \
--exclude='Code Cache' \
--exclude='GPUCache' \
--exclude='Service Worker' \
--exclude='ShaderCache' \
--exclude='GraphiteDawnCache' \
--exclude='component_crx_cache' \
--exclude='extensions_crx_cache' \
--exclude='Sessions' \
--exclude='File System' \
--exclude='blob_storage' \
--exclude='Cookies' \
--exclude='Cookies-journal' \
"${SOURCE_PROFILE}/Default/" "${PW_PROFILE_DIR}/Default/"
cp "${SOURCE_PROFILE}/Local State" "${PW_PROFILE_DIR}/" 2>/dev/null || true
cp "${SOURCE_PROFILE}/First Run" "${PW_PROFILE_DIR}/" 2>/dev/null || true
fi
# Always refresh cookies via SQLite .backup (safe with Chrome running)
rm -f "${PW_PROFILE_DIR}/Default/Cookies" "${PW_PROFILE_DIR}/Default/Cookies-journal"
sqlite3 "${SOURCE_PROFILE}/Default/Cookies" \
".backup ${PW_PROFILE_DIR}/Default/Cookies" 2>/dev/null
}
ensure_patch() {
local cb
cb=$(find "${PW_CACHE_BASE}" -path '*playwright-core/lib/coreBundle.js' 2>/dev/null | head -1)
if [ -z "$cb" ]; then
echo "[setup] @playwright/cli not yet installed; npx -y will install it"
return
fi
if grep -q -- '--use-mock-keychain' "$cb"; then
echo "[setup] patching playwright to use real keychain..."
[ ! -f "${cb}.bak" ] && cp "$cb" "${cb}.bak"
sed -i '' \
-e 's|"--password-store=basic"|"--password-store=keychain"|' \
-e 's|"--use-mock-keychain",|"--use-real-keychain",|' \
"$cb"
fi
}
ensure_profile_copy
ensure_patch
export PATH="${HOME}/.asdf/shims:${PATH}"
if [ "${1:-}" = "open" ]; then
shift
exec npx -y @playwright/cli@latest open --headed "$@" \
--browser chrome \
--profile "${PW_PROFILE_DIR}"
fi
exec npx -y @playwright/cli@latest \
"$@" \
--browser chrome \
--profile "${PW_PROFILE_DIR}"
A few notes on the rsync exclude list. All the cache directories are excluded because they're large, regenerable, and sometimes hold OS-specific binary blobs that Chrome will rebuild on first launch. Sessions is excluded so Playwright's Chrome doesn't try to restore tabs from my daily browsing. File System and blob_storage are excluded for size. Cookies and Cookies-journal are excluded specifically because we handle them via .backup immediately after the rsync, and we want that to be the authoritative copy.
Local State and First Run are copied separately. Local State is where Chrome stores the encrypted master key reference and a few profile-level settings. First Run is a sentinel file that suppresses the first-run wizard.
The patched diff itself is two lines:
- "--use-mock-keychain",
+ "--use-real-keychain",
- "--password-store=basic"
+ "--password-store=keychain"
That's the whole keychain fix. Two strings.
A side issue, headless mode breaks the clipboard
The script forces --headed for the open subcommand, and there's a story behind that. My publish scripts work by pbcopy-ing the title and body into the clipboard, focusing the editor field via Playwright, and then sending Cmd+V. CodeMirror, Substack's editor, dev.to's editor — they all behave better with a real paste than with type() calls that fire individual keypress events. Markdown formatting survives. Code blocks stay intact. Smart-quote autocorrect doesn't fire.
But headless Chromium doesn't have a system clipboard. navigator.clipboard.readText() returns empty, the paste handler sees no data, and the form silently stays empty. I lost an hour to that one before realizing the open command was defaulting to headless mode in the version of @playwright/cli I was on. Forcing --headed makes the daemon run as a real Chrome window with full clipboard access, which is what I want anyway because I sometimes want to glance at the publish flow while it's running.
The non-open commands pass through unchanged, so anything else that wants headless behavior still gets it.
What it's worth
Five to ten minutes of manual relogins per reboot, multiplied by however often macOS decides to update overnight. Across a year that's hours I get back, and more importantly the publish scripts now run unattended. I push a draft, the script opens the right tab, pastes the right content, and I review the rendered preview before clicking publish.
If you're running a similar setup, the script is generic. Change SOURCE_PROFILE if you use a non-Default Chrome profile, change PW_PROFILE_DIR if you don't trust /tmp to survive your reboot policy, and the rest should work.
This is the kind of small infrastructure work that makes solo operations possible. We build a lot of these at Codens, where the day job is wiring AI agents into the same kind of publishing and dev pipelines.
Top comments (0)