Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-claude-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [master]
paths:
- "claude-code/.devcontainer/Dockerfile"
- "claude-code/.devcontainer/*.sh"
schedule:
- cron: '13 11 * * *'
workflow_dispatch:
Expand Down
17 changes: 14 additions & 3 deletions claude-code/.devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand All @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions claude-code/.devcontainer/claude-sandbox/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 5 additions & 0 deletions claude-code/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
20 changes: 5 additions & 15 deletions claude-code/.devcontainer/init-plugins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
37 changes: 37 additions & 0 deletions claude-code/.devcontainer/patch-playwright-mcp.sh
Original file line number Diff line number Diff line change
@@ -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
63 changes: 49 additions & 14 deletions claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Expand All @@ -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

Expand Down