diff --git a/.github/workflows/build-claude-code.yml b/.github/workflows/build-claude-code.yml index cd2c860..76779fe 100644 --- a/.github/workflows/build-claude-code.yml +++ b/.github/workflows/build-claude-code.yml @@ -5,6 +5,7 @@ on: branches: [master] paths: - "claude-code/.devcontainer/Dockerfile" + - "claude-code/.devcontainer/*.sh" schedule: - cron: '13 11 * * *' workflow_dispatch: diff --git a/claude-code/.devcontainer/Dockerfile b/claude-code/.devcontainer/Dockerfile index e6bec7e..a5c98e6 100644 --- a/claude-code/.devcontainer/Dockerfile +++ b/claude-code/.devcontainer/Dockerfile @@ -166,7 +166,8 @@ USER node # - chromium: used by Playwright and agent-browser via # PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH (see .claude/rules/dockerfile.md). # - fonts-freefont-ttf: baseline fonts for headless rendering. -# Kept out of the base stage so the sandbox target stays lean. +# Mirrored into the sandbox stage too — the firewall blocks deb.debian.org, +# so chromium cannot be added at runtime. USER root RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ @@ -199,7 +200,10 @@ CMD ["sleep", "infinity"] # ─── SANDBOX — network-restricted environment ───────────────────────────────── FROM base AS sandbox -# Firewall packages (not needed in default target) +# Firewall packages (not needed in default target). +# Chromium + fonts are also baked in here — the sandbox firewall blocks +# deb.debian.org, so they cannot be added at runtime. Required for the +# Playwright MCP plugin to launch a browser. USER root RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ @@ -208,7 +212,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ipset \ iproute2 \ dnsutils \ - aggregate + aggregate \ + chromium \ + fonts-freefont-ttf + +# Use the apt-installed chromium instead of Playwright-managed browsers +# (matches the default stage; see comment above the default chromium block). +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium # Passwordless sudo for node user — needed for: # - "sudo chown" on named volumes (node_modules isolation) diff --git a/claude-code/.devcontainer/claude-sandbox/devcontainer.json b/claude-code/.devcontainer/claude-sandbox/devcontainer.json index 1281a84..3f6d2ea 100644 --- a/claude-code/.devcontainer/claude-sandbox/devcontainer.json +++ b/claude-code/.devcontainer/claude-sandbox/devcontainer.json @@ -121,10 +121,12 @@ "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}" }, // The find command chowns all node_modules volume mount points in one pass. - // Note: the sandbox image does not include chromium — projects needing - // browser testing should install it via a downstream Dockerfile. + // Chromium is baked into the sandbox image (firewall blocks runtime install). "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", - // Firewall init — script is bind-mounted from the project - "postStartCommand": "sudo /usr/local/bin/init-firewall.sh", + // Firewall init (bind-mounted from project) + re-patch the Playwright MCP + // plugin's .mcp.json. The patch runs on every start so plugin auto-updates + // between sessions don't leave MCP pointing at the missing chrome channel. + // See issue #85. + "postStartCommand": "sudo /usr/local/bin/init-firewall.sh && bash .devcontainer/patch-playwright-mcp.sh", "waitFor": "postStartCommand" } diff --git a/claude-code/.devcontainer/devcontainer.json b/claude-code/.devcontainer/devcontainer.json index 6fdb804..b6f0855 100644 --- a/claude-code/.devcontainer/devcontainer.json +++ b/claude-code/.devcontainer/devcontainer.json @@ -111,5 +111,10 @@ // Chromium is baked into the image via apt — no playwright install step needed. // Projects' playwright.config.ts should use process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH. "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", + // Re-patch the Playwright MCP plugin's .mcp.json on every start. + // Plugin auto-updates between sessions create new cache dirs whose .mcp.json + // points at /opt/google/chrome/chrome (which we don't ship). Without this, + // MCP silently breaks until the next image rebuild. See issue #85. + "postStartCommand": "bash .devcontainer/patch-playwright-mcp.sh", "waitFor": "postCreateCommand" } diff --git a/claude-code/.devcontainer/init-plugins.sh b/claude-code/.devcontainer/init-plugins.sh index 99a91fe..aa865bb 100644 --- a/claude-code/.devcontainer/init-plugins.sh +++ b/claude-code/.devcontainer/init-plugins.sh @@ -56,21 +56,11 @@ for plugin in "${PLUGINS[@]}"; do fi done -# ── Playwright MCP: use Chromium on ARM64 (#64) ───────────────────────────── -# @playwright/mcp defaults to --browser chrome, which has no Linux ARM64 builds. -# Patch the plugin config to use chromium instead. No-op on AMD64. -# Plugins run from the cache dir (~/.claude/plugins/cache), not the marketplace -# source dir. The cache path includes a version hash — resolve via find. -if [ "$(uname -m)" = "aarch64" ]; then - PLAYWRIGHT_MCP_CONFIG=$(find "$HOME/.claude/plugins/cache" \ - -path "*/playwright*/.mcp.json" -print -quit 2>/dev/null) - if [ -n "$PLAYWRIGHT_MCP_CONFIG" ]; then - jq '.playwright.args = ["@playwright/mcp@latest", "--browser", "chromium"]' \ - "$PLAYWRIGHT_MCP_CONFIG" > /tmp/playwright-mcp.json \ - && mv /tmp/playwright-mcp.json "$PLAYWRIGHT_MCP_CONFIG" - echo "✔ Playwright MCP patched for ARM64 Chromium" - fi -fi +# ── Playwright MCP: route every cached .mcp.json to system chromium ───────── +# Universal across arches (no Chrome stable binary in either default or sandbox). +# Patch logic lives in patch-playwright-mcp.sh so it can also be invoked from +# postStartCommand to catch plugin auto-updates between sessions (#85). +bash "$(dirname "$0")/patch-playwright-mcp.sh" # ── rtk init (token-optimized CLI proxy) ──────────────────────────────────── # Global hook-first mode: installs only the PreToolUse rewrite hook to ~/.claude/, diff --git a/claude-code/.devcontainer/patch-playwright-mcp.sh b/claude-code/.devcontainer/patch-playwright-mcp.sh new file mode 100755 index 0000000..48b1037 --- /dev/null +++ b/claude-code/.devcontainer/patch-playwright-mcp.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Patch every cached @playwright/mcp .mcp.json to launch the system chromium. +# Idempotent — safe to run on every container start. +# +# Why this exists: +# @playwright/mcp's default --browser is the chrome channel, which looks at +# /opt/google/chrome/chrome — we ship /usr/bin/chromium instead. The +# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH env var is a project convention, not +# read by Playwright automatically, so MCP needs an explicit --executable-path. +# +# Why a loop instead of `find -print -quit`: +# Plugin auto-updates extract into fresh cache dirs (one per version hash), +# each with an unpatched .mcp.json. Patching only the first match (#64) left +# newer cache dirs broken until container rebuild. +# +# Wire-up: invoke from postCreateCommand (init-plugins.sh) and postStartCommand +# (devcontainer.json) so plugin auto-updates between sessions get re-patched. + +set -euo pipefail + +if [ ! -x /usr/bin/chromium ]; then + exit 0 +fi + +find "$HOME/.claude/plugins/cache" -path "*/playwright*/.mcp.json" 2>/dev/null \ +| while read -r MCP_CONFIG; do + jq '.playwright.args = [ + "@playwright/mcp@latest", + "--browser", "chromium", + "--executable-path", "/usr/bin/chromium", + "--no-sandbox", + "--headless" + ]' \ + "$MCP_CONFIG" > /tmp/playwright-mcp.json \ + && mv /tmp/playwright-mcp.json "$MCP_CONFIG" \ + && echo "✔ Playwright MCP patched: $MCP_CONFIG" +done diff --git a/claude-code/README.md b/claude-code/README.md index 4ccc618..b4cbd8d 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -22,7 +22,9 @@ Projects consume these pre-built images and control their own tool versions via | rtk, ralphex | Always-latest from GitHub Releases | Dev infrastructure (like Claude Code) — no version pinning needed in projects | | Claude Code | npm global install | npm avoids rate limiting that affects the native installer in parallel CI builds | -**Default-only:** passwordless sudo, agent-browser, system Chromium (used by Playwright via `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`) +**Both targets:** system Chromium + `fonts-freefont-ttf` (used by Playwright and the Playwright MCP plugin via `/usr/bin/chromium`) + +**Default-only:** passwordless sudo, agent-browser **Sandbox-only:** iptables, ipset, iproute2, dnsutils, aggregate, firewall sudo rule @@ -76,17 +78,20 @@ Projects consuming these images need the following files in their repository. 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. -### Optional: `.devcontainer/init-plugins.sh` +### Optional: `.devcontainer/init-plugins.sh` and `.devcontainer/patch-playwright-mcp.sh` -Claude Code plugin initialization. Runs once at container creation. Idempotent. See [`.devcontainer/init-plugins.sh`](.devcontainer/init-plugins.sh) for a template. +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. -Wire it into `postCreateCommand` in your `devcontainer.json`: +Wire them into `devcontainer.json`: ```jsonc -"postCreateCommand": "bash .devcontainer/init-plugins.sh" +"postCreateCommand": "bash .devcontainer/init-plugins.sh", +"postStartCommand": "bash .devcontainer/patch-playwright-mcp.sh" ``` -Mark as executable: `chmod +x init-plugins.sh` +`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. + +Mark as executable: `chmod +x init-plugins.sh patch-playwright-mcp.sh` ### Sandbox-only: `.devcontainer/claude-sandbox/init-firewall.sh` @@ -178,6 +183,7 @@ The template includes extensions for Claude Code, Bun, OXC, Tailwind, YAML, Dock ├── devcontainer.json ← default devcontainer ├── docker-compose.yml ← default compose (image + pull_policy) ├── init-plugins.sh ← Claude Code plugin setup (optional) +├── patch-playwright-mcp.sh ← Playwright MCP .mcp.json patch (optional) └── claude-sandbox/ ├── devcontainer.json ← sandbox devcontainer ├── docker-compose.yml ← sandbox compose (image + pull_policy) @@ -213,11 +219,12 @@ The `sudo find` in `updateContentCommand` chowns all `node_modules` directories ## Playwright Strategy -The image uses the system `chromium` package (apt-installed in the `default` -target) instead of Playwright-managed browser binaries. This avoids version -coupling between `@playwright/mcp` (alpha `playwright-core` builds) and cached +Both image variants ship the system `chromium` package (apt-installed) +instead of Playwright-managed browser binaries. This avoids version coupling +between `@playwright/mcp` (alpha `playwright-core` builds) and cached binaries, and keeps the image significantly smaller than shipping a -Playwright-managed Chromium per rebuild. +Playwright-managed Chromium per rebuild. Sandbox needs it baked in because +the firewall blocks `deb.debian.org` at runtime. The Dockerfile sets: @@ -241,10 +248,38 @@ export default defineConfig({ }); ``` -**Sandbox note:** `chromium` is installed only in the `default` target. -Projects using the `sandbox` image that need browser testing should add -chromium themselves (via a thin downstream Dockerfile or container -`postStart`). +### Playwright MCP plugin + +The `playwright@claude-plugins-official` plugin's `@playwright/mcp` defaults +to the `chrome` channel (`/opt/google/chrome/chrome`), which the image does +not ship. The included `patch-playwright-mcp.sh` rewrites every cached +`.mcp.json` under `~/.claude/plugins/cache/` to use the system chromium: + +```json +{ + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--browser", "chromium", + "--executable-path", "/usr/bin/chromium", + "--no-sandbox", + "--headless" + ] + } +} +``` + +`init-plugins.sh` invokes the patch script at `postCreateCommand`, and the +template `devcontainer.json` files run it again at `postStartCommand` so +plugin auto-updates between sessions cannot leave MCP pointing at the +missing chrome channel. + +To debug, inspect the patched configs after a container start: + +```bash +cat ~/.claude/plugins/cache/claude-plugins-official/playwright/*/.mcp.json +``` ## Build Args