diff --git a/.github/workflows/build-claude-code.yml b/.github/workflows/build-claude-code.yml index 1d4c6b1..af4602f 100644 --- a/.github/workflows/build-claude-code.yml +++ b/.github/workflows/build-claude-code.yml @@ -75,19 +75,19 @@ jobs: matrix: include: - image-suffix: claude-code - verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" + verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node && stat -c %U /home/node/.config/gh | grep -qx node" runner: ubuntu-24.04 arch: amd64 - image-suffix: claude-code - verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" + verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node && stat -c %U /home/node/.config/gh | grep -qx node" runner: ubuntu-24.04-arm arch: arm64 - image-suffix: claude-code-sandbox - verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" + verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node && stat -c %U /home/node/.config/gh | grep -qx node" runner: ubuntu-24.04 arch: amd64 - image-suffix: claude-code-sandbox - verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" + verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node && stat -c %U /home/node/.config/gh | grep -qx node" runner: ubuntu-24.04-arm arch: arm64 runs-on: ${{ matrix.runner }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a8355..2f9f3fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,12 +117,12 @@ jobs: add_image "claude-code" \ "claude-code/.devcontainer" \ "claude-code/.devcontainer/Dockerfile" \ - "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" \ + "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node && stat -c %U /home/node/.config/gh | grep -qx node" \ "default" add_image "claude-code-sandbox" \ "claude-code/.devcontainer" \ "claude-code/.devcontainer/Dockerfile" \ - "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" \ + "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node && stat -c %U /home/node/.config/gh | grep -qx node" \ "sandbox" fi diff --git a/claude-code/.devcontainer/Dockerfile b/claude-code/.devcontainer/Dockerfile index 21682a1..676a8dd 100644 --- a/claude-code/.devcontainer/Dockerfile +++ b/claude-code/.devcontainer/Dockerfile @@ -108,7 +108,7 @@ RUN mkdir -p /workspace/node_modules \ /home/node/.claude \ /home/node/.local/share \ /home/node/.local/state \ - /home/node/.config \ + /home/node/.config/gh \ /home/node/.cache \ /commandhistory \ && chown -R node:node /workspace /home/node/.claude /home/node/.local \ diff --git a/claude-code/.devcontainer/claude-sandbox/devcontainer.json b/claude-code/.devcontainer/claude-sandbox/devcontainer.json index 983bd91..bc7c1ad 100644 --- a/claude-code/.devcontainer/claude-sandbox/devcontainer.json +++ b/claude-code/.devcontainer/claude-sandbox/devcontainer.json @@ -114,6 +114,8 @@ // ── Persistent config ────────────────────────────────────────────── "source=myproject-claude-config-${localWorkspaceFolderBasename},target=/home/node/.claude,type=volume", "source=myproject-zsh-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume", + // gh CLI auth/state (~/.config/gh/hosts.yml) — keeps `gh auth login` across rebuilds + "source=myproject-gh-config-${localWorkspaceFolderBasename},target=/home/node/.config/gh,type=volume", // ── Firewall script ──────────────────────────────────────────────── // The image provides iptables/ipset packages and sudo rule but NOT the script itself. // Each project provides its own script via bind mount to customize the domain allowlist. @@ -131,7 +133,9 @@ }, // The find command chowns all node_modules volume mount points in one pass. // 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 /commandhistory && mise install && bun install", + // bun install and init-plugins.sh are skipped when their file is absent. Plugins + // install here, before postStartCommand brings the firewall up. + "postCreateCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude /commandhistory /home/node/.config/gh && mise install && if [ -f package.json ]; then bun install; fi && if [ -f .devcontainer/init-plugins.sh ]; then bash .devcontainer/init-plugins.sh; fi", // Firewall init (bind-mounted from project) + re-patch the Playwright MCP // plugin's .mcp.json. The patch is defense in depth alongside the SessionStart // hook in /etc/claude-code/managed-settings.json, which handles the case where diff --git a/claude-code/.devcontainer/devcontainer.json b/claude-code/.devcontainer/devcontainer.json index 8215a2b..b505dca 100644 --- a/claude-code/.devcontainer/devcontainer.json +++ b/claude-code/.devcontainer/devcontainer.json @@ -105,7 +105,9 @@ // "source=myproject-node-modules-web-${localWorkspaceFolderBasename},target=/workspace/apps/web/node_modules,type=volume", // ── Persistent config ────────────────────────────────────────────── "source=myproject-claude-config-${localWorkspaceFolderBasename},target=/home/node/.claude,type=volume", - "source=myproject-zsh-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume" + "source=myproject-zsh-history-${localWorkspaceFolderBasename},target=/commandhistory,type=volume", + // gh CLI auth/state (~/.config/gh/hosts.yml) — keeps `gh auth login` across rebuilds + "source=myproject-gh-config-${localWorkspaceFolderBasename},target=/home/node/.config/gh,type=volume" ], "containerEnv": { "TZ": "${localEnv:TZ:America/Edmonton}", @@ -116,13 +118,15 @@ // 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. + // bun install is skipped until the project has a package.json. // 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 /commandhistory && mise install && bun install", + "updateContentCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude /commandhistory /home/node/.config/gh && mise install && if [ -f package.json ]; then bun install; fi", // Re-patch the Playwright MCP plugin's .mcp.json on every start. Defense in // depth alongside the SessionStart hook in /etc/claude-code/managed-settings.json, // which handles the case where the plugin auto-updates mid-container-run. // See issues #85, #87, #98. - "postStartCommand": "/usr/local/bin/patch-playwright-mcp", - "waitFor": "postCreateCommand" + // No postCreateCommand: claude CLI calls there race the extension's OAuth sign-in + // (#58). Run `bash .devcontainer/init-plugins.sh` once after signing in. + "postStartCommand": "/usr/local/bin/patch-playwright-mcp" } diff --git a/claude-code/.devcontainer/init-plugins.sh b/claude-code/.devcontainer/init-plugins.sh index 6f64ac7..e1d9a33 100755 --- a/claude-code/.devcontainer/init-plugins.sh +++ b/claude-code/.devcontainer/init-plugins.sh @@ -1,9 +1,9 @@ #!/bin/bash -# Claude Code plugin initialization — runs once at container creation. -# Idempotent — safe to run multiple times. +# Claude Code plugin initialization. Idempotent — safe to run multiple times. # -# Wire into postCreateCommand in your devcontainer.json: -# "postCreateCommand": "bash .devcontainer/init-plugins.sh" +# Sandbox variant: runs from postCreateCommand, before the firewall comes up. +# Default variant: run `bash .devcontainer/init-plugins.sh` once after signing in — +# from postCreateCommand it races the extension's OAuth sign-in (#58). set -euo pipefail diff --git a/claude-code/README.md b/claude-code/README.md index 1c33a3f..269677d 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -57,7 +57,7 @@ Copy these to your project's `.devcontainer/`: - [`.devcontainer/docker-compose.yml`](.devcontainer/docker-compose.yml) — image reference (kept fresh by the `initializeCommand` pull in `devcontainer.json`) - [`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json) — full config with VS Code extensions, zsh shell, OXC formatter, node_modules volume isolation, and lifecycle commands -**Key settings included:** zsh + bash terminal profiles, OXC formatter (with comments for switching to Biome/Prettier), node_modules/Claude config/zsh history volume mounts, and `updateContentCommand` for mise/bun setup. +**Key settings included:** zsh + bash terminal profiles, OXC formatter (with comments for switching to Biome/Prettier), node_modules/Claude config/zsh history/gh CLI config volume mounts, and `updateContentCommand` for mise/bun setup (`bun install` is skipped until the project has a `package.json`). There is deliberately no `postCreateCommand`: see [`init-plugins.sh`](#optional-devcontainerinit-pluginssh). ### Sandbox variant @@ -66,9 +66,9 @@ Copy these to your project's `.devcontainer/claude-sandbox/`: - [`.devcontainer/claude-sandbox/docker-compose.yml`](.devcontainer/claude-sandbox/docker-compose.yml) — sandbox image reference - [`.devcontainer/claude-sandbox/devcontainer.json`](.devcontainer/claude-sandbox/devcontainer.json) — full config with `NET_ADMIN`/`NET_RAW` capabilities, Claude Dark theme, `claudeCode.allowDangerouslySkipPermissions`, node_modules volume isolation, firewall script bind mount, and `CLAUDE_CODE_OAUTH_TOKEN` injection -**Sandbox differences from default:** `capAdd` for iptables, `postStartCommand` runs the firewall script, `claudeCode.allowDangerouslySkipPermissions` enabled, and OAuth token must be injected from the host (see [Sandbox Authentication](#sandbox-authentication)). +**Sandbox differences from default:** `capAdd` for iptables, setup (including `init-plugins.sh`) runs in `postCreateCommand` before `postStartCommand` brings up the firewall, `claudeCode.allowDangerouslySkipPermissions` enabled, and an optional host-injected OAuth token for standalone use (see [Sandbox Authentication](#sandbox-authentication)). -**Shared volumes:** Both variants use `${localWorkspaceFolderBasename}` in volume names, so they share node_modules, Claude config, and zsh history. Install packages in one variant and both benefit. Docker named volumes support multi-container access, so both can run simultaneously — just avoid running `bun install` in both at the same time. +**Shared volumes:** Both variants use `${localWorkspaceFolderBasename}` in volume names, so they share node_modules, Claude config, zsh history, and gh CLI auth (`~/.config/gh`). Install packages in one variant and both benefit. Docker named volumes support multi-container access, so both can run simultaneously — just avoid running `bun install` in both at the same time. ## Project Setup Guide @@ -82,12 +82,14 @@ Only pin tools that affect project stability — dev infrastructure (rtk, ralphe Claude Code plugin initialization. `init-plugins.sh` registers marketplaces, installs plugins, and invokes the image-baked `/usr/local/bin/patch-playwright-mcp` to rewrite every cached Playwright MCP `.mcp.json` to launch the system chromium. Idempotent. See [`.devcontainer/init-plugins.sh`](.devcontainer/init-plugins.sh) for the template. -Wire into `devcontainer.json`: +- **Sandbox variant:** its `postCreateCommand` already runs the script, if present, before `postStartCommand` brings up the firewall. +- **Default variant:** run it once yourself after signing in to Claude Code: -```jsonc -"postCreateCommand": "bash .devcontainer/init-plugins.sh", -"postStartCommand": "/usr/local/bin/patch-playwright-mcp" -``` + ```bash + bash .devcontainer/init-plugins.sh + ``` + + It is not wired into `postCreateCommand` on purpose. `claude` CLI calls made there race the Claude Code extension's OAuth sign-in and can corrupt auth state, even with `waitFor` set (#58). `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. @@ -393,7 +395,7 @@ to use the system chromium: } ``` -`init-plugins.sh` invokes the patch binary at `postCreateCommand`, and the +`init-plugins.sh` invokes the patch binary when it runs, 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.