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
69 changes: 31 additions & 38 deletions claude-code/.devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ RUN set -eux; \
FROM node:24-trixie-slim AS base

ARG GIT_DELTA_VERSION=0.18.2
ARG PLAYWRIGHT_VERSION=1.58.2

# System packages shared by all targets
# - ca-certificates: SSL/TLS for HTTPS connections
Expand Down Expand Up @@ -115,14 +114,15 @@ USER root
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -sS https://starship.rs/install.sh | sh -s -- --yes
RUN curl https://mise.run | sh \
&& cp /root/.local/bin/mise /usr/local/bin/mise
&& cp /root/.local/bin/mise /usr/local/bin/mise \
&& rm -rf /root/.local
SHELL ["/bin/sh", "-c"]

# Configure starship and fish shell.
# Mise is installed as a tool manager — projects run `mise install` at container
# creation to install their specific tool versions from .mise.toml.
# Node is NOT installed via mise — it's provided by the base image;
# mise shims would shadow the base image's npx, breaking Playwright installation.
# Node is NOT installed via mise — it's provided by the base image; mise shims
# would shadow the base image's npx, breaking global npm-installed CLIs.
USER node
RUN mkdir -p /home/node/.config/fish \
&& starship preset no-runtime-versions -o /home/node/.config/starship.toml \
Expand All @@ -137,22 +137,6 @@ ENV MISE_TRUSTED_CONFIG_PATHS="/workspace"
COPY --from=rtk-download /usr/local/bin/rtk /usr/local/bin/rtk
COPY --from=ralphex-download /usr/local/bin/ralphex /usr/local/bin/ralphex

# ── Playwright (headless shell for browser testing) ───────────────────────────
# Two-layer strategy for multi-project compatibility:
# 1. System deps (install-deps) — heavy, needs root, version-stable across
# nearby releases. Baked into the image so projects don't need sudo.
# 2. Browser binary (install --only-shell) — light, version-specific.
# Baked as a cache; projects MUST run `npx playwright install --only-shell chromium`
# at container creation to ensure the binary matches their @playwright/test.
# That command is idempotent: no-op when versions match, ~10s download if not.
USER root
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
USER node
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install --only-shell
ENV PLAYWRIGHT_VERSION=${PLAYWRIGHT_VERSION}

# ── Claude Code CLI ───────────────────────────────────────────────────────────
# Using npm instead of the native installer (curl claude.ai/install.sh | bash).
# The native installer is recommended for interactive use, but rate-limits (429)
Expand All @@ -161,10 +145,11 @@ ENV PLAYWRIGHT_VERSION=${PLAYWRIGHT_VERSION}
# reasons" — Docker/CI parallel builds are exactly that reason.
# See: https://code.claude.com/docs/en/getting-started#install-with-npm
# Auto-updates don't matter here — the image rebuilds daily.
USER root
RUN --mount=type=cache,target=/root/.npm \
npm install -g @anthropic-ai/claude-code
USER node
#
# Install as the node user so all files land with node ownership from the start.
# A later `chown -R` in another layer would duplicate every file (overlayfs
# treats an ownership change as a rewrite), adding hundreds of MB.
RUN npm install -g @anthropic-ai/claude-code

# ─── DEFAULT — full dev environment ───────────────────────────────────────────
FROM base AS default
Expand All @@ -177,24 +162,32 @@ RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node-nopasswd \
&& chmod 0440 /etc/sudoers.d/node-nopasswd
USER node

# agent-browser: headless browser automation for AI agents
# amd64: Chrome for Testing (Google's official automation channel)
# arm64: system Chromium (Chrome for Testing has no ARM64 Linux builds)
ARG AGENT_BROWSER_VERSION=latest
# System Chromium + fonts for headless browser testing.
# - 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.
USER root
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
npm install -g agent-browser@${AGENT_BROWSER_VERSION} \
&& ARCH=$(dpkg --print-architecture) \
&& if [ "$ARCH" = "amd64" ]; then \
agent-browser install --with-deps; \
else \
apt-get update && apt-get install -y --no-install-recommends chromium; \
fi \
&& chown -R node:node /usr/local/share/npm-global
apt-get update && apt-get install -y --no-install-recommends \
chromium \
fonts-freefont-ttf
USER node

# Use the apt-installed chromium instead of Playwright-managed browsers.
# Avoids version coupling between @playwright/mcp (alpha playwright-core builds)
# and cached browser binaries. Projects using @playwright/test must point their
# playwright.config.ts at process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium

# agent-browser: headless browser automation for AI agents.
# Uses the apt-installed chromium above.
# Installed as the node user (see claude-code install above for rationale).
ARG AGENT_BROWSER_VERSION=latest
RUN npm install -g agent-browser@${AGENT_BROWSER_VERSION}

