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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"* ]]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user