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.
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-puppeteerfor browser tests) kept failing withCould 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-scriptsto just the onenpm installstep in the container (not a Dockerfile-wideENV, 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
postinstalland an explicitpuppeteer browsers install chromestep 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=trueon thenpm installstep specifically, so the postinstall step becomes a no-op and our explicit step is the only place Chrome gets installed.Bug 3:
npx puppeteernot resolving to the pinned versionPuppeteer was only a transitive dependency (via
@web/test-runner-puppeteer), not listed directly inpackage.json. Running barenpx puppeteer browsers install chromeisn't guaranteed to resolve to the exact pinned version sitting innode_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 inpackage-lock.jsonactually expects at test time.Fix: invoke
./node_modules/.bin/puppeteerdirectly instead ofnpx puppeteer, falling back tonpxonly 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
chromebinary itself was still missing — along with most of Chrome's resource files (.paks,.solibs, 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
unzipbinary — it produced a complete, correct install (all ~280MB of it, including the 276MBchromebinary). 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
chromebinary is missing after Puppeteer's own install step, re-extract the already-downloaded zip with systemunzipas a repair step, rather than re-downloading. (Also addedunzipto 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 missinglibnss3,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 minimalnode:*-bookworm-slimbase image doesn't ship.Fix: Puppeteer ships its own official Dockerfile, which runs
puppeteer browsers install chrome --install-depsas root at build time —--install-depstriggersaptinstalling exactly the right OS-specific dependency list (rather than us hand-maintaining one). Since--install-depsrequires root and the container needs to run as non-rootnodeat 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-runapt-get updatefor this step, since the Dockerfile's earlierapt-get installhad 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_ADMINcapability so its own unprivileged-namespace sandbox can work. We deliberately run the container as non-rootnodethroughout (Claude Code itself refuses--dangerously-skip-permissionsas root, for its own safety reasons), so setting up a SUID-root binary wasn't an option. The common workaround of passing--no-sandboxto Chrome wasn't viable either, since that flag lives in each repo's own test/launch config — not something a generic tool likeagent-runnercan inject.Fix:
--cap-add=SYS_ADMINondocker 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-onlyalong 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.