LABEL org.opencontainers.image.source="https://github.com/gatezh/devcontainers" \
org.opencontainers.image.description="Claude Code devcontainer — full dev environment with agent-browser, Playwright, and passwordless sudo" \
org.opencontainers.image.licenses="MIT" \
Expand Down
5 changes: 3 additions & 2 deletions claude-code/.devcontainer/claude-sandbox/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@
// injected from the host. See "Sandbox Authentication" section in README.
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}"
},
// Runs before postStartCommand (firewall), so network is still available for browser downloads.
// The find command chowns all node_modules volume mount points in one pass.
"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 && npx playwright install --only-shell chromium",
// Note: the sandbox image does not include chromium — projects needing
// browser testing should install it via a downstream Dockerfile.
"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",
"waitFor": "postStartCommand"
Expand Down
6 changes: 3 additions & 3 deletions claude-code/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@
// sudo chown fixes volume ownership — safety net in case Docker volume population didn't apply.
// The find command chowns all node_modules volume mount points in one pass.
// mise install reads .mise.toml and installs project-specific tool versions.
// playwright install ensures the correct browser binary for the project's @playwright/test version
// (idempotent — skips download if the image's cached binary already matches).
"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 && npx playwright install --only-shell chromium",
// 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",
"waitFor": "postCreateCommand"
}
61 changes: 30 additions & 31 deletions claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ Projects consume these pre-built images and control their own tool versions via
| Mise | The tool manager itself (not the tools) | Projects run `mise install` at container creation for their tool versions |
| 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 |
| Playwright | System deps + browser binary at a pinned version | See [Playwright version strategy](#playwright-version-strategy) |

**Default-only:** passwordless sudo, agent-browser + full Chromium
**Default-only:** passwordless sudo, agent-browser, system Chromium (used by Playwright via `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`)

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

Expand Down Expand Up @@ -56,7 +55,7 @@ Copy these to your project's `.devcontainer/`:
- [`.devcontainer/docker-compose.yml`](.devcontainer/docker-compose.yml) — image reference with `pull_policy: always`
- [`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json) — full config with VS Code extensions, fish shell, OXC formatter, node_modules volume isolation, and lifecycle commands

**Key settings included:** fish + bash terminal profiles, OXC formatter (with comments for switching to Biome/Prettier), node_modules/Claude config/fish history volume mounts, and `updateContentCommand` for mise/bun/Playwright setup.
**Key settings included:** fish + bash terminal profiles, OXC formatter (with comments for switching to Biome/Prettier), node_modules/Claude config/fish history volume mounts, and `updateContentCommand` for mise/bun setup.

### Sandbox variant

Expand Down Expand Up @@ -212,37 +211,46 @@ The template only mounts root `node_modules` by default. For monorepo projects,

The `sudo find` in `updateContentCommand` chowns all `node_modules` directories in one pass, so additional mounts are handled automatically.

## Playwright Version Strategy
## Playwright Strategy

The image uses a two-layer strategy so that any project can use any `@playwright/test` version:
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
binaries, and keeps the image significantly smaller than shipping a
Playwright-managed Chromium per rebuild.

| Layer | What | Baked in image? | Version-sensitive? |
|-------|------|-----------------|--------------------|
| System deps (`install-deps`) | OS libraries (libgbm, libnss3, …) | Yes (needs root) | Stable across nearby versions |
| Browser binary (`install --only-shell`) | Headless Chromium shell | Yes, as cache | Exact match required |

**Prerequisite:** Your project must have `@playwright/test` in `devDependencies`. Without it, `npx` will prompt interactively to download the `playwright` package, which hangs container creation (no TTY). Pin it to the image's baked version (check `$PLAYWRIGHT_VERSION`) or your preferred version:
The Dockerfile sets:

```bash
bun add -d @playwright/test # or: npm install -D @playwright/test
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
```

**One-time project setup** — add this to your container creation command (`updateContentCommand` or `postCreateCommand`, as shown in [Quick Start](#quick-start)):

```bash
npx playwright install --only-shell chromium
`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` is a project convention — Playwright
does **not** read it automatically. Each project's `playwright.config.ts`
must honor it:

```ts
// playwright.config.ts
export default defineConfig({
use: {
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
},
},
});
```

This is **idempotent** — if the cached binary already matches, it's a no-op (~0s). If the project's `@playwright/test` version differs from the image, it downloads the correct binary (~10s, once at container creation).

The baked version is available at runtime via the `PLAYWRIGHT_VERSION` environment variable.
**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`).

## Build Args

| Arg | Default | Description |
|-----|---------|-------------|
| `GIT_DELTA_VERSION` | `0.18.2` | git-delta version |
| `PLAYWRIGHT_VERSION` | `1.58.2` | Playwright browser binary version |
| `AGENT_BROWSER_VERSION` | `latest` | agent-browser version (default target only) |

## Building Locally / Local Fallback
Expand Down Expand Up @@ -311,25 +319,16 @@ docker buildx build \

## Startup Timeline

### Warm start (Playwright version matches image)
```
Pull image ──────────────────────── (cached)
mise install (bun, hugo, etc.) ──── (~15s, downloads pre-built binaries)
bun install ─────────────────────── (~15s, cached in named volume)
playwright install --only-shell chromium (~0s, binary already cached — no-op)
project setup ───────────────────── (db:migrate, init-plugins, etc.)
Total: ~45s warm
```

### Playwright version mismatch
```
Pull image ──────────────────────── (cached)
mise install (bun, hugo, etc.) ──── (~15s)
bun install ─────────────────────── (~15s)
playwright install --only-shell chromium (~10s, downloads correct browser binary)
project setup ───────────────────── (db:migrate, init-plugins, etc.)
Total: ~55s warm
```
Chromium is baked into the image via apt — no per-container browser
download step required.

## Resources

Expand Down