Add Playwright/Chromium for testing frontend changes

Installs playwright + a matching Chromium build at image build time
(system deps as root via install-deps, npm package + browser binary as
node so they land in node's own npm prefix/cache), so a session never
needs network access for either. NODE_PATH makes require("playwright")
work from any script, not just one with its own local node_modules -
global npm install alone only puts the CLI on PATH.

Testing against a project's own local dev server needs no firewall
changes: localhost traffic never goes through the egress firewall.
Verified the firewall does apply to the browser's own traffic for
anything else, the same as curl/git - a non-allowlisted domain gets
blocked from inside Playwright too, not silently bypassed, while an
allowlisted one still works.

Chromium's own internal sandbox is documented honestly rather than
assumed either way: it launches without --no-sandbox and without error,
but its setuid helper isn't actually setuid here, and this host has the
same unprivileged-userns restriction that blocks bubblewrap - so it's
likely not providing real isolation, just not hard-failing about it the
way bubblewrap does. Not treated as a security boundary regardless: this
container is already the boundary, and a compromised session already has
full node-level bash access that a Chromium exploit couldn't exceed.

Test suite grew to 29: Playwright is installed and require()-able, it
actually launches and renders (not just installs), and the firewall
applies to its traffic.
This commit is contained in:
2026-07-20 17:32:07 -07:00
parent 7267e1ca77
commit df3399b0d3
4 changed files with 117 additions and 0 deletions
+32
View File
@@ -151,12 +151,24 @@ RUN ARCH=$(dpkg --print-architecture) && \
rm go.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
# `playwright` package and browser binary install as node, further down,
# so they land in node's own npm prefix/cache correctly.
ARG PLAYWRIGHT_VERSION=1.61.1
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Set up non-root user
USER node
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# A global npm install only puts a package's CLI on PATH, not its module on
# Node's require() resolution path - NODE_PATH is what makes `require
# ("playwright")` work from any script anywhere, not just the project's own
# node_modules (which it likely doesn't have without its own npm install).
ENV NODE_PATH=/usr/local/share/npm-global/lib/node_modules
# Set the default shell to zsh rather than sh
ENV SHELL=/bin/zsh
@@ -184,6 +196,26 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# doesn't have.
RUN npm install -g tree-sitter-cli@0.24.7
# Playwright, for testing frontend changes against a locally-running dev
# server - localhost traffic never goes through the egress firewall, so
# this needs no allowlist changes for that. System deps were already
# installed as root above; this installs the npm package and downloads the
# matching Chromium build into node's own cache, both at build time so a
# session never needs network access for either.
#
# Chromium's own internal process sandbox is questionable here: it launches
# without --no-sandbox and without error, but its setuid sandbox helper
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
# has the same unprivileged-userns restriction that broke bubblewrap - so
# its namespace-based isolation almost certainly isn't active either, it's
# just not hard-failing the way bubblewrap did. Not treated as a real
# security boundary either way: this container is already the boundary that
# matters, and a compromised session already has full `node`-level bash
# access, which a Chromium exploit couldn't exceed regardless of whichever
# of its own internal sandbox layers happen to be active.
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
# helper from this repo's bin/ directory, the claude wrapper that pins
# --dangerously-skip-permissions, and this user's real nvim config.
+34
View File
@@ -106,6 +106,40 @@ convention of writing specs to `~/specs/` (also carried into the container)
actually work there instead of writing into a path that vanishes with the
session.
## Testing frontend changes
Playwright (Chromium) is pre-installed, browser included - `require
("playwright")` works from any script, anywhere in the container, not just
a project with its own `npm install playwright`. Both the npm package and
the matching Chromium build are baked in at image build time, so a session
never needs network access for either.
Testing against a project's own local dev server needs no firewall changes
at all - `localhost`/loopback traffic never goes through the egress
firewall in the first place. Testing against a real external site works
the same as any other network use: the domain needs to be on the allowlist
(`ALLOWED_DOMAINS`, or `bin/claude-sandbox-allow <domain>` for a running
session) - verified the firewall applies to the browser's own traffic
exactly like everything else (a non-allowlisted domain gets blocked from
inside the browser too, not just from `curl`/`git`).
If a project has its *own* pinned `playwright`/`@playwright/test`
devDependency at a different version than the one baked in here, its
`npm install` may still try to download a matching Chromium build from
Playwright's own CDN, which isn't on the default allowlist - allow it the
same way as any other one-off domain need if that comes up.
Chromium's own internal process sandbox is best-effort here, not something
to rely on: it launches without erroring, but its setuid sandbox helper
isn't actually setuid in this image, and this host has the same
unprivileged-userns restriction that keeps Claude Code's own internal
sandboxing (see Security notes) from working either - so its namespace-
based isolation almost certainly isn't active, Chromium just doesn't
hard-fail the way `bubblewrap` does when it's missing. Not treated as a
real boundary: this container is already the boundary that matters, and a
compromised session already has full `node`-level bash access, which a
Chromium exploit couldn't exceed regardless.
## Sandbox self-awareness
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`, registered in
+42
View File
@@ -160,3 +160,45 @@ dexec_node() {
run dexec ipset test allowed-domains "$ip"
[ "$status" -eq 0 ]
}
@test "Playwright/Chromium actually launches and renders, not just installs" {
dexec_node bash -c 'cat > /tmp/pw-launch-test.js <<'"'"'EOF'"'"'
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({});
const page = await browser.newPage();
await page.setContent("<h1>ok</h1>");
const text = await page.textContent("h1");
if (text !== "ok") throw new Error("unexpected content: " + text);
await browser.close();
console.log("PLAYWRIGHT_LAUNCH_OK");
})();
EOF'
run dexec_node node /tmp/pw-launch-test.js
[ "$status" -eq 0 ]
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
}
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
# The whole point of pre-baking the browser at build time is that a
# session never needs new network access for it - confirm the egress
# allowlist actually governs what the browser itself can reach, the same
# as every other tool in the container.
dexec_node bash -c 'cat > /tmp/pw-firewall-test.js <<'"'"'EOF'"'"'
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({});
const page = await browser.newPage();
try {
await page.goto("http://example.com", { timeout: 5000 });
console.log("REACHED_BLOCKED_DOMAIN");
} catch (e) {
console.log("BLOCKED_AS_EXPECTED");
}
await browser.close();
})();
EOF'
run dexec_node node /tmp/pw-firewall-test.js
[[ "$output" == *"BLOCKED_AS_EXPECTED"* ]]
! [[ "$output" == *"REACHED_BLOCKED_DOMAIN"* ]]
}
+9
View File
@@ -19,6 +19,15 @@ setup() {
[ "$status" -ne 0 ]
}
@test "Playwright is installed and require()-able globally" {
# A global npm install only puts the CLI on PATH, not the module on
# Node's require() resolution path - NODE_PATH is what makes
# require("playwright") work from any script, not just one with its own
# local node_modules (see runtime.bats for the actual launch test).
grep -q 'npm install -g playwright' "$SANDBOX_DIR/Dockerfile"
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
}
@test "every ipset add in init-firewall.sh is duplicate-safe" {
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
# ipset add without -exist errors on a duplicate entry, and set -e turns