Skip to content

The Puppeteer-in-Docker saga: six layered bugs to get headless Chrome working in an isolated container #57

Description

@jackfranklin

Context

agent-runner (this repo) runs Claude Code in an isolated Docker container to implement GitHub issues and open PRs against real projects, unattended. One test repo (a Node/npm project using Puppeteer via @web/test-runner-puppeteer for browser tests) kept failing with Could not find Chrome, and getting it working end-to-end turned into five distinct, stacked bugs. Writing them down in order because each one looked like "the" problem until the next layer appeared.

Bug 1: npm silently blocking postinstall scripts

Symptom: Could not find Chrome (ver. ...) during tests, no obvious cause.

Cause: npm is rolling out a new install-script approval system (warnings in npm 11, hard failures in npm 12) that blocks lifecycle scripts — including Puppeteer's own postinstall, which is what downloads Chrome — unless explicitly allowed.

Fix: scope --dangerously-allow-all-scripts to just the one npm install step in the container (not a Dockerfile-wide ENV, so it doesn't silently apply to every future npm invocation for every repo this tool ever touches).

Bug 2: two competing downloads racing on the same cache folder

Symptom: same error, even with scripts allowed.

Cause: once postinstall scripts could run, both Puppeteer's own postinstall and an explicit puppeteer browsers install chrome step we'd added as a fallback were both trying to download Chrome into ~/.cache/puppeteer. The race left a corrupted, partially-extracted install: "the browser folder exists but the executable is missing."

Fix: PUPPETEER_SKIP_DOWNLOAD=true on the npm install step specifically, so the postinstall step becomes a no-op and our explicit step is the only place Chrome gets installed.

Bug 3: npx puppeteer not resolving to the pinned version

Puppeteer was only a transitive dependency (via @web/test-runner-puppeteer), not listed directly in package.json. Running bare npx puppeteer browsers install chrome isn't guaranteed to resolve to the exact pinned version sitting in node_modules — if npx's local-bin lookup misses it, it silently falls back to fetching whatever's latest on the registry, which can target a completely different Chrome build than what the pinned version in package-lock.json actually expects at test time.

Fix: invoke ./node_modules/.bin/puppeteer directly instead of npx puppeteer, falling back to npx only if no local binary exists (e.g. puppeteer-core-only setups).

Bug 4: Puppeteer's own zip extraction silently incomplete

Symptom: the download completed (zip fully present, correct byte size, ~184MB), but the chrome binary itself was still missing — along with most of Chrome's resource files (.paks, .so libs, locale files). Only a handful of small leaf files/dirs (ABOUT, MEIPreload/, WidevineCdm/, PrivacySandboxAttestationsPreloaded/) actually extracted.

Diagnosis: manually re-extracted the same zip with the system unzip binary — it produced a complete, correct install (all ~280MB of it, including the 276MB chrome binary). This proved the zip itself was fine; Puppeteer's own bundled (JS-based) zip extraction was what was broken in this container environment.

Fix: if the chrome binary is missing after Puppeteer's own install step, re-extract the already-downloaded zip with system unzip as a repair step, rather than re-downloading. (Also added unzip to the image, on a hunch it was the missing piece — it wasn't, on its own, since Puppeteer doesn't shell out to it. Still needed for the manual repair step and for debugging.)

Bug 5: missing OS-level shared libraries for Chrome to launch

Symptom: once extraction was fixed and Chrome could actually be found, it still failed to run — apt-level errors about missing libnss3, libatk-bridge2.0-0, libgtk-3-0, fonts-liberation, and about a dozen others. Headless Chrome needs a pile of OS shared libraries that a minimal node:*-bookworm-slim base image doesn't ship.

Fix: Puppeteer ships its own official Dockerfile, which runs puppeteer browsers install chrome --install-deps as root at build time — --install-deps triggers apt installing exactly the right OS-specific dependency list (rather than us hand-maintaining one). Since --install-deps requires root and the container needs to run as non-root node at runtime (more on that in Bug 6), this had to be a separate build-time-only step, using a throwaway Chrome download purely to trigger the apt install. Also had to re-run apt-get update for this step, since the Dockerfile's earlier apt-get install had already deleted /var/lib/apt/lists/* to shrink the image — simplest fix was to stop deleting the lists until the very end of the build instead of deleting-then-refetching mid-build.

Bug 6: no usable sandbox

Symptom: FATAL: No usable sandbox! — Chrome refused to launch even with all libraries present.

Cause: Chrome's sandbox needs either a properly configured SUID-root sandbox binary, or the SYS_ADMIN capability so its own unprivileged-namespace sandbox can work. We deliberately run the container as non-root node throughout (Claude Code itself refuses --dangerously-skip-permissions as root, for its own safety reasons), so setting up a SUID-root binary wasn't an option. The common workaround of passing --no-sandbox to Chrome wasn't viable either, since that flag lives in each repo's own test/launch config — not something a generic tool like agent-runner can inject.

Fix: --cap-add=SYS_ADMIN on docker run. This is Puppeteer's own documented recommendation for exactly this Docker scenario. It's a meaningfully broad capability (grants more than just sandboxing — mount/umount, etc.) but acceptable here since it's scoped to an already-isolated, ephemeral, per-run container, not the host.

Result

Tests pass. Six layered bugs across dependency install policy, a download race, npm/npx version resolution, a genuine bug in Puppeteer's bundled extraction, missing OS libraries, and container sandboxing — each one only visible once the previous layer was peeled back. Also built agent-run --test-only along the way (clone + install + test, skipping Claude/PR entirely) specifically to make iterating on this kind of environment bug fast instead of re-running a full, slow, costly Claude session for every attempted fix.

Relevant code: agent-runner/Dockerfile, agent-runner/entrypoint.sh, agent-runner/bin/agent-run, agent-runner/README.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions