Skip to content

Commit 63650a3

Browse files
authored
fix(claude-code): make Playwright MCP work in both image variants (#85) (#86)
Plugin defaults to the chrome channel (/opt/google/chrome/chrome), which neither variant ships. Adds chromium to sandbox (firewall blocks runtime install), patches every cached .mcp.json to launch /usr/bin/chromium with --no-sandbox --headless, and re-runs the patch on postStartCommand so plugin auto-updates between sessions don't silently regress.
1 parent bdb0811 commit 63650a3

7 files changed

Lines changed: 117 additions & 36 deletions

File tree

‎.github/workflows/build-claude-code.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [master]
66
paths:
77
- "claude-code/.devcontainer/Dockerfile"
8+
- "claude-code/.devcontainer/*.sh"
89
schedule:
910
- cron: '13 11 * * *'
1011
workflow_dispatch:

‎claude-code/.devcontainer/Dockerfile‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ USER node
166166
# - chromium: used by Playwright and agent-browser via
167167
# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH (see .claude/rules/dockerfile.md).
168168
# - fonts-freefont-ttf: baseline fonts for headless rendering.
169-
# Kept out of the base stage so the sandbox target stays lean.
169+
# Mirrored into the sandbox stage too — the firewall blocks deb.debian.org,
170+
# so chromium cannot be added at runtime.
170171
USER root
171172
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
172173
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
@@ -199,7 +200,10 @@ CMD ["sleep", "infinity"]
199200
# ─── SANDBOX — network-restricted environment ─────────────────────────────────
200201
FROM base AS sandbox
201202

202-
# Firewall packages (not needed in default target)
203+
# Firewall packages (not needed in default target).
204+
# Chromium + fonts are also baked in here — the sandbox firewall blocks
205+
# deb.debian.org, so they cannot be added at runtime. Required for the
206+
# Playwright MCP plugin to launch a browser.
203207
USER root
204208
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
205209
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
@@ -208,7 +212,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
208212
ipset \
209213
iproute2 \
210214
dnsutils \
211-
aggregate
215+
aggregate \
216+
chromium \
217+
fonts-freefont-ttf
218+
219+
# Use the apt-installed chromium instead of Playwright-managed browsers
220+
# (matches the default stage; see comment above the default chromium block).
221+
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
222+
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
212223

213224
# Passwordless sudo for node user — needed for:
214225
# - "sudo chown" on named volumes (node_modules isolation)

‎claude-code/.devcontainer/claude-sandbox/devcontainer.json‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,12 @@
121121
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}"
122122
},
123123
// The find command chowns all node_modules volume mount points in one pass.
124-
// Note: the sandbox image does not include chromium — projects needing
125-
// browser testing should install it via a downstream Dockerfile.
124+
// Chromium is baked into the sandbox image (firewall blocks runtime install).
126125
"postCreateCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude && mise install && bun install",
127-
// Firewall init — script is bind-mounted from the project
128-
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
126+
// Firewall init (bind-mounted from project) + re-patch the Playwright MCP
127+
// plugin's .mcp.json. The patch runs on every start so plugin auto-updates
128+
// between sessions don't leave MCP pointing at the missing chrome channel.
129+
// See issue #85.
130+
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh && bash .devcontainer/patch-playwright-mcp.sh",
129131
"waitFor": "postStartCommand"
130132
}

‎claude-code/.devcontainer/devcontainer.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,10 @@
111111
// Chromium is baked into the image via apt — no playwright install step needed.
112112
// Projects' playwright.config.ts should use process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
113113
"updateContentCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude && mise install && bun install",
114+
// Re-patch the Playwright MCP plugin's .mcp.json on every start.
115+
// Plugin auto-updates between sessions create new cache dirs whose .mcp.json
116+
// points at /opt/google/chrome/chrome (which we don't ship). Without this,
117+
// MCP silently breaks until the next image rebuild. See issue #85.
118+
"postStartCommand": "bash .devcontainer/patch-playwright-mcp.sh",
114119
"waitFor": "postCreateCommand"
115120
}

‎claude-code/.devcontainer/init-plugins.sh‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,11 @@ for plugin in "${PLUGINS[@]}"; do
5656
fi
5757
done
5858

59-
# ── Playwright MCP: use Chromium on ARM64 (#64) ─────────────────────────────
60-
# @playwright/mcp defaults to --browser chrome, which has no Linux ARM64 builds.
61-
# Patch the plugin config to use chromium instead. No-op on AMD64.
62-
# Plugins run from the cache dir (~/.claude/plugins/cache), not the marketplace
63-
# source dir. The cache path includes a version hash — resolve via find.
64-
if [ "$(uname -m)" = "aarch64" ]; then
65-
PLAYWRIGHT_MCP_CONFIG=$(find "$HOME/.claude/plugins/cache" \
66-
-path "*/playwright*/.mcp.json" -print -quit 2>/dev/null)
67-
if [ -n "$PLAYWRIGHT_MCP_CONFIG" ]; then
68-
jq '.playwright.args = ["@playwright/mcp@latest", "--browser", "chromium"]' \
69-
"$PLAYWRIGHT_MCP_CONFIG" > /tmp/playwright-mcp.json \
70-
&& mv /tmp/playwright-mcp.json "$PLAYWRIGHT_MCP_CONFIG"
71-
echo "✔ Playwright MCP patched for ARM64 Chromium"
72-
fi
73-
fi
59+
# ── Playwright MCP: route every cached .mcp.json to system chromium ─────────
60+
# Universal across arches (no Chrome stable binary in either default or sandbox).
61+
# Patch logic lives in patch-playwright-mcp.sh so it can also be invoked from
62+
# postStartCommand to catch plugin auto-updates between sessions (#85).
63+
bash "$(dirname "$0")/patch-playwright-mcp.sh"
7464

7565
# ── rtk init (token-optimized CLI proxy) ────────────────────────────────────
7666
# Global hook-first mode: installs only the PreToolUse rewrite hook to ~/.claude/,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/bash
2+
# Patch every cached @playwright/mcp .mcp.json to launch the system chromium.
3+
# Idempotent — safe to run on every container start.
4+
#
5+
# Why this exists:
6+
# @playwright/mcp's default --browser is the chrome channel, which looks at
7+
# /opt/google/chrome/chrome — we ship /usr/bin/chromium instead. The
8+
# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH env var is a project convention, not
9+
# read by Playwright automatically, so MCP needs an explicit --executable-path.
10+
#
11+
# Why a loop instead of `find -print -quit`:
12+
# Plugin auto-updates extract into fresh cache dirs (one per version hash),
13+
# each with an unpatched .mcp.json. Patching only the first match (#64) left
14+
# newer cache dirs broken until container rebuild.
15+
#
16+
# Wire-up: invoke from postCreateCommand (init-plugins.sh) and postStartCommand
17+
# (devcontainer.json) so plugin auto-updates between sessions get re-patched.
18+
19+
set -euo pipefail
20+
21+
if [ ! -x /usr/bin/chromium ]; then
22+
exit 0
23+
fi
24+
25+
find "$HOME/.claude/plugins/cache" -path "*/playwright*/.mcp.json" 2>/dev/null \
26+
| while read -r MCP_CONFIG; do
27+
jq '.playwright.args = [
28+
"@playwright/mcp@latest",
29+
"--browser", "chromium",
30+
"--executable-path", "/usr/bin/chromium",
31+
"--no-sandbox",
32+
"--headless"
33+
]' \
34+
"$MCP_CONFIG" > /tmp/playwright-mcp.json \
35+
&& mv /tmp/playwright-mcp.json "$MCP_CONFIG" \
36+
&& echo "✔ Playwright MCP patched: $MCP_CONFIG"
37+
done

‎claude-code/README.md‎

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ Projects consume these pre-built images and control their own tool versions via
2222
| rtk, ralphex | Always-latest from GitHub Releases | Dev infrastructure (like Claude Code) — no version pinning needed in projects |
2323
| Claude Code | npm global install | npm avoids rate limiting that affects the native installer in parallel CI builds |
2424

25-
**Default-only:** passwordless sudo, agent-browser, system Chromium (used by Playwright via `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`)
25+
**Both targets:** system Chromium + `fonts-freefont-ttf` (used by Playwright and the Playwright MCP plugin via `/usr/bin/chromium`)
26+
27+
**Default-only:** passwordless sudo, agent-browser
2628

2729
**Sandbox-only:** iptables, ipset, iproute2, dnsutils, aggregate, firewall sudo rule
2830

@@ -76,17 +78,20 @@ Projects consuming these images need the following files in their repository.
7678

7779
Only pin tools that affect project stability — dev infrastructure (rtk, ralphex, Claude Code) is pre-installed in the image at latest. See [`mise.toml`](mise.toml) for a template.
7880

79-
### Optional: `.devcontainer/init-plugins.sh`
81+
### Optional: `.devcontainer/init-plugins.sh` and `.devcontainer/patch-playwright-mcp.sh`
8082

81-
Claude Code plugin initialization. Runs once at container creation. Idempotent. See [`.devcontainer/init-plugins.sh`](.devcontainer/init-plugins.sh) for a template.
83+
Claude Code plugin initialization. `init-plugins.sh` registers marketplaces, installs plugins, and (via `patch-playwright-mcp.sh`) rewrites every cached Playwright MCP `.mcp.json` to launch the system chromium. Both scripts are idempotent. See [`.devcontainer/init-plugins.sh`](.devcontainer/init-plugins.sh) and [`.devcontainer/patch-playwright-mcp.sh`](.devcontainer/patch-playwright-mcp.sh) for templates.
8284

83-
Wire it into `postCreateCommand` in your `devcontainer.json`:
85+
Wire them into `devcontainer.json`:
8486

8587
```jsonc
86-
"postCreateCommand": "bash .devcontainer/init-plugins.sh"
88+
"postCreateCommand": "bash .devcontainer/init-plugins.sh",
89+
"postStartCommand": "bash .devcontainer/patch-playwright-mcp.sh"
8790
```
8891

89-
Mark as executable: `chmod +x init-plugins.sh`
92+
`postStartCommand` re-runs the patch on every container start so plugin auto-updates between sessions cannot leave MCP pointing at the missing chrome channel. See the [Playwright Strategy](#playwright-strategy) section.
93+
94+
Mark as executable: `chmod +x init-plugins.sh patch-playwright-mcp.sh`
9095

9196
### Sandbox-only: `.devcontainer/claude-sandbox/init-firewall.sh`
9297

@@ -178,6 +183,7 @@ The template includes extensions for Claude Code, Bun, OXC, Tailwind, YAML, Dock
178183
├── devcontainer.json ← default devcontainer
179184
├── docker-compose.yml ← default compose (image + pull_policy)
180185
├── init-plugins.sh ← Claude Code plugin setup (optional)
186+
├── patch-playwright-mcp.sh ← Playwright MCP .mcp.json patch (optional)
181187
└── claude-sandbox/
182188
├── devcontainer.json ← sandbox devcontainer
183189
├── docker-compose.yml ← sandbox compose (image + pull_policy)
@@ -213,11 +219,12 @@ The `sudo find` in `updateContentCommand` chowns all `node_modules` directories
213219

214220
## Playwright Strategy
215221

216-
The image uses the system `chromium` package (apt-installed in the `default`
217-
target) instead of Playwright-managed browser binaries. This avoids version
218-
coupling between `@playwright/mcp` (alpha `playwright-core` builds) and cached
222+
Both image variants ship the system `chromium` package (apt-installed)
223+
instead of Playwright-managed browser binaries. This avoids version coupling
224+
between `@playwright/mcp` (alpha `playwright-core` builds) and cached
219225
binaries, and keeps the image significantly smaller than shipping a
220-
Playwright-managed Chromium per rebuild.
226+
Playwright-managed Chromium per rebuild. Sandbox needs it baked in because
227+
the firewall blocks `deb.debian.org` at runtime.
221228

222229
The Dockerfile sets:
223230

@@ -241,10 +248,38 @@ export default defineConfig({
241248
});
242249
```
243250

244-
**Sandbox note:** `chromium` is installed only in the `default` target.
245-
Projects using the `sandbox` image that need browser testing should add
246-
chromium themselves (via a thin downstream Dockerfile or container
247-
`postStart`).
251+
### Playwright MCP plugin
252+
253+
The `playwright@claude-plugins-official` plugin's `@playwright/mcp` defaults
254+
to the `chrome` channel (`/opt/google/chrome/chrome`), which the image does
255+
not ship. The included `patch-playwright-mcp.sh` rewrites every cached
256+
`.mcp.json` under `~/.claude/plugins/cache/` to use the system chromium:
257+
258+
```json
259+
{
260+
"playwright": {
261+
"command": "npx",
262+
"args": [
263+
"@playwright/mcp@latest",
264+
"--browser", "chromium",
265+
"--executable-path", "/usr/bin/chromium",
266+
"--no-sandbox",
267+
"--headless"
268+
]
269+
}
270+
}
271+
```
272+
273+
`init-plugins.sh` invokes the patch script at `postCreateCommand`, and the
274+
template `devcontainer.json` files run it again at `postStartCommand` so
275+
plugin auto-updates between sessions cannot leave MCP pointing at the
276+
missing chrome channel.
277+
278+
To debug, inspect the patched configs after a container start:
279+
280+
```bash
281+
cat ~/.claude/plugins/cache/claude-plugins-official/playwright/*/.mcp.json
282+
```
248283

249284
## Build Args
250285

0 commit comments

Comments
 (0